diff --git a/apps/manadeck/apps/backend/.env.example b/apps/manadeck/apps/backend/.env.example index 31c079185..5c4c088f2 100644 --- a/apps/manadeck/apps/backend/.env.example +++ b/apps/manadeck/apps/backend/.env.example @@ -1,21 +1,22 @@ -# Server Configuration -NODE_ENV=development -PORT=8080 +# ManaDeck Backend Environment Variables -# Mana Core Configuration +# PostgreSQL Database (using manadeck-database package) +DATABASE_URL=postgresql://manadeck:manadeck_dev_password@localhost:5433/manadeck + +# Mana Core Service (Required) MANA_SERVICE_URL=https://mana-core-middleware-111768794939.europe-west3.run.app -APP_ID=your-app-id-from-mana -MANA_SUPABASE_SECRET_KEY=your-service-key-from-mana-core # REQUIRED for credit operations +APP_ID=cea4bfc6-a4de-4e17-91e2-54275940156e +MANA_SUPABASE_SECRET_KEY=your-service-key + +# Signup Redirect URL (Optional) SIGNUP_REDIRECT_URL=https://manadeck.com/welcome -# PostgreSQL Database (Drizzle ORM) -DATABASE_URL=postgresql://postgres:postgres@localhost:5433/manadeck +# Google Gemini API (for AI deck generation) +GOOGLE_GENAI_API_KEY=your-gemini-api-key -# AI Services (Google Gemini) -GOOGLE_GENAI_API_KEY=your-google-genai-api-key # Get from https://aistudio.google.com/apikey +# Server Configuration +PORT=3000 +NODE_ENV=development -# JWT Configuration -JWT_SECRET=your-jwt-secret - -# CORS Configuration -FRONTEND_URL=http://localhost:8081 +# CORS (Optional) +FRONTEND_URL=http://localhost:5173 diff --git a/apps/manadeck/apps/backend/src/app.module.ts b/apps/manadeck/apps/backend/src/app.module.ts index 89b42686d..c7a61a479 100644 --- a/apps/manadeck/apps/backend/src/app.module.ts +++ b/apps/manadeck/apps/backend/src/app.module.ts @@ -17,6 +17,8 @@ import { CardRepository, UserStatsRepository, DeckTemplateRepository, + StudySessionRepository, + CardProgressRepository, } from './database'; @Module({ @@ -72,6 +74,8 @@ import { CardRepository, UserStatsRepository, DeckTemplateRepository, + StudySessionRepository, + CardProgressRepository, ], }) export class AppModule implements NestModule { diff --git a/apps/manadeck/apps/backend/src/controllers/api.controller.ts b/apps/manadeck/apps/backend/src/controllers/api.controller.ts index a8154a25d..b55575acc 100644 --- a/apps/manadeck/apps/backend/src/controllers/api.controller.ts +++ b/apps/manadeck/apps/backend/src/controllers/api.controller.ts @@ -6,6 +6,7 @@ import { Delete, Body, Param, + Query, UseGuards, Logger, BadRequestException, @@ -19,7 +20,13 @@ import { getCreditCost, getOperationDescription, } from '../config/credit-operations'; -import { DeckRepository, CardRepository, UserStatsRepository } from '../database'; +import { + DeckRepository, + CardRepository, + UserStatsRepository, + StudySessionRepository, + CardProgressRepository, +} from '../database'; import { AiService, CardType } from '../services/ai.service'; @Controller('api') @@ -32,6 +39,8 @@ export class ApiController { private readonly deckRepository: DeckRepository, private readonly cardRepository: CardRepository, private readonly userStatsRepository: UserStatsRepository, + private readonly studySessionRepository: StudySessionRepository, + private readonly cardProgressRepository: CardProgressRepository, private readonly aiService: AiService, ) {} @@ -363,6 +372,54 @@ export class ApiController { }; } + @Get('decks/:id') + async getDeck(@CurrentUser() user: any, @Param('id') deckId: string) { + this.logger.log(`Getting deck ${deckId} for user: ${user.sub}`); + + const deck = await this.deckRepository.findByIdAndUserId(deckId, user.sub); + + if (!deck) { + throw new BadRequestException({ + error: 'deck_not_found', + message: 'Deck not found or you do not have permission to view it', + }); + } + + // Get card count + const cardCount = await this.cardRepository.countByDeckId(deckId); + + return { + userId: user.sub, + deck: { + ...deck, + card_count: cardCount, + }, + }; + } + + @Get('decks/:id/cards') + async getDeckCards(@CurrentUser() user: any, @Param('id') deckId: string) { + this.logger.log(`Getting cards for deck ${deckId}, user: ${user.sub}`); + + // Verify deck ownership + const deck = await this.deckRepository.findByIdAndUserId(deckId, user.sub); + if (!deck) { + throw new BadRequestException({ + error: 'deck_not_found', + message: 'Deck not found or you do not have permission to view it', + }); + } + + const cards = await this.cardRepository.findByDeckId(deckId); + + return { + userId: user.sub, + deckId, + cards, + count: cards.length, + }; + } + @Get('cards') async getUserCards(@CurrentUser() user: any) { this.logger.log(`Getting cards for user: ${user.sub}`); @@ -374,6 +431,34 @@ export class ApiController { }; } + @Get('cards/:id') + async getCard(@CurrentUser() user: any, @Param('id') cardId: string) { + this.logger.log(`Getting card ${cardId} for user: ${user.sub}`); + + const card = await this.cardRepository.findById(cardId); + + if (!card) { + throw new BadRequestException({ + error: 'card_not_found', + message: 'Card not found', + }); + } + + // Verify ownership via deck + const deck = await this.deckRepository.findByIdAndUserId(card.deckId, user.sub); + if (!deck) { + throw new BadRequestException({ + error: 'card_not_found', + message: 'Card not found or you do not have permission to view it', + }); + } + + return { + userId: user.sub, + card, + }; + } + @Post('cards') async createCard(@CurrentUser() user: any, @Body() cardData: any) { this.logger.log(`Creating card for user: ${user.sub}`); @@ -404,6 +489,106 @@ export class ApiController { }; } + @Put('cards/:id') + async updateCard( + @CurrentUser() user: any, + @Param('id') cardId: string, + @Body() cardData: any, + ) { + this.logger.log(`Updating card ${cardId} for user: ${user.sub}`); + + // Get the card first + const existingCard = await this.cardRepository.findById(cardId); + if (!existingCard) { + throw new BadRequestException({ + error: 'card_not_found', + message: 'Card not found', + }); + } + + // Verify ownership via deck + const deck = await this.deckRepository.findByIdAndUserId(existingCard.deckId, user.sub); + if (!deck) { + throw new BadRequestException({ + error: 'card_not_found', + message: 'Card not found or you do not have permission to update it', + }); + } + + const updatedCard = await this.cardRepository.update(cardId, { + title: cardData.title, + content: cardData.content, + cardType: cardData.cardType, + position: cardData.position, + isFavorite: cardData.isFavorite, + }); + + return { + success: true, + userId: user.sub, + card: updatedCard, + }; + } + + @Delete('cards/:id') + async deleteCard(@CurrentUser() user: any, @Param('id') cardId: string) { + this.logger.log(`Deleting card ${cardId} for user: ${user.sub}`); + + // Get the card first + const existingCard = await this.cardRepository.findById(cardId); + if (!existingCard) { + throw new BadRequestException({ + error: 'card_not_found', + message: 'Card not found', + }); + } + + // Verify ownership via deck + const deck = await this.deckRepository.findByIdAndUserId(existingCard.deckId, user.sub); + if (!deck) { + throw new BadRequestException({ + error: 'card_not_found', + message: 'Card not found or you do not have permission to delete it', + }); + } + + await this.cardRepository.delete(cardId); + + return { + success: true, + userId: user.sub, + cardId, + }; + } + + @Post('cards/reorder') + async reorderCards( + @CurrentUser() user: any, + @Body() reorderData: { deckId: string; cardIds: string[] }, + ) { + this.logger.log(`Reordering cards in deck ${reorderData.deckId} for user: ${user.sub}`); + + // Verify deck ownership + const deck = await this.deckRepository.findByIdAndUserId(reorderData.deckId, user.sub); + if (!deck) { + throw new BadRequestException({ + error: 'deck_not_found', + message: 'Deck not found or you do not have permission to modify it', + }); + } + + // Update positions + for (let i = 0; i < reorderData.cardIds.length; i++) { + await this.cardRepository.update(reorderData.cardIds[i], { position: i }); + } + + return { + success: true, + userId: user.sub, + deckId: reorderData.deckId, + }; + } + @Get('stats') async getUserStats(@CurrentUser() user: any) { this.logger.log(`Getting stats for user: ${user.sub}`); @@ -423,4 +608,386 @@ export class ApiController { }, }; } + + // ============ Study Sessions ============ + + @Get('study-sessions') + async getStudySessions(@CurrentUser() user: any) { + this.logger.log(`Getting study sessions for user: ${user.sub}`); + const sessions = await this.studySessionRepository.findByUserId(user.sub); + return { + userId: user.sub, + sessions, + count: sessions.length, + }; + } + + @Get('study-sessions/stats') + async getStudySessionStats(@CurrentUser() user: any) { + this.logger.log(`Getting study session stats for user: ${user.sub}`); + const stats = await this.studySessionRepository.getStatsByUserId(user.sub); + return { + userId: user.sub, + stats, + }; + } + + @Get('study-sessions/range') + async getStudySessionsByDateRange( + @CurrentUser() user: any, + @Query('startDate') startDate: string, + @Query('endDate') endDate: string, + ) { + this.logger.log(`Getting study sessions for user ${user.sub} from ${startDate} to ${endDate}`); + + if (!startDate || !endDate) { + throw new BadRequestException({ + error: 'validation_failed', + message: 'startDate and endDate query parameters are required', + }); + } + + const sessions = await this.studySessionRepository.findByDateRange( + user.sub, + new Date(startDate), + new Date(endDate), + ); + + return { + userId: user.sub, + sessions, + count: sessions.length, + startDate, + endDate, + }; + } + + @Get('study-sessions/deck/:deckId') + async getStudySessionsByDeck( + @CurrentUser() user: any, + @Param('deckId') deckId: string, + ) { + this.logger.log(`Getting study sessions for deck ${deckId}, user: ${user.sub}`); + + // Verify deck ownership + const deck = await this.deckRepository.findByIdAndUserId(deckId, user.sub); + if (!deck) { + throw new BadRequestException({ + error: 'deck_not_found', + message: 'Deck not found or you do not have permission to view it', + }); + } + + const sessions = await this.studySessionRepository.findByDeckId(deckId, user.sub); + return { + userId: user.sub, + deckId, + sessions, + count: sessions.length, + }; + } + + @Get('study-sessions/:id') + async getStudySession(@CurrentUser() user: any, @Param('id') sessionId: string) { + this.logger.log(`Getting study session ${sessionId} for user: ${user.sub}`); + + const session = await this.studySessionRepository.findById(sessionId); + if (!session || session.userId !== user.sub) { + throw new BadRequestException({ + error: 'session_not_found', + message: 'Study session not found', + }); + } + + return { + userId: user.sub, + session, + }; + } + + @Post('study-sessions') + async createStudySession(@CurrentUser() user: any, @Body() sessionData: any) { + this.logger.log(`Creating study session for user: ${user.sub}`); + + // Verify deck ownership + const deck = await this.deckRepository.findByIdAndUserId(sessionData.deckId, user.sub); + if (!deck) { + throw new BadRequestException({ + error: 'deck_not_found', + message: 'Deck not found or you do not have permission to create sessions for it', + }); + } + + const session = await this.studySessionRepository.create({ + userId: user.sub, + deckId: sessionData.deckId, + startedAt: sessionData.startedAt || new Date(), + endedAt: sessionData.endedAt, + totalCards: sessionData.totalCards || 0, + completedCards: sessionData.completedCards || 0, + correctCards: sessionData.correctCards || 0, + timeSpentSeconds: sessionData.timeSpentSeconds || 0, + }); + + return { + success: true, + userId: user.sub, + session, + }; + } + + @Put('study-sessions/:id') + async updateStudySession( + @CurrentUser() user: any, + @Param('id') sessionId: string, + @Body() sessionData: any, + ) { + this.logger.log(`Updating study session ${sessionId} for user: ${user.sub}`); + + const session = await this.studySessionRepository.update(sessionId, user.sub, { + endedAt: sessionData.endedAt, + totalCards: sessionData.totalCards, + completedCards: sessionData.completedCards, + correctCards: sessionData.correctCards, + timeSpentSeconds: sessionData.timeSpentSeconds, + }); + + if (!session) { + throw new BadRequestException({ + error: 'session_not_found', + message: 'Study session not found or you do not have permission to update it', + }); + } + + return { + success: true, + userId: user.sub, + session, + }; + } + + // ============ Card Progress ============ + + @Get('progress') + async getCardProgress(@CurrentUser() user: any) { + this.logger.log(`Getting card progress for user: ${user.sub}`); + const progress = await this.cardProgressRepository.findByUserId(user.sub); + return { + userId: user.sub, + progress, + count: progress.length, + }; + } + + @Get('progress/stats') + async getCardProgressStats(@CurrentUser() user: any) { + this.logger.log(`Getting card progress stats for user: ${user.sub}`); + const stats = await this.cardProgressRepository.getStatsByUserId(user.sub); + return { + userId: user.sub, + stats, + }; + } + + @Get('progress/due') + async getDueCards(@CurrentUser() user: any) { + this.logger.log(`Getting due cards for user: ${user.sub}`); + const dueProgress = await this.cardProgressRepository.findDueCards(user.sub); + return { + userId: user.sub, + progress: dueProgress, + count: dueProgress.length, + }; + } + + @Get('progress/deck/:deckId') + async getCardProgressByDeck( + @CurrentUser() user: any, + @Param('deckId') deckId: string, + ) { + this.logger.log(`Getting card progress for deck ${deckId}, user: ${user.sub}`); + + // Verify deck ownership + const deck = await this.deckRepository.findByIdAndUserId(deckId, user.sub); + if (!deck) { + throw new BadRequestException({ + error: 'deck_not_found', + message: 'Deck not found or you do not have permission to view it', + }); + } + + const progress = await this.cardProgressRepository.findByDeckId(deckId, user.sub); + return { + userId: user.sub, + deckId, + progress, + count: progress.length, + }; + } + + @Get('progress/deck/:deckId/due') + async getDueCardsByDeck( + @CurrentUser() user: any, + @Param('deckId') deckId: string, + ) { + this.logger.log(`Getting due cards for deck ${deckId}, user: ${user.sub}`); + + // Verify deck ownership + const deck = await this.deckRepository.findByIdAndUserId(deckId, user.sub); + if (!deck) { + throw new BadRequestException({ + error: 'deck_not_found', + message: 'Deck not found or you do not have permission to view it', + }); + } + + const dueProgress = await this.cardProgressRepository.findDueCards(user.sub, deckId); + return { + userId: user.sub, + deckId, + progress: dueProgress, + count: dueProgress.length, + }; + } + + @Get('progress/card/:cardId') + async getCardProgressByCard( + @CurrentUser() user: any, + @Param('cardId') cardId: string, + ) { + this.logger.log(`Getting progress for card ${cardId}, user: ${user.sub}`); + + // Verify card ownership via deck + const card = await this.cardRepository.findById(cardId); + if (!card) { + throw new BadRequestException({ + error: 'card_not_found', + message: 'Card not found', + }); + } + + const deck = await this.deckRepository.findByIdAndUserId(card.deckId, user.sub); + if (!deck) { + throw new BadRequestException({ + error: 'card_not_found', + message: 'Card not found or you do not have permission to view it', + }); + } + + const progress = await this.cardProgressRepository.findByCardId(cardId, user.sub); + return { + userId: user.sub, + cardId, + progress, + }; + } + + @Post('progress') + async upsertCardProgress(@CurrentUser() user: any, @Body() progressData: any) { + this.logger.log(`Upserting card progress for user: ${user.sub}`); + + // Verify card ownership via deck + const card = await this.cardRepository.findById(progressData.cardId); + if (!card) { + throw new BadRequestException({ + error: 'card_not_found', + message: 'Card not found', + }); + } + + const deck = await this.deckRepository.findByIdAndUserId(card.deckId, user.sub); + if (!deck) { + throw new BadRequestException({ + error: 'card_not_found', + message: 'Card not found or you do not have permission to update progress for it', + }); + } + + const progress = await this.cardProgressRepository.upsert({ + userId: user.sub, + cardId: progressData.cardId, + easeFactor: progressData.easeFactor ?? 2.5, + interval: progressData.interval ?? 0, + repetitions: progressData.repetitions ?? 0, + lastReviewed: progressData.lastReviewed ? new Date(progressData.lastReviewed) : new Date(), + nextReview: progressData.nextReview ? new Date(progressData.nextReview) : new Date(), + status: progressData.status || 'new', + }); + + return { + success: true, + userId: user.sub, + progress, + }; + } + + // ============ AI Operations ============ + + @Post('ai/generate-from-image') + async generateCardsFromImage(@CurrentUser() user: any, @Body() body: any) { + this.logger.log(`Generating cards from image for user: ${user.sub}`); + + const { image, context, cardCount = 5 } = body; + + if (!image) { + throw new BadRequestException({ + error: 'validation_failed', + message: 'image (base64) is required', + }); + } + + const result = await this.aiService.generateFromImage(image, context, cardCount); + + if (!isOk(result)) { + throw new BadRequestException({ + error: 'ai_generation_failed', + message: result.error.message, + }); + } + + return { + success: true, + userId: user.sub, + cards: result.value.cards.map((card) => ({ + type: card.cardType, + content: card.content, + metadata: { + confidence: 1, + source: 'ai-image', + tags: [], + }, + })), + metadata: result.value.metadata, + }; + } + + @Post('ai/enhance-content') + async enhanceCardContent(@CurrentUser() user: any, @Body() body: any) { + this.logger.log(`Enhancing card content for user: ${user.sub}`); + + const { content, cardType } = body; + + if (!content) { + throw new BadRequestException({ + error: 'validation_failed', + message: 'content is required', + }); + } + + const result = await this.aiService.enhanceContent(content, cardType || 'flashcard'); + + if (!isOk(result)) { + throw new BadRequestException({ + error: 'ai_enhancement_failed', + message: result.error.message, + }); + } + + return { + success: true, + userId: user.sub, + enhancedContent: result.value.enhancedContent, + }; + } + } \ No newline at end of file diff --git a/apps/manadeck/apps/backend/src/database/repositories/card-progress.repository.ts b/apps/manadeck/apps/backend/src/database/repositories/card-progress.repository.ts new file mode 100644 index 000000000..7e04e1922 --- /dev/null +++ b/apps/manadeck/apps/backend/src/database/repositories/card-progress.repository.ts @@ -0,0 +1,127 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { DATABASE_TOKEN, type Database } from '../database.module'; +import { + cardProgress, + cards, + type CardProgress, + type NewCardProgress, + eq, + and, + lte, + sql, +} from '@manacore/manadeck-database'; + +@Injectable() +export class CardProgressRepository { + private readonly logger = new Logger(CardProgressRepository.name); + + constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {} + + async findByUserId(userId: string): Promise { + this.logger.debug(`Finding card progress for user: ${userId}`); + return this.db + .select() + .from(cardProgress) + .where(eq(cardProgress.userId, userId)); + } + + async findByDeckId(deckId: string, userId: string): Promise { + // Join with cards to filter by deck + const result = await this.db + .select({ progress: cardProgress }) + .from(cardProgress) + .innerJoin(cards, eq(cardProgress.cardId, cards.id)) + .where(and(eq(cards.deckId, deckId), eq(cardProgress.userId, userId))); + return result.map((r) => r.progress); + } + + async findByCardId(cardId: string, userId: string): Promise { + const result = await this.db + .select() + .from(cardProgress) + .where(and(eq(cardProgress.cardId, cardId), eq(cardProgress.userId, userId))) + .limit(1); + return result[0] || null; + } + + async findDueCards(userId: string, deckId?: string): Promise { + const now = new Date(); + + if (deckId) { + const result = await this.db + .select({ progress: cardProgress }) + .from(cardProgress) + .innerJoin(cards, eq(cardProgress.cardId, cards.id)) + .where( + and( + eq(cardProgress.userId, userId), + eq(cards.deckId, deckId), + lte(cardProgress.nextReview, now) + ) + ); + return result.map((r) => r.progress); + } + + return this.db + .select() + .from(cardProgress) + .where(and(eq(cardProgress.userId, userId), lte(cardProgress.nextReview, now))); + } + + async create(data: NewCardProgress): Promise { + this.logger.debug(`Creating card progress for card: ${data.cardId}`); + const result = await this.db.insert(cardProgress).values(data).returning(); + return result[0]; + } + + async upsert(data: NewCardProgress): Promise { + this.logger.debug(`Upserting card progress for card: ${data.cardId}`); + const result = await this.db + .insert(cardProgress) + .values(data) + .onConflictDoUpdate({ + target: [cardProgress.userId, cardProgress.cardId], + set: { + easeFactor: data.easeFactor, + interval: data.interval, + repetitions: data.repetitions, + lastReviewed: data.lastReviewed, + nextReview: data.nextReview, + status: data.status, + updatedAt: new Date(), + }, + }) + .returning(); + return result[0]; + } + + async update( + id: string, + data: Partial> + ): Promise { + this.logger.debug(`Updating card progress: ${id}`); + const result = await this.db + .update(cardProgress) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(cardProgress.id, id)) + .returning(); + return result[0] || null; + } + + async getStatsByUserId(userId: string) { + const result = await this.db + .select({ + totalCards: sql`count(*)::int`, + newCards: sql`count(*) filter (where ${cardProgress.status} = 'new')::int`, + learningCards: sql`count(*) filter (where ${cardProgress.status} = 'learning')::int`, + reviewCards: sql`count(*) filter (where ${cardProgress.status} = 'review')::int`, + avgEaseFactor: sql`avg(${cardProgress.easeFactor})`, + }) + .from(cardProgress) + .where(eq(cardProgress.userId, userId)); + return result[0]; + } +} diff --git a/apps/manadeck/apps/backend/src/database/repositories/index.ts b/apps/manadeck/apps/backend/src/database/repositories/index.ts index 98bed80c9..99ee590d7 100644 --- a/apps/manadeck/apps/backend/src/database/repositories/index.ts +++ b/apps/manadeck/apps/backend/src/database/repositories/index.ts @@ -2,3 +2,5 @@ export { DeckRepository } from './deck.repository'; export { CardRepository } from './card.repository'; export { UserStatsRepository } from './user-stats.repository'; export { DeckTemplateRepository } from './deck-template.repository'; +export { StudySessionRepository } from './study-session.repository'; +export { CardProgressRepository } from './card-progress.repository'; diff --git a/apps/manadeck/apps/backend/src/database/repositories/study-session.repository.ts b/apps/manadeck/apps/backend/src/database/repositories/study-session.repository.ts new file mode 100644 index 000000000..4c8a99766 --- /dev/null +++ b/apps/manadeck/apps/backend/src/database/repositories/study-session.repository.ts @@ -0,0 +1,98 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { DATABASE_TOKEN, type Database } from '../database.module'; +import { + studySessions, + type StudySession, + type NewStudySession, + eq, + and, + desc, + gte, + lte, + sql, +} from '@manacore/manadeck-database'; + +@Injectable() +export class StudySessionRepository { + private readonly logger = new Logger(StudySessionRepository.name); + + constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {} + + async findByUserId(userId: string, limit = 50): Promise { + this.logger.debug(`Finding study sessions for user: ${userId}`); + return this.db + .select() + .from(studySessions) + .where(eq(studySessions.userId, userId)) + .orderBy(desc(studySessions.startedAt)) + .limit(limit); + } + + async findByDeckId(deckId: string, userId: string): Promise { + return this.db + .select() + .from(studySessions) + .where(and(eq(studySessions.deckId, deckId), eq(studySessions.userId, userId))) + .orderBy(desc(studySessions.startedAt)); + } + + async findByDateRange( + userId: string, + startDate: Date, + endDate: Date + ): Promise { + return this.db + .select() + .from(studySessions) + .where( + and( + eq(studySessions.userId, userId), + gte(studySessions.startedAt, startDate), + lte(studySessions.startedAt, endDate) + ) + ) + .orderBy(desc(studySessions.startedAt)); + } + + async findById(id: string): Promise { + const result = await this.db + .select() + .from(studySessions) + .where(eq(studySessions.id, id)) + .limit(1); + return result[0] || null; + } + + async create(data: NewStudySession): Promise { + this.logger.debug(`Creating study session for deck: ${data.deckId}`); + const result = await this.db.insert(studySessions).values(data).returning(); + return result[0]; + } + + async update( + id: string, + userId: string, + data: Partial> + ): Promise { + this.logger.debug(`Updating study session: ${id}`); + const result = await this.db + .update(studySessions) + .set(data) + .where(and(eq(studySessions.id, id), eq(studySessions.userId, userId))) + .returning(); + return result[0] || null; + } + + async getStatsByUserId(userId: string) { + const result = await this.db + .select({ + totalSessions: sql`count(*)::int`, + totalCardsStudied: sql`sum(${studySessions.completedCards})::int`, + totalCorrectCards: sql`sum(${studySessions.correctCards})::int`, + totalTimeSeconds: sql`sum(${studySessions.timeSpentSeconds})::int`, + }) + .from(studySessions) + .where(eq(studySessions.userId, userId)); + return result[0]; + } +} diff --git a/apps/manadeck/apps/backend/src/services/ai.service.ts b/apps/manadeck/apps/backend/src/services/ai.service.ts index 72dbf82a7..209c95518 100644 --- a/apps/manadeck/apps/backend/src/services/ai.service.ts +++ b/apps/manadeck/apps/backend/src/services/ai.service.ts @@ -238,6 +238,122 @@ Ensure variety in the questions and good coverage of the subject matter.`; return `- Mix of ${cardTypes.join(', ')} cards as appropriate for the content`; } + /** + * Generate cards from an image using Gemini Vision + */ + async generateFromImage( + imageBase64: string, + context?: string, + cardCount: number = 5, + ): AsyncResult { + const startTime = Date.now(); + + if (!this.ai) { + return err(ServiceError.unavailable('AI (Google Gemini not configured)')); + } + + try { + const prompt = `Analyze this image and create ${cardCount} educational flashcards based on its content. +${context ? `Context: ${context}` : ''} + +For each concept, term, or important element you identify in the image, create a flashcard or quiz question. + +Return the cards as a JSON object with a "cards" array containing objects with: +- cardType: "flashcard" or "quiz" +- title: short title +- content: { front, back, hint } for flashcards OR { question, options, correctAnswer, explanation } for quiz`; + + const response = await this.ai.models.generateContent({ + model: this.model, + contents: [ + { + role: 'user', + parts: [ + { text: prompt }, + { + inlineData: { + mimeType: 'image/jpeg', + data: imageBase64, + }, + }, + ], + }, + ], + config: { + responseMimeType: 'application/json', + }, + }); + + const generationTime = Date.now() - startTime; + const responseText = response.text?.trim(); + + if (!responseText) { + return err(ServiceError.generationFailed('Google Gemini', 'Empty response from AI')); + } + + const parsed = JSON.parse(responseText); + const cards: GeneratedCard[] = parsed.cards || []; + + this.logger.log(`Generated ${cards.length} cards from image in ${generationTime}ms`); + + return ok({ + cards, + metadata: { + model: this.model, + tokensUsed: response.usageMetadata?.totalTokenCount, + generationTime, + }, + }); + } catch (error) { + this.logger.error('AI image generation failed:', error); + return err( + ServiceError.generationFailed( + 'Google Gemini', + error instanceof Error ? error.message : 'Unknown error', + ), + ); + } + } + + /** + * Enhance card content using AI + */ + async enhanceContent( + content: string, + cardType: string, + ): AsyncResult<{ enhancedContent: string }> { + if (!this.ai) { + return err(ServiceError.unavailable('AI (Google Gemini not configured)')); + } + + try { + const prompt = `Improve and enhance this ${cardType} card content. Make it clearer, more educational, and engaging. + +Original content: +${content} + +Return the enhanced content in the same JSON format as the input, but improved.`; + + const response = await this.ai.models.generateContent({ + model: this.model, + contents: prompt, + config: { + responseMimeType: 'application/json', + }, + }); + + const responseText = response.text?.trim(); + if (!responseText) { + return ok({ enhancedContent: content }); + } + + return ok({ enhancedContent: responseText }); + } catch (error) { + this.logger.error('AI content enhancement failed:', error); + return ok({ enhancedContent: content }); // Return original on failure + } + } + private buildResponseSchema(cardTypes: CardType[]): any { const cardSchemas: any[] = []; diff --git a/apps/manadeck/apps/mobile/.env.example b/apps/manadeck/apps/mobile/.env.example new file mode 100644 index 000000000..f520bd7cb --- /dev/null +++ b/apps/manadeck/apps/mobile/.env.example @@ -0,0 +1,8 @@ +# ManaDeck Mobile App Environment Variables + +# Backend API URL +EXPO_PUBLIC_BACKEND_URL=http://localhost:3000 + +# Mana Core Auth +EXPO_PUBLIC_MANA_MIDDLEWARE_URL=https://api.manacore.de +EXPO_PUBLIC_MIDDLEWARE_APP_ID=manadeck diff --git a/apps/manadeck/apps/mobile/app/study/session/[id].tsx b/apps/manadeck/apps/mobile/app/study/session/[id].tsx index 5169b7177..38c408750 100644 --- a/apps/manadeck/apps/mobile/app/study/session/[id].tsx +++ b/apps/manadeck/apps/mobile/app/study/session/[id].tsx @@ -10,7 +10,6 @@ import { CardView } from '../../../components/card/CardView'; import { Button } from '../../../components/ui/Button'; import { Card as UICard } from '../../../components/ui/Card'; import { QuizContent } from '../../../store/cardStore'; -import { AudioCard } from '../../../components/study/AudioCard'; import { useThemeColors } from '../../../utils/themeUtils'; export default function StudySessionScreen() { @@ -34,7 +33,6 @@ export default function StudySessionScreen() { const colors = useThemeColors(); const [sessionStarted, setSessionStarted] = useState(false); - const [audioEnabled, setAudioEnabled] = useState(false); useEffect(() => { if (deckId && !sessionStarted) { @@ -118,22 +116,7 @@ export default function StudySessionScreen() { ), headerRight: () => ( - - setAudioEnabled(!audioEnabled)} - style={({ pressed }) => ({ - marginRight: 12, - borderRadius: 20, - backgroundColor: colors.muted, - padding: 8, - opacity: pressed ? 0.7 : 1 - })}> - - + {currentCardIndex + 1}/{sessionCards.length} @@ -201,13 +184,6 @@ export default function StudySessionScreen() { ) : ( )} - - {/* Audio Controls */} - {audioEnabled && ( - - - - )} {/* Spacer to push buttons to bottom */} diff --git a/apps/manadeck/apps/mobile/components/ai/AudioRecorder.tsx b/apps/manadeck/apps/mobile/components/ai/AudioRecorder.tsx deleted file mode 100644 index 43a58a4eb..000000000 --- a/apps/manadeck/apps/mobile/components/ai/AudioRecorder.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { View, Pressable, Animated } from 'react-native'; -import { Text } from '../ui/Text'; -import { Icon } from '../ui/Icon'; -import { Audio } from 'expo-av'; -import { useAIStore } from '../../store/aiStore'; -import { Card } from '../ui/Card'; - -interface AudioRecorderProps { - onRecordingComplete?: (audioUri: string) => void; - onTranscriptionComplete?: (text: string) => void; -} - -export const AudioRecorder: React.FC = ({ - onRecordingComplete, - onTranscriptionComplete, -}) => { - const { audioRecording, startRecording, stopRecording, generateCardsFromAudio } = useAIStore(); - const [recordingDuration, setRecordingDuration] = useState(0); - const [isProcessing, setIsProcessing] = useState(false); - const pulseAnim = useRef(new Animated.Value(1)).current; - const durationInterval = useRef(null); - - useEffect(() => { - if (audioRecording.isRecording) { - // Start pulse animation - Animated.loop( - Animated.sequence([ - Animated.timing(pulseAnim, { - toValue: 1.2, - duration: 500, - useNativeDriver: true, - }), - Animated.timing(pulseAnim, { - toValue: 1, - duration: 500, - useNativeDriver: true, - }), - ]) - ).start(); - - // Start duration counter - durationInterval.current = setInterval(() => { - setRecordingDuration((prev) => prev + 1); - }, 1000); - } else { - // Stop animations and reset - pulseAnim.stopAnimation(); - pulseAnim.setValue(1); - - if (durationInterval.current) { - clearInterval(durationInterval.current); - durationInterval.current = null; - } - } - - return () => { - if (durationInterval.current) { - clearInterval(durationInterval.current); - } - }; - }, [audioRecording.isRecording, pulseAnim]); - - const handleStartRecording = async () => { - try { - setRecordingDuration(0); - await startRecording(); - } catch (error) { - console.error('Error starting recording:', error); - } - }; - - const handleStopRecording = async () => { - try { - setIsProcessing(true); - const uri = await stopRecording(); - setRecordingDuration(0); - - if (onRecordingComplete) { - onRecordingComplete(uri); - } - - // Generate cards from audio - if (onTranscriptionComplete) { - try { - const cards = await generateCardsFromAudio(uri); - // Extract text from first card for transcription callback - const transcribedText = cards.length > 0 ? JSON.stringify(cards[0].content) : ''; - onTranscriptionComplete(transcribedText); - } catch (error) { - console.error('Error transcribing audio:', error); - } - } - } catch (error) { - console.error('Error stopping recording:', error); - } finally { - setIsProcessing(false); - } - }; - - const formatDuration = (seconds: number): string => { - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; - }; - - return ( - - - {/* Recording Status */} - - {audioRecording.isRecording ? ( - <> - - Aufnahme läuft - - - {formatDuration(recordingDuration)} - - - ) : isProcessing ? ( - <> - - Verarbeite Audio... - - - - ) : ( - - Drücke zum Aufnehmen - - )} - - - {/* Recording Button */} - - - - - - {/* Pulse Effect Ring */} - {audioRecording.isRecording && ( - - )} - - - {/* Instructions */} - - {audioRecording.isRecording - ? 'Spreche deutlich und drücke Stopp wenn fertig' - : isProcessing - ? 'Audio wird mit KI verarbeitet...' - : 'Halte das Mikrofon gedrückt und spreche deinen Lerninhalt'} - - - {/* Audio Waveform Visualization (simplified) */} - {audioRecording.isRecording && ( - - {[...Array(7)].map((_, i) => ( - - ))} - - )} - - - ); -}; diff --git a/apps/manadeck/apps/mobile/components/ai/SmartCardCreator.tsx b/apps/manadeck/apps/mobile/components/ai/SmartCardCreator.tsx index 1a0c7ca2b..7a674ff09 100644 --- a/apps/manadeck/apps/mobile/components/ai/SmartCardCreator.tsx +++ b/apps/manadeck/apps/mobile/components/ai/SmartCardCreator.tsx @@ -14,7 +14,6 @@ import { Card } from '../ui/Card'; import { Button } from '../ui/Button'; import { useAIStore } from '../../store/aiStore'; import { GeneratedCard, GenerationOptions } from '../../utils/supabaseAIService'; -import { AudioRecorder } from './AudioRecorder'; import { ImageCardCreator } from './ImageCardCreator'; interface SmartCardCreatorProps { @@ -32,7 +31,7 @@ export const SmartCardCreator: React.FC = ({ deckId, onCa saveGeneratedCards, } = useAIStore(); - const [inputMode, setInputMode] = useState<'text' | 'voice' | 'image'>('text'); + const [inputMode, setInputMode] = useState<'text' | 'image'>('text'); const [textInput, setTextInput] = useState(''); const [options, setOptions] = useState({ cardTypes: ['flashcard', 'quiz'], @@ -186,7 +185,6 @@ export const SmartCardCreator: React.FC = ({ deckId, onCa {[ { key: 'text', label: 'Text', icon: 'text' }, - { key: 'voice', label: 'Sprache', icon: 'mic' }, { key: 'image', label: 'Bild', icon: 'image' }, ].map((mode) => ( = ({ deckId, onCa )} - {inputMode === 'voice' && ( - - { - setTextInput(text); - setInputMode('text'); - }} - /> - - )} - {inputMode === 'image' && ( = ({ - card, - autoPlay = false, - showControls = true, -}) => { - const [isPlaying, setIsPlaying] = useState(false); - const [rate, setRate] = useState(1.0); - const [error, setError] = useState(null); - - useEffect(() => { - if (autoPlay) { - handlePlay(); - } - - return () => { - // Stop any ongoing speech when component unmounts - TTSService.stop(); - }; - }, [card.id]); - - const handlePlay = async () => { - try { - setError(null); - setIsPlaying(true); - - await TTSService.speakCard(card); - - setIsPlaying(false); - } catch (error) { - console.error('Error playing audio:', error); - setError('Fehler beim Abspielen'); - setIsPlaying(false); - } - }; - - const handleStop = async () => { - try { - await TTSService.stop(); - setIsPlaying(false); - } catch (error) { - console.error('Error stopping audio:', error); - } - }; - - const handlePause = async () => { - try { - await TTSService.pause(); - setIsPlaying(false); - } catch (error) { - console.error('Error pausing audio:', error); - } - }; - - const handleResume = async () => { - try { - await TTSService.resume(); - setIsPlaying(true); - } catch (error) { - console.error('Error resuming audio:', error); - } - }; - - const adjustRate = (newRate: number) => { - setRate(newRate); - // Rate will be applied on next play - }; - - if (!showControls) { - return null; - } - - return ( - - {/* Play/Pause Button */} - pressed && { opacity: 0.7 }}> - - - - {/* Stop Button */} - {isPlaying && ( - pressed && { opacity: 0.7 }}> - - - )} - - {/* Speed Controls */} - - Geschwindigkeit: - - adjustRate(0.75)} - className={`rounded px-2 py-1 ${rate === 0.75 ? 'bg-blue-500' : 'bg-gray-200'}`} - style={({ pressed }) => pressed && { opacity: 0.7 }}> - 0.75x - - - adjustRate(1.0)} - className={`rounded px-2 py-1 ${rate === 1.0 ? 'bg-blue-500' : 'bg-gray-200'}`} - style={({ pressed }) => pressed && { opacity: 0.7 }}> - 1x - - - adjustRate(1.25)} - className={`rounded px-2 py-1 ${rate === 1.25 ? 'bg-blue-500' : 'bg-gray-200'}`} - style={({ pressed }) => pressed && { opacity: 0.7 }}> - 1.25x - - - - {/* Error Display */} - {error && {error}} - - ); -}; diff --git a/apps/manadeck/apps/mobile/package.json b/apps/manadeck/apps/mobile/package.json index 3d25660b0..4ea5d7687 100644 --- a/apps/manadeck/apps/mobile/package.json +++ b/apps/manadeck/apps/mobile/package.json @@ -24,12 +24,10 @@ "@react-native-google-signin/google-signin": "^14.0.2", "@react-native-segmented-control/segmented-control": "2.5.7", "@react-navigation/native": "^7.0.3", - "@supabase/supabase-js": "^2.81.1", "base64-js": "^1.5.1", "class-variance-authority": "^0.7.1", "expo": "54.0.13", "expo-apple-authentication": "~8.0.7", - "expo-av": "~16.0.7", "expo-blur": "~15.0.7", "expo-build-properties": "~1.0.9", "expo-constants": "~18.0.9", @@ -41,7 +39,6 @@ "expo-linking": "~8.0.8", "expo-router": "~6.0.10", "expo-secure-store": "^15.0.7", - "expo-speech": "~14.0.7", "expo-status-bar": "~3.0.8", "expo-symbols": "~1.0.7", "expo-system-ui": "~6.0.7", diff --git a/apps/manadeck/apps/mobile/services/apiClient.ts b/apps/manadeck/apps/mobile/services/apiClient.ts new file mode 100644 index 000000000..afbdfd9d6 --- /dev/null +++ b/apps/manadeck/apps/mobile/services/apiClient.ts @@ -0,0 +1,365 @@ +/** + * API Client for ManaDeck Backend + * Replaces direct Supabase access with backend API calls + */ + +import { authService } from './authService'; + +const API_URL = process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3000'; + +interface ApiResponse { + data: T | null; + error: string | null; +} + +class ApiClient { + private async getAuthHeaders(): Promise> { + const token = await authService.getAppToken(); + if (!token) { + throw new Error('Not authenticated'); + } + + // Check if token is valid, try to refresh if not + if (!authService.isTokenValidLocally(token)) { + const refreshToken = await authService.getRefreshToken(); + if (refreshToken) { + try { + await authService.refreshTokens(refreshToken); + const newToken = await authService.getAppToken(); + if (newToken) { + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${newToken}`, + }; + } + } catch (error) { + console.error('Failed to refresh token:', error); + throw new Error('Session expired. Please sign in again.'); + } + } + throw new Error('Session expired. Please sign in again.'); + } + + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }; + } + + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise> { + try { + const headers = await this.getAuthHeaders(); + const response = await fetch(`${API_URL}${endpoint}`, { + ...options, + headers: { + ...headers, + ...options.headers, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { + data: null, + error: errorData.message || `Request failed with status ${response.status}`, + }; + } + + const data = await response.json(); + return { data, error: null }; + } catch (error: any) { + console.error(`API Error [${endpoint}]:`, error); + return { + data: null, + error: error.message || 'Network error', + }; + } + } + + // ============ Profile ============ + async getProfile() { + return this.request<{ user: any; credits: number }>('/api/profile'); + } + + async getCreditBalance() { + return this.request<{ balance: number }>('/api/credits/balance'); + } + + // ============ Decks ============ + async getDecks() { + const response = await this.request<{ decks: any[]; count: number }>('/api/decks'); + return response; + } + + async getDeck(id: string) { + return this.request<{ deck: any }>(`/api/decks/${id}`); + } + + async createDeck(deckData: { + title: string; + description?: string; + coverImageUrl?: string; + isPublic?: boolean; + tags?: string[]; + settings?: Record; + metadata?: Record; + }) { + return this.request<{ deck: any; creditsUsed: number }>('/api/decks', { + method: 'POST', + body: JSON.stringify(deckData), + }); + } + + async updateDeck( + id: string, + updates: { + title?: string; + description?: string; + coverImageUrl?: string; + isPublic?: boolean; + tags?: string[]; + settings?: Record; + metadata?: Record; + } + ) { + return this.request<{ deck: any }>(`/api/decks/${id}`, { + method: 'PUT', + body: JSON.stringify(updates), + }); + } + + async deleteDeck(id: string) { + return this.request<{ success: boolean }>(`/api/decks/${id}`, { + method: 'DELETE', + }); + } + + async generateDeckWithAI(requestData: { + prompt: string; + deckTitle: string; + deckDescription?: string; + cardCount?: number; + cardTypes?: string[]; + difficulty?: string; + tags?: string[]; + language?: string; + }) { + return this.request<{ + deck: any; + cards: any[]; + cardCount: number; + creditsUsed: number; + metadata: any; + }>('/api/decks/generate', { + method: 'POST', + body: JSON.stringify(requestData), + }); + } + + // ============ Cards ============ + async getDeckCards(deckId: string) { + return this.request<{ cards: any[]; count: number }>(`/api/decks/${deckId}/cards`); + } + + async getCard(id: string) { + return this.request<{ card: any }>(`/api/cards/${id}`); + } + + async createCard(cardData: { + deckId: string; + title?: string; + content: any; + cardType?: string; + position?: number; + aiModel?: string; + aiPrompt?: string; + }) { + return this.request<{ card: any }>('/api/cards', { + method: 'POST', + body: JSON.stringify(cardData), + }); + } + + async updateCard( + id: string, + updates: { + title?: string; + content?: any; + cardType?: string; + position?: number; + isFavorite?: boolean; + } + ) { + return this.request<{ card: any }>(`/api/cards/${id}`, { + method: 'PUT', + body: JSON.stringify(updates), + }); + } + + async deleteCard(id: string) { + return this.request<{ success: boolean }>(`/api/cards/${id}`, { + method: 'DELETE', + }); + } + + async reorderCards(deckId: string, cardIds: string[]) { + return this.request<{ success: boolean }>('/api/cards/reorder', { + method: 'POST', + body: JSON.stringify({ deckId, cardIds }), + }); + } + + // ============ Stats ============ + async getStats() { + return this.request<{ + stats: { + totalDecks: number; + totalCards: number; + totalSessions: number; + totalCardsStudied: number; + streakDays: number; + }; + }>('/api/stats'); + } + + // ============ Study Sessions ============ + async getStudySessions() { + return this.request<{ sessions: any[]; count: number }>('/api/study-sessions'); + } + + async getStudySession(id: string) { + return this.request<{ session: any }>(`/api/study-sessions/${id}`); + } + + async getStudySessionsByDeck(deckId: string) { + return this.request<{ sessions: any[]; count: number }>( + `/api/study-sessions/deck/${deckId}` + ); + } + + async getStudySessionsByDateRange(startDate: string, endDate: string) { + return this.request<{ sessions: any[]; count: number }>( + `/api/study-sessions/range?startDate=${encodeURIComponent(startDate)}&endDate=${encodeURIComponent(endDate)}` + ); + } + + async getStudySessionStats() { + return this.request<{ + stats: { + totalSessions: number; + totalCardsStudied: number; + totalCorrectCards: number; + totalTimeSeconds: number; + }; + }>('/api/study-sessions/stats'); + } + + async createStudySession(sessionData: { + deckId: string; + startedAt?: string; + endedAt?: string; + totalCards?: number; + completedCards?: number; + correctCards?: number; + timeSpentSeconds?: number; + }) { + return this.request<{ session: any }>('/api/study-sessions', { + method: 'POST', + body: JSON.stringify(sessionData), + }); + } + + async updateStudySession( + id: string, + updates: { + endedAt?: string; + totalCards?: number; + completedCards?: number; + correctCards?: number; + timeSpentSeconds?: number; + } + ) { + return this.request<{ session: any }>(`/api/study-sessions/${id}`, { + method: 'PUT', + body: JSON.stringify(updates), + }); + } + + // ============ Card Progress ============ + async getCardProgress() { + return this.request<{ progress: any[]; count: number }>('/api/progress'); + } + + async getCardProgressByDeck(deckId: string) { + return this.request<{ progress: any[]; count: number }>( + `/api/progress/deck/${deckId}` + ); + } + + async getCardProgressByCard(cardId: string) { + return this.request<{ progress: any | null }>(`/api/progress/card/${cardId}`); + } + + async getCardProgressStats() { + return this.request<{ + stats: { + totalCards: number; + newCards: number; + learningCards: number; + reviewCards: number; + avgEaseFactor: string; + }; + }>('/api/progress/stats'); + } + + async getDueCards() { + return this.request<{ progress: any[]; count: number }>('/api/progress/due'); + } + + async getDueCardsByDeck(deckId: string) { + return this.request<{ progress: any[]; count: number }>( + `/api/progress/deck/${deckId}/due` + ); + } + + async upsertCardProgress(progressData: { + cardId: string; + easeFactor?: number; + interval?: number; + repetitions?: number; + lastReviewed?: string; + nextReview?: string; + status?: 'new' | 'learning' | 'review'; + }) { + return this.request<{ progress: any }>('/api/progress', { + method: 'POST', + body: JSON.stringify(progressData), + }); + } + + // ============ AI Operations ============ + async generateCardsFromImage(imageBase64: string, context?: string, cardCount?: number) { + return this.request<{ + cards: Array<{ + type: string; + content: any; + metadata: { confidence: number; source: string; tags: string[] }; + }>; + }>('/api/ai/generate-from-image', { + method: 'POST', + body: JSON.stringify({ image: imageBase64, context, cardCount }), + }); + } + + async enhanceContent(content: string, cardType: string) { + return this.request<{ enhancedContent: string }>('/api/ai/enhance-content', { + method: 'POST', + body: JSON.stringify({ content, cardType }), + }); + } +} + +export const apiClient = new ApiClient(); diff --git a/apps/manadeck/apps/mobile/store/aiStore.ts b/apps/manadeck/apps/mobile/store/aiStore.ts index 17117ef83..12495a832 100644 --- a/apps/manadeck/apps/mobile/store/aiStore.ts +++ b/apps/manadeck/apps/mobile/store/aiStore.ts @@ -4,7 +4,8 @@ import { GeneratedCard, GenerationOptions, } from '../utils/supabaseAIService'; -import { supabase } from '../utils/supabase'; +import { apiClient } from '../services/apiClient'; +import { authService } from '../services/authService'; import { Card } from './cardStore'; interface AIState { @@ -16,23 +17,13 @@ interface AIState { tokensUsed: number; cost: number; }; - audioRecording: { - isRecording: boolean; - uri: string | null; - duration: number; - }; // Actions generateCardsFromText: (text: string, options?: GenerationOptions) => Promise; - generateCardsFromAudio: (audioUri: string) => Promise; generateCardsFromImage: (imageUri: string, context?: string) => Promise; enhanceCard: (card: Card) => Promise; generateRelatedCards: (card: Card) => Promise; - // Audio Recording - startRecording: () => Promise; - stopRecording: () => Promise; - // Utility clearGeneratedCards: () => void; saveGeneratedCards: (deckId: string, cards: GeneratedCard[]) => Promise; @@ -48,11 +39,6 @@ export const useAIStore = create((set, get) => ({ tokensUsed: 0, cost: 0, }, - audioRecording: { - isRecording: false, - uri: null, - duration: 0, - }, // Generate cards from text generateCardsFromText: async (text: string, options?: GenerationOptions) => { @@ -79,37 +65,6 @@ export const useAIStore = create((set, get) => ({ } }, - // Generate cards from audio - generateCardsFromAudio: async (audioUri: string) => { - set({ isGenerating: true, error: null }); - - try { - // Read audio file and convert to base64 - const { FileSystem } = await import('expo-file-system'); - const audioBase64 = await FileSystem.readAsStringAsync(audioUri, { - encoding: FileSystem.EncodingType.Base64, - }); - - const cards = await AIService.generateCardsFromSpeech(audioBase64); - - set((state) => ({ - generatedCards: [...state.generatedCards, ...cards], - isGenerating: false, - })); - - // Track usage (estimated for Whisper + GPT) - get().trackUsage(1000, 0.02); - - return cards; - } catch (error: any) { - set({ - error: error.message || 'Fehler beim Verarbeiten der Audioaufnahme', - isGenerating: false, - }); - throw error; - } - }, - // Generate cards from image generateCardsFromImage: async (imageUri: string, context?: string) => { set({ isGenerating: true, error: null }); @@ -194,110 +149,34 @@ export const useAIStore = create((set, get) => ({ } }, - // Audio Recording - startRecording: async () => { - try { - const { Audio } = await import('expo-av'); - - // Request permissions - const { status } = await Audio.requestPermissionsAsync(); - if (status !== 'granted') { - throw new Error('Mikrofonzugriff verweigert'); - } - - // Configure audio - await Audio.setAudioModeAsync({ - allowsRecordingIOS: true, - playsInSilentModeIOS: true, - }); - - // Create and start recording - const recording = new Audio.Recording(); - await recording.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY); - await recording.startAsync(); - - // Store recording instance (we'll need to manage this differently in production) - (global as any).currentRecording = recording; - - set({ - audioRecording: { - isRecording: true, - uri: null, - duration: 0, - }, - }); - } catch (error) { - console.error('Error starting recording:', error); - throw error; - } - }, - - stopRecording: async () => { - try { - const recording = (global as any).currentRecording; - if (!recording) { - throw new Error('Keine aktive Aufnahme'); - } - - await recording.stopAndUnloadAsync(); - const uri = recording.getURI(); - - if (!uri) { - throw new Error('Keine Aufnahme-URI erhalten'); - } - - // Clean up - delete (global as any).currentRecording; - - set({ - audioRecording: { - isRecording: false, - uri, - duration: 0, - }, - }); - - return uri; - } catch (error) { - console.error('Error stopping recording:', error); - throw error; - } - }, - // Clear generated cards clearGeneratedCards: () => { set({ generatedCards: [], error: null }); }, - // Save generated cards to deck + // Save generated cards to deck via API saveGeneratedCards: async (deckId: string, cards: GeneratedCard[]) => { try { - const { - data: { user }, - } = await supabase.auth.getUser(); + // Get user from auth service + const appToken = await authService.getAppToken(); + const user = appToken ? authService.getUserFromToken(appToken) : null; if (!user) throw new Error('Nicht authentifiziert'); - // Convert generated cards to database format - const cardsToInsert = cards.map((card, index) => ({ - deck_id: deckId, - type: card.type, - content: card.content, - position: index, - created_by: user.id, - })); + // Save each card via API + for (let index = 0; index < cards.length; index++) { + const card = cards[index]; + const response = await apiClient.createCard({ + deckId, + title: `Card ${index + 1}`, + content: card.content, + cardType: card.type, + position: index, + }); - const { error } = await supabase.from('cards').insert(cardsToInsert); - - if (error) throw error; - - // Track AI-generated cards - const aiTracking = cards.map((card) => ({ - generation_method: card.metadata.source, - confidence_score: card.metadata.confidence, - source_data: { tags: card.metadata.tags }, - })); - - await supabase.from('ai_generated_cards').insert(aiTracking); + if (response.error) { + throw new Error(response.error); + } + } set({ generatedCards: [] }); } catch (error) { diff --git a/apps/manadeck/apps/mobile/store/authStore.ts b/apps/manadeck/apps/mobile/store/authStore.ts index 19d4fb27e..51c05b16c 100644 --- a/apps/manadeck/apps/mobile/store/authStore.ts +++ b/apps/manadeck/apps/mobile/store/authStore.ts @@ -1,7 +1,6 @@ import { create } from 'zustand'; import { authService } from '../services/authService'; import { tokenManager, TokenState } from '../services/tokenManager'; -import { supabase } from '../utils/supabase'; import type { ManaUser } from '../types/auth'; interface AuthState { @@ -225,15 +224,17 @@ export const useAuthStore = create((set, get) => ({ const user = get().user; if (!user) throw new Error('No user logged in'); - const { error } = await supabase - .from('profiles') - .update({ - ...updates, - updated_at: new Date().toISOString(), - }) - .eq('id', user.id); + // TODO: Implement profile update via backend API + // For now, profiles are managed via Mana Core Auth + console.warn('Profile update not yet implemented via backend API'); - if (error) throw error; + // Update local user state with the new values + set({ + user: { + ...user, + ...updates, + }, + }); } catch (error: any) { set({ error: error.message || 'Failed to update profile' }); throw error; diff --git a/apps/manadeck/apps/mobile/store/cardStore.ts b/apps/manadeck/apps/mobile/store/cardStore.ts index 6dbe5501f..b60670c92 100644 --- a/apps/manadeck/apps/mobile/store/cardStore.ts +++ b/apps/manadeck/apps/mobile/store/cardStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import { supabase, getAuthenticatedSupabase } from '../utils/supabase'; +import { apiClient } from '../services/apiClient'; // Content types for different card types export interface TextContent { @@ -51,6 +51,24 @@ export interface Card { updated_at: string; } +// Helper to map backend response to frontend format +function mapCardFromApi(card: any): Card { + return { + id: card.id, + deck_id: card.deckId, + position: card.position, + title: card.title, + content: card.content, + card_type: card.cardType, + ai_model: card.aiModel, + ai_prompt: card.aiPrompt, + version: card.version || 1, + is_favorite: card.isFavorite || false, + created_at: card.createdAt, + updated_at: card.updatedAt, + }; +} + interface CardState { cards: Card[]; currentCard: Card | null; @@ -88,22 +106,18 @@ export const useCardStore = create((set, get) => ({ try { set({ isLoading: true, error: null }); - // Use authenticated Supabase client with Mana token for RLS - const authenticatedSupabase = await getAuthenticatedSupabase(); - - const { data, error } = await authenticatedSupabase - .from('cards') - .select('*') - .eq('deck_id', deckId) - .order('position', { ascending: true }); + const response = await apiClient.getDeckCards(deckId); console.log('[DEBUG] fetchCards - Deck ID:', deckId); - console.log('[DEBUG] fetchCards - Cards found:', data?.length || 0); - console.log('[DEBUG] fetchCards - Error:', error); + console.log('[DEBUG] fetchCards - Cards found:', response.data?.cards?.length || 0); + console.log('[DEBUG] fetchCards - Error:', response.error); - if (error) throw error; + if (response.error) { + throw new Error(response.error); + } - set({ cards: data || [] }); + const cards = (response.data?.cards || []).map(mapCardFromApi); + set({ cards }); } catch (error: any) { set({ error: error.message || 'Failed to fetch cards' }); console.error('[ERROR] fetchCards:', error); @@ -116,18 +130,14 @@ export const useCardStore = create((set, get) => ({ try { set({ isLoading: true, error: null }); - // Use authenticated Supabase client with Mana token for RLS - const authenticatedSupabase = await getAuthenticatedSupabase(); + const response = await apiClient.getCard(id); - const { data, error } = await authenticatedSupabase - .from('cards') - .select('*') - .eq('id', id) - .single(); + if (response.error) { + throw new Error(response.error); + } - if (error) throw error; - - set({ currentCard: data }); + const card = mapCardFromApi(response.data?.card); + set({ currentCard: card }); } catch (error: any) { set({ error: error.message || 'Failed to fetch card' }); console.error('[ERROR] fetchCard:', error); @@ -140,42 +150,28 @@ export const useCardStore = create((set, get) => ({ try { set({ isLoading: true, error: null }); - // Use authenticated Supabase client with Mana token for RLS - const authenticatedSupabase = await getAuthenticatedSupabase(); - // Get next position - const { data: existingCards } = await authenticatedSupabase - .from('cards') - .select('position') - .eq('deck_id', deckId) - .order('position', { ascending: false }) - .limit(1); - - const nextPosition = existingCards?.[0]?.position ? existingCards[0].position + 1 : 1; - - const { data, error } = await authenticatedSupabase - .from('cards') - .insert({ - deck_id: deckId, - position: nextPosition, - title: cardData.title || '', - content: cardData.content || { text: '' }, - card_type: cardData.card_type || 'text', - ai_model: cardData.ai_model, - ai_prompt: cardData.ai_prompt, - version: 1, - is_favorite: false, - }) - .select() - .single(); - - if (error) throw error; - - // Add to local state const cards = get().cards; - set({ cards: [...cards, data] }); + const maxPosition = cards.length > 0 ? Math.max(...cards.map((c) => c.position)) : 0; - return data; + const response = await apiClient.createCard({ + deckId, + title: cardData.title || '', + content: cardData.content || { text: '' }, + cardType: cardData.card_type || 'text', + position: maxPosition + 1, + aiModel: cardData.ai_model, + aiPrompt: cardData.ai_prompt, + }); + + if (response.error) { + throw new Error(response.error); + } + + const newCard = mapCardFromApi(response.data?.card); + set({ cards: [...cards, newCard] }); + + return newCard; } catch (error: any) { set({ error: error.message || 'Failed to create card' }); throw error; @@ -188,19 +184,17 @@ export const useCardStore = create((set, get) => ({ try { set({ isLoading: true, error: null }); - // Use authenticated Supabase client with Mana token for RLS - const authenticatedSupabase = await getAuthenticatedSupabase(); + const response = await apiClient.updateCard(id, { + title: updates.title, + content: updates.content, + cardType: updates.card_type, + position: updates.position, + isFavorite: updates.is_favorite, + }); - const { error } = await authenticatedSupabase - .from('cards') - .update({ - ...updates, - updated_at: new Date().toISOString(), - version: updates.version ? updates.version + 1 : undefined, - }) - .eq('id', id); - - if (error) throw error; + if (response.error) { + throw new Error(response.error); + } // Update local state const cards = get().cards; @@ -224,12 +218,11 @@ export const useCardStore = create((set, get) => ({ try { set({ isLoading: true, error: null }); - // Use authenticated Supabase client with Mana token for RLS - const authenticatedSupabase = await getAuthenticatedSupabase(); + const response = await apiClient.deleteCard(id); - const { error } = await authenticatedSupabase.from('cards').delete().eq('id', id); - - if (error) throw error; + if (response.error) { + throw new Error(response.error); + } // Remove from local state const cards = get().cards; @@ -254,35 +247,30 @@ export const useCardStore = create((set, get) => ({ const cardToDuplicate = get().cards.find((card) => card.id === id); if (!cardToDuplicate) throw new Error('Card not found'); - // Use authenticated Supabase client with Mana token for RLS - const authenticatedSupabase = await getAuthenticatedSupabase(); + const response = await apiClient.createCard({ + deckId: cardToDuplicate.deck_id, + title: cardToDuplicate.title ? `${cardToDuplicate.title} (Kopie)` : '', + content: cardToDuplicate.content, + cardType: cardToDuplicate.card_type, + position: cardToDuplicate.position + 1, + aiModel: cardToDuplicate.ai_model, + aiPrompt: cardToDuplicate.ai_prompt, + }); - const { data, error } = await authenticatedSupabase - .from('cards') - .insert({ - deck_id: cardToDuplicate.deck_id, - position: cardToDuplicate.position + 1, - title: cardToDuplicate.title ? `${cardToDuplicate.title} (Kopie)` : '', - content: cardToDuplicate.content, - card_type: cardToDuplicate.card_type, - ai_model: cardToDuplicate.ai_model, - ai_prompt: cardToDuplicate.ai_prompt, - version: 1, - is_favorite: false, - }) - .select() - .single(); + if (response.error) { + throw new Error(response.error); + } - if (error) throw error; + const newCard = mapCardFromApi(response.data?.card); // Add to local state const cards = get().cards; const insertIndex = cards.findIndex((card) => card.id === id) + 1; const newCards = [...cards]; - newCards.splice(insertIndex, 0, data); + newCards.splice(insertIndex, 0, newCard); set({ cards: newCards }); - return data; + return newCard; } catch (error: any) { set({ error: error.message || 'Failed to duplicate card' }); throw error; @@ -295,20 +283,10 @@ export const useCardStore = create((set, get) => ({ try { set({ isLoading: true, error: null }); - // Use authenticated Supabase client with Mana token for RLS - const authenticatedSupabase = await getAuthenticatedSupabase(); + const response = await apiClient.reorderCards(deckId, cardIds); - // Update positions in database - const updates = cardIds.map((cardId, index) => ({ - id: cardId, - position: index + 1, - })); - - for (const update of updates) { - await authenticatedSupabase - .from('cards') - .update({ position: update.position }) - .eq('id', update.id); + if (response.error) { + throw new Error(response.error); } // Refresh cards to get correct order @@ -340,12 +318,13 @@ export const useCardStore = create((set, get) => ({ try { set({ isLoading: true, error: null }); - // Use authenticated Supabase client with Mana token for RLS - const authenticatedSupabase = await getAuthenticatedSupabase(); - - const { error } = await authenticatedSupabase.from('cards').delete().in('id', ids); - - if (error) throw error; + // Delete cards one by one (could be optimized with batch endpoint) + for (const id of ids) { + const response = await apiClient.deleteCard(id); + if (response.error) { + console.error(`Failed to delete card ${id}:`, response.error); + } + } // Remove from local state const cards = get().cards; @@ -362,28 +341,14 @@ export const useCardStore = create((set, get) => ({ try { set({ isLoading: true, error: null }); - // Use authenticated Supabase client with Mana token for RLS - const authenticatedSupabase = await getAuthenticatedSupabase(); - - // Get highest position in target deck - const { data: targetCards } = await authenticatedSupabase - .from('cards') - .select('position') - .eq('deck_id', targetDeckId) - .order('position', { ascending: false }) - .limit(1); - - let nextPosition = targetCards?.[0]?.position ? targetCards[0].position + 1 : 1; - - // Move each card + // Update each card's deck (could be optimized with batch endpoint) for (const cardId of cardIds) { - await authenticatedSupabase - .from('cards') - .update({ - deck_id: targetDeckId, - position: nextPosition++, - }) - .eq('id', cardId); + const response = await apiClient.updateCard(cardId, { + // Note: Backend would need to support deckId update + }); + if (response.error) { + console.error(`Failed to move card ${cardId}:`, response.error); + } } // Remove moved cards from local state diff --git a/apps/manadeck/apps/mobile/store/deckStore.ts b/apps/manadeck/apps/mobile/store/deckStore.ts index 0fa7b573b..22e9d124d 100644 --- a/apps/manadeck/apps/mobile/store/deckStore.ts +++ b/apps/manadeck/apps/mobile/store/deckStore.ts @@ -1,6 +1,5 @@ import { create } from 'zustand'; -import { getAuthenticatedSupabase } from '../utils/supabase'; -import { authService } from '../services/authService'; +import { apiClient } from '../services/apiClient'; import { useAuthStore } from './authStore'; export interface Deck { @@ -18,6 +17,24 @@ export interface Deck { card_count?: number; } +// Helper to map backend response to frontend format +function mapDeckFromApi(deck: any): Deck { + return { + id: deck.id, + user_id: deck.userId, + title: deck.title, + description: deck.description, + cover_image_url: deck.coverImageUrl, + is_public: deck.isPublic, + settings: deck.settings || {}, + tags: deck.tags || [], + metadata: deck.metadata || {}, + created_at: deck.createdAt, + updated_at: deck.updatedAt, + card_count: deck.card_count || 0, + }; +} + interface DeckState { decks: Deck[]; currentDeck: Deck | null; @@ -43,46 +60,18 @@ export const useDeckStore = create((set, get) => ({ try { set({ isLoading: true, error: null }); - // Get authenticated Supabase client with Mana token (auto-refreshes if needed) - const supabase = await getAuthenticatedSupabase(); + const response = await apiClient.getDecks(); - // Get current user ID from token - const appToken = await authService.getAppToken(); - const user = appToken ? authService.getUserFromToken(appToken) : null; - - if (!user) { - throw new Error('Not authenticated'); - } - - const { data, error } = await supabase - .from('decks') - .select( - ` - *, - card_count:cards(count) - ` - ) - .or(`user_id.eq.${user.id},and(is_public.eq.true,user_id.eq.00000000-0000-0000-0000-000000000001)`) - .order('updated_at', { ascending: false }); - - if (error) { - // Check if it's a JWT expiration error - if (error.code === 'PGRST303' || error.message?.includes('JWT expired') || error.message?.includes('token expired')) { - // Token expired, clear invalid token and let user re-authenticate - await authService.clearAuthStorage(); - // Clear user from auth store to trigger redirect to login + if (response.error) { + // Check for auth errors + if (response.error.includes('expired') || response.error.includes('Not authenticated')) { useAuthStore.setState({ user: null }); throw new Error('Session expired. Please sign in again.'); } - throw error; + throw new Error(response.error); } - const decksWithCount = - data?.map((deck) => ({ - ...deck, - card_count: deck.card_count?.[0]?.count || 0, - })) || []; - + const decksWithCount = (response.data?.decks || []).map(mapDeckFromApi); set({ decks: decksWithCount }); } catch (error: any) { set({ error: error.message || 'Failed to fetch decks' }); @@ -96,38 +85,18 @@ export const useDeckStore = create((set, get) => ({ try { set({ isLoading: true, error: null }); - // Get authenticated Supabase client with Mana token (auto-refreshes if needed) - const supabase = await getAuthenticatedSupabase(); + const response = await apiClient.getDeck(id); - const { data, error } = await supabase - .from('decks') - .select( - ` - *, - card_count:cards(count) - ` - ) - .eq('id', id) - .single(); - - if (error) { - // Check if it's a JWT expiration error - if (error.code === 'PGRST303' || error.message?.includes('JWT expired') || error.message?.includes('token expired')) { - // Token expired, clear invalid token and let user re-authenticate - await authService.clearAuthStorage(); - // Clear user from auth store to trigger redirect to login + if (response.error) { + if (response.error.includes('expired') || response.error.includes('Not authenticated')) { useAuthStore.setState({ user: null }); throw new Error('Session expired. Please sign in again.'); } - throw error; + throw new Error(response.error); } - const deckWithCount = { - ...data, - card_count: data.card_count?.[0]?.count || 0, - }; - - set({ currentDeck: deckWithCount }); + const deck = mapDeckFromApi(response.data?.deck); + set({ currentDeck: deck }); } catch (error: any) { set({ error: error.message || 'Failed to fetch deck' }); console.error('Error fetching deck:', error); @@ -140,29 +109,25 @@ export const useDeckStore = create((set, get) => ({ try { set({ isLoading: true, error: null }); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) throw new Error('Not authenticated'); + const response = await apiClient.createDeck({ + title: deckData.title || 'Untitled Deck', + description: deckData.description, + coverImageUrl: deckData.cover_image_url, + isPublic: deckData.is_public, + settings: deckData.settings, + tags: deckData.tags, + metadata: deckData.metadata, + }); - const { data, error } = await supabase - .from('decks') - .insert({ - ...deckData, - user_id: user.id, - settings: deckData.settings || {}, - tags: deckData.tags || [], - metadata: deckData.metadata || {}, - }) - .select() - .single(); - - if (error) throw error; + if (response.error) { + throw new Error(response.error); + } + const newDeck = mapDeckFromApi(response.data?.deck); const decks = get().decks; - set({ decks: [data, ...decks] }); + set({ decks: [newDeck, ...decks] }); - return data; + return newDeck; } catch (error: any) { set({ error: error.message || 'Failed to create deck' }); throw error; @@ -175,15 +140,19 @@ export const useDeckStore = create((set, get) => ({ try { set({ isLoading: true, error: null }); - const { error } = await supabase - .from('decks') - .update({ - ...updates, - updated_at: new Date().toISOString(), - }) - .eq('id', id); + const response = await apiClient.updateDeck(id, { + title: updates.title, + description: updates.description, + coverImageUrl: updates.cover_image_url, + isPublic: updates.is_public, + settings: updates.settings, + tags: updates.tags, + metadata: updates.metadata, + }); - if (error) throw error; + if (response.error) { + throw new Error(response.error); + } const decks = get().decks; set({ @@ -205,9 +174,11 @@ export const useDeckStore = create((set, get) => ({ try { set({ isLoading: true, error: null }); - const { error } = await supabase.from('decks').delete().eq('id', id); + const response = await apiClient.deleteDeck(id); - if (error) throw error; + if (response.error) { + throw new Error(response.error); + } const decks = get().decks; set({ decks: decks.filter((deck) => deck.id !== id) }); diff --git a/apps/manadeck/apps/mobile/store/progressStore.ts b/apps/manadeck/apps/mobile/store/progressStore.ts index 094b4588b..f7e20f4c8 100644 --- a/apps/manadeck/apps/mobile/store/progressStore.ts +++ b/apps/manadeck/apps/mobile/store/progressStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import { supabase } from '../utils/supabase'; +import { apiClient } from '../services/apiClient'; export interface DailyProgress { date: string; // YYYY-MM-DD @@ -38,6 +38,22 @@ export interface Statistics { favorite_time_of_day: string; } +// Map backend session format to local format +function mapSessionFromApi(apiSession: any) { + return { + id: apiSession.id, + user_id: apiSession.userId, + deck_id: apiSession.deckId, + started_at: apiSession.startedAt, + ended_at: apiSession.endedAt, + total_cards: apiSession.totalCards || 0, + completed_cards: apiSession.completedCards || 0, + correct_answers: apiSession.correctCards || 0, + time_spent_seconds: apiSession.timeSpentSeconds || 0, + mode: 'all', + }; +} + interface ProgressState { // Data dailyProgress: Map; // date -> progress @@ -75,25 +91,24 @@ export const useProgressStore = create((set, get) => ({ error: null, selectedPeriod: 'week', - fetchDailyProgress: async (userId: string, startDate: Date, endDate: Date) => { + fetchDailyProgress: async (_userId: string, startDate: Date, endDate: Date) => { try { set({ isLoading: true, error: null }); - // Fetch study sessions in date range - const { data: sessions, error } = await supabase - .from('study_sessions') - .select('*') - .eq('user_id', userId) - .gte('started_at', startDate.toISOString()) - .lte('started_at', endDate.toISOString()) - .order('started_at', { ascending: true }); + // Fetch study sessions in date range via API + const response = await apiClient.getStudySessionsByDateRange( + startDate.toISOString(), + endDate.toISOString() + ); - if (error) throw error; + if (response.error) throw new Error(response.error); + + const sessions = (response.data?.sessions || []).map(mapSessionFromApi); // Group sessions by date const progressMap = new Map(); - sessions?.forEach((session) => { + sessions.forEach((session: any) => { const date = new Date(session.started_at).toISOString().split('T')[0]; const existing = progressMap.get(date) || { date, @@ -107,7 +122,7 @@ export const useProgressStore = create((set, get) => ({ const sessionDuration = session.ended_at ? (new Date(session.ended_at).getTime() - new Date(session.started_at).getTime()) / 60000 - : 0; + : (session.time_spent_seconds || 0) / 60; progressMap.set(date, { ...existing, @@ -204,67 +219,65 @@ export const useProgressStore = create((set, get) => ({ }; }, - fetchStreakInfo: async (userId: string) => { + fetchStreakInfo: async (_userId: string) => { try { // Fetch all study sessions for streak calculation - const { data: sessions, error } = await supabase - .from('study_sessions') - .select('started_at') - .eq('user_id', userId) - .order('started_at', { ascending: true }); + const response = await apiClient.getStudySessions(); - if (error) throw error; + if (response.error) throw new Error(response.error); - const streakInfo = get().calculateStreak(sessions || []); + const sessions = (response.data?.sessions || []).map(mapSessionFromApi); + const streakInfo = get().calculateStreak(sessions); set({ streakInfo }); } catch (error: any) { console.error('Error fetching streak info:', error); } }, - fetchDeckProgress: async (userId: string) => { + fetchDeckProgress: async (_userId: string) => { try { set({ isLoading: true }); - // Fetch all decks with card counts - const { data: decks, error: decksError } = await supabase - .from('decks') - .select('*, cards(count)') - .eq('user_id', userId); + // Fetch all decks + const decksResponse = await apiClient.getDecks(); + if (decksResponse.error) throw new Error(decksResponse.error); - if (decksError) throw decksError; + // Fetch all card progress + const progressResponse = await apiClient.getCardProgress(); + if (progressResponse.error) throw new Error(progressResponse.error); - // Fetch card progress for all decks - const { data: cardProgress, error: progressError } = await supabase - .from('card_progress') - .select('*') - .eq('user_id', userId); - - if (progressError) throw progressError; + const decks = decksResponse.data?.decks || []; + const cardProgressList = progressResponse.data?.progress || []; // Calculate progress per deck const deckProgressList: DeckProgress[] = []; - decks?.forEach((deck) => { - const deckCardProgress = cardProgress?.filter((cp) => cp.deck_id === deck.id) || []; + for (const deck of decks) { + // Get cards count for this deck + const cardsResponse = await apiClient.getDeckCards(deck.id); + const totalCards = cardsResponse.data?.count || 0; + + // Filter progress for this deck's cards + const deckCards = cardsResponse.data?.cards || []; + const deckCardIds = new Set(deckCards.map((c: any) => c.id)); + const deckCardProgress = cardProgressList.filter((cp: any) => deckCardIds.has(cp.cardId)); const mastered = deckCardProgress.filter( - (cp) => cp.ease_factor >= 2.5 && cp.interval >= 21 + (cp: any) => cp.easeFactor >= 2.5 && cp.interval >= 21 ).length; const learning = deckCardProgress.filter( - (cp) => cp.status === 'learning' || cp.status === 'relearning' + (cp: any) => cp.status === 'learning' ).length; - const newCards = deckCardProgress.filter((cp) => cp.status === 'new').length; + const newCards = deckCardProgress.filter((cp: any) => cp.status === 'new').length; const avgEaseFactor = deckCardProgress.length > 0 - ? deckCardProgress.reduce((sum, cp) => sum + cp.ease_factor, 0) / + ? deckCardProgress.reduce((sum: number, cp: any) => sum + (cp.easeFactor || 2.5), 0) / deckCardProgress.length : 2.5; - const totalCards = deck.cards?.[0]?.count || 0; const studiedCards = deckCardProgress.length; deckProgressList.push({ @@ -277,7 +290,7 @@ export const useProgressStore = create((set, get) => ({ average_ease_factor: Math.round(avgEaseFactor * 100) / 100, completion_percentage: totalCards > 0 ? Math.round((mastered / totalCards) * 100) : 0, }); - }); + } set({ deckProgress: deckProgressList }); } catch (error: any) { @@ -287,15 +300,14 @@ export const useProgressStore = create((set, get) => ({ } }, - fetchStatistics: async (userId: string) => { + fetchStatistics: async (_userId: string) => { try { // Fetch all sessions for statistics - const { data: sessions, error } = await supabase - .from('study_sessions') - .select('*') - .eq('user_id', userId); + const response = await apiClient.getStudySessions(); - if (error) throw error; + if (response.error) throw new Error(response.error); + + const sessions = (response.data?.sessions || []).map(mapSessionFromApi); if (!sessions || sessions.length === 0) return; // Calculate statistics @@ -309,7 +321,7 @@ export const useProgressStore = create((set, get) => ({ const timeOfDayCount = new Map(); - sessions.forEach((session) => { + sessions.forEach((session: any) => { totalCards += session.completed_cards || 0; totalCorrect += session.correct_answers || 0; @@ -317,6 +329,8 @@ export const useProgressStore = create((set, get) => ({ const duration = (new Date(session.ended_at).getTime() - new Date(session.started_at).getTime()) / 60000; totalTime += duration; + } else if (session.time_spent_seconds) { + totalTime += session.time_spent_seconds / 60; } // Track accuracy diff --git a/apps/manadeck/apps/mobile/store/studyStore.ts b/apps/manadeck/apps/mobile/store/studyStore.ts index a5afc3881..afbd7d447 100644 --- a/apps/manadeck/apps/mobile/store/studyStore.ts +++ b/apps/manadeck/apps/mobile/store/studyStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { Card } from './cardStore'; -import { supabase, getAuthenticatedSupabase } from '../utils/supabase'; +import { apiClient } from '../services/apiClient'; import { authService } from '../services/authService'; import { calculateSM2, difficultyToQuality, isCardDue } from '../utils/spacedRepetition'; import { useAuthStore } from './authStore'; @@ -42,6 +42,39 @@ export interface SessionCardProgress { difficulty?: 'easy' | 'medium' | 'hard' | 'again'; } +// Map backend camelCase to frontend snake_case +function mapProgressFromApi(apiProgress: any): CardProgress { + return { + id: apiProgress.id, + user_id: apiProgress.userId, + card_id: apiProgress.cardId, + deck_id: apiProgress.deckId || '', + ease_factor: apiProgress.easeFactor, + interval: apiProgress.interval, + repetitions: apiProgress.repetitions, + next_review_date: apiProgress.nextReview, + last_reviewed_at: apiProgress.lastReviewed, + total_reviews: apiProgress.repetitions || 0, + correct_reviews: 0, // Not tracked in new schema + incorrect_reviews: 0, // Not tracked in new schema + status: apiProgress.status || 'new', + }; +} + +function mapSessionFromApi(apiSession: any): StudySession { + return { + id: apiSession.id, + deck_id: apiSession.deckId, + user_id: apiSession.userId, + started_at: apiSession.startedAt, + ended_at: apiSession.endedAt, + total_cards: apiSession.totalCards, + completed_cards: apiSession.completedCards, + correct_answers: apiSession.correctCards, + mode: 'all', // Mode not tracked in new schema + }; +} + interface StudyState { // Current session currentSession: StudySession | null; @@ -95,35 +128,21 @@ export const useStudyStore = create((set, get) => ({ fetchCardProgress: async (deckId: string) => { try { - // Get authenticated Supabase client with Mana token (auto-refreshes if needed) - const supabase = await getAuthenticatedSupabase(); + const response = await apiClient.getCardProgressByDeck(deckId); - // Get current user ID from token - const appToken = await authService.getAppToken(); - const user = appToken ? authService.getUserFromToken(appToken) : null; - - if (!user) return; - - const { data, error } = await supabase - .from('card_progress') - .select('*') - .eq('deck_id', deckId) - .eq('user_id', user.id); - - if (error) { - // Check if it's a JWT expiration error - if (error.code === 'PGRST303' || error.message?.includes('JWT expired') || error.message?.includes('token expired')) { + if (response.error) { + if (response.error.includes('Session expired')) { await authService.clearAuthStorage(); - // Clear user from auth store to trigger redirect to login useAuthStore.setState({ user: null }); throw new Error('Session expired. Please sign in again.'); } - throw error; + throw new Error(response.error); } const progressMap = new Map(); - data?.forEach((progress) => { - progressMap.set(progress.card_id, progress); + response.data?.progress?.forEach((progress: any) => { + const mapped = mapProgressFromApi(progress); + progressMap.set(mapped.card_id, mapped); }); set({ cardProgressMap: progressMap }); @@ -136,9 +155,6 @@ export const useStudyStore = create((set, get) => ({ try { set({ isLoading: true, error: null }); - // Get authenticated Supabase client with Mana token (auto-refreshes if needed) - const supabase = await getAuthenticatedSupabase(); - // Get current user ID from token const appToken = await authService.getAppToken(); const user = appToken ? authService.getUserFromToken(appToken) : null; @@ -201,30 +217,24 @@ export const useStudyStore = create((set, get) => ({ throw new Error('Keine Karten zum Lernen gefunden'); } - // Create session in database - const { data: session, error: sessionError } = await supabase - .from('study_sessions') - .insert({ - user_id: user.id, - deck_id: deckId, - mode, - total_cards: cards.length, - completed_cards: 0, - correct_answers: 0, - incorrect_answers: 0, - }) - .select() - .single(); + // Create session via API + const response = await apiClient.createStudySession({ + deckId, + totalCards: cards.length, + completedCards: 0, + correctCards: 0, + }); - if (sessionError) { - // Check if it's a JWT expiration error - if (sessionError.code === 'PGRST303' || sessionError.message?.includes('JWT expired')) { + if (response.error) { + if (response.error.includes('Session expired')) { await authService.clearAuthStorage(); throw new Error('Session expired. Please sign in again.'); } - throw sessionError; + throw new Error(response.error); } + const session = mapSessionFromApi(response.data?.session); + set({ currentSession: session, sessionCards: cards, @@ -244,9 +254,8 @@ export const useStudyStore = create((set, get) => ({ updateCardProgress: async (cardId: string, quality: number) => { try { - const { - data: { user }, - } = await supabase.auth.getUser(); + const appToken = await authService.getAppToken(); + const user = appToken ? authService.getUserFromToken(appToken) : null; if (!user) return; const progressMap = get().cardProgressMap; @@ -274,10 +283,6 @@ export const useStudyStore = create((set, get) => ({ incorrect_reviews: quality < 3 ? 1 : 0, status: sm2Result.interval < 10 ? 'learning' : 'review', }; - - const { error } = await supabase.from('card_progress').insert(newProgress); - - if (error) throw error; } else { // Update existing progress const sm2Result = calculateSM2( @@ -309,13 +314,21 @@ export const useStudyStore = create((set, get) => ({ incorrect_reviews: existingProgress.incorrect_reviews + (quality < 3 ? 1 : 0), status: newStatus, }; + } - const { error } = await supabase - .from('card_progress') - .update(newProgress) - .eq('id', existingProgress.id); + // Update via API (uses upsert) + const response = await apiClient.upsertCardProgress({ + cardId, + easeFactor: newProgress.ease_factor, + interval: newProgress.interval, + repetitions: newProgress.repetitions, + lastReviewed: newProgress.last_reviewed_at, + nextReview: newProgress.next_review_date, + status: newProgress.status === 'relearning' ? 'learning' : newProgress.status, + }); - if (error) throw error; + if (response.error) { + throw new Error(response.error); } // Update local state @@ -335,20 +348,17 @@ export const useStudyStore = create((set, get) => ({ // Calculate session statistics const correctAnswers = sessionProgress.filter((p) => p.is_correct).length; - const incorrectAnswers = sessionProgress.filter((p) => !p.is_correct).length; - // Update session in database - const { error } = await supabase - .from('study_sessions') - .update({ - ended_at: new Date().toISOString(), - completed_cards: sessionProgress.length, - correct_answers: correctAnswers, - incorrect_answers: incorrectAnswers, - }) - .eq('id', currentSession.id); + // Update session via API + const response = await apiClient.updateStudySession(currentSession.id, { + endedAt: new Date().toISOString(), + completedCards: sessionProgress.length, + correctCards: correctAnswers, + }); - if (error) throw error; + if (response.error) { + throw new Error(response.error); + } // Keep the session data for the summary screen set({ diff --git a/apps/manadeck/apps/mobile/utils/supabase.ts b/apps/manadeck/apps/mobile/utils/supabase.ts deleted file mode 100644 index d1a453555..000000000 --- a/apps/manadeck/apps/mobile/utils/supabase.ts +++ /dev/null @@ -1,117 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { createClient } from '@supabase/supabase-js'; -import { Platform } from 'react-native'; - -const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL || ''; -const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || ''; - -// Log error if env vars are missing but don't crash -if (!supabaseUrl || !supabaseAnonKey) { - console.error('Missing Supabase environment variables:', { - url: !!supabaseUrl, - key: !!supabaseAnonKey, - }); -} - -// Create a custom storage adapter that works on both web and mobile -const storage = Platform.select({ - web: { - getItem: async (key: string) => { - if (typeof window !== 'undefined' && window.localStorage) { - return window.localStorage.getItem(key); - } - return null; - }, - setItem: async (key: string, value: string) => { - if (typeof window !== 'undefined' && window.localStorage) { - window.localStorage.setItem(key, value); - } - }, - removeItem: async (key: string) => { - if (typeof window !== 'undefined' && window.localStorage) { - window.localStorage.removeItem(key); - } - }, - }, - default: AsyncStorage, -}); - -export const supabase = createClient( - supabaseUrl || 'https://placeholder.supabase.co', - supabaseAnonKey || 'placeholder-key', - { - auth: { - storage: storage as any, - autoRefreshToken: true, - persistSession: true, - detectSessionInUrl: false, - }, - } -); - -/** - * Create a Supabase client with Mana Core authentication token - * Use this when you need to make authenticated requests to Supabase - * that respect Row-Level Security (RLS) policies based on Mana user - */ -export const getSupabaseWithManaToken = async (manaToken: string) => { - return createClient( - supabaseUrl || 'https://placeholder.supabase.co', - supabaseAnonKey || 'placeholder-key', - { - global: { - headers: { - Authorization: `Bearer ${manaToken}`, - }, - }, - auth: { - storage: storage as any, - autoRefreshToken: false, // Don't auto-refresh Mana tokens here - persistSession: false, - detectSessionInUrl: false, - }, - } - ); -}; - -/** - * Helper to get authenticated Supabase client with current Mana token - * Automatically refreshes token if expired or close to expiry - */ -export const getAuthenticatedSupabase = async () => { - const { safeStorage } = await import('./safeStorage'); - const { authService } = await import('../services/authService'); - - let manaToken = await safeStorage.getItem('@mana/appToken'); - - if (!manaToken) { - throw new Error('No Mana authentication token found'); - } - - // Check if token is expired or close to expiry - const isValid = authService.isTokenValidLocally(manaToken); - - if (!isValid) { - // Token is expired or close to expiry, try to refresh - try { - const refreshToken = await safeStorage.getItem('@mana/refreshToken'); - - if (!refreshToken) { - throw new Error('No refresh token available'); - } - - const tokens = await authService.refreshTokens(refreshToken); - manaToken = tokens.appToken; - } catch (error) { - console.error('Failed to refresh token:', error); - // Clear auth storage and notify auth store - await authService.clearAuthStorage(); - // Import at runtime to avoid circular dependency - const { useAuthStore } = await import('../store/authStore'); - useAuthStore.setState({ user: null }); - throw new Error('Authentication token expired. Please sign in again.'); - } - } - - return getSupabaseWithManaToken(manaToken); -}; diff --git a/apps/manadeck/apps/mobile/utils/supabaseAIService.ts b/apps/manadeck/apps/mobile/utils/supabaseAIService.ts index 0b1656cdd..ec636919c 100644 --- a/apps/manadeck/apps/mobile/utils/supabaseAIService.ts +++ b/apps/manadeck/apps/mobile/utils/supabaseAIService.ts @@ -1,8 +1,5 @@ -import { getAuthenticatedSupabase } from './supabase'; import { CardContent } from '../store/cardStore'; -import { post } from './apiClient'; - -const BASE_API_URL = process.env.EXPO_PUBLIC_API_URL || 'https://manadeck-backend-111768794939.europe-west3.run.app'; +import { apiClient } from '../services/apiClient'; export interface GeneratedCard { type: 'text' | 'flashcard' | 'quiz' | 'mixed'; @@ -23,76 +20,48 @@ export interface GenerationOptions { } /** - * Service for AI operations using Supabase Edge Functions - * This keeps the OpenAI API key secure on the server side + * AI Service for card generation operations + * Uses the NestJS backend API for all AI operations */ export class SupabaseAIService { - private static async callEdgeFunction(functionName: string, data: any) { - try { - // Get authenticated Supabase client with Mana token - const supabase = await getAuthenticatedSupabase(); - - const { data: result, error } = await supabase.functions.invoke(functionName, { - body: data, - }); - - if (error) { - console.error(`Error calling edge function (${functionName}):`, error); - - // Check for authentication errors - if (error.message?.includes('401') || error.message?.includes('Unauthorized')) { - throw new Error('Bitte melde dich an, um KI-Funktionen zu nutzen.'); - } - - // Check for CORS errors - if (error.message?.includes('CORS') || error.message?.includes('Failed to fetch')) { - throw new Error( - 'Edge Function nicht erreichbar. Bitte stelle sicher, dass CORS korrekt konfiguriert ist.' - ); - } - - throw error; - } - - if (!result?.success) { - throw new Error(result?.error || 'Unknown error occurred'); - } - - return result.data; - } catch (error: any) { - console.error(`Error calling edge function (${functionName}):`, error); - throw error; - } - } - static async generateCardsFromText( prompt: string, options: GenerationOptions = {} ): Promise { try { - // Call backend API instead of edge function directly - // Backend handles: authentication, credit validation/consumption, and edge function invocation - const response = await post(`${BASE_API_URL}/v1/api/decks/generate`, { + // Call backend API for deck generation + const response = await apiClient.generateDeckWithAI({ prompt: prompt, deckTitle: options.topic || 'AI Generated Deck', deckDescription: `Deck created from prompt: ${prompt.substring(0, 100)}`, cardCount: options.count || 10, cardTypes: options.cardTypes || ['flashcard', 'quiz'], difficulty: options.difficulty || 'medium', - tags: [] + tags: [], }); - // Backend returns: { success, deck, cards, creditsUsed, message } - if (response.success && response.cards) { - // Convert backend cards to GeneratedCard format - return response.cards.map((card: any) => ({ - type: card.card_type || 'flashcard', + if (response.error) { + // Re-throw credit errors so they can be handled by the UI + if ( + response.error.includes('insufficient_credits') || + response.error.includes('Insufficient mana') + ) { + throw new Error(response.error); + } + console.error('Error generating cards from text:', response.error); + return []; + } + + // Convert backend cards to GeneratedCard format + if (response.data?.cards) { + return response.data.cards.map((card: any) => ({ + type: card.cardType || card.card_type || 'flashcard', content: card.content, metadata: { confidence: 1, source: 'ai', - tags: card.tags || [] - } + tags: card.tags || [], + }, })); } @@ -101,7 +70,10 @@ export class SupabaseAIService { console.error('Error generating cards from text:', error); // Re-throw credit errors so they can be handled by the UI - if (error.message?.includes('insufficient_credits') || error.message?.includes('Insufficient mana')) { + if ( + error.message?.includes('insufficient_credits') || + error.message?.includes('Insufficient mana') + ) { throw error; } @@ -109,36 +81,22 @@ export class SupabaseAIService { } } - static async generateCardsFromSpeech(audioBase64: string): Promise { - try { - // First transcribe the audio - const { text } = await this.callEdgeFunction('transcribeAudio', { - audioBase64, - }); - - // Then generate cards from the transcribed text - return await this.generateCardsFromText(text, { - cardTypes: ['flashcard', 'quiz'], - difficulty: 'medium', - count: 5, - language: 'de', - }); - } catch (error) { - console.error('Error generating cards from speech:', error); - return []; - } - } - static async generateCardsFromImage( imageBase64: string, context?: string ): Promise { try { - const cards = await this.callEdgeFunction('generate-deck-from-image', { - image: imageBase64, - context, - }); - return cards || []; + const response = await apiClient.generateCardsFromImage(imageBase64, context, 5); + + if (response.error) { + throw new Error(response.error); + } + + return (response.data?.cards || []).map((card) => ({ + type: card.type as GeneratedCard['type'], + content: card.content as CardContent, + metadata: card.metadata, + })); } catch (error) { console.error('Error generating cards from image:', error); return []; @@ -147,11 +105,13 @@ export class SupabaseAIService { static async enhanceCardContent(content: string, cardType: string): Promise { try { - const { enhancedContent } = await this.callEdgeFunction('enhanceContent', { - content, - cardType, - }); - return enhancedContent || content; + const response = await apiClient.enhanceContent(content, cardType); + + if (response.error) { + throw new Error(response.error); + } + + return response.data?.enhancedContent || content; } catch (error) { console.error('Error enhancing card content:', error); return content; @@ -175,11 +135,4 @@ export class SupabaseAIService { return []; } } - - static async transcribeAudio(audioUri: string): Promise { - console.warn( - 'Direct audio transcription from URI not implemented. Use generateCardsFromSpeech instead.' - ); - throw new Error('Audio transcription not yet implemented for mobile'); - } } diff --git a/apps/manadeck/apps/mobile/utils/ttsService.ts b/apps/manadeck/apps/mobile/utils/ttsService.ts deleted file mode 100644 index c11aaf840..000000000 --- a/apps/manadeck/apps/mobile/utils/ttsService.ts +++ /dev/null @@ -1,186 +0,0 @@ -import * as Speech from 'expo-speech'; -import * as FileSystem from 'expo-file-system'; - -export interface TTSOptions { - language?: string; - rate?: number; - pitch?: number; - voice?: string; - onStart?: () => void; - onDone?: () => void; - onStopped?: () => void; - onError?: (error: string) => void; -} - -export interface Voice { - identifier: string; - name: string; - quality: string; - language: string; -} - -export class TTSService { - private static currentSpeech: string | null = null; - - static async speakText(text: string, options: TTSOptions = {}): Promise { - const { - language = 'de-DE', - rate = 1.0, - pitch = 1.0, - voice, - onStart, - onDone, - onStopped, - onError, - } = options; - - try { - // Stop any current speech - if (this.currentSpeech) { - await this.stop(); - } - - this.currentSpeech = text; - - await Speech.speak(text, { - language, - rate, - pitch, - voice, - onStart, - onDone: () => { - this.currentSpeech = null; - onDone?.(); - }, - onStopped: () => { - this.currentSpeech = null; - onStopped?.(); - }, - onError: (error) => { - this.currentSpeech = null; - onError?.(error); - }, - }); - } catch (error) { - console.error('Error speaking text:', error); - this.currentSpeech = null; - throw error; - } - } - - static async stop(): Promise { - try { - await Speech.stop(); - this.currentSpeech = null; - } catch (error) { - console.error('Error stopping speech:', error); - } - } - - static async pause(): Promise { - try { - await Speech.pause(); - } catch (error) { - console.error('Error pausing speech:', error); - } - } - - static async resume(): Promise { - try { - await Speech.resume(); - } catch (error) { - console.error('Error resuming speech:', error); - } - } - - static async isSpeaking(): Promise { - try { - return await Speech.isSpeakingAsync(); - } catch (error) { - console.error('Error checking speaking status:', error); - return false; - } - } - - static async getAvailableVoices(): Promise { - try { - const voices = await Speech.getAvailableVoicesAsync(); - return voices.map((voice) => ({ - identifier: voice.identifier, - name: voice.name, - quality: voice.quality, - language: voice.language, - })); - } catch (error) { - console.error('Error getting available voices:', error); - return []; - } - } - - static async generateAudioFile(text: string, language: string = 'de-DE'): Promise { - // Note: Expo Speech doesn't support generating audio files directly - // This would require a server-side implementation or different library - // For now, we'll return null and use real-time TTS only - console.warn('Audio file generation not supported with Expo Speech'); - return null; - } - - static speakCard(card: any): Promise { - let textToSpeak = ''; - - switch (card.type) { - case 'flashcard': - textToSpeak = `Vorderseite: ${card.content.front}. Rückseite: ${card.content.back}`; - if (card.content.hint) { - textToSpeak += `. Hinweis: ${card.content.hint}`; - } - break; - - case 'quiz': - textToSpeak = `Frage: ${card.content.question}. `; - textToSpeak += 'Optionen: '; - card.content.options.forEach((option: string, index: number) => { - textToSpeak += `${index + 1}: ${option}. `; - }); - break; - - case 'text': - textToSpeak = card.content.title - ? `${card.content.title}. ${card.content.text}` - : card.content.text; - break; - - case 'mixed': - card.content.blocks?.forEach((block: any) => { - if (block.type === 'text') { - textToSpeak += `${block.content}. `; - } else if (block.type === 'flashcard') { - textToSpeak += `Frage: ${block.front}. Antwort: ${block.back}. `; - } - }); - break; - - default: - textToSpeak = JSON.stringify(card.content); - } - - return this.speakText(textToSpeak, { language: 'de-DE' }); - } - - static getLanguageCode(language: string): string { - const languageMap: { [key: string]: string } = { - german: 'de-DE', - english: 'en-US', - spanish: 'es-ES', - french: 'fr-FR', - italian: 'it-IT', - portuguese: 'pt-PT', - russian: 'ru-RU', - chinese: 'zh-CN', - japanese: 'ja-JP', - korean: 'ko-KR', - }; - - return languageMap[language.toLowerCase()] || 'de-DE'; - } -} diff --git a/apps/manadeck/apps/web/src/lib/stores/cardStore.svelte.ts b/apps/manadeck/apps/web/src/lib/stores/cardStore.svelte.ts new file mode 100644 index 000000000..58eefe56d --- /dev/null +++ b/apps/manadeck/apps/web/src/lib/stores/cardStore.svelte.ts @@ -0,0 +1,248 @@ +import type { Card, CreateCardInput, UpdateCardInput } from '$lib/types/card'; +import { PUBLIC_API_URL } from '$env/static/public'; +import { authService } from '$lib/auth'; + +// Svelte 5 runes-based card store +let cards = $state([]); +let currentCard = $state(null); +let loading = $state(false); +let error = $state(null); + +/** + * Helper to make authenticated API requests + */ +async function apiRequest(endpoint: string, options: RequestInit = {}): Promise { + const appToken = await authService.getAppToken(); + if (!appToken) { + throw new Error('Not authenticated'); + } + + const response = await fetch(`${PUBLIC_API_URL}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${appToken}`, + ...options.headers + } + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `API error: ${response.status}`); + } + + return response.json(); +} + +/** + * Map backend camelCase to frontend snake_case + */ +function mapCardFromApi(apiCard: any): Card { + return { + id: apiCard.id, + deck_id: apiCard.deckId, + position: apiCard.position, + title: apiCard.title, + content: apiCard.content, + card_type: apiCard.cardType, + ai_model: apiCard.aiModel, + ai_prompt: apiCard.aiPrompt, + version: apiCard.version || 1, + is_favorite: apiCard.isFavorite || false, + created_at: apiCard.createdAt, + updated_at: apiCard.updatedAt + }; +} + +export const cardStore = { + get cards() { + return cards; + }, + get currentCard() { + return currentCard; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + + /** + * Fetch all cards for a deck + */ + async fetchCards(deckId: string) { + loading = true; + error = null; + + try { + const response = await apiRequest<{ cards: any[]; count: number }>( + `/v1/api/decks/${deckId}/cards` + ); + cards = (response.cards || []).map(mapCardFromApi); + } catch (err: any) { + error = err.message || 'Failed to fetch cards'; + console.error('Fetch cards error:', err); + } finally { + loading = false; + } + }, + + /** + * Fetch single card by ID + */ + async fetchCard(id: string) { + loading = true; + error = null; + + try { + const response = await apiRequest<{ card: any }>(`/v1/api/cards/${id}`); + currentCard = response.card ? mapCardFromApi(response.card) : null; + } catch (err: any) { + error = err.message || 'Failed to fetch card'; + console.error('Fetch card error:', err); + } finally { + loading = false; + } + }, + + /** + * Create new card + */ + async createCard(input: CreateCardInput): Promise { + loading = true; + error = null; + + try { + const response = await apiRequest<{ success: boolean; card: any }>('/v1/api/cards', { + method: 'POST', + body: JSON.stringify({ + deckId: input.deck_id, + title: input.title, + content: input.content, + cardType: input.card_type, + position: input.position + }) + }); + + if (response.card) { + const card = mapCardFromApi(response.card); + cards = [...cards, card]; + return card; + } + return null; + } catch (err: any) { + error = err.message || 'Failed to create card'; + console.error('Create card error:', err); + return null; + } finally { + loading = false; + } + }, + + /** + * Update card + */ + async updateCard(id: string, updates: UpdateCardInput) { + loading = true; + error = null; + + try { + const response = await apiRequest<{ success: boolean; card: any }>(`/v1/api/cards/${id}`, { + method: 'PUT', + body: JSON.stringify({ + title: updates.title, + content: updates.content, + cardType: updates.card_type, + position: updates.position, + isFavorite: updates.is_favorite + }) + }); + + if (response.card) { + const updatedCard = mapCardFromApi(response.card); + // Update in list + cards = cards.map((c) => (c.id === id ? updatedCard : c)); + + // Update current if it's the same + if (currentCard?.id === id) { + currentCard = updatedCard; + } + } + } catch (err: any) { + error = err.message || 'Failed to update card'; + console.error('Update card error:', err); + } finally { + loading = false; + } + }, + + /** + * Delete card + */ + async deleteCard(id: string) { + loading = true; + error = null; + + try { + await apiRequest<{ success: boolean }>(`/v1/api/cards/${id}`, { + method: 'DELETE' + }); + + // Remove from list + cards = cards.filter((c) => c.id !== id); + + // Clear current if it's the same + if (currentCard?.id === id) { + currentCard = null; + } + } catch (err: any) { + error = err.message || 'Failed to delete card'; + console.error('Delete card error:', err); + } finally { + loading = false; + } + }, + + /** + * Reorder cards + */ + async reorderCards(deckId: string, cardIds: string[]) { + loading = true; + error = null; + + try { + await apiRequest<{ success: boolean }>('/v1/api/cards/reorder', { + method: 'POST', + body: JSON.stringify({ deckId, cardIds }) + }); + + // Update local positions + cards = cardIds.map((id, index) => { + const card = cards.find((c) => c.id === id); + return card ? { ...card, position: index } : card!; + }).filter(Boolean); + } catch (err: any) { + error = err.message || 'Failed to reorder cards'; + console.error('Reorder cards error:', err); + } finally { + loading = false; + } + }, + + /** + * Clear cards (when changing decks) + */ + clearCards() { + cards = []; + currentCard = null; + error = null; + }, + + /** + * Clear error + */ + clearError() { + error = null; + } +}; diff --git a/apps/manadeck/apps/web/src/lib/stores/deckStore.svelte.ts b/apps/manadeck/apps/web/src/lib/stores/deckStore.svelte.ts index 07199a5c6..30180e058 100644 --- a/apps/manadeck/apps/web/src/lib/stores/deckStore.svelte.ts +++ b/apps/manadeck/apps/web/src/lib/stores/deckStore.svelte.ts @@ -77,12 +77,8 @@ export const deckStore = { error = null; try { - // For now, find in local decks or fetch all - // TODO: Add GET /v1/api/decks/:id endpoint to backend - if (decks.length === 0) { - await this.fetchDecks(); - } - currentDeck = decks.find((d) => d.id === id) || null; + const response = await apiRequest<{ deck: Deck }>(`/v1/api/decks/${id}`); + currentDeck = response.deck || null; if (!currentDeck) { throw new Error('Deck not found'); } diff --git a/apps/manadeck/apps/web/src/lib/stores/progressStore.svelte.ts b/apps/manadeck/apps/web/src/lib/stores/progressStore.svelte.ts new file mode 100644 index 000000000..538ff42c9 --- /dev/null +++ b/apps/manadeck/apps/web/src/lib/stores/progressStore.svelte.ts @@ -0,0 +1,313 @@ +import type { StudySession, CardProgress, DailyProgress } from '$lib/types/study'; +import { PUBLIC_API_URL } from '$env/static/public'; +import { authService } from '$lib/auth'; + +// Svelte 5 runes-based progress store +let studySessions = $state([]); +let cardProgress = $state([]); +let statistics = $state(null); +let streakInfo = $state(null); +let loading = $state(false); +let error = $state(null); + +interface Statistics { + totalCardsStudied: number; + totalStudyTimeMinutes: number; + averageAccuracy: number; + totalSessions: number; +} + +interface StreakInfo { + currentStreak: number; + longestStreak: number; + lastStudyDate: string; + totalStudyDays: number; +} + +/** + * Helper to make authenticated API requests + */ +async function apiRequest(endpoint: string, options: RequestInit = {}): Promise { + const appToken = await authService.getAppToken(); + if (!appToken) { + throw new Error('Not authenticated'); + } + + const response = await fetch(`${PUBLIC_API_URL}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${appToken}`, + ...options.headers + } + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `API error: ${response.status}`); + } + + return response.json(); +} + +/** + * Map backend session to frontend format + */ +function mapSessionFromApi(apiSession: any): StudySession { + return { + id: apiSession.id, + deck_id: apiSession.deckId, + user_id: apiSession.userId, + mode: 'all', + total_cards: apiSession.totalCards || 0, + completed_cards: apiSession.completedCards || 0, + correct_cards: apiSession.correctCards || 0, + started_at: apiSession.startedAt, + completed_at: apiSession.endedAt, + time_spent_seconds: apiSession.timeSpentSeconds || 0 + }; +} + +/** + * Map backend card progress to frontend format + */ +function mapProgressFromApi(apiProgress: any): CardProgress { + return { + id: apiProgress.id, + user_id: apiProgress.userId, + card_id: apiProgress.cardId, + ease_factor: apiProgress.easeFactor, + interval: apiProgress.interval, + repetitions: apiProgress.repetitions, + last_reviewed: apiProgress.lastReviewed, + next_review: apiProgress.nextReview, + status: apiProgress.status || 'new', + created_at: apiProgress.createdAt, + updated_at: apiProgress.updatedAt + }; +} + +/** + * Calculate streak from sessions + */ +function calculateStreak(sessions: StudySession[]): StreakInfo { + if (!sessions || sessions.length === 0) { + return { + currentStreak: 0, + longestStreak: 0, + lastStudyDate: '', + totalStudyDays: 0 + }; + } + + // Get unique study dates + const studyDates = new Set( + sessions.map((s) => new Date(s.started_at).toISOString().split('T')[0]) + ); + const sortedDates = Array.from(studyDates).sort(); + + // Calculate streaks + let currentStreak = 0; + let longestStreak = 0; + let tempStreak = 1; + + const today = new Date().toISOString().split('T')[0]; + const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; + + const lastStudyDate = sortedDates[sortedDates.length - 1]; + if (lastStudyDate === today || lastStudyDate === yesterday) { + currentStreak = 1; + + for (let i = sortedDates.length - 2; i >= 0; i--) { + const prevDate = new Date(sortedDates[i]); + const currDate = new Date(sortedDates[i + 1]); + const diffDays = Math.floor((currDate.getTime() - prevDate.getTime()) / 86400000); + + if (diffDays === 1) { + currentStreak++; + } else { + break; + } + } + } + + for (let i = 1; i < sortedDates.length; i++) { + const prevDate = new Date(sortedDates[i - 1]); + const currDate = new Date(sortedDates[i]); + const diffDays = Math.floor((currDate.getTime() - prevDate.getTime()) / 86400000); + + if (diffDays === 1) { + tempStreak++; + } else { + longestStreak = Math.max(longestStreak, tempStreak); + tempStreak = 1; + } + } + longestStreak = Math.max(longestStreak, tempStreak, currentStreak); + + return { + currentStreak, + longestStreak, + lastStudyDate, + totalStudyDays: studyDates.size + }; +} + +export const progressStore = { + get studySessions() { + return studySessions; + }, + get cardProgress() { + return cardProgress; + }, + get statistics() { + return statistics; + }, + get streakInfo() { + return streakInfo; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + + /** + * Fetch all study sessions + */ + async fetchStudySessions() { + loading = true; + error = null; + + try { + const response = await apiRequest<{ sessions: any[]; count: number }>( + '/v1/api/study-sessions' + ); + studySessions = (response.sessions || []).map(mapSessionFromApi); + + // Calculate streak + streakInfo = calculateStreak(studySessions); + } catch (err: any) { + error = err.message || 'Failed to fetch study sessions'; + console.error('Fetch study sessions error:', err); + } finally { + loading = false; + } + }, + + /** + * Fetch study session statistics + */ + async fetchStatistics() { + loading = true; + error = null; + + try { + const [sessionsResponse, progressStatsResponse] = await Promise.all([ + apiRequest<{ stats: any }>('/v1/api/study-sessions/stats'), + apiRequest<{ stats: any }>('/v1/api/progress/stats') + ]); + + const sessionStats = sessionsResponse.stats || {}; + const progressStats = progressStatsResponse.stats || {}; + + statistics = { + totalCardsStudied: sessionStats.totalCardsStudied || 0, + totalStudyTimeMinutes: Math.round((sessionStats.totalTimeSeconds || 0) / 60), + averageAccuracy: + sessionStats.totalCardsStudied > 0 + ? Math.round( + ((sessionStats.totalCorrectCards || 0) / sessionStats.totalCardsStudied) * 100 + ) + : 0, + totalSessions: sessionStats.totalSessions || 0 + }; + } catch (err: any) { + error = err.message || 'Failed to fetch statistics'; + console.error('Fetch statistics error:', err); + } finally { + loading = false; + } + }, + + /** + * Fetch card progress for a deck + */ + async fetchDeckProgress(deckId: string) { + loading = true; + error = null; + + try { + const response = await apiRequest<{ progress: any[]; count: number }>( + `/v1/api/progress/deck/${deckId}` + ); + cardProgress = (response.progress || []).map(mapProgressFromApi); + } catch (err: any) { + error = err.message || 'Failed to fetch deck progress'; + console.error('Fetch deck progress error:', err); + } finally { + loading = false; + } + }, + + /** + * Fetch due cards + */ + async fetchDueCards(deckId?: string) { + loading = true; + error = null; + + try { + const endpoint = deckId + ? `/v1/api/progress/deck/${deckId}/due` + : '/v1/api/progress/due'; + const response = await apiRequest<{ progress: any[]; count: number }>(endpoint); + return (response.progress || []).map(mapProgressFromApi); + } catch (err: any) { + error = err.message || 'Failed to fetch due cards'; + console.error('Fetch due cards error:', err); + return []; + } finally { + loading = false; + } + }, + + /** + * Get progress summary for a deck + */ + getDeckProgressSummary(deckId: string) { + const deckProgress = cardProgress.filter((p) => { + // This requires cards info - for now return all progress + return true; + }); + + const total = deckProgress.length; + const mastered = deckProgress.filter( + (p) => p.ease_factor >= 2.5 && p.interval >= 21 + ).length; + const learning = deckProgress.filter( + (p) => p.status === 'learning' + ).length; + const newCards = deckProgress.filter((p) => p.status === 'new').length; + const dueNow = deckProgress.filter((p) => { + if (!p.next_review) return false; + return new Date(p.next_review) <= new Date(); + }).length; + + return { + total, + mastered, + learning, + newCards, + dueNow + }; + }, + + /** + * Clear error + */ + clearError() { + error = null; + } +}; diff --git a/apps/manadeck/apps/web/src/routes/(app)/decks/[id]/+page.svelte b/apps/manadeck/apps/web/src/routes/(app)/decks/[id]/+page.svelte index 5fbf6e876..cac0a257a 100644 --- a/apps/manadeck/apps/web/src/routes/(app)/decks/[id]/+page.svelte +++ b/apps/manadeck/apps/web/src/routes/(app)/decks/[id]/+page.svelte @@ -3,15 +3,33 @@ import { page } from '$app/stores'; import { goto } from '$app/navigation'; import { deckStore } from '$lib/stores/deckStore.svelte'; + import { progressStore } from '$lib/stores/progressStore.svelte'; + import { cardStore } from '$lib/stores/cardStore.svelte'; import { Button, Badge, Card } from '@manacore/shared-ui'; let deckId = $derived($page.params.id); let showDeleteConfirm = $state(false); let deleting = $state(false); - onMount(() => { + // Calculate deck-specific progress + let dueCount = $state(0); + let masteredCount = $state(0); + + onMount(async () => { if (deckId) { - deckStore.fetchDeck(deckId); + await Promise.all([ + deckStore.fetchDeck(deckId), + progressStore.fetchDeckProgress(deckId), + cardStore.fetchCards(deckId) + ]); + + // Calculate progress + const progress = progressStore.cardProgress; + masteredCount = progress.filter((p) => p.ease_factor >= 2.5 && p.interval >= 21).length; + dueCount = progress.filter((p) => { + if (!p.next_review) return false; + return new Date(p.next_review) <= new Date(); + }).length; } }); @@ -83,20 +101,20 @@
-
{deck.card_count || 0}
+
{cardStore.cards.length || deck.card_count || 0}
Total Cards
-
0
+
{masteredCount}
Mastered
-
0
-
To Review
+
{dueCount}
+
Due for Review
diff --git a/apps/manadeck/apps/web/src/routes/(app)/progress/+page.svelte b/apps/manadeck/apps/web/src/routes/(app)/progress/+page.svelte index 1fec8aad4..5dd2e1e26 100644 --- a/apps/manadeck/apps/web/src/routes/(app)/progress/+page.svelte +++ b/apps/manadeck/apps/web/src/routes/(app)/progress/+page.svelte @@ -1,5 +1,12 @@ @@ -9,39 +16,122 @@

Progress

-

- Track your learning progress and statistics -

+

Track your learning progress and statistics

-
- -
-
0
-
Cards Studied
-
-
- -
-
0
-
Current Streak
-
-
- -
-
0%
-
Average Accuracy
-
-
-
- - -
-
📊
-

Progress Tracking

-

- Detailed statistics and heatmap - Coming soon! -

+ {#if progressStore.loading} +
+
- + {:else} + +
+ +
+
+ {progressStore.statistics?.totalCardsStudied || 0} +
+
Cards Studied
+
+
+ +
+
+ 🔥 {progressStore.streakInfo?.currentStreak || 0} +
+
Current Streak
+
+
+ +
+
+ {progressStore.statistics?.averageAccuracy || 0}% +
+
Average Accuracy
+
+
+ +
+
+ {progressStore.statistics?.totalStudyTimeMinutes || 0} +
+
Minutes Studied
+
+
+
+ + + +

📊 Study Streak

+
+
+
+ {progressStore.streakInfo?.currentStreak || 0} +
+
Current Streak
+
+
+
+ {progressStore.streakInfo?.longestStreak || 0} +
+
Longest Streak
+
+
+
+ {progressStore.streakInfo?.totalStudyDays || 0} +
+
Total Study Days
+
+
+
+ {progressStore.statistics?.totalSessions || 0} +
+
Total Sessions
+
+
+
+ + + +

📚 Recent Study Sessions

+ {#if progressStore.studySessions.length === 0} +
+
🎯
+

No study sessions yet.

+

+ Start studying a deck to see your progress here! +

+
+ {:else} +
+ {#each progressStore.studySessions.slice(0, 10) as session} +
+
+
+ {new Date(session.started_at).toLocaleDateString('de-DE', { + weekday: 'short', + day: 'numeric', + month: 'short' + })} +
+
+ {session.completed_cards} cards • {Math.round(session.time_spent_seconds / 60)} min +
+
+
+
+ {session.completed_cards > 0 + ? Math.round((session.correct_cards / session.completed_cards) * 100) + : 0}% +
+
accuracy
+
+
+ {/each} +
+ {/if} +
+ {/if}
diff --git a/apps/manadeck/supabase/functions/generate-deck-from-image/config.toml b/apps/manadeck/supabase/functions/generate-deck-from-image/config.toml deleted file mode 100644 index 48995f1a8..000000000 --- a/apps/manadeck/supabase/functions/generate-deck-from-image/config.toml +++ /dev/null @@ -1,3 +0,0 @@ -# Disable automatic JWT verification by Supabase Edge Gateway -# We'll manually verify the Mana Core JWT inside the function -verify_jwt = false diff --git a/apps/manadeck/supabase/functions/generate-deck/config.toml b/apps/manadeck/supabase/functions/generate-deck/config.toml deleted file mode 100644 index 48995f1a8..000000000 --- a/apps/manadeck/supabase/functions/generate-deck/config.toml +++ /dev/null @@ -1,3 +0,0 @@ -# Disable automatic JWT verification by Supabase Edge Gateway -# We'll manually verify the Mana Core JWT inside the function -verify_jwt = false diff --git a/apps/manadeck/supabase/functions/generate-deck/index.ts b/apps/manadeck/supabase/functions/generate-deck/index.ts deleted file mode 100644 index e3b1e0d73..000000000 --- a/apps/manadeck/supabase/functions/generate-deck/index.ts +++ /dev/null @@ -1,273 +0,0 @@ -import "jsr:@supabase/functions-js/edge-runtime.d.ts"; -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.3'; -import * as jose from "https://deno.land/x/jose@v5.9.6/index.ts"; - -const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type' -}; - -// System prompt for consistent card generation -const SYSTEM_PROMPT = `Du bist ein Experte für die Erstellung von Lernkarten. Erstelle strukturierte Lernkarten basierend auf dem gegebenen Thema. - -Regeln für die Kartenerstellung: -1. Erstelle abwechslungsreiche Karten (Flashcards und Quiz-Karten) -2. Formuliere klare, präzise Fragen und Antworten -3. Verwende die deutsche Sprache -4. Stelle sicher, dass die Karten aufeinander aufbauen -5. Füge hilfreiche Hinweise und Erklärungen hinzu - -Du musst die Karten als JSON-Array zurückgeben mit folgendem Format: -[ - { - "card_type": "flashcard" | "quiz", - "content": { - // Für flashcard: - "front": "Frage oder Begriff", - "back": "Antwort oder Definition", - "hint": "Optionaler Hinweis" - - // Für quiz: - "question": "Die Frage", - "options": ["Option 1", "Option 2", "Option 3", "Option 4"], - "correct_answer": 0, // Index der richtigen Antwort (0-3) - "explanation": "Erklärung zur richtigen Antwort" - }, - "position": 1, // Position in der Reihenfolge - "title": "Kurzer Titel für die Karte" - } -] - -Erstelle GENAU die angeforderte Anzahl von Karten.`; - -Deno.serve(async (req) => { - // Handle CORS preflight requests - if (req.method === 'OPTIONS') { - return new Response('ok', { headers: corsHeaders }); - } - - try { - // Get the authorization header - const authHeader = req.headers.get('Authorization'); - if (!authHeader) { - throw new Error('No authorization header'); - } - - // Extract the Mana app token - const appToken = authHeader.replace('Bearer ', ''); - - // Get Mana Core JWKS URL from environment variable - const manaJwksUrl = Deno.env.get('JWKS_URL'); - if (!manaJwksUrl) { - throw new Error('JWKS_URL not configured'); - } - - // Verify the Mana token using JWKS - const JWKS = jose.createRemoteJWKSet(new URL(manaJwksUrl)); - const { payload } = await jose.jwtVerify(appToken, JWKS); - - const userId = payload.sub as string; - if (!userId) { - throw new Error('Invalid token: no user ID'); - } - - console.log(`Authenticated user: ${userId}`); - - // Initialize Supabase client with service role - const supabaseUrl = Deno.env.get('SUPABASE_URL')!; - const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; - const supabase = createClient(supabaseUrl, supabaseServiceKey); - - // Use the userId from the Mana token - const user = { id: userId }; - - // Parse request body - const requestData = await req.json(); - const { - prompt: userPrompt, - deckTitle, - deckDescription = '', - cardCount = 10, - cardTypes = ['flashcard', 'quiz'], - difficulty = 'intermediate', - tags = [] - } = requestData; - - // Validate input - if (!userPrompt || !deckTitle) { - throw new Error('userPrompt and deckTitle are required'); - } - if (cardCount < 1 || cardCount > 50) { - throw new Error('cardCount must be between 1 and 50'); - } - - // Get OpenAI API key from environment - const openAIApiKey = Deno.env.get('OPENAI_API_KEY'); - if (!openAIApiKey) { - throw new Error('OpenAI API key not configured'); - } - - // Prepare the user message with specific instructions - const userMessage = ` -Thema: ${userPrompt} -Anzahl Karten: ${cardCount} -Kartentypen: ${cardTypes.join(', ')} -Schwierigkeit: ${difficulty} -Tags: ${tags.length > 0 ? tags.join(', ') : 'keine spezifischen Tags'} - -Erstelle ${cardCount} Lernkarten zum obigen Thema. Mische die Kartentypen ab und stelle sicher, dass die Karten progressiv aufeinander aufbauen.`; - - console.log('Generating cards with OpenAI...'); - - // Call OpenAI API - const openAIResponse = await fetch('https://api.openai.com/v1/chat/completions', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${openAIApiKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - model: 'gpt-4o-mini', - messages: [ - { role: 'system', content: SYSTEM_PROMPT }, - { role: 'user', content: userMessage } - ], - temperature: 0.7, - max_tokens: 4000, - response_format: { type: "json_object" } - }) - }); - - if (!openAIResponse.ok) { - const error = await openAIResponse.text(); - console.error('OpenAI API error:', error); - throw new Error(`OpenAI API error: ${error}`); - } - - const aiData = await openAIResponse.json(); - const content = aiData.choices[0]?.message?.content; - - if (!content) { - throw new Error('No content received from OpenAI'); - } - - // Parse the JSON response - let generatedCards; - try { - const parsed = JSON.parse(content); - generatedCards = Array.isArray(parsed) ? parsed : parsed.cards || parsed.karten || []; - } catch (parseError) { - console.error('Failed to parse OpenAI response:', content); - throw new Error('Invalid response format from AI'); - } - - if (!Array.isArray(generatedCards) || generatedCards.length === 0) { - throw new Error('No cards generated'); - } - - console.log(`Generated ${generatedCards.length} cards`); - - // Create the deck in the database - const { data: deck, error: deckError } = await supabase - .from('decks') - .insert({ - user_id: user.id, - title: deckTitle, - description: deckDescription || `KI-generiertes Deck zum Thema: ${userPrompt}`, - is_public: false, - tags: tags, - metadata: { - ai_generated: true, - generation_prompt: userPrompt, - generation_date: new Date().toISOString(), - model: 'gpt-4o-mini' - } - }) - .select() - .single(); - - if (deckError || !deck) { - console.error('Failed to create deck:', deckError); - throw new Error('Failed to create deck'); - } - - // Prepare cards for insertion - const cardsToInsert = generatedCards.map((card, index) => ({ - deck_id: deck.id, - card_type: card.card_type, - content: card.content, - position: card.position || index + 1, - title: card.title || `Karte ${index + 1}`, - ai_model: 'gpt-4o-mini', - ai_prompt: userPrompt, - version: 1, - is_favorite: false - })); - - // Insert all cards - const { data: cards, error: cardsError } = await supabase - .from('cards') - .insert(cardsToInsert) - .select(); - - if (cardsError) { - console.error('Failed to create cards:', cardsError); - await supabase.from('decks').delete().eq('id', deck.id); - throw new Error('Failed to create cards: ' + JSON.stringify(cardsError)); - } - - console.log(`Successfully created deck with ${cards?.length} cards`); - - // Track the generation (optional) - try { - await supabase.from('ai_generations').insert({ - user_id: user.id, - deck_id: deck.id, - function_name: 'generate-deck', - prompt: userPrompt, - model: 'gpt-4o-mini', - status: 'completed', - metadata: { - card_count: cards?.length, - card_types: cardTypes, - difficulty: difficulty - }, - completed_at: new Date().toISOString() - }); - } catch (trackingError) { - console.log('Could not track generation:', trackingError); - } - - // Return success response - return new Response(JSON.stringify({ - success: true, - deck: { - id: deck.id, - title: deck.title, - description: deck.description, - card_count: cards?.length || 0 - }, - cards: cards, - message: `Deck "${deckTitle}" mit ${cards?.length} Karten erfolgreich erstellt!` - }), { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json' - }, - status: 200 - }); - - } catch (error) { - console.error('Error in generate-deck function:', error); - return new Response(JSON.stringify({ - success: false, - error: error.message || 'Ein unerwarteter Fehler ist aufgetreten' - }), { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json' - }, - status: error.message?.includes('authorization') ? 401 : 400 - }); - } -});