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
JavaScript
;
/**
* @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;