UNPKG

mcp-quiz-server

Version:

🧠 AI-Powered Quiz Management via Model Context Protocol (MCP) - Create, manage, and take quizzes directly from VS Code, Claude, and other AI agents.

504 lines (503 loc) • 20 kB
"use strict"; /** * @fileoverview TypeORM Quiz Repository Implementation - Infrastructure Layer * @version 1.0.0 * @since 2025-07-29 * @lastUpdated 2025-07-29 * @module TypeORMQuizRepository Infrastructure Implementation * @description Concrete implementation of IQuizRepository using TypeORM. * Handles quiz persistence, querying, and data mapping between * domain entities and database entities. * @contributors Claude Code Agent * @dependencies TypeORM, domain entities, database entities * @requirements REQ-ARCH-001 (Clean Architecture Infrastructure Layer) * @testCoverage Integration tests for database operations */ Object.defineProperty(exports, "__esModule", { value: true }); exports.TypeORMQuizRepository = void 0; const Quiz_1 = require("../../domain/entities/Quiz"); const Question_1 = require("../../domain/entities/Question"); const QuizId_1 = require("../../domain/value-objects/QuizId"); const QuestionId_1 = require("../../domain/value-objects/QuestionId"); const IQuizRepository_1 = require("../../domain/repositories/IQuizRepository"); // Import database entities const Quiz_2 = require("../../database/entities/Quiz"); const Question_2 = require("../../database/entities/Question"); /** * TypeORM Quiz Repository Implementation * * @description Concrete implementation of the quiz repository interface using TypeORM. * Handles the mapping between domain entities and database entities, * implements complex queries, and manages transactions. * * @example * ```typescript * const dataSource = await getDataSource(); * const repository = new TypeORMQuizRepository(dataSource); * * const quiz = Quiz.create({ title: "Test Quiz", category: "test" }); * await repository.save(quiz); * ``` * * @since 2025-07-29 * @author Claude Code Agent * @requirements REQ-ARCH-001 (Clean Architecture Infrastructure Layer) */ class TypeORMQuizRepository { constructor(dataSource) { this.dataSource = dataSource; this.quizRepository = dataSource.getRepository(Quiz_2.Quiz); this.questionRepository = dataSource.getRepository(Question_2.Question); } /** * Save a quiz (create or update) */ async save(quiz) { try { const quizEntity = this.mapDomainToEntity(quiz); const questionEntities = quiz.questions.map(q => this.mapDomainQuestionToEntity(q, quiz.id)); await this.dataSource.transaction(async (manager) => { // Save quiz await manager.save(Quiz_2.Quiz, quizEntity); // Delete existing questions if updating if (questionEntities.length > 0) { await manager.delete(Question_2.Question, { quizId: quiz.id.value }); } // Save questions if (questionEntities.length > 0) { await manager.save(Question_2.Question, questionEntities); } }); } catch (error) { if (error instanceof Error && error.message.includes('UNIQUE constraint')) { throw new IQuizRepository_1.DuplicateQuizError(quiz.title); } throw new IQuizRepository_1.RepositoryError('Failed to save quiz', 'SAVE_ERROR', error instanceof Error ? error : undefined); } } /** * Find quiz by ID */ async findById(id) { try { const quizEntity = await this.quizRepository.findOne({ where: { id: id.value }, }); if (!quizEntity) { return null; } return this.mapEntityToDomain(quizEntity, []); } catch (error) { throw new IQuizRepository_1.RepositoryError('Failed to find quiz by ID', 'FIND_ERROR', error instanceof Error ? error : undefined); } } /** * Find quiz by ID with questions loaded */ async findByIdWithQuestions(id) { try { const quizEntity = await this.quizRepository.findOne({ where: { id: id.value }, relations: ['questions'], }); if (!quizEntity) { return null; } const questionEntities = quizEntity.questions || []; const questions = questionEntities.map(q => this.mapEntityToDomainQuestion(q)); return this.mapEntityToDomain(quizEntity, questions); } catch (error) { throw new IQuizRepository_1.RepositoryError('Failed to find quiz with questions', 'FIND_ERROR', error instanceof Error ? error : undefined); } } /** * Find all quizzes (with pagination) */ async findAll(options = {}) { try { const { limit = 50, offset = 0, sortBy = 'createdAt', sortOrder = 'desc', includeQuestions = false, } = options; const queryBuilder = this.createBaseQueryBuilder(includeQuestions); // Apply sorting queryBuilder.orderBy(`quiz.${sortBy}`, sortOrder.toUpperCase()); // Apply pagination queryBuilder.skip(offset).take(limit + 1); // +1 to check if there are more const [quizEntities, totalCount] = await Promise.all([ queryBuilder.getMany(), this.quizRepository.count(), ]); const hasMore = quizEntities.length > limit; if (hasMore) { quizEntities.pop(); // Remove the extra item } const quizzes = await Promise.all(quizEntities.map(entity => this.mapEntityToDomainWithQuestions(entity, includeQuestions))); return { quizzes, totalCount, hasMore, offset, limit, }; } catch (error) { throw new IQuizRepository_1.RepositoryError('Failed to find all quizzes', 'FIND_ERROR', error instanceof Error ? error : undefined); } } /** * Find quizzes by category */ async findByCategory(category, options = {}) { const criteria = { category }; return this.search(criteria, options); } /** * Find quizzes by difficulty */ async findByDifficulty(difficulty, options = {}) { const criteria = { difficulty }; return this.search(criteria, options); } /** * Search quizzes with complex criteria */ async search(criteria, options = {}) { try { const { limit = 50, offset = 0, sortBy = 'createdAt', sortOrder = 'desc', includeQuestions = false, } = options; const queryBuilder = this.createBaseQueryBuilder(includeQuestions); // Apply search criteria this.applyCriteria(queryBuilder, criteria); // Apply sorting queryBuilder.orderBy(`quiz.${sortBy}`, sortOrder.toUpperCase()); // Apply pagination queryBuilder.skip(offset).take(limit + 1); const [quizEntities, totalCount] = await Promise.all([ queryBuilder.getMany(), this.countWithCriteria(criteria), ]); const hasMore = quizEntities.length > limit; if (hasMore) { quizEntities.pop(); } const quizzes = await Promise.all(quizEntities.map(entity => this.mapEntityToDomainWithQuestions(entity, includeQuestions))); return { quizzes, totalCount, hasMore, offset, limit, }; } catch (error) { throw new IQuizRepository_1.RepositoryError('Failed to search quizzes', 'SEARCH_ERROR', error instanceof Error ? error : undefined); } } /** * Find active quizzes */ async findActive(options = {}) { const criteria = { isActive: true }; return this.search(criteria, options); } /** * Find recently created quizzes */ async findRecent(days = 7, options = {}) { const createdAfter = new Date(); createdAfter.setDate(createdAfter.getDate() - days); const criteria = { createdAfter }; return this.search(criteria, options); } /** * Count total number of quizzes */ async count(criteria) { try { if (!criteria) { return await this.quizRepository.count(); } return await this.countWithCriteria(criteria); } catch (error) { throw new IQuizRepository_1.RepositoryError('Failed to count quizzes', 'COUNT_ERROR', error instanceof Error ? error : undefined); } } /** * Check if quiz exists by ID */ async exists(id) { try { const count = await this.quizRepository.count({ where: { id: id.value } }); return count > 0; } catch (error) { throw new IQuizRepository_1.RepositoryError('Failed to check quiz existence', 'EXISTS_ERROR', error instanceof Error ? error : undefined); } } /** * Check if quiz title is unique */ async isTitleUnique(title, excludeId) { try { const queryBuilder = this.quizRepository.createQueryBuilder('quiz'); queryBuilder.where('quiz.title = :title', { title }); if (excludeId) { queryBuilder.andWhere('quiz.id != :excludeId', { excludeId: excludeId.value }); } const count = await queryBuilder.getCount(); return count === 0; } catch (error) { throw new IQuizRepository_1.RepositoryError('Failed to check title uniqueness', 'UNIQUE_CHECK_ERROR', error instanceof Error ? error : undefined); } } /** * Delete quiz by ID */ async delete(id) { try { const result = await this.dataSource.transaction(async (manager) => { // Delete questions first (cascade should handle this, but explicit is better) await manager.delete(Question_2.Question, { quizId: id.value }); // Delete quiz const deleteResult = await manager.delete(Quiz_2.Quiz, { id: id.value }); return deleteResult.affected || 0; }); return result > 0; } catch (error) { throw new IQuizRepository_1.RepositoryError('Failed to delete quiz', 'DELETE_ERROR', error instanceof Error ? error : undefined); } } /** * Soft delete quiz (mark as inactive) */ async softDelete(id) { try { const result = await this.quizRepository.update({ id: id.value }, { isActive: false, updatedAt: new Date() }); return (result.affected || 0) > 0; } catch (error) { throw new IQuizRepository_1.RepositoryError('Failed to soft delete quiz', 'SOFT_DELETE_ERROR', error instanceof Error ? error : undefined); } } /** * Get quiz statistics */ async getStatistics(id) { try { // This would typically involve complex queries across multiple tables // For now, return basic statistics from the quiz itself const quiz = await this.quizRepository.findOne({ where: { id: id.value } }); if (!quiz) { return null; } // In a real implementation, this would query the results table return { quizId: id.value, totalAttempts: quiz.totalAttempts, averageScore: quiz.averageScore, highestScore: 100, // Would be calculated from results lowestScore: 0, // Would be calculated from results completionRate: 85, // Would be calculated from results questionStatistics: [], // Would be populated from question results }; } catch (error) { throw new IQuizRepository_1.RepositoryError('Failed to get quiz statistics', 'STATS_ERROR', error instanceof Error ? error : undefined); } } /** * Save multiple quizzes in a single transaction */ async saveMany(quizzes) { try { await this.dataSource.transaction(async (manager) => { for (const quiz of quizzes) { const quizEntity = this.mapDomainToEntity(quiz); const questionEntities = quiz.questions.map(q => this.mapDomainQuestionToEntity(q, quiz.id)); await manager.save(Quiz_2.Quiz, quizEntity); if (questionEntities.length > 0) { await manager.delete(Question_2.Question, { quizId: quiz.id.value }); await manager.save(Question_2.Question, questionEntities); } } }); } catch (error) { throw new IQuizRepository_1.RepositoryError('Failed to save multiple quizzes', 'BATCH_SAVE_ERROR', error instanceof Error ? error : undefined); } } /** * Find multiple quizzes by IDs */ async findByIds(ids, includeQuestions = false) { try { const idValues = ids.map(id => id.value); const queryBuilder = this.createBaseQueryBuilder(includeQuestions); queryBuilder.whereInIds(idValues); const quizEntities = await queryBuilder.getMany(); const result = new Map(); for (const entity of quizEntities) { const quiz = await this.mapEntityToDomainWithQuestions(entity, includeQuestions); result.set(entity.id, quiz); } return result; } catch (error) { throw new IQuizRepository_1.RepositoryError('Failed to find quizzes by IDs', 'FIND_BY_IDS_ERROR', error instanceof Error ? error : undefined); } } /** * Delete multiple quizzes */ async deleteMany(ids) { try { const idValues = ids.map(id => id.value); const result = await this.dataSource.transaction(async (manager) => { // Delete questions first await manager.delete(Question_2.Question, { quizId: { $in: idValues } }); // Delete quizzes const deleteResult = await manager.delete(Quiz_2.Quiz, { id: { $in: idValues } }); return deleteResult.affected || 0; }); return result; } catch (error) { throw new IQuizRepository_1.RepositoryError('Failed to delete multiple quizzes', 'BATCH_DELETE_ERROR', error instanceof Error ? error : undefined); } } /** * Execute operations within a transaction */ async transaction(operation) { return await this.dataSource.transaction(async (manager) => { const transactionalRepo = new TypeORMQuizRepository(manager); return await operation(transactionalRepo); }); } /** * Helper Methods */ createBaseQueryBuilder(includeQuestions) { const queryBuilder = this.quizRepository.createQueryBuilder('quiz'); if (includeQuestions) { queryBuilder.leftJoinAndSelect('quiz.questions', 'question'); queryBuilder.addOrderBy('question.order', 'ASC'); } return queryBuilder; } applyCriteria(queryBuilder, criteria) { if (criteria.category) { queryBuilder.andWhere('quiz.category = :category', { category: criteria.category }); } if (criteria.difficulty) { queryBuilder.andWhere('quiz.difficulty = :difficulty', { difficulty: criteria.difficulty }); } if (criteria.isActive !== undefined) { queryBuilder.andWhere('quiz.isActive = :isActive', { isActive: criteria.isActive }); } if (criteria.titleContains) { queryBuilder.andWhere('quiz.title LIKE :titleContains', { titleContains: `%${criteria.titleContains}%`, }); } if (criteria.createdAfter) { queryBuilder.andWhere('quiz.createdAt >= :createdAfter', { createdAfter: criteria.createdAfter, }); } if (criteria.createdBefore) { queryBuilder.andWhere('quiz.createdAt <= :createdBefore', { createdBefore: criteria.createdBefore, }); } if (criteria.minQuestions !== undefined) { queryBuilder.andWhere('quiz.questionCount >= :minQuestions', { minQuestions: criteria.minQuestions, }); } if (criteria.maxQuestions !== undefined) { queryBuilder.andWhere('quiz.questionCount <= :maxQuestions', { maxQuestions: criteria.maxQuestions, }); } } async countWithCriteria(criteria) { const queryBuilder = this.quizRepository.createQueryBuilder('quiz'); this.applyCriteria(queryBuilder, criteria); return await queryBuilder.getCount(); } async mapEntityToDomainWithQuestions(entity, includeQuestions) { let questions = []; if (includeQuestions) { if (entity.questions && entity.questions.length > 0) { questions = entity.questions.map(q => this.mapEntityToDomainQuestion(q)); } } return this.mapEntityToDomain(entity, questions); } mapDomainToEntity(quiz) { const entity = new Quiz_2.Quiz(); entity.id = quiz.id.value; entity.title = quiz.title; entity.description = quiz.description; entity.category = quiz.category; entity.difficulty = quiz.difficulty; entity.timeLimit = quiz.timeLimit; entity.isActive = quiz.isActive; entity.totalAttempts = 0; // This would be updated by results entity.metadata = quiz.metadata; entity.createdAt = quiz.createdAt; entity.updatedAt = quiz.updatedAt; return entity; } mapEntityToDomain(entity, questions) { return Quiz_1.Quiz.fromPersistence({ id: QuizId_1.QuizId.fromString(entity.id), title: entity.title, description: entity.description, category: entity.category, difficulty: entity.difficulty, timeLimit: entity.timeLimit, isActive: entity.isActive, questions, createdAt: entity.createdAt, updatedAt: entity.updatedAt, metadata: entity.metadata, }); } mapDomainQuestionToEntity(question, quizId) { const entity = new Question_2.Question(); entity.id = question.id.value; entity.quizId = quizId.value; entity.question = question.questionText; entity.options = question.options; entity.answer = question.correctAnswer; entity.explanation = question.explanation; entity.order = question.order; entity.type = question.type; entity.points = question.points; entity.metadata = question.metadata; entity.createdAt = question.createdAt; entity.updatedAt = question.updatedAt; return entity; } mapEntityToDomainQuestion(entity) { return Question_1.Question.fromPersistence({ id: QuestionId_1.QuestionId.fromString(entity.id), questionText: entity.question, options: entity.options, correctAnswer: entity.answer, explanation: entity.explanation, order: entity.order, type: entity.type, points: entity.points, metadata: entity.metadata, createdAt: entity.createdAt, updatedAt: entity.updatedAt, }); } } exports.TypeORMQuizRepository = TypeORMQuizRepository;