diff --git a/src/app.module.ts b/src/app.module.ts index b3e30c3..fcc32ce 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -24,6 +24,7 @@ import { WalletModule } from './wallet/wallet.module'; import { DifficultyScalingModule } from './difficulty-scaling/difficulty-scaling.module'; import { TournamentsModule } from './tournaments/tournaments.module'; import { RabbitMQModule } from './rabbitmq/rabbitmq.module'; +import { TutorialModule } from './tutorial/tutorial.module'; import { ReferralsModule } from './referrals/referrals.module'; import { SaveGameModule } from './save-game/save-game.module'; @@ -75,6 +76,7 @@ import { PuzzleModule } from './puzzle/puzzle.module'; HintsModule, DifficultyScalingModule, TournamentsModule, + TutorialModule, ReferralsModule, SaveGameModule, diff --git a/src/tutorial/controllers/contextual-help.controller.ts b/src/tutorial/controllers/contextual-help.controller.ts new file mode 100644 index 0000000..76d4ebb --- /dev/null +++ b/src/tutorial/controllers/contextual-help.controller.ts @@ -0,0 +1,170 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + ParseUUIDPipe, + HttpCode, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { ContextualHelpService } from '../services/contextual-help.service'; +import { LocalizationService } from '../services/localization.service'; +import { + CreateContextualHelpDto, + UpdateContextualHelpDto, + ContextualHelpFilterDto, + TriggerContextualHelpDto, + RecordHelpInteractionDto, +} from '../dto'; + +@Controller('contextual-help') +export class ContextualHelpController { + private readonly logger = new Logger(ContextualHelpController.name); + + constructor( + private readonly helpService: ContextualHelpService, + private readonly localizationService: LocalizationService, + ) {} + + // CRUD Operations + @Post() + async create(@Body() dto: CreateContextualHelpDto) { + this.logger.log(`Creating contextual help: ${dto.name}`); + return this.helpService.create(dto); + } + + @Get() + async findAll(@Query() filters: ContextualHelpFilterDto) { + this.logger.log(`Fetching contextual help with filters: ${JSON.stringify(filters)}`); + return this.helpService.findAll(filters); + } + + @Get(':id') + async findOne( + @Param('id', ParseUUIDPipe) id: string, + @Query('locale') locale?: string, + ) { + this.logger.log(`Fetching contextual help: ${id}`); + const help = await this.helpService.findById(id); + + if (locale) { + return this.localizationService.localizeHelp(help, locale); + } + + return help; + } + + @Patch(':id') + async update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateContextualHelpDto, + ) { + this.logger.log(`Updating contextual help: ${id}`); + return this.helpService.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + async delete(@Param('id', ParseUUIDPipe) id: string) { + this.logger.log(`Deleting contextual help: ${id}`); + await this.helpService.delete(id); + } + + // Trigger and Display + @Post('user/:userId/trigger') + async triggerHelp( + @Param('userId', ParseUUIDPipe) userId: string, + @Body() dto: TriggerContextualHelpDto, + @Query('locale') locale?: string, + ) { + this.logger.log(`Triggering help for user ${userId} in context: ${dto.context}`); + const help = await this.helpService.triggerHelp(userId, dto); + + if (help && locale) { + return this.localizationService.localizeHelp(help, locale); + } + + return help; + } + + @Post('user/:userId/interaction') + async recordInteraction( + @Param('userId', ParseUUIDPipe) userId: string, + @Body() dto: RecordHelpInteractionDto, + ) { + this.logger.log(`Recording interaction for user ${userId} on help ${dto.helpId}`); + await this.helpService.recordInteraction(userId, dto); + return { message: 'Interaction recorded successfully' }; + } + + @Get('user/:userId/history') + async getUserHistory( + @Param('userId', ParseUUIDPipe) userId: string, + @Query('helpId') helpId?: string, + ) { + this.logger.log(`Fetching help history for user ${userId}`); + return this.helpService.getUserHelpHistory(userId, helpId); + } + + // Integration Endpoints + @Get('puzzle-start/:userId/:puzzleType') + async getHelpForPuzzleStart( + @Param('userId', ParseUUIDPipe) userId: string, + @Param('puzzleType') puzzleType: string, + @Query('locale') locale?: string, + ) { + this.logger.log(`Getting puzzle start help for user ${userId}, type: ${puzzleType}`); + const help = await this.helpService.getHelpForPuzzleStart(userId, puzzleType); + + if (help && locale) { + return this.localizationService.localizeHelp(help, locale); + } + + return help; + } + + @Get('repeated-failure/:userId/:puzzleId') + async getHelpForRepeatedFailure( + @Param('userId', ParseUUIDPipe) userId: string, + @Param('puzzleId', ParseUUIDPipe) puzzleId: string, + @Query('attempts') attempts: number, + @Query('locale') locale?: string, + ) { + this.logger.log(`Getting failure help for user ${userId}, puzzle: ${puzzleId}`); + const help = await this.helpService.getHelpForRepeatedFailure(userId, puzzleId, attempts); + + if (help && locale) { + return this.localizationService.localizeHelp(help, locale); + } + + return help; + } + + @Get('feature/:userId/:feature') + async getHelpForFeature( + @Param('userId', ParseUUIDPipe) userId: string, + @Param('feature') feature: string, + @Query('locale') locale?: string, + ) { + this.logger.log(`Getting feature help for user ${userId}, feature: ${feature}`); + const help = await this.helpService.getHelpForFeature(userId, feature); + + if (help && locale) { + return this.localizationService.localizeHelp(help, locale); + } + + return help; + } + + // Analytics + @Get('analytics') + async getHelpAnalytics(@Query('helpId') helpId?: string) { + this.logger.log('Fetching contextual help analytics'); + return this.helpService.getHelpAnalytics(helpId); + } +} diff --git a/src/tutorial/controllers/index.ts b/src/tutorial/controllers/index.ts new file mode 100644 index 0000000..6752ff1 --- /dev/null +++ b/src/tutorial/controllers/index.ts @@ -0,0 +1,4 @@ +export * from './tutorial.controller'; +export * from './tutorial-progress.controller'; +export * from './contextual-help.controller'; +export * from './tutorial-analytics.controller'; diff --git a/src/tutorial/controllers/tutorial-analytics.controller.ts b/src/tutorial/controllers/tutorial-analytics.controller.ts new file mode 100644 index 0000000..238b00a --- /dev/null +++ b/src/tutorial/controllers/tutorial-analytics.controller.ts @@ -0,0 +1,148 @@ +import { + Controller, + Get, + Param, + Query, + ParseUUIDPipe, + Logger, +} from '@nestjs/common'; +import { TutorialAnalyticsService } from '../services/tutorial-analytics.service'; +import { + DateRangeDto, + TutorialAnalyticsFilterDto, + TutorialEffectivenessFilterDto, + AnalyticsExportFilterDto, +} from '../dto'; + +@Controller('tutorial-analytics') +export class TutorialAnalyticsController { + private readonly logger = new Logger(TutorialAnalyticsController.name); + + constructor(private readonly analyticsService: TutorialAnalyticsService) {} + + // Completion Rates + @Get('completion-rate/:tutorialId') + async getCompletionRate( + @Param('tutorialId', ParseUUIDPipe) tutorialId: string, + @Query() dateRange: DateRangeDto, + ) { + this.logger.log(`Getting completion rate for tutorial: ${tutorialId}`); + const rate = await this.analyticsService.getCompletionRate(tutorialId, dateRange); + return { tutorialId, rate }; + } + + @Get('completion-rates') + async getAllCompletionRates(@Query() filters: TutorialAnalyticsFilterDto) { + this.logger.log('Getting all tutorial completion rates'); + return this.analyticsService.getAllCompletionRates(filters); + } + + // Step Completion Rates + @Get('step-completion-rates/:tutorialId') + async getStepCompletionRates( + @Param('tutorialId', ParseUUIDPipe) tutorialId: string, + ) { + this.logger.log(`Getting step completion rates for tutorial: ${tutorialId}`); + return this.analyticsService.getStepCompletionRates(tutorialId); + } + + // Drop-off Analysis + @Get('drop-off/:tutorialId') + async getDropOffAnalysis( + @Param('tutorialId', ParseUUIDPipe) tutorialId: string, + ) { + this.logger.log(`Getting drop-off analysis for tutorial: ${tutorialId}`); + return this.analyticsService.getDropOffAnalysis(tutorialId); + } + + @Get('drop-off-points') + async getCommonDropOffPoints() { + this.logger.log('Getting common drop-off points across all tutorials'); + return this.analyticsService.getCommonDropOffPoints(); + } + + // Effectiveness Reports + @Get('effectiveness/:tutorialId') + async getEffectivenessReport( + @Param('tutorialId', ParseUUIDPipe) tutorialId: string, + @Query() filters: TutorialEffectivenessFilterDto, + ) { + this.logger.log(`Getting effectiveness report for tutorial: ${tutorialId}`); + return this.analyticsService.getEffectivenessReport(tutorialId, filters); + } + + @Get('step-effectiveness/:stepId') + async getStepEffectiveness(@Param('stepId', ParseUUIDPipe) stepId: string) { + this.logger.log(`Getting effectiveness for step: ${stepId}`); + return this.analyticsService.getStepEffectiveness(stepId); + } + + // User Analytics + @Get('user/:userId/learning-profile') + async getUserLearningProfile(@Param('userId', ParseUUIDPipe) userId: string) { + this.logger.log(`Getting learning profile for user: ${userId}`); + return this.analyticsService.getUserLearningProfile(userId); + } + + // Average Time Metrics + @Get('average-time/:tutorialId') + async getAverageCompletionTime( + @Param('tutorialId', ParseUUIDPipe) tutorialId: string, + ) { + this.logger.log(`Getting average completion time for tutorial: ${tutorialId}`); + const time = await this.analyticsService.getAverageTimeToCompletion(tutorialId); + return { tutorialId, averageCompletionTimeSeconds: time }; + } + + // Hint Usage Analytics + @Get('hint-usage/:tutorialId') + async getHintUsageAnalytics( + @Param('tutorialId', ParseUUIDPipe) tutorialId: string, + ) { + this.logger.log(`Getting hint usage analytics for tutorial: ${tutorialId}`); + return this.analyticsService.getHintUsageAnalytics(tutorialId); + } + + // Error Patterns + @Get('error-patterns/:tutorialId') + async getErrorPatterns(@Param('tutorialId', ParseUUIDPipe) tutorialId: string) { + this.logger.log(`Getting error patterns for tutorial: ${tutorialId}`); + return this.analyticsService.getErrorPatterns(tutorialId); + } + + // Dashboard + @Get('dashboard') + async getDashboardReport(@Query() dateRange: DateRangeDto) { + this.logger.log('Generating tutorial analytics dashboard'); + return this.analyticsService.generateDashboardReport(dateRange); + } + + // Real-time Metrics + @Get('active-users') + async getActiveUsers() { + this.logger.log('Getting active tutorial users count'); + const count = await this.analyticsService.getActiveUsers(); + return { count }; + } + + @Get('completions/:interval') + async getCurrentCompletions(@Param('interval') interval: 'hour' | 'day') { + this.logger.log(`Getting completions for interval: ${interval}`); + const count = await this.analyticsService.getCurrentCompletions(interval); + return { interval, count }; + } + + // Export + @Get('export') + async exportAnalytics(@Query() filters: AnalyticsExportFilterDto) { + this.logger.log('Exporting tutorial analytics'); + return this.analyticsService.exportAnalyticsData(filters); + } + + // Event Query + @Get('events') + async getEvents(@Query() filters: TutorialAnalyticsFilterDto) { + this.logger.log(`Querying tutorial analytics events`); + return this.analyticsService.queryEvents(filters); + } +} diff --git a/src/tutorial/controllers/tutorial-progress.controller.ts b/src/tutorial/controllers/tutorial-progress.controller.ts new file mode 100644 index 0000000..80fa831 --- /dev/null +++ b/src/tutorial/controllers/tutorial-progress.controller.ts @@ -0,0 +1,140 @@ +import { + Controller, + Get, + Post, + Body, + Param, + ParseUUIDPipe, + Logger, +} from '@nestjs/common'; +import { TutorialProgressService } from '../services/tutorial-progress.service'; +import { + StartTutorialDto, + UpdateStepProgressDto, + SkipTutorialDto, + SkipStepDto, + ResumeTutorialDto, + SaveCheckpointDto, +} from '../dto'; + +@Controller('tutorial-progress') +export class TutorialProgressController { + private readonly logger = new Logger(TutorialProgressController.name); + + constructor(private readonly progressService: TutorialProgressService) {} + + // Start Tutorial + @Post('user/:userId/start') + async startTutorial( + @Param('userId', ParseUUIDPipe) userId: string, + @Body() dto: StartTutorialDto, + ) { + this.logger.log(`User ${userId} starting tutorial: ${dto.tutorialId}`); + return this.progressService.startTutorial(userId, dto); + } + + // Get All Progress for User + @Get('user/:userId') + async getAllProgress(@Param('userId', ParseUUIDPipe) userId: string) { + this.logger.log(`Fetching all progress for user: ${userId}`); + return this.progressService.getAllUserProgress(userId); + } + + // Get Specific Tutorial Progress + @Get('user/:userId/tutorial/:tutorialId') + async getProgress( + @Param('userId', ParseUUIDPipe) userId: string, + @Param('tutorialId', ParseUUIDPipe) tutorialId: string, + ) { + this.logger.log(`Fetching progress for user ${userId} on tutorial ${tutorialId}`); + return this.progressService.getUserProgress(userId, tutorialId); + } + + // Update Step Progress + @Post('user/:userId/step') + async updateStepProgress( + @Param('userId', ParseUUIDPipe) userId: string, + @Body() dto: UpdateStepProgressDto, + ) { + this.logger.log(`Updating step progress for user ${userId}`); + return this.progressService.updateStepProgress(userId, dto); + } + + // Skip Tutorial + @Post('user/:userId/skip') + async skipTutorial( + @Param('userId', ParseUUIDPipe) userId: string, + @Body() dto: SkipTutorialDto, + ) { + this.logger.log(`User ${userId} skipping tutorial: ${dto.tutorialId}`); + return this.progressService.skipTutorial(userId, dto); + } + + // Skip Step + @Post('user/:userId/skip-step') + async skipStep( + @Param('userId', ParseUUIDPipe) userId: string, + @Body() dto: SkipStepDto, + ) { + this.logger.log(`User ${userId} skipping step: ${dto.stepId}`); + return this.progressService.skipStep(userId, dto); + } + + // Resume Tutorial + @Post('user/:userId/resume') + async resumeTutorial( + @Param('userId', ParseUUIDPipe) userId: string, + @Body() dto: ResumeTutorialDto, + ) { + this.logger.log(`User ${userId} resuming tutorial: ${dto.tutorialId}`); + return this.progressService.resumeTutorial(userId, dto); + } + + // Save Checkpoint + @Post('user/:userId/checkpoint') + async saveCheckpoint( + @Param('userId', ParseUUIDPipe) userId: string, + @Body() dto: SaveCheckpointDto, + ) { + this.logger.log(`Saving checkpoint for user ${userId} on tutorial ${dto.tutorialId}`); + await this.progressService.saveCheckpoint(userId, dto); + return { message: 'Checkpoint saved successfully' }; + } + + // Get Next Step + @Get('user/:userId/tutorial/:tutorialId/next-step') + async getNextStep( + @Param('userId', ParseUUIDPipe) userId: string, + @Param('tutorialId', ParseUUIDPipe) tutorialId: string, + ) { + this.logger.log(`Getting next step for user ${userId} on tutorial ${tutorialId}`); + return this.progressService.getNextStep(userId, tutorialId); + } + + // Get Adaptive State + @Get('user/:userId/tutorial/:tutorialId/adaptive-state') + async getAdaptiveState( + @Param('userId', ParseUUIDPipe) userId: string, + @Param('tutorialId', ParseUUIDPipe) tutorialId: string, + ) { + this.logger.log(`Getting adaptive state for user ${userId} on tutorial ${tutorialId}`); + return this.progressService.getAdaptiveState(userId, tutorialId); + } + + // Complete Tutorial + @Post('user/:userId/tutorial/:tutorialId/complete') + async completeTutorial( + @Param('userId', ParseUUIDPipe) userId: string, + @Param('tutorialId', ParseUUIDPipe) tutorialId: string, + ) { + this.logger.log(`Completing tutorial ${tutorialId} for user ${userId}`); + return this.progressService.completeTutorial(userId, tutorialId); + } + + // Get Completed Tutorials + @Get('user/:userId/completed') + async getCompletedTutorials(@Param('userId', ParseUUIDPipe) userId: string) { + this.logger.log(`Getting completed tutorials for user ${userId}`); + return this.progressService.getCompletedTutorials(userId); + } +} diff --git a/src/tutorial/controllers/tutorial.controller.ts b/src/tutorial/controllers/tutorial.controller.ts new file mode 100644 index 0000000..e516582 --- /dev/null +++ b/src/tutorial/controllers/tutorial.controller.ts @@ -0,0 +1,200 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + ParseUUIDPipe, + HttpCode, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { TutorialService } from '../services/tutorial.service'; +import { LocalizationService } from '../services/localization.service'; +import { + CreateTutorialDto, + UpdateTutorialDto, + TutorialFilterDto, + CreateTutorialStepDto, + UpdateTutorialStepDto, + StepOrderDto, +} from '../dto'; + +@Controller('tutorials') +export class TutorialController { + private readonly logger = new Logger(TutorialController.name); + + constructor( + private readonly tutorialService: TutorialService, + private readonly localizationService: LocalizationService, + ) {} + + // Tutorial CRUD + @Post() + async create(@Body() dto: CreateTutorialDto) { + this.logger.log(`Creating tutorial: ${dto.name}`); + return this.tutorialService.create(dto); + } + + @Get() + async findAll(@Query() filters: TutorialFilterDto) { + this.logger.log(`Fetching tutorials with filters: ${JSON.stringify(filters)}`); + return this.tutorialService.findAll(filters); + } + + @Get('onboarding') + async getOnboardingCurriculum() { + this.logger.log('Fetching onboarding curriculum'); + return this.tutorialService.getOnboardingCurriculum(); + } + + @Get('recommended/:userId') + async getRecommendedTutorials(@Param('userId', ParseUUIDPipe) userId: string) { + this.logger.log(`Fetching recommended tutorials for user: ${userId}`); + return this.tutorialService.getRecommendedTutorials(userId); + } + + @Get('mechanic/:mechanic') + async getTutorialsByMechanic(@Param('mechanic') mechanic: string) { + this.logger.log(`Fetching tutorials for mechanic: ${mechanic}`); + return this.tutorialService.getTutorialsByMechanic(mechanic); + } + + @Get(':id') + async findOne( + @Param('id', ParseUUIDPipe) id: string, + @Query('locale') locale?: string, + ) { + this.logger.log(`Fetching tutorial: ${id}`); + const tutorial = await this.tutorialService.findById(id); + + if (locale) { + return this.localizationService.localizeTutorial(tutorial, locale); + } + + return tutorial; + } + + @Patch(':id') + async update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateTutorialDto, + ) { + this.logger.log(`Updating tutorial: ${id}`); + return this.tutorialService.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + async delete(@Param('id', ParseUUIDPipe) id: string) { + this.logger.log(`Deleting tutorial: ${id}`); + await this.tutorialService.delete(id); + } + + // Prerequisites validation + @Get(':id/prerequisites/:userId') + async validatePrerequisites( + @Param('id', ParseUUIDPipe) id: string, + @Param('userId', ParseUUIDPipe) userId: string, + ) { + this.logger.log(`Validating prerequisites for tutorial ${id} and user ${userId}`); + return this.tutorialService.validatePrerequisites(userId, id); + } + + // Step Management + @Post(':tutorialId/steps') + async createStep( + @Param('tutorialId', ParseUUIDPipe) tutorialId: string, + @Body() dto: CreateTutorialStepDto, + ) { + this.logger.log(`Creating step for tutorial: ${tutorialId}`); + dto.tutorialId = tutorialId; + return this.tutorialService.createStep(dto); + } + + @Get(':tutorialId/steps') + async getSteps( + @Param('tutorialId', ParseUUIDPipe) tutorialId: string, + @Query('locale') locale?: string, + ) { + this.logger.log(`Fetching steps for tutorial: ${tutorialId}`); + const steps = await this.tutorialService.getStepsByTutorial(tutorialId); + + if (locale) { + return Promise.all( + steps.map((step) => this.localizationService.localizeStep(step, locale)), + ); + } + + return steps; + } + + @Get(':tutorialId/steps/:stepId') + async getStep( + @Param('stepId', ParseUUIDPipe) stepId: string, + @Query('locale') locale?: string, + ) { + this.logger.log(`Fetching step: ${stepId}`); + const step = await this.tutorialService.getStepById(stepId); + + if (locale) { + return this.localizationService.localizeStep(step, locale); + } + + return step; + } + + @Patch(':tutorialId/steps/:stepId') + async updateStep( + @Param('stepId', ParseUUIDPipe) stepId: string, + @Body() dto: UpdateTutorialStepDto, + ) { + this.logger.log(`Updating step: ${stepId}`); + return this.tutorialService.updateStep(stepId, dto); + } + + @Delete(':tutorialId/steps/:stepId') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteStep(@Param('stepId', ParseUUIDPipe) stepId: string) { + this.logger.log(`Deleting step: ${stepId}`); + await this.tutorialService.deleteStep(stepId); + } + + @Post(':tutorialId/steps/reorder') + async reorderSteps( + @Param('tutorialId', ParseUUIDPipe) tutorialId: string, + @Body() orders: StepOrderDto[], + ) { + this.logger.log(`Reordering steps for tutorial: ${tutorialId}`); + await this.tutorialService.reorderSteps(tutorialId, orders); + return { message: 'Steps reordered successfully' }; + } + + // Localization endpoints + @Get(':id/locales') + async getSupportedLocales() { + return this.localizationService.getSupportedLocales(); + } + + @Post(':id/translations/:locale') + async importTranslations( + @Param('locale') locale: string, + @Body() translations: Record, + ) { + await this.localizationService.importTranslations(locale, translations); + return { message: `Imported translations for locale: ${locale}` }; + } + + @Get(':id/translations/:locale') + async getTranslations(@Param('locale') locale: string) { + return this.localizationService.getTranslationsForLocale(locale); + } + + @Get(':id/translations/:locale/validate') + async validateTranslations(@Param('locale') locale: string) { + return this.localizationService.validateTranslations(locale); + } +} diff --git a/src/tutorial/dto/analytics.dto.ts b/src/tutorial/dto/analytics.dto.ts new file mode 100644 index 0000000..19019a2 --- /dev/null +++ b/src/tutorial/dto/analytics.dto.ts @@ -0,0 +1,204 @@ +import { + IsString, + IsEnum, + IsBoolean, + IsOptional, + IsUUID, + IsDate, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class DateRangeDto { + @IsDate() + @Type(() => Date) + @IsOptional() + startDate?: Date; + + @IsDate() + @Type(() => Date) + @IsOptional() + endDate?: Date; +} + +export class TutorialAnalyticsFilterDto { + @IsUUID() + @IsOptional() + tutorialId?: string; + + @IsUUID() + @IsOptional() + stepId?: string; + + @IsDate() + @Type(() => Date) + @IsOptional() + startDate?: Date; + + @IsDate() + @Type(() => Date) + @IsOptional() + endDate?: Date; + + @IsString() + @IsOptional() + eventType?: string; + + @IsEnum(['day', 'week', 'month']) + @IsOptional() + groupBy?: string; +} + +export class TutorialEffectivenessFilterDto { + @IsDate() + @Type(() => Date) + @IsOptional() + startDate?: Date; + + @IsDate() + @Type(() => Date) + @IsOptional() + endDate?: Date; + + @IsBoolean() + @IsOptional() + includeStepBreakdown?: boolean; + + @IsBoolean() + @IsOptional() + includeDropOffAnalysis?: boolean; +} + +export class AnalyticsExportFilterDto { + @IsUUID() + @IsOptional() + tutorialId?: string; + + @IsDate() + @Type(() => Date) + @IsOptional() + startDate?: Date; + + @IsDate() + @Type(() => Date) + @IsOptional() + endDate?: Date; + + @IsEnum(['csv', 'json']) + @IsOptional() + format?: 'csv' | 'json'; + + @IsBoolean() + @IsOptional() + includeUserDetails?: boolean; +} + +// Response Types +export interface CompletionRateReport { + tutorialId: string; + tutorialName: string; + totalStarted: number; + totalCompleted: number; + completionRate: number; + averageCompletionTime: number; +} + +export interface DropOffAnalysis { + tutorialId: string; + tutorialName: string; + totalStarted: number; + dropOffPoints: Array<{ + stepId: string; + stepTitle: string; + stepOrder: number; + usersReached: number; + usersDropped: number; + dropOffRate: number; + averageTimeBeforeDropOff: number; + }>; + overallDropOffRate: number; +} + +export interface StepEffectiveness { + stepId: string; + stepTitle: string; + completionRate: number; + averageAttempts: number; + averageTimeSpent: number; + hintUsageRate: number; + commonErrors: Array<{ error: string; count: number }>; + skipRate: number; +} + +export interface EffectivenessReport { + tutorialId: string; + tutorialName: string; + period: { startDate: Date; endDate: Date }; + metrics: { + completionRate: number; + averageScore: number; + averageCompletionTime: number; + totalUsers: number; + activeUsers: number; + }; + stepBreakdown?: StepEffectiveness[]; + dropOffAnalysis?: DropOffAnalysis; + trends: Array<{ + date: string; + completions: number; + averageScore: number; + }>; +} + +export interface LearningProfile { + userId: string; + totalTutorialsStarted: number; + totalTutorialsCompleted: number; + overallCompletionRate: number; + averageLearningSpeed: 'slow' | 'normal' | 'fast'; + strongAreas: string[]; + improvementAreas: string[]; + preferredContentTypes: string[]; + totalTimeSpent: number; + recentActivity: Array<{ + tutorialId: string; + tutorialName: string; + status: string; + lastActivityAt: Date; + }>; +} + +export interface TutorialDashboardReport { + period: { startDate: Date; endDate: Date }; + overview: { + totalTutorials: number; + activeTutorials: number; + totalUsersOnboarded: number; + averageCompletionRate: number; + activeUsersToday: number; + }; + topTutorials: Array<{ + tutorialId: string; + tutorialName: string; + completionRate: number; + totalCompletions: number; + }>; + needsAttention: Array<{ + tutorialId: string; + tutorialName: string; + issue: string; + metric: number; + }>; + recentCompletions: Array<{ + userId: string; + tutorialId: string; + tutorialName: string; + completedAt: Date; + score: number; + }>; + trends: Array<{ + date: string; + starts: number; + completions: number; + activeUsers: number; + }>; +} diff --git a/src/tutorial/dto/contextual-help.dto.ts b/src/tutorial/dto/contextual-help.dto.ts new file mode 100644 index 0000000..c12ceb5 --- /dev/null +++ b/src/tutorial/dto/contextual-help.dto.ts @@ -0,0 +1,251 @@ +import { + IsString, + IsEnum, + IsNumber, + IsBoolean, + IsOptional, + IsArray, + IsObject, + IsUUID, + Min, + MinLength, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { PartialType } from '@nestjs/mapped-types'; +import { + TriggerContext, + HelpDisplayType, +} from '../entities/contextual-help.entity'; +import { InteractionAction } from '../entities/contextual-help-interaction.entity'; + +export class HelpContentDto { + @IsString() + @MinLength(3) + title: string; + + @IsString() + @MinLength(10) + body: string; + + @IsEnum(['tooltip', 'modal', 'overlay', 'sidebar', 'banner']) + type: HelpDisplayType; + + @IsObject() + @IsOptional() + media?: { + imageUrl?: string; + videoUrl?: string; + animationUrl?: string; + }; + + @IsArray() + @IsOptional() + actions?: Array<{ + label: string; + action: 'dismiss' | 'learn_more' | 'show_tutorial' | 'custom'; + targetUrl?: string; + tutorialId?: string; + }>; +} + +export class TriggerConditionsDto { + @IsNumber() + @Min(0) + @IsOptional() + minAttempts?: number; + + @IsNumber() + @Min(0) + @IsOptional() + maxAttempts?: number; + + @IsNumber() + @Min(0) + @IsOptional() + timeThreshold?: number; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + errorPatterns?: string[]; + + @IsObject() + @IsOptional() + userLevel?: { min?: number; max?: number }; + + @IsBoolean() + @IsOptional() + hasCompletedTutorial?: boolean; + + @IsUUID() + @IsOptional() + tutorialId?: string; +} + +export class DisplayRulesDto { + @IsNumber() + @Min(1) + @IsOptional() + maxShowCount?: number; + + @IsNumber() + @Min(0) + @IsOptional() + cooldownSeconds?: number; + + @IsBoolean() + @IsOptional() + showOnce?: boolean; + + @IsBoolean() + @IsOptional() + dismissable?: boolean; + + @IsNumber() + @Min(0) + @IsOptional() + autoHideAfter?: number; +} + +export class CreateContextualHelpDto { + @IsString() + @MinLength(3) + @MaxLength(100) + name: string; + + @IsEnum([ + 'puzzle_start', + 'hint_needed', + 'repeated_failure', + 'first_visit', + 'feature_discovery', + 'idle_timeout', + 'achievement_near', + 'custom', + ]) + triggerContext: TriggerContext; + + @IsString() + @IsOptional() + targetFeature?: string; + + @IsString() + @IsOptional() + targetPuzzleType?: string; + + @IsNumber() + @Min(0) + @IsOptional() + priority?: number; + + @ValidateNested() + @Type(() => HelpContentDto) + content: HelpContentDto; + + @ValidateNested() + @Type(() => TriggerConditionsDto) + @IsOptional() + triggerConditions?: TriggerConditionsDto; + + @ValidateNested() + @Type(() => DisplayRulesDto) + @IsOptional() + displayRules?: DisplayRulesDto; + + @IsBoolean() + @IsOptional() + isActive?: boolean; +} + +export class UpdateContextualHelpDto extends PartialType(CreateContextualHelpDto) {} + +export class ContextualHelpFilterDto { + @IsEnum([ + 'puzzle_start', + 'hint_needed', + 'repeated_failure', + 'first_visit', + 'feature_discovery', + 'idle_timeout', + 'achievement_near', + 'custom', + ]) + @IsOptional() + triggerContext?: TriggerContext; + + @IsString() + @IsOptional() + targetFeature?: string; + + @IsString() + @IsOptional() + targetPuzzleType?: string; + + @IsBoolean() + @IsOptional() + isActive?: boolean; +} + +export class TriggerContextualHelpDto { + @IsString() + context: string; + + @IsUUID() + @IsOptional() + puzzleId?: string; + + @IsString() + @IsOptional() + puzzleType?: string; + + @IsString() + @IsOptional() + feature?: string; + + @IsNumber() + @Min(0) + @IsOptional() + attempts?: number; + + @IsNumber() + @Min(0) + @IsOptional() + timeSpent?: number; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + recentErrors?: string[]; + + @IsNumber() + @IsOptional() + userLevel?: number; +} + +export class RecordHelpInteractionDto { + @IsUUID() + helpId: string; + + @IsEnum(['shown', 'dismissed', 'clicked', 'completed', 'auto_hidden']) + action: InteractionAction; + + @IsNumber() + @Min(0) + @IsOptional() + viewDuration?: number; + + @IsString() + @IsOptional() + actionTaken?: string; + + @IsObject() + @IsOptional() + context?: { + puzzleId?: string; + sessionId?: string; + currentStep?: string; + errorState?: string; + }; +} diff --git a/src/tutorial/dto/index.ts b/src/tutorial/dto/index.ts new file mode 100644 index 0000000..1847bd1 --- /dev/null +++ b/src/tutorial/dto/index.ts @@ -0,0 +1,4 @@ +export * from './tutorial.dto'; +export * from './progress.dto'; +export * from './contextual-help.dto'; +export * from './analytics.dto'; diff --git a/src/tutorial/dto/progress.dto.ts b/src/tutorial/dto/progress.dto.ts new file mode 100644 index 0000000..945339e --- /dev/null +++ b/src/tutorial/dto/progress.dto.ts @@ -0,0 +1,140 @@ +import { + IsString, + IsEnum, + IsNumber, + IsBoolean, + IsOptional, + IsArray, + IsObject, + IsUUID, + Min, + Max, +} from 'class-validator'; +import { StepProgressStatus } from '../entities/user-tutorial-progress.entity'; + +export class StartTutorialDto { + @IsUUID() + tutorialId: string; + + @IsUUID() + @IsOptional() + sessionId?: string; + + @IsBoolean() + @IsOptional() + resumeFromCheckpoint?: boolean; +} + +export class UpdateStepProgressDto { + @IsUUID() + tutorialId: string; + + @IsUUID() + stepId: string; + + @IsEnum(['in_progress', 'completed', 'skipped', 'failed']) + status: StepProgressStatus; + + @IsNumber() + @Min(0) + @IsOptional() + timeSpent?: number; + + @IsNumber() + @Min(0) + @Max(100) + @IsOptional() + score?: number; + + @IsNumber() + @Min(0) + @IsOptional() + hintsUsed?: number; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + errors?: string[]; + + @IsObject() + @IsOptional() + interactiveResult?: any; + + @IsObject() + @IsOptional() + saveState?: any; +} + +export class SkipTutorialDto { + @IsUUID() + tutorialId: string; + + @IsString() + @IsOptional() + reason?: string; + + @IsBoolean() + @IsOptional() + confirmSkip?: boolean; +} + +export class SkipStepDto { + @IsUUID() + tutorialId: string; + + @IsUUID() + stepId: string; + + @IsString() + @IsOptional() + reason?: string; +} + +export class ResumeTutorialDto { + @IsUUID() + tutorialId: string; + + @IsUUID() + @IsOptional() + fromStepId?: string; + + @IsBoolean() + @IsOptional() + fromCheckpoint?: boolean; +} + +export class SaveCheckpointDto { + @IsUUID() + tutorialId: string; + + @IsUUID() + stepId: string; + + @IsObject() + state: any; +} + +export class CompleteTutorialDto { + @IsUUID() + tutorialId: string; + + @IsNumber() + @Min(0) + @Max(100) + @IsOptional() + finalScore?: number; + + @IsString() + @IsOptional() + feedback?: string; +} + +export class UserProgressFilterDto { + @IsEnum(['not_started', 'in_progress', 'completed', 'skipped', 'abandoned']) + @IsOptional() + status?: string; + + @IsUUID() + @IsOptional() + tutorialId?: string; +} diff --git a/src/tutorial/dto/tutorial.dto.ts b/src/tutorial/dto/tutorial.dto.ts new file mode 100644 index 0000000..28e67b7 --- /dev/null +++ b/src/tutorial/dto/tutorial.dto.ts @@ -0,0 +1,314 @@ +import { + IsString, + IsEnum, + IsNumber, + IsBoolean, + IsOptional, + IsArray, + IsObject, + IsUUID, + Min, + Max, + MinLength, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { PartialType, OmitType } from '@nestjs/mapped-types'; +import { + TutorialType, + DifficultyLevel, + TutorialMetadata, +} from '../entities/tutorial.entity'; +import { + StepType, + StepContent, + InteractiveConfig, + CompletionCriteria, + AdaptivePacing, + StepAccessibility, +} from '../entities/tutorial-step.entity'; + +// Tutorial DTOs +export class CreateTutorialDto { + @IsString() + @MinLength(3) + @MaxLength(100) + name: string; + + @IsString() + @MinLength(10) + description: string; + + @IsEnum(['onboarding', 'mechanic', 'advanced', 'refresher']) + type: TutorialType; + + @IsString() + @MinLength(2) + @MaxLength(50) + category: string; + + @IsEnum(['beginner', 'easy', 'medium', 'hard', 'expert']) + @IsOptional() + difficultyLevel?: DifficultyLevel; + + @IsNumber() + @Min(0) + @IsOptional() + order?: number; + + @IsNumber() + @Min(0) + @IsOptional() + estimatedDurationMinutes?: number; + + @IsArray() + @IsUUID(4, { each: true }) + @IsOptional() + prerequisites?: string[]; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + targetMechanics?: string[]; + + @IsBoolean() + @IsOptional() + isSkippable?: boolean; + + @IsBoolean() + @IsOptional() + isActive?: boolean; + + @IsObject() + @IsOptional() + metadata?: TutorialMetadata; +} + +export class UpdateTutorialDto extends PartialType(CreateTutorialDto) {} + +export class TutorialFilterDto { + @IsEnum(['onboarding', 'mechanic', 'advanced', 'refresher']) + @IsOptional() + type?: TutorialType; + + @IsString() + @IsOptional() + category?: string; + + @IsEnum(['beginner', 'easy', 'medium', 'hard', 'expert']) + @IsOptional() + difficultyLevel?: DifficultyLevel; + + @IsBoolean() + @IsOptional() + isActive?: boolean; + + @IsString() + @IsOptional() + targetMechanic?: string; +} + +// Step Content DTOs +export class StepContentDto { + @IsString() + @MinLength(5) + instructions: string; + + @IsObject() + @IsOptional() + richContent?: { markdown?: string; html?: string }; + + @IsObject() + @IsOptional() + media?: { + images?: Array<{ url: string; alt: string; caption?: string }>; + videos?: Array<{ url: string; caption?: string; duration?: number }>; + animations?: Array<{ url: string; type: string }>; + }; + + @IsArray() + @IsOptional() + highlights?: Array<{ + elementSelector: string; + description: string; + action?: 'click' | 'hover' | 'focus'; + }>; + + @IsArray() + @IsOptional() + tooltips?: Array<{ + target: string; + content: string; + position: 'top' | 'bottom' | 'left' | 'right'; + }>; +} + +export class InteractiveConfigDto { + @IsEnum(['drag-drop', 'click-sequence', 'input', 'selection', 'puzzle-mini']) + type: string; + + @IsObject() + config: Record; + + @IsObject() + expectedOutcome: any; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + hints?: string[]; + + @IsNumber() + @Min(1) + @IsOptional() + maxAttempts?: number; +} + +export class CompletionCriteriaDto { + @IsEnum(['auto', 'action', 'quiz', 'time', 'manual']) + type: string; + + @IsArray() + @IsOptional() + conditions?: Array<{ + field: string; + operator: string; + value: any; + }>; + + @IsNumber() + @Min(0) + @Max(100) + @IsOptional() + minimumScore?: number; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + requiredActions?: string[]; +} + +export class AdaptivePacingDto { + @IsNumber() + @Min(0) + @IsOptional() + minTimeOnStep?: number; + + @IsBoolean() + @IsOptional() + skipIfProficient?: boolean; + + @IsNumber() + @Min(0) + @Max(1) + @IsOptional() + proficiencyThreshold?: number; + + @IsNumber() + @Min(0) + @Max(1) + @IsOptional() + repeatIfStrugglingThreshold?: number; + + @IsBoolean() + @IsOptional() + adaptiveHints?: boolean; +} + +export class StepAccessibilityDto { + @IsString() + @IsOptional() + ariaLabel?: string; + + @IsString() + @IsOptional() + screenReaderText?: string; + + @IsObject() + @IsOptional() + keyboardShortcuts?: Record; + + @IsObject() + @IsOptional() + reducedMotionAlternative?: any; + + @IsBoolean() + @IsOptional() + highContrastSupport?: boolean; +} + +// TutorialStep DTOs +export class CreateTutorialStepDto { + @IsUUID() + tutorialId: string; + + @IsNumber() + @Min(1) + order: number; + + @IsString() + @MinLength(3) + @MaxLength(100) + title: string; + + @IsEnum(['instruction', 'interactive', 'practice', 'quiz', 'demonstration', 'checkpoint']) + type: StepType; + + @ValidateNested() + @Type(() => StepContentDto) + content: StepContentDto; + + @ValidateNested() + @Type(() => InteractiveConfigDto) + @IsOptional() + interactive?: InteractiveConfigDto; + + @ValidateNested() + @Type(() => CompletionCriteriaDto) + @IsOptional() + completionCriteria?: CompletionCriteriaDto; + + @ValidateNested() + @Type(() => AdaptivePacingDto) + @IsOptional() + adaptivePacing?: AdaptivePacingDto; + + @ValidateNested() + @Type(() => StepAccessibilityDto) + @IsOptional() + accessibility?: StepAccessibilityDto; + + @IsBoolean() + @IsOptional() + isOptional?: boolean; + + @IsBoolean() + @IsOptional() + isActive?: boolean; + + @IsNumber() + @Min(0) + @IsOptional() + timeLimit?: number; +} + +export class UpdateTutorialStepDto extends PartialType( + OmitType(CreateTutorialStepDto, ['tutorialId'] as const), +) {} + +export class StepOrderDto { + @IsUUID() + id: string; + + @IsNumber() + @Min(1) + order: number; +} + +export class ReorderStepsDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => StepOrderDto) + orders: StepOrderDto[]; +} diff --git a/src/tutorial/entities/contextual-help-interaction.entity.ts b/src/tutorial/entities/contextual-help-interaction.entity.ts new file mode 100644 index 0000000..496f491 --- /dev/null +++ b/src/tutorial/entities/contextual-help-interaction.entity.ts @@ -0,0 +1,64 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { ContextualHelp } from './contextual-help.entity'; + +export type InteractionAction = 'shown' | 'dismissed' | 'clicked' | 'completed' | 'auto_hidden'; + +export interface InteractionContext { + puzzleId?: string; + sessionId?: string; + currentStep?: string; + errorState?: string; + deviceInfo?: { + deviceType?: string; + browser?: string; + screenSize?: string; + }; +} + +@Entity('contextual_help_interactions') +@Index(['userId', 'helpId']) +@Index(['userId', 'triggerContext']) +@Index(['helpId', 'action']) +export class ContextualHelpInteraction { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + userId: string; + + @Column({ type: 'uuid' }) + @Index() + helpId: string; + + @Column({ type: 'varchar', length: 50 }) + triggerContext: string; + + @Column({ type: 'varchar', length: 20 }) + action: InteractionAction; + + @Column({ type: 'int', nullable: true }) + viewDuration?: number; + + @Column({ type: 'varchar', length: 50, nullable: true }) + actionTaken?: string; + + @Column({ type: 'jsonb', nullable: true }) + context?: InteractionContext; + + @CreateDateColumn({ type: 'timestamptz' }) + @Index() + createdAt: Date; + + @ManyToOne(() => ContextualHelp, (help) => help.interactions, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'helpId' }) + help: ContextualHelp; +} diff --git a/src/tutorial/entities/contextual-help.entity.ts b/src/tutorial/entities/contextual-help.entity.ts new file mode 100644 index 0000000..1e79d35 --- /dev/null +++ b/src/tutorial/entities/contextual-help.entity.ts @@ -0,0 +1,123 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { ContextualHelpInteraction } from './contextual-help-interaction.entity'; + +export type TriggerContext = + | 'puzzle_start' + | 'hint_needed' + | 'repeated_failure' + | 'first_visit' + | 'feature_discovery' + | 'idle_timeout' + | 'achievement_near' + | 'custom'; + +export type HelpDisplayType = 'tooltip' | 'modal' | 'overlay' | 'sidebar' | 'banner'; + +export interface HelpContent { + title: string; + body: string; + type: HelpDisplayType; + media?: { + imageUrl?: string; + videoUrl?: string; + animationUrl?: string; + }; + actions?: Array<{ + label: string; + action: 'dismiss' | 'learn_more' | 'show_tutorial' | 'custom'; + targetUrl?: string; + tutorialId?: string; + }>; +} + +export interface TriggerConditions { + minAttempts?: number; + maxAttempts?: number; + timeThreshold?: number; + errorPatterns?: string[]; + userLevel?: { min?: number; max?: number }; + hasCompletedTutorial?: boolean; + tutorialId?: string; +} + +export interface DisplayRules { + maxShowCount?: number; + cooldownSeconds?: number; + showOnce?: boolean; + dismissable?: boolean; + autoHideAfter?: number; +} + +export interface HelpLocalization { + titleKey?: string; + bodyKey?: string; + actionsKeys?: Record; +} + +export interface HelpAnalytics { + totalShown?: number; + dismissRate?: number; + actionTakenRate?: number; + averageViewTime?: number; +} + +@Entity('contextual_help') +@Index(['triggerContext', 'isActive']) +@Index(['targetFeature']) +@Index(['targetPuzzleType']) +export class ContextualHelp { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'varchar', length: 50 }) + @Index() + triggerContext: TriggerContext; + + @Column({ type: 'varchar', length: 50, nullable: true }) + targetFeature?: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + targetPuzzleType?: string; + + @Column({ type: 'int', default: 0 }) + priority: number; + + @Column({ type: 'jsonb' }) + content: HelpContent; + + @Column({ type: 'jsonb', default: {} }) + triggerConditions: TriggerConditions; + + @Column({ type: 'jsonb', default: {} }) + displayRules: DisplayRules; + + @Column({ type: 'boolean', default: true }) + @Index() + isActive: boolean; + + @Column({ type: 'jsonb', default: {} }) + localization: HelpLocalization; + + @Column({ type: 'jsonb', default: {} }) + analytics: HelpAnalytics; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; + + @OneToMany(() => ContextualHelpInteraction, (interaction) => interaction.help) + interactions: ContextualHelpInteraction[]; +} diff --git a/src/tutorial/entities/index.ts b/src/tutorial/entities/index.ts new file mode 100644 index 0000000..d1e596b --- /dev/null +++ b/src/tutorial/entities/index.ts @@ -0,0 +1,6 @@ +export * from './tutorial.entity'; +export * from './tutorial-step.entity'; +export * from './user-tutorial-progress.entity'; +export * from './contextual-help.entity'; +export * from './contextual-help-interaction.entity'; +export * from './tutorial-analytics-event.entity'; diff --git a/src/tutorial/entities/tutorial-analytics-event.entity.ts b/src/tutorial/entities/tutorial-analytics-event.entity.ts new file mode 100644 index 0000000..f027a4c --- /dev/null +++ b/src/tutorial/entities/tutorial-analytics-event.entity.ts @@ -0,0 +1,86 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export type TutorialEventType = + | 'tutorial_started' + | 'tutorial_completed' + | 'tutorial_skipped' + | 'tutorial_abandoned' + | 'step_started' + | 'step_completed' + | 'step_skipped' + | 'step_failed' + | 'hint_used' + | 'error_made' + | 'checkpoint_saved' + | 'checkpoint_restored' + | 'adaptive_pace_changed' + | 'contextual_help_shown' + | 'contextual_help_dismissed'; + +export interface EventPayload { + timeSpent?: number; + attempts?: number; + score?: number; + hintsUsed?: number; + errorDetails?: any; + userAction?: string; + adaptiveDecision?: string; + previousStep?: string; + nextStep?: string; + skipReason?: string; + completionSummary?: { + totalTime: number; + totalSteps: number; + completedSteps: number; + overallScore: number; + }; + deviceInfo?: { + deviceType?: string; + browser?: string; + screenSize?: string; + os?: string; + }; + [key: string]: any; +} + +@Entity('tutorial_analytics_events') +@Index(['eventType', 'createdAt']) +@Index(['userId', 'tutorialId']) +@Index(['tutorialId', 'stepId']) +@Index(['sessionId']) +export class TutorialAnalyticsEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 50 }) + @Index() + eventType: TutorialEventType; + + @Column({ type: 'uuid', nullable: true }) + @Index() + userId?: string; + + @Column({ type: 'uuid', nullable: true }) + @Index() + tutorialId?: string; + + @Column({ type: 'uuid', nullable: true }) + @Index() + stepId?: string; + + @Column({ type: 'uuid', nullable: true }) + sessionId?: string; + + @Column({ type: 'jsonb' }) + payload: EventPayload; + + @CreateDateColumn({ type: 'timestamptz' }) + @Index() + createdAt: Date; +} diff --git a/src/tutorial/entities/tutorial-step.entity.ts b/src/tutorial/entities/tutorial-step.entity.ts new file mode 100644 index 0000000..71588d4 --- /dev/null +++ b/src/tutorial/entities/tutorial-step.entity.ts @@ -0,0 +1,145 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tutorial } from './tutorial.entity'; + +export type StepType = 'instruction' | 'interactive' | 'practice' | 'quiz' | 'demonstration' | 'checkpoint'; + +export interface StepContent { + instructions: string; + richContent?: { + markdown?: string; + html?: string; + }; + media?: { + images?: Array<{ url: string; alt: string; caption?: string }>; + videos?: Array<{ url: string; caption?: string; duration?: number }>; + animations?: Array<{ url: string; type: string }>; + }; + highlights?: Array<{ + elementSelector: string; + description: string; + action?: 'click' | 'hover' | 'focus'; + }>; + tooltips?: Array<{ + target: string; + content: string; + position: 'top' | 'bottom' | 'left' | 'right'; + }>; +} + +export interface InteractiveConfig { + type: 'drag-drop' | 'click-sequence' | 'input' | 'selection' | 'puzzle-mini'; + config: Record; + expectedOutcome: any; + hints?: string[]; + maxAttempts?: number; +} + +export interface CompletionCriteria { + type: 'auto' | 'action' | 'quiz' | 'time' | 'manual'; + conditions?: Array<{ + field: string; + operator: 'equals' | 'greater_than' | 'less_than' | 'contains'; + value: any; + }>; + minimumScore?: number; + requiredActions?: string[]; +} + +export interface AdaptivePacing { + minTimeOnStep?: number; + skipIfProficient?: boolean; + proficiencyThreshold?: number; + repeatIfStrugglingThreshold?: number; + adaptiveHints?: boolean; +} + +export interface StepAccessibility { + ariaLabel?: string; + screenReaderText?: string; + keyboardShortcuts?: Record; + reducedMotionAlternative?: any; + highContrastSupport?: boolean; +} + +export interface StepLocalization { + titleKey?: string; + instructionsKey?: string; + contentKeys?: Record; +} + +export interface StepAnalytics { + averageTimeSpent?: number; + completionRate?: number; + hintUsageRate?: number; + commonErrors?: string[]; +} + +@Entity('tutorial_steps') +@Index(['tutorialId', 'order']) +@Index(['type', 'isActive']) +export class TutorialStep { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + tutorialId: string; + + @Column({ type: 'int' }) + order: number; + + @Column({ type: 'varchar', length: 100 }) + title: string; + + @Column({ type: 'varchar', length: 20 }) + type: StepType; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'boolean', default: false }) + isOptional: boolean; + + @Column({ type: 'int', nullable: true }) + timeLimit?: number; + + @Column({ type: 'jsonb' }) + content: StepContent; + + @Column({ type: 'jsonb', nullable: true }) + interactive?: InteractiveConfig; + + @Column({ type: 'jsonb', default: {} }) + completionCriteria: CompletionCriteria; + + @Column({ type: 'jsonb', default: {} }) + adaptivePacing: AdaptivePacing; + + @Column({ type: 'jsonb', default: {} }) + localization: StepLocalization; + + @Column({ type: 'jsonb', default: {} }) + accessibility: StepAccessibility; + + @Column({ type: 'jsonb', default: {} }) + analytics: StepAnalytics; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => Tutorial, (tutorial) => tutorial.steps, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'tutorialId' }) + tutorial: Tutorial; +} diff --git a/src/tutorial/entities/tutorial.entity.ts b/src/tutorial/entities/tutorial.entity.ts new file mode 100644 index 0000000..131e89d --- /dev/null +++ b/src/tutorial/entities/tutorial.entity.ts @@ -0,0 +1,109 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { TutorialStep } from './tutorial-step.entity'; +import { UserTutorialProgress } from './user-tutorial-progress.entity'; + +export type TutorialType = 'onboarding' | 'mechanic' | 'advanced' | 'refresher'; +export type DifficultyLevel = 'beginner' | 'easy' | 'medium' | 'hard' | 'expert'; + +export interface TutorialMetadata { + version?: string; + tags?: string[]; + targetAudience?: string[]; + unlockConditions?: { + minLevel?: number; + requiredAchievements?: string[]; + completedTutorials?: string[]; + }; + rewardsOnCompletion?: { + xp?: number; + achievementId?: string; + unlockedFeatures?: string[]; + }; +} + +export interface TutorialAnalytics { + totalStarted?: number; + totalCompleted?: number; + averageCompletionTime?: number; + dropOffRate?: number; + lastCalculatedAt?: Date; +} + +@Entity('tutorials') +@Index(['type', 'isActive']) +@Index(['difficultyLevel', 'order']) +@Index(['category']) +export class Tutorial { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 100 }) + @Index() + name: string; + + @Column({ type: 'text' }) + description: string; + + @Column({ type: 'varchar', length: 20, default: 'onboarding' }) + @Index() + type: TutorialType; + + @Column({ type: 'varchar', length: 50 }) + @Index() + category: string; + + @Column({ type: 'varchar', length: 20, default: 'easy' }) + @Index() + difficultyLevel: DifficultyLevel; + + @Column({ type: 'int', default: 0 }) + @Index() + order: number; + + @Column({ type: 'int', default: 0 }) + estimatedDurationMinutes: number; + + @Column({ type: 'simple-array', default: '' }) + prerequisites: string[]; + + @Column({ type: 'simple-array', default: '' }) + targetMechanics: string[]; + + @Column({ type: 'boolean', default: true }) + @Index() + isActive: boolean; + + @Column({ type: 'boolean', default: false }) + isSkippable: boolean; + + @Column({ type: 'jsonb', default: {} }) + metadata: TutorialMetadata; + + @Column({ type: 'jsonb', default: {} }) + analytics: TutorialAnalytics; + + @CreateDateColumn({ type: 'timestamptz' }) + @Index() + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ type: 'timestamptz' }) + deletedAt?: Date; + + @OneToMany(() => TutorialStep, (step) => step.tutorial) + steps: TutorialStep[]; + + @OneToMany(() => UserTutorialProgress, (progress) => progress.tutorial) + userProgress: UserTutorialProgress[]; +} diff --git a/src/tutorial/entities/user-tutorial-progress.entity.ts b/src/tutorial/entities/user-tutorial-progress.entity.ts new file mode 100644 index 0000000..3c98ece --- /dev/null +++ b/src/tutorial/entities/user-tutorial-progress.entity.ts @@ -0,0 +1,131 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tutorial } from './tutorial.entity'; + +export type ProgressStatus = 'not_started' | 'in_progress' | 'completed' | 'skipped' | 'abandoned'; +export type LearningSpeed = 'slow' | 'normal' | 'fast'; +export type StepProgressStatus = 'pending' | 'in_progress' | 'completed' | 'skipped' | 'failed'; + +export interface StepProgress { + stepId: string; + stepOrder: number; + status: StepProgressStatus; + attempts: number; + timeSpent: number; + score?: number; + hintsUsed: number; + completedAt?: Date; + errors?: string[]; + feedback?: string; +} + +export interface AdaptiveState { + learningSpeed: LearningSpeed; + proficiencyLevel: number; + strugglingAreas?: string[]; + strongAreas?: string[]; + recommendedPace?: number; + skipEligibleSteps?: string[]; + repeatRecommendedSteps?: string[]; +} + +export interface SessionData { + lastSessionId?: string; + saveState?: any; + checkpoints?: Array<{ + stepId: string; + state: any; + savedAt: Date; + }>; +} + +export interface ProgressAnalytics { + averageStepTime?: number; + hintUsageRate?: number; + errorRate?: number; + retryRate?: number; + deviceType?: string; + browserType?: string; +} + +@Entity('user_tutorial_progress') +@Index(['userId', 'tutorialId'], { unique: true }) +@Index(['userId', 'status']) +@Index(['tutorialId', 'status']) +export class UserTutorialProgress { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + userId: string; + + @Column({ type: 'uuid' }) + @Index() + tutorialId: string; + + @Column({ type: 'varchar', length: 20, default: 'not_started' }) + @Index() + status: ProgressStatus; + + @Column({ type: 'int', default: 0 }) + currentStepOrder: number; + + @Column({ type: 'uuid', nullable: true }) + currentStepId?: string; + + @Column({ type: 'int', default: 0 }) + completedSteps: number; + + @Column({ type: 'int', default: 0 }) + totalSteps: number; + + @Column({ type: 'decimal', precision: 5, scale: 2, default: 0 }) + progressPercentage: number; + + @Column({ type: 'int', default: 0 }) + totalTimeSpent: number; + + @Column({ type: 'decimal', precision: 5, scale: 2, nullable: true }) + overallScore?: number; + + @Column({ type: 'timestamptz', nullable: true }) + startedAt?: Date; + + @Column({ type: 'timestamptz', nullable: true }) + completedAt?: Date; + + @Column({ type: 'timestamptz', nullable: true }) + lastActivityAt?: Date; + + @Column({ type: 'jsonb', default: [] }) + stepProgress: StepProgress[]; + + @Column({ type: 'jsonb', default: { learningSpeed: 'normal', proficiencyLevel: 0 } }) + adaptiveState: AdaptiveState; + + @Column({ type: 'jsonb', default: {} }) + sessionData: SessionData; + + @Column({ type: 'jsonb', default: {} }) + analytics: ProgressAnalytics; + + @CreateDateColumn({ type: 'timestamptz' }) + @Index() + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => Tutorial, (tutorial) => tutorial.userProgress, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'tutorialId' }) + tutorial: Tutorial; +} diff --git a/src/tutorial/services/contextual-help.service.ts b/src/tutorial/services/contextual-help.service.ts new file mode 100644 index 0000000..69557b5 --- /dev/null +++ b/src/tutorial/services/contextual-help.service.ts @@ -0,0 +1,322 @@ +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, MoreThanOrEqual } from 'typeorm'; +import { ContextualHelp } from '../entities/contextual-help.entity'; +import { ContextualHelpInteraction } from '../entities/contextual-help-interaction.entity'; +import { + CreateContextualHelpDto, + UpdateContextualHelpDto, + ContextualHelpFilterDto, + TriggerContextualHelpDto, + RecordHelpInteractionDto, +} from '../dto'; + +@Injectable() +export class ContextualHelpService { + private readonly logger = new Logger(ContextualHelpService.name); + + constructor( + @InjectRepository(ContextualHelp) + private readonly helpRepo: Repository, + @InjectRepository(ContextualHelpInteraction) + private readonly interactionRepo: Repository, + ) {} + + // CRUD Operations + async create(dto: CreateContextualHelpDto): Promise { + const help = this.helpRepo.create({ + ...dto, + triggerConditions: dto.triggerConditions || {}, + displayRules: dto.displayRules || {}, + localization: {}, + analytics: {}, + }); + const saved = await this.helpRepo.save(help); + this.logger.log(`Created contextual help: ${saved.id} - ${saved.name}`); + return saved; + } + + async findById(id: string): Promise { + const help = await this.helpRepo.findOne({ where: { id } }); + if (!help) { + throw new NotFoundException(`Contextual help not found: ${id}`); + } + return help; + } + + async findAll(filters?: ContextualHelpFilterDto): Promise { + const query = this.helpRepo.createQueryBuilder('help'); + + if (filters?.triggerContext) { + query.andWhere('help.triggerContext = :triggerContext', { + triggerContext: filters.triggerContext, + }); + } + if (filters?.targetFeature) { + query.andWhere('help.targetFeature = :targetFeature', { + targetFeature: filters.targetFeature, + }); + } + if (filters?.targetPuzzleType) { + query.andWhere('help.targetPuzzleType = :targetPuzzleType', { + targetPuzzleType: filters.targetPuzzleType, + }); + } + if (filters?.isActive !== undefined) { + query.andWhere('help.isActive = :isActive', { isActive: filters.isActive }); + } + + query.orderBy('help.priority', 'DESC'); + return query.getMany(); + } + + async update(id: string, dto: UpdateContextualHelpDto): Promise { + const help = await this.findById(id); + Object.assign(help, dto); + const updated = await this.helpRepo.save(help); + this.logger.log(`Updated contextual help: ${id}`); + return updated; + } + + async delete(id: string): Promise { + const help = await this.findById(id); + await this.helpRepo.remove(help); + this.logger.log(`Deleted contextual help: ${id}`); + } + + // Trigger Logic + async triggerHelp( + userId: string, + dto: TriggerContextualHelpDto, + ): Promise { + const applicable = await this.getApplicableHelp(userId, dto); + + if (applicable.length === 0) { + return null; + } + + // Pick highest priority help that should be shown + for (const help of applicable) { + if (await this.shouldShowHelp(userId, help.id)) { + // Record that help was shown + await this.recordInteraction(userId, { + helpId: help.id, + action: 'shown', + context: { + puzzleId: dto.puzzleId, + }, + }); + + // Update analytics + await this.updateHelpAnalytics(help.id, 'shown'); + + return help; + } + } + + return null; + } + + async getApplicableHelp( + userId: string, + dto: TriggerContextualHelpDto, + ): Promise { + const query = this.helpRepo + .createQueryBuilder('help') + .where('help.isActive = true') + .andWhere('help.triggerContext = :context', { context: dto.context }); + + if (dto.feature) { + query.andWhere('(help.targetFeature IS NULL OR help.targetFeature = :feature)', { + feature: dto.feature, + }); + } + + if (dto.puzzleType) { + query.andWhere('(help.targetPuzzleType IS NULL OR help.targetPuzzleType = :puzzleType)', { + puzzleType: dto.puzzleType, + }); + } + + const candidates = await query.orderBy('help.priority', 'DESC').getMany(); + + // Filter by trigger conditions + const filtered = candidates.filter((help) => + this.checkTriggerConditions(help, dto), + ); + + return filtered; + } + + async shouldShowHelp(userId: string, helpId: string): Promise { + const help = await this.findById(helpId); + const rules = help.displayRules; + + // Check showOnce + if (rules.showOnce) { + const shown = await this.interactionRepo.findOne({ + where: { userId, helpId, action: 'shown' }, + }); + if (shown) return false; + } + + // Check maxShowCount + if (rules.maxShowCount) { + const showCount = await this.getShowCount(userId, helpId); + if (showCount >= rules.maxShowCount) return false; + } + + // Check cooldown + if (rules.cooldownSeconds) { + const lastShown = await this.getLastShownTime(userId, helpId); + if (lastShown) { + const cooldownEnd = new Date(lastShown.getTime() + rules.cooldownSeconds * 1000); + if (new Date() < cooldownEnd) return false; + } + } + + return true; + } + + private checkTriggerConditions( + help: ContextualHelp, + dto: TriggerContextualHelpDto, + ): boolean { + const conditions = help.triggerConditions; + + if (conditions.minAttempts !== undefined && dto.attempts !== undefined) { + if (dto.attempts < conditions.minAttempts) return false; + } + + if (conditions.maxAttempts !== undefined && dto.attempts !== undefined) { + if (dto.attempts > conditions.maxAttempts) return false; + } + + if (conditions.timeThreshold !== undefined && dto.timeSpent !== undefined) { + if (dto.timeSpent < conditions.timeThreshold) return false; + } + + if (conditions.userLevel && dto.userLevel !== undefined) { + if (conditions.userLevel.min !== undefined && dto.userLevel < conditions.userLevel.min) { + return false; + } + if (conditions.userLevel.max !== undefined && dto.userLevel > conditions.userLevel.max) { + return false; + } + } + + if (conditions.errorPatterns && dto.recentErrors) { + const hasMatchingError = conditions.errorPatterns.some((pattern) => + dto.recentErrors!.some((error) => error.includes(pattern)), + ); + if (!hasMatchingError) return false; + } + + return true; + } + + // Interaction Tracking + async recordInteraction(userId: string, dto: RecordHelpInteractionDto): Promise { + const help = await this.findById(dto.helpId); + + const interaction = this.interactionRepo.create({ + userId, + helpId: dto.helpId, + triggerContext: help.triggerContext, + action: dto.action, + viewDuration: dto.viewDuration, + actionTaken: dto.actionTaken, + context: dto.context, + }); + + await this.interactionRepo.save(interaction); + + // Update analytics based on action + await this.updateHelpAnalytics(dto.helpId, dto.action); + } + + async getUserHelpHistory(userId: string, helpId?: string): Promise { + const where: any = { userId }; + if (helpId) { + where.helpId = helpId; + } + + return this.interactionRepo.find({ + where, + order: { createdAt: 'DESC' }, + take: 50, + }); + } + + async getShowCount(userId: string, helpId: string): Promise { + return this.interactionRepo.count({ + where: { userId, helpId, action: 'shown' }, + }); + } + + async getLastShownTime(userId: string, helpId: string): Promise { + const interaction = await this.interactionRepo.findOne({ + where: { userId, helpId, action: 'shown' }, + order: { createdAt: 'DESC' }, + }); + return interaction?.createdAt || null; + } + + // Integration Points + async getHelpForPuzzleStart( + userId: string, + puzzleType: string, + ): Promise { + return this.triggerHelp(userId, { + context: 'puzzle_start', + puzzleType, + }); + } + + async getHelpForRepeatedFailure( + userId: string, + puzzleId: string, + attempts: number, + ): Promise { + return this.triggerHelp(userId, { + context: 'repeated_failure', + puzzleId, + attempts, + }); + } + + async getHelpForFeature(userId: string, feature: string): Promise { + return this.triggerHelp(userId, { + context: 'feature_discovery', + feature, + }); + } + + // Analytics Updates + private async updateHelpAnalytics(helpId: string, action: string): Promise { + const help = await this.findById(helpId); + const analytics = help.analytics || {}; + + if (action === 'shown') { + analytics.totalShown = (analytics.totalShown || 0) + 1; + } + + if (action === 'dismissed') { + const totalShown = analytics.totalShown || 1; + const dismissed = await this.interactionRepo.count({ + where: { helpId, action: 'dismissed' }, + }); + analytics.dismissRate = dismissed / totalShown; + } + + if (action === 'clicked' || action === 'completed') { + const totalShown = analytics.totalShown || 1; + const actioned = await this.interactionRepo.count({ + where: { helpId, action: 'clicked' }, + }); + analytics.actionTakenRate = actioned / totalShown; + } + + await this.helpRepo.update(helpId, { analytics }); + } +} diff --git a/src/tutorial/services/index.ts b/src/tutorial/services/index.ts new file mode 100644 index 0000000..2dead5c --- /dev/null +++ b/src/tutorial/services/index.ts @@ -0,0 +1,5 @@ +export * from './tutorial.service'; +export * from './tutorial-progress.service'; +export * from './contextual-help.service'; +export * from './tutorial-analytics.service'; +export * from './localization.service'; diff --git a/src/tutorial/services/localization.service.ts b/src/tutorial/services/localization.service.ts new file mode 100644 index 0000000..ab7b983 --- /dev/null +++ b/src/tutorial/services/localization.service.ts @@ -0,0 +1,262 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Tutorial } from '../entities/tutorial.entity'; +import { TutorialStep } from '../entities/tutorial-step.entity'; +import { ContextualHelp } from '../entities/contextual-help.entity'; + +export interface LocalizedTutorial extends Omit { + name: string; + description: string; + locale: string; +} + +export interface LocalizedStep extends Omit { + title: string; + content: { + instructions: string; + [key: string]: any; + }; + locale: string; +} + +export interface LocalizedContextualHelp extends Omit { + content: { + title: string; + body: string; + [key: string]: any; + }; + locale: string; +} + +export interface TranslationValidationResult { + locale: string; + totalKeys: number; + missingKeys: string[]; + isComplete: boolean; +} + +@Injectable() +export class LocalizationService { + private readonly logger = new Logger(LocalizationService.name); + + // In-memory translation storage (in production, use database or i18n files) + private translations: Map> = new Map(); + private supportedLocales: string[] = ['en', 'es', 'fr', 'de', 'ja', 'zh', 'ko', 'pt', 'ru', 'ar']; + private defaultLocale = 'en'; + + constructor() { + // Initialize with default locale + this.translations.set('en', new Map()); + } + + // Translation Management + async getTranslation( + key: string, + locale: string, + params?: Record, + ): Promise { + const localeTranslations = this.translations.get(locale); + let translation = localeTranslations?.get(key); + + // Fallback to default locale + if (!translation && locale !== this.defaultLocale) { + const defaultTranslations = this.translations.get(this.defaultLocale); + translation = defaultTranslations?.get(key); + } + + // Return key if no translation found + if (!translation) { + this.logger.warn(`Missing translation for key: ${key} in locale: ${locale}`); + return key; + } + + // Replace parameters + if (params) { + Object.entries(params).forEach(([param, value]) => { + translation = translation!.replace(new RegExp(`{{${param}}}`, 'g'), String(value)); + }); + } + + return translation; + } + + async setTranslation(key: string, locale: string, value: string): Promise { + if (!this.translations.has(locale)) { + this.translations.set(locale, new Map()); + } + this.translations.get(locale)!.set(key, value); + } + + async getTranslationsForLocale(locale: string): Promise> { + const localeTranslations = this.translations.get(locale); + if (!localeTranslations) { + return {}; + } + return Object.fromEntries(localeTranslations); + } + + async importTranslations(locale: string, translations: Record): Promise { + if (!this.translations.has(locale)) { + this.translations.set(locale, new Map()); + } + const localeMap = this.translations.get(locale)!; + Object.entries(translations).forEach(([key, value]) => { + localeMap.set(key, value); + }); + this.logger.log(`Imported ${Object.keys(translations).length} translations for locale: ${locale}`); + } + + async exportTranslations(locale: string): Promise> { + return this.getTranslationsForLocale(locale); + } + + // Locale Management + async getSupportedLocales(): Promise { + return this.supportedLocales; + } + + async addLocale(locale: string): Promise { + if (!this.supportedLocales.includes(locale)) { + this.supportedLocales.push(locale); + this.translations.set(locale, new Map()); + this.logger.log(`Added new locale: ${locale}`); + } + } + + async setDefaultLocale(locale: string): Promise { + if (!this.supportedLocales.includes(locale)) { + throw new Error(`Locale ${locale} is not supported`); + } + this.defaultLocale = locale; + } + + async detectUserLocale(userId: string): Promise { + // In a real implementation, this would check user preferences from database + // For now, return default locale + return this.defaultLocale; + } + + // Content Localization + async localizeTutorial(tutorial: Tutorial, locale: string): Promise { + const nameKey = `tutorial.${tutorial.id}.name`; + const descriptionKey = `tutorial.${tutorial.id}.description`; + + const localizedName = await this.getTranslation(nameKey, locale); + const localizedDescription = await this.getTranslation(descriptionKey, locale); + + return { + ...tutorial, + name: localizedName !== nameKey ? localizedName : tutorial.name, + description: localizedDescription !== descriptionKey ? localizedDescription : tutorial.description, + locale, + }; + } + + async localizeStep(step: TutorialStep, locale: string): Promise { + const titleKey = step.localization?.titleKey || `tutorial.step.${step.id}.title`; + const instructionsKey = step.localization?.instructionsKey || `tutorial.step.${step.id}.instructions`; + + const localizedTitle = await this.getTranslation(titleKey, locale); + const localizedInstructions = await this.getTranslation(instructionsKey, locale); + + // Localize additional content keys if present + const localizedContent = { ...step.content }; + localizedContent.instructions = + localizedInstructions !== instructionsKey ? localizedInstructions : step.content.instructions; + + if (step.localization?.contentKeys) { + for (const [field, key] of Object.entries(step.localization.contentKeys)) { + const localizedValue = await this.getTranslation(key, locale); + if (localizedValue !== key) { + (localizedContent as any)[field] = localizedValue; + } + } + } + + return { + ...step, + title: localizedTitle !== titleKey ? localizedTitle : step.title, + content: localizedContent, + locale, + }; + } + + async localizeHelp(help: ContextualHelp, locale: string): Promise { + const titleKey = help.localization?.titleKey || `contextual_help.${help.id}.title`; + const bodyKey = help.localization?.bodyKey || `contextual_help.${help.id}.body`; + + const localizedTitle = await this.getTranslation(titleKey, locale); + const localizedBody = await this.getTranslation(bodyKey, locale); + + const localizedContent = { + ...help.content, + title: localizedTitle !== titleKey ? localizedTitle : help.content.title, + body: localizedBody !== bodyKey ? localizedBody : help.content.body, + }; + + // Localize action labels if present + if (help.content.actions && help.localization?.actionsKeys) { + localizedContent.actions = await Promise.all( + help.content.actions.map(async (action, index) => { + const labelKey = help.localization?.actionsKeys?.[`action_${index}_label`]; + if (labelKey) { + const localizedLabel = await this.getTranslation(labelKey, locale); + return { + ...action, + label: localizedLabel !== labelKey ? localizedLabel : action.label, + }; + } + return action; + }), + ); + } + + return { + ...help, + content: localizedContent, + locale, + }; + } + + // Validation + async getMissingTranslations(locale: string): Promise { + const defaultKeys = Array.from(this.translations.get(this.defaultLocale)?.keys() || []); + const localeKeys = Array.from(this.translations.get(locale)?.keys() || []); + const localeKeySet = new Set(localeKeys); + + return defaultKeys.filter((key) => !localeKeySet.has(key)); + } + + async validateTranslations(locale: string): Promise { + const missingKeys = await this.getMissingTranslations(locale); + const totalKeys = this.translations.get(this.defaultLocale)?.size || 0; + + return { + locale, + totalKeys, + missingKeys, + isComplete: missingKeys.length === 0, + }; + } + + // Utility method to generate translation keys for a tutorial + generateTutorialKeys(tutorialId: string): string[] { + return [ + `tutorial.${tutorialId}.name`, + `tutorial.${tutorialId}.description`, + ]; + } + + generateStepKeys(stepId: string): string[] { + return [ + `tutorial.step.${stepId}.title`, + `tutorial.step.${stepId}.instructions`, + ]; + } + + generateHelpKeys(helpId: string): string[] { + return [ + `contextual_help.${helpId}.title`, + `contextual_help.${helpId}.body`, + ]; + } +} diff --git a/src/tutorial/services/tutorial-analytics.service.ts b/src/tutorial/services/tutorial-analytics.service.ts new file mode 100644 index 0000000..d1209e9 --- /dev/null +++ b/src/tutorial/services/tutorial-analytics.service.ts @@ -0,0 +1,416 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; +import { Tutorial } from '../entities/tutorial.entity'; +import { TutorialStep } from '../entities/tutorial-step.entity'; +import { UserTutorialProgress } from '../entities/user-tutorial-progress.entity'; +import { TutorialAnalyticsEvent, TutorialEventType } from '../entities/tutorial-analytics-event.entity'; +import { + TutorialAnalyticsFilterDto, + TutorialEffectivenessFilterDto, + AnalyticsExportFilterDto, + CompletionRateReport, + DropOffAnalysis, + StepEffectiveness, + EffectivenessReport, + LearningProfile, + TutorialDashboardReport, +} from '../dto'; + +interface DateRange { + startDate?: Date; + endDate?: Date; +} + +@Injectable() +export class TutorialAnalyticsService { + private readonly logger = new Logger(TutorialAnalyticsService.name); + + constructor( + @InjectRepository(TutorialAnalyticsEvent) + private readonly eventRepo: Repository, + @InjectRepository(UserTutorialProgress) + private readonly progressRepo: Repository, + @InjectRepository(Tutorial) + private readonly tutorialRepo: Repository, + @InjectRepository(TutorialStep) + private readonly stepRepo: Repository, + ) {} + + // Event Tracking + async trackEvent(event: { + eventType: TutorialEventType; + userId?: string; + tutorialId?: string; + stepId?: string; + sessionId?: string; + payload: any; + }): Promise { + const analyticsEvent = this.eventRepo.create({ + eventType: event.eventType, + userId: event.userId, + tutorialId: event.tutorialId, + stepId: event.stepId, + sessionId: event.sessionId, + payload: event.payload, + }); + + await this.eventRepo.save(analyticsEvent); + } + + // Completion Rate Analytics + async getTutorialCompletionRate( + tutorialId: string, + dateRange?: DateRange, + ): Promise { + const whereClause: any = { tutorialId }; + + if (dateRange?.startDate) { + whereClause.createdAt = MoreThanOrEqual(dateRange.startDate); + } + + const total = await this.progressRepo.count({ where: whereClause }); + const completed = await this.progressRepo.count({ + where: { ...whereClause, status: 'completed' }, + }); + + return total > 0 ? (completed / total) * 100 : 0; + } + + async getStepCompletionRates(tutorialId: string): Promise { + const steps = await this.stepRepo.find({ + where: { tutorialId, isActive: true }, + order: { order: 'ASC' }, + }); + + const progress = await this.progressRepo.find({ + where: { tutorialId }, + }); + + return steps.map((step) => { + const stepProgressData = progress.flatMap((p) => + p.stepProgress.filter((sp) => sp.stepId === step.id), + ); + + const completed = stepProgressData.filter((sp) => sp.status === 'completed').length; + const total = stepProgressData.length || 1; + const skipped = stepProgressData.filter((sp) => sp.status === 'skipped').length; + + const attempts = stepProgressData.map((sp) => sp.attempts); + const times = stepProgressData.map((sp) => sp.timeSpent); + const hints = stepProgressData.map((sp) => sp.hintsUsed); + + const errors: Record = {}; + stepProgressData.forEach((sp) => { + (sp.errors || []).forEach((error) => { + errors[error] = (errors[error] || 0) + 1; + }); + }); + + return { + stepId: step.id, + stepTitle: step.title, + completionRate: (completed / total) * 100, + averageAttempts: attempts.length > 0 ? attempts.reduce((a, b) => a + b, 0) / attempts.length : 0, + averageTimeSpent: times.length > 0 ? times.reduce((a, b) => a + b, 0) / times.length : 0, + hintUsageRate: hints.length > 0 ? hints.filter((h) => h > 0).length / hints.length : 0, + commonErrors: Object.entries(errors) + .map(([error, count]) => ({ error, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 5), + skipRate: (skipped / total) * 100, + }; + }); + } + + async getOverallCompletionRate(dateRange?: DateRange): Promise { + const whereClause: any = {}; + + if (dateRange?.startDate && dateRange?.endDate) { + whereClause.createdAt = Between(dateRange.startDate, dateRange.endDate); + } + + const total = await this.progressRepo.count({ where: whereClause }); + const completed = await this.progressRepo.count({ + where: { ...whereClause, status: 'completed' }, + }); + + return total > 0 ? (completed / total) * 100 : 0; + } + + // Drop-off Analysis + async getDropOffAnalysis(tutorialId: string): Promise { + const tutorial = await this.tutorialRepo.findOne({ where: { id: tutorialId } }); + const steps = await this.stepRepo.find({ + where: { tutorialId, isActive: true }, + order: { order: 'ASC' }, + }); + + const progress = await this.progressRepo.find({ + where: { tutorialId }, + }); + + const totalStarted = progress.length; + const dropOffPoints = steps.map((step) => { + const usersReached = progress.filter((p) => + p.stepProgress.some((sp) => sp.stepId === step.id), + ).length; + + const usersCompleted = progress.filter((p) => + p.stepProgress.some((sp) => sp.stepId === step.id && sp.status === 'completed'), + ).length; + + const usersDropped = usersReached - usersCompleted; + + const timesBeforeDrop = progress + .filter( + (p) => + p.status !== 'completed' && + p.stepProgress.some( + (sp) => sp.stepId === step.id && sp.status !== 'completed', + ), + ) + .map((p) => p.stepProgress.find((sp) => sp.stepId === step.id)?.timeSpent || 0); + + return { + stepId: step.id, + stepTitle: step.title, + stepOrder: step.order, + usersReached, + usersDropped, + dropOffRate: usersReached > 0 ? (usersDropped / usersReached) * 100 : 0, + averageTimeBeforeDropOff: + timesBeforeDrop.length > 0 + ? timesBeforeDrop.reduce((a, b) => a + b, 0) / timesBeforeDrop.length + : 0, + }; + }); + + const totalCompleted = progress.filter((p) => p.status === 'completed').length; + const overallDropOffRate = totalStarted > 0 ? ((totalStarted - totalCompleted) / totalStarted) * 100 : 0; + + return { + tutorialId, + tutorialName: tutorial?.name || '', + totalStarted, + dropOffPoints, + overallDropOffRate, + }; + } + + async getCommonDropOffPoints(): Promise<{ stepId: string; tutorialId: string; dropOffRate: number }[]> { + const tutorials = await this.tutorialRepo.find({ where: { isActive: true } }); + const allDropOffs: { stepId: string; tutorialId: string; dropOffRate: number }[] = []; + + for (const tutorial of tutorials) { + const analysis = await this.getDropOffAnalysis(tutorial.id); + analysis.dropOffPoints.forEach((point) => { + if (point.dropOffRate > 20) { + allDropOffs.push({ + stepId: point.stepId, + tutorialId: tutorial.id, + dropOffRate: point.dropOffRate, + }); + } + }); + } + + return allDropOffs.sort((a, b) => b.dropOffRate - a.dropOffRate).slice(0, 10); + } + + // Effectiveness Measurement + async getTutorialEffectivenessReport( + tutorialId: string, + filters?: TutorialEffectivenessFilterDto, + ): Promise { + const tutorial = await this.tutorialRepo.findOne({ where: { id: tutorialId } }); + if (!tutorial) { + throw new Error(`Tutorial not found: ${tutorialId}`); + } + + const whereClause: any = { tutorialId }; + if (filters?.startDate && filters?.endDate) { + whereClause.createdAt = Between(filters.startDate, filters.endDate); + } + + const progress = await this.progressRepo.find({ where: whereClause }); + const completed = progress.filter((p) => p.status === 'completed'); + + const scores = completed.filter((p) => p.overallScore).map((p) => Number(p.overallScore)); + const times = completed.filter((p) => p.totalTimeSpent > 0).map((p) => p.totalTimeSpent); + + const metrics = { + completionRate: progress.length > 0 ? (completed.length / progress.length) * 100 : 0, + averageScore: scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0, + averageCompletionTime: times.length > 0 ? times.reduce((a, b) => a + b, 0) / times.length : 0, + totalUsers: progress.length, + activeUsers: progress.filter( + (p) => p.lastActivityAt && new Date().getTime() - p.lastActivityAt.getTime() < 7 * 24 * 60 * 60 * 1000, + ).length, + }; + + const report: EffectivenessReport = { + tutorialId, + tutorialName: tutorial.name, + period: { + startDate: filters?.startDate || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + endDate: filters?.endDate || new Date(), + }, + metrics, + trends: [], + }; + + if (filters?.includeStepBreakdown) { + report.stepBreakdown = await this.getStepCompletionRates(tutorialId); + } + + if (filters?.includeDropOffAnalysis) { + report.dropOffAnalysis = await this.getDropOffAnalysis(tutorialId); + } + + return report; + } + + // User Learning Analytics + async getUserLearningProfile(userId: string): Promise { + const progress = await this.progressRepo.find({ + where: { userId }, + relations: ['tutorial'], + }); + + const completed = progress.filter((p) => p.status === 'completed'); + const speeds = progress.map((p) => p.adaptiveState.learningSpeed); + const speedCounts = { slow: 0, normal: 0, fast: 0 }; + speeds.forEach((s) => (speedCounts[s] = (speedCounts[s] || 0) + 1)); + const averageSpeed = Object.entries(speedCounts).sort((a, b) => b[1] - a[1])[0]?.[0] as + | 'slow' + | 'normal' + | 'fast'; + + const strongAreas = new Set(); + const improvementAreas = new Set(); + + progress.forEach((p) => { + (p.adaptiveState.strongAreas || []).forEach((area) => strongAreas.add(area)); + (p.adaptiveState.strugglingAreas || []).forEach((area) => improvementAreas.add(area)); + }); + + const totalTimeSpent = progress.reduce((sum, p) => sum + p.totalTimeSpent, 0); + + return { + userId, + totalTutorialsStarted: progress.length, + totalTutorialsCompleted: completed.length, + overallCompletionRate: progress.length > 0 ? (completed.length / progress.length) * 100 : 0, + averageLearningSpeed: averageSpeed || 'normal', + strongAreas: Array.from(strongAreas), + improvementAreas: Array.from(improvementAreas), + preferredContentTypes: [], + totalTimeSpent, + recentActivity: progress + .sort((a, b) => (b.lastActivityAt?.getTime() || 0) - (a.lastActivityAt?.getTime() || 0)) + .slice(0, 5) + .map((p) => ({ + tutorialId: p.tutorialId, + tutorialName: p.tutorial?.name || '', + status: p.status, + lastActivityAt: p.lastActivityAt || p.updatedAt, + })), + }; + } + + // Dashboard Report + async generateDashboardReport(dateRange?: DateRange): Promise { + const startDate = dateRange?.startDate || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const endDate = dateRange?.endDate || new Date(); + + const tutorials = await this.tutorialRepo.find({ where: { isActive: true } }); + const progress = await this.progressRepo.find({ + where: { createdAt: Between(startDate, endDate) }, + relations: ['tutorial'], + }); + + const completed = progress.filter((p) => p.status === 'completed'); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const activeToday = progress.filter( + (p) => p.lastActivityAt && p.lastActivityAt >= today, + ).length; + + // Top tutorials by completion rate + const tutorialStats = await Promise.all( + tutorials.slice(0, 10).map(async (t) => { + const rate = await this.getTutorialCompletionRate(t.id, dateRange); + const completions = progress.filter( + (p) => p.tutorialId === t.id && p.status === 'completed', + ).length; + return { + tutorialId: t.id, + tutorialName: t.name, + completionRate: rate, + totalCompletions: completions, + }; + }), + ); + + // Needs attention (low completion rates) + const needsAttention = tutorialStats + .filter((t) => t.completionRate < 50 && t.totalCompletions > 0) + .map((t) => ({ + tutorialId: t.tutorialId, + tutorialName: t.tutorialName, + issue: 'Low completion rate', + metric: t.completionRate, + })); + + return { + period: { startDate, endDate }, + overview: { + totalTutorials: tutorials.length, + activeTutorials: tutorials.filter((t) => t.isActive).length, + totalUsersOnboarded: completed.length, + averageCompletionRate: await this.getOverallCompletionRate(dateRange), + activeUsersToday: activeToday, + }, + topTutorials: tutorialStats.sort((a, b) => b.completionRate - a.completionRate).slice(0, 5), + needsAttention, + recentCompletions: completed + .sort((a, b) => (b.completedAt?.getTime() || 0) - (a.completedAt?.getTime() || 0)) + .slice(0, 10) + .map((p) => ({ + userId: p.userId, + tutorialId: p.tutorialId, + tutorialName: p.tutorial?.name || '', + completedAt: p.completedAt || p.updatedAt, + score: Number(p.overallScore) || 0, + })), + trends: [], + }; + } + + // Real-time Metrics + async getActiveUsers(): Promise { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + return this.progressRepo.count({ + where: { + status: 'in_progress', + lastActivityAt: MoreThanOrEqual(oneHourAgo), + }, + }); + } + + async getCurrentCompletions(interval: 'hour' | 'day'): Promise { + const since = + interval === 'hour' + ? new Date(Date.now() - 60 * 60 * 1000) + : new Date(Date.now() - 24 * 60 * 60 * 1000); + + return this.progressRepo.count({ + where: { + status: 'completed', + completedAt: MoreThanOrEqual(since), + }, + }); + } +} diff --git a/src/tutorial/services/tutorial-progress.service.ts b/src/tutorial/services/tutorial-progress.service.ts new file mode 100644 index 0000000..c030889 --- /dev/null +++ b/src/tutorial/services/tutorial-progress.service.ts @@ -0,0 +1,529 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + ForbiddenException, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Tutorial } from '../entities/tutorial.entity'; +import { TutorialStep } from '../entities/tutorial-step.entity'; +import { + UserTutorialProgress, + StepProgress, + AdaptiveState, + LearningSpeed, +} from '../entities/user-tutorial-progress.entity'; +import { TutorialService } from './tutorial.service'; +import { TutorialAnalyticsService } from './tutorial-analytics.service'; +import { + StartTutorialDto, + UpdateStepProgressDto, + SkipTutorialDto, + SkipStepDto, + ResumeTutorialDto, + SaveCheckpointDto, +} from '../dto'; + +interface ResumeResponse { + progress: UserTutorialProgress; + nextStep: TutorialStep | null; + checkpoint?: any; +} + +@Injectable() +export class TutorialProgressService { + private readonly logger = new Logger(TutorialProgressService.name); + + constructor( + @InjectRepository(UserTutorialProgress) + private readonly progressRepo: Repository, + @InjectRepository(TutorialStep) + private readonly stepRepo: Repository, + private readonly tutorialService: TutorialService, + private readonly analyticsService: TutorialAnalyticsService, + ) {} + + // Progress Management + async startTutorial(userId: string, dto: StartTutorialDto): Promise { + const tutorial = await this.tutorialService.findById(dto.tutorialId); + + // Check prerequisites + const { valid, missing } = await this.tutorialService.validatePrerequisites( + userId, + dto.tutorialId, + ); + if (!valid) { + throw new ForbiddenException( + `Prerequisites not met. Missing tutorials: ${missing.join(', ')}`, + ); + } + + // Check for existing progress + let progress = await this.progressRepo.findOne({ + where: { userId, tutorialId: dto.tutorialId }, + }); + + if (progress) { + if (progress.status === 'completed') { + // Allow restart + progress.status = 'in_progress'; + progress.currentStepOrder = 0; + progress.currentStepId = undefined; + progress.completedSteps = 0; + progress.progressPercentage = 0; + progress.totalTimeSpent = 0; + progress.stepProgress = []; + progress.startedAt = new Date(); + progress.completedAt = undefined; + } else if (dto.resumeFromCheckpoint && progress.sessionData?.checkpoints?.length) { + // Resume from checkpoint + progress.lastActivityAt = new Date(); + return this.progressRepo.save(progress); + } + } else { + // Get total steps count + const steps = await this.tutorialService.getStepsByTutorial(dto.tutorialId); + + progress = this.progressRepo.create({ + userId, + tutorialId: dto.tutorialId, + status: 'in_progress', + currentStepOrder: 0, + totalSteps: steps.length, + completedSteps: 0, + progressPercentage: 0, + totalTimeSpent: 0, + stepProgress: [], + adaptiveState: { learningSpeed: 'normal', proficiencyLevel: 0 }, + sessionData: { lastSessionId: dto.sessionId }, + startedAt: new Date(), + lastActivityAt: new Date(), + }); + } + + const saved = await this.progressRepo.save(progress); + + // Track analytics + await this.analyticsService.trackEvent({ + eventType: 'tutorial_started', + userId, + tutorialId: dto.tutorialId, + payload: { sessionId: dto.sessionId }, + }); + + this.logger.log(`User ${userId} started tutorial: ${dto.tutorialId}`); + return saved; + } + + async updateStepProgress(userId: string, dto: UpdateStepProgressDto): Promise { + const progress = await this.getOrCreateProgress(userId, dto.tutorialId); + const step = await this.tutorialService.getStepById(dto.stepId); + + // Find or create step progress entry + let stepProgress = progress.stepProgress.find((sp) => sp.stepId === dto.stepId); + if (!stepProgress) { + stepProgress = { + stepId: dto.stepId, + stepOrder: step.order, + status: 'pending', + attempts: 0, + timeSpent: 0, + hintsUsed: 0, + }; + progress.stepProgress.push(stepProgress); + } + + // Update step progress + stepProgress.status = dto.status; + stepProgress.attempts += 1; + stepProgress.timeSpent += dto.timeSpent || 0; + stepProgress.score = dto.score; + stepProgress.hintsUsed += dto.hintsUsed || 0; + if (dto.errors) { + stepProgress.errors = [...(stepProgress.errors || []), ...dto.errors]; + } + + if (dto.status === 'completed') { + stepProgress.completedAt = new Date(); + progress.completedSteps = progress.stepProgress.filter( + (sp) => sp.status === 'completed', + ).length; + + // Track analytics + await this.analyticsService.trackEvent({ + eventType: 'step_completed', + userId, + tutorialId: dto.tutorialId, + stepId: dto.stepId, + payload: { + timeSpent: dto.timeSpent, + score: dto.score, + attempts: stepProgress.attempts, + hintsUsed: stepProgress.hintsUsed, + }, + }); + } + + // Update current step to next + const nextStep = await this.getNextStep(userId, dto.tutorialId); + if (nextStep) { + progress.currentStepId = nextStep.id; + progress.currentStepOrder = nextStep.order; + } + + // Update progress percentage + progress.progressPercentage = + progress.totalSteps > 0 + ? Math.round((progress.completedSteps / progress.totalSteps) * 100) + : 0; + + // Update total time + progress.totalTimeSpent += dto.timeSpent || 0; + progress.lastActivityAt = new Date(); + + // Save checkpoint if provided + if (dto.saveState) { + await this.saveCheckpoint(userId, { + tutorialId: dto.tutorialId, + stepId: dto.stepId, + state: dto.saveState, + }); + } + + // Adjust adaptive pacing + await this.adjustAdaptivePacing(progress, stepProgress); + + const saved = await this.progressRepo.save(progress); + + // Check if tutorial is complete + if (progress.completedSteps >= progress.totalSteps) { + await this.completeTutorial(userId, dto.tutorialId); + } + + return saved; + } + + async completeTutorial(userId: string, tutorialId: string): Promise { + const progress = await this.getUserProgress(userId, tutorialId); + + progress.status = 'completed'; + progress.completedAt = new Date(); + progress.progressPercentage = 100; + + // Calculate overall score + const scores = progress.stepProgress + .filter((sp) => sp.score !== undefined) + .map((sp) => sp.score!); + if (scores.length > 0) { + progress.overallScore = scores.reduce((a, b) => a + b, 0) / scores.length; + } + + const saved = await this.progressRepo.save(progress); + + // Track analytics + await this.analyticsService.trackEvent({ + eventType: 'tutorial_completed', + userId, + tutorialId, + payload: { + completionSummary: { + totalTime: progress.totalTimeSpent, + totalSteps: progress.totalSteps, + completedSteps: progress.completedSteps, + overallScore: progress.overallScore || 0, + }, + }, + }); + + // Update tutorial analytics + await this.tutorialService.updateTutorialAnalytics(tutorialId); + + this.logger.log(`User ${userId} completed tutorial: ${tutorialId}`); + return saved; + } + + async getUserProgress(userId: string, tutorialId: string): Promise { + const progress = await this.progressRepo.findOne({ + where: { userId, tutorialId }, + relations: ['tutorial'], + }); + if (!progress) { + throw new NotFoundException(`Progress not found for user ${userId} on tutorial ${tutorialId}`); + } + return progress; + } + + async getAllUserProgress(userId: string): Promise { + return this.progressRepo.find({ + where: { userId }, + relations: ['tutorial'], + order: { lastActivityAt: 'DESC' }, + }); + } + + // Skip and Resume + async skipTutorial(userId: string, dto: SkipTutorialDto): Promise { + const tutorial = await this.tutorialService.findById(dto.tutorialId); + + if (!tutorial.isSkippable && !dto.confirmSkip) { + throw new BadRequestException( + 'This tutorial is not skippable. Set confirmSkip to true to force skip.', + ); + } + + let progress = await this.progressRepo.findOne({ + where: { userId, tutorialId: dto.tutorialId }, + }); + + if (!progress) { + const steps = await this.tutorialService.getStepsByTutorial(dto.tutorialId); + progress = this.progressRepo.create({ + userId, + tutorialId: dto.tutorialId, + totalSteps: steps.length, + adaptiveState: { learningSpeed: 'normal', proficiencyLevel: 0 }, + sessionData: {}, + }); + } + + progress.status = 'skipped'; + progress.lastActivityAt = new Date(); + + const saved = await this.progressRepo.save(progress); + + // Track analytics + await this.analyticsService.trackEvent({ + eventType: 'tutorial_skipped', + userId, + tutorialId: dto.tutorialId, + payload: { skipReason: dto.reason }, + }); + + this.logger.log(`User ${userId} skipped tutorial: ${dto.tutorialId}`); + return saved; + } + + async skipStep(userId: string, dto: SkipStepDto): Promise { + const progress = await this.getUserProgress(userId, dto.tutorialId); + const step = await this.tutorialService.getStepById(dto.stepId); + + if (!step.isOptional) { + throw new BadRequestException('This step is not optional and cannot be skipped.'); + } + + let stepProgress = progress.stepProgress.find((sp) => sp.stepId === dto.stepId); + if (!stepProgress) { + stepProgress = { + stepId: dto.stepId, + stepOrder: step.order, + status: 'skipped', + attempts: 0, + timeSpent: 0, + hintsUsed: 0, + }; + progress.stepProgress.push(stepProgress); + } else { + stepProgress.status = 'skipped'; + } + + progress.lastActivityAt = new Date(); + + // Move to next step + const nextStep = await this.getNextStep(userId, dto.tutorialId); + if (nextStep) { + progress.currentStepId = nextStep.id; + progress.currentStepOrder = nextStep.order; + } + + return this.progressRepo.save(progress); + } + + async resumeTutorial(userId: string, dto: ResumeTutorialDto): Promise { + const progress = await this.getUserProgress(userId, dto.tutorialId); + + if (progress.status === 'completed') { + throw new BadRequestException('Tutorial already completed. Start again to restart.'); + } + + progress.status = 'in_progress'; + progress.lastActivityAt = new Date(); + + let nextStep: TutorialStep | null = null; + let checkpoint: any = undefined; + + if (dto.fromStepId) { + nextStep = await this.tutorialService.getStepById(dto.fromStepId); + progress.currentStepId = dto.fromStepId; + progress.currentStepOrder = nextStep.order; + } else if (dto.fromCheckpoint && progress.sessionData?.checkpoints?.length) { + const latestCheckpoint = progress.sessionData.checkpoints[ + progress.sessionData.checkpoints.length - 1 + ]; + nextStep = await this.tutorialService.getStepById(latestCheckpoint.stepId); + checkpoint = latestCheckpoint.state; + progress.currentStepId = latestCheckpoint.stepId; + } else { + nextStep = await this.getNextStep(userId, dto.tutorialId); + } + + await this.progressRepo.save(progress); + + return { progress, nextStep, checkpoint }; + } + + async saveCheckpoint(userId: string, dto: SaveCheckpointDto): Promise { + const progress = await this.getUserProgress(userId, dto.tutorialId); + + const checkpoints = progress.sessionData?.checkpoints || []; + checkpoints.push({ + stepId: dto.stepId, + state: dto.state, + savedAt: new Date(), + }); + + // Keep only last 5 checkpoints + if (checkpoints.length > 5) { + checkpoints.shift(); + } + + progress.sessionData = { + ...progress.sessionData, + checkpoints, + }; + + await this.progressRepo.save(progress); + + await this.analyticsService.trackEvent({ + eventType: 'checkpoint_saved', + userId, + tutorialId: dto.tutorialId, + stepId: dto.stepId, + payload: {}, + }); + } + + // Adaptive Pacing + async getNextStep(userId: string, tutorialId: string): Promise { + const progress = await this.progressRepo.findOne({ + where: { userId, tutorialId }, + }); + + const completedStepIds = new Set( + progress?.stepProgress + .filter((sp) => sp.status === 'completed' || sp.status === 'skipped') + .map((sp) => sp.stepId) || [], + ); + + const steps = await this.tutorialService.getStepsByTutorial(tutorialId); + const nextStep = steps.find( + (step) => step.isActive && !completedStepIds.has(step.id), + ); + + if (!nextStep) return null; + + // Check if step should be skipped due to proficiency + if (progress && await this.shouldSkipStep(progress, nextStep)) { + // Mark as auto-skipped and get next + await this.updateStepProgress(userId, { + tutorialId, + stepId: nextStep.id, + status: 'skipped', + }); + return this.getNextStep(userId, tutorialId); + } + + return nextStep; + } + + async getAdaptiveState(userId: string, tutorialId: string): Promise { + const progress = await this.getUserProgress(userId, tutorialId); + return progress.adaptiveState; + } + + private async shouldSkipStep( + progress: UserTutorialProgress, + step: TutorialStep, + ): Promise { + if (!step.adaptivePacing?.skipIfProficient) return false; + + const threshold = step.adaptivePacing.proficiencyThreshold || 0.8; + return progress.adaptiveState.proficiencyLevel >= threshold; + } + + private async adjustAdaptivePacing( + progress: UserTutorialProgress, + stepProgress: StepProgress, + ): Promise { + const state = progress.adaptiveState; + + // Calculate learning speed based on time spent vs average + const avgStepTime = progress.totalTimeSpent / (progress.completedSteps || 1); + if (stepProgress.timeSpent < avgStepTime * 0.5) { + state.learningSpeed = 'fast'; + } else if (stepProgress.timeSpent > avgStepTime * 1.5) { + state.learningSpeed = 'slow'; + } else { + state.learningSpeed = 'normal'; + } + + // Update proficiency based on scores and attempts + if (stepProgress.score !== undefined) { + const performanceScore = (stepProgress.score / 100) * (1 / stepProgress.attempts); + state.proficiencyLevel = (state.proficiencyLevel + performanceScore) / 2; + } + + // Track struggling areas based on errors + if (stepProgress.errors && stepProgress.errors.length > 2) { + state.strugglingAreas = state.strugglingAreas || []; + state.strugglingAreas.push(stepProgress.stepId); + } + + // Track strong areas based on high scores + if (stepProgress.score && stepProgress.score >= 90 && stepProgress.attempts === 1) { + state.strongAreas = state.strongAreas || []; + state.strongAreas.push(stepProgress.stepId); + } + + progress.adaptiveState = state; + } + + private async getOrCreateProgress( + userId: string, + tutorialId: string, + ): Promise { + let progress = await this.progressRepo.findOne({ + where: { userId, tutorialId }, + }); + + if (!progress) { + const steps = await this.tutorialService.getStepsByTutorial(tutorialId); + progress = this.progressRepo.create({ + userId, + tutorialId, + status: 'in_progress', + totalSteps: steps.length, + completedSteps: 0, + progressPercentage: 0, + totalTimeSpent: 0, + stepProgress: [], + adaptiveState: { learningSpeed: 'normal', proficiencyLevel: 0 }, + sessionData: {}, + startedAt: new Date(), + lastActivityAt: new Date(), + }); + progress = await this.progressRepo.save(progress); + } + + return progress; + } + + async getCompletedTutorials(userId: string): Promise { + const progress = await this.progressRepo.find({ + where: { userId, status: 'completed' }, + select: ['tutorialId'], + }); + return progress.map((p) => p.tutorialId); + } +} diff --git a/src/tutorial/services/tutorial.service.ts b/src/tutorial/services/tutorial.service.ts new file mode 100644 index 0000000..560fa31 --- /dev/null +++ b/src/tutorial/services/tutorial.service.ts @@ -0,0 +1,268 @@ +import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { Tutorial } from '../entities/tutorial.entity'; +import { TutorialStep } from '../entities/tutorial-step.entity'; +import { UserTutorialProgress } from '../entities/user-tutorial-progress.entity'; +import { + CreateTutorialDto, + UpdateTutorialDto, + TutorialFilterDto, + CreateTutorialStepDto, + UpdateTutorialStepDto, + StepOrderDto, +} from '../dto'; + +@Injectable() +export class TutorialService { + private readonly logger = new Logger(TutorialService.name); + + constructor( + @InjectRepository(Tutorial) + private readonly tutorialRepo: Repository, + @InjectRepository(TutorialStep) + private readonly stepRepo: Repository, + @InjectRepository(UserTutorialProgress) + private readonly progressRepo: Repository, + ) {} + + // Tutorial CRUD + async create(dto: CreateTutorialDto): Promise { + const tutorial = this.tutorialRepo.create({ + ...dto, + prerequisites: dto.prerequisites || [], + targetMechanics: dto.targetMechanics || [], + metadata: dto.metadata || {}, + analytics: {}, + }); + const saved = await this.tutorialRepo.save(tutorial); + this.logger.log(`Created tutorial: ${saved.id} - ${saved.name}`); + return saved; + } + + async findById(id: string): Promise { + const tutorial = await this.tutorialRepo.findOne({ + where: { id }, + relations: ['steps'], + }); + if (!tutorial) { + throw new NotFoundException(`Tutorial not found: ${id}`); + } + return tutorial; + } + + async findAll(filters?: TutorialFilterDto): Promise { + const query = this.tutorialRepo.createQueryBuilder('tutorial'); + + if (filters?.type) { + query.andWhere('tutorial.type = :type', { type: filters.type }); + } + if (filters?.category) { + query.andWhere('tutorial.category = :category', { category: filters.category }); + } + if (filters?.difficultyLevel) { + query.andWhere('tutorial.difficultyLevel = :difficultyLevel', { + difficultyLevel: filters.difficultyLevel, + }); + } + if (filters?.isActive !== undefined) { + query.andWhere('tutorial.isActive = :isActive', { isActive: filters.isActive }); + } + if (filters?.targetMechanic) { + query.andWhere(':mechanic = ANY(tutorial.targetMechanics)', { + mechanic: filters.targetMechanic, + }); + } + + query.orderBy('tutorial.order', 'ASC'); + return query.getMany(); + } + + async update(id: string, dto: UpdateTutorialDto): Promise { + const tutorial = await this.findById(id); + Object.assign(tutorial, dto); + const updated = await this.tutorialRepo.save(tutorial); + this.logger.log(`Updated tutorial: ${id}`); + return updated; + } + + async delete(id: string): Promise { + const tutorial = await this.findById(id); + await this.tutorialRepo.softRemove(tutorial); + this.logger.log(`Soft deleted tutorial: ${id}`); + } + + // Curriculum Methods + async getOnboardingCurriculum(): Promise { + return this.tutorialRepo.find({ + where: { type: 'onboarding', isActive: true }, + relations: ['steps'], + order: { order: 'ASC' }, + }); + } + + async getTutorialsByMechanic(mechanic: string): Promise { + return this.tutorialRepo + .createQueryBuilder('tutorial') + .where('tutorial.isActive = true') + .andWhere(':mechanic = ANY(tutorial.targetMechanics)', { mechanic }) + .orderBy('tutorial.difficultyLevel', 'ASC') + .addOrderBy('tutorial.order', 'ASC') + .getMany(); + } + + async getRecommendedTutorials(userId: string): Promise { + // Get user's completed tutorials + const userProgress = await this.progressRepo.find({ + where: { userId, status: 'completed' }, + select: ['tutorialId'], + }); + const completedIds = userProgress.map((p) => p.tutorialId); + + // Get in-progress tutorials first + const inProgress = await this.progressRepo.find({ + where: { userId, status: 'in_progress' }, + relations: ['tutorial'], + }); + + // Get uncompleted active tutorials + const query = this.tutorialRepo + .createQueryBuilder('tutorial') + .where('tutorial.isActive = true'); + + if (completedIds.length > 0) { + query.andWhere('tutorial.id NOT IN (:...completedIds)', { completedIds }); + } + + // Check prerequisites + const available = await query.orderBy('tutorial.order', 'ASC').getMany(); + + const recommended = available.filter((t) => { + if (!t.prerequisites || t.prerequisites.length === 0) return true; + return t.prerequisites.every((prereq) => completedIds.includes(prereq)); + }); + + // Prioritize in-progress tutorials + const inProgressTutorials = inProgress.map((p) => p.tutorial).filter(Boolean); + const notStarted = recommended.filter( + (t) => !inProgressTutorials.some((ip) => ip?.id === t.id), + ); + + return [...inProgressTutorials, ...notStarted].slice(0, 10); + } + + async validatePrerequisites( + userId: string, + tutorialId: string, + ): Promise<{ valid: boolean; missing: string[] }> { + const tutorial = await this.findById(tutorialId); + + if (!tutorial.prerequisites || tutorial.prerequisites.length === 0) { + return { valid: true, missing: [] }; + } + + const completedProgress = await this.progressRepo.find({ + where: { + userId, + tutorialId: In(tutorial.prerequisites), + status: 'completed', + }, + }); + + const completedIds = new Set(completedProgress.map((p) => p.tutorialId)); + const missing = tutorial.prerequisites.filter((prereq) => !completedIds.has(prereq)); + + return { + valid: missing.length === 0, + missing, + }; + } + + // Step Management + async createStep(dto: CreateTutorialStepDto): Promise { + // Verify tutorial exists + await this.findById(dto.tutorialId); + + const step = this.stepRepo.create({ + ...dto, + completionCriteria: dto.completionCriteria || { type: 'auto' }, + adaptivePacing: dto.adaptivePacing || {}, + accessibility: dto.accessibility || {}, + analytics: {}, + localization: {}, + }); + + const saved = await this.stepRepo.save(step); + this.logger.log(`Created step: ${saved.id} for tutorial: ${dto.tutorialId}`); + return saved; + } + + async getStepsByTutorial(tutorialId: string): Promise { + return this.stepRepo.find({ + where: { tutorialId, isActive: true }, + order: { order: 'ASC' }, + }); + } + + async getStepById(stepId: string): Promise { + const step = await this.stepRepo.findOne({ where: { id: stepId } }); + if (!step) { + throw new NotFoundException(`Step not found: ${stepId}`); + } + return step; + } + + async updateStep(stepId: string, dto: UpdateTutorialStepDto): Promise { + const step = await this.getStepById(stepId); + Object.assign(step, dto); + const updated = await this.stepRepo.save(step); + this.logger.log(`Updated step: ${stepId}`); + return updated; + } + + async deleteStep(stepId: string): Promise { + const step = await this.getStepById(stepId); + await this.stepRepo.remove(step); + this.logger.log(`Deleted step: ${stepId}`); + } + + async reorderSteps(tutorialId: string, orders: StepOrderDto[]): Promise { + await this.findById(tutorialId); + + const updates = orders.map((order) => + this.stepRepo.update(order.id, { order: order.order }), + ); + + await Promise.all(updates); + this.logger.log(`Reordered ${orders.length} steps for tutorial: ${tutorialId}`); + } + + // Analytics Helpers + async updateTutorialAnalytics(tutorialId: string): Promise { + const progress = await this.progressRepo.find({ + where: { tutorialId }, + }); + + const totalStarted = progress.length; + const completed = progress.filter((p) => p.status === 'completed'); + const totalCompleted = completed.length; + const completionTimes = completed + .filter((p) => p.totalTimeSpent > 0) + .map((p) => p.totalTimeSpent); + const averageCompletionTime = + completionTimes.length > 0 + ? completionTimes.reduce((a, b) => a + b, 0) / completionTimes.length + : 0; + const dropOffRate = totalStarted > 0 ? (totalStarted - totalCompleted) / totalStarted : 0; + + await this.tutorialRepo.update(tutorialId, { + analytics: { + totalStarted, + totalCompleted, + averageCompletionTime, + dropOffRate, + lastCalculatedAt: new Date(), + }, + }); + } +} diff --git a/src/tutorial/tutorial.module.ts b/src/tutorial/tutorial.module.ts new file mode 100644 index 0000000..d42b1d0 --- /dev/null +++ b/src/tutorial/tutorial.module.ts @@ -0,0 +1,69 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +// Entities +import { + Tutorial, + TutorialStep, + UserTutorialProgress, + ContextualHelp, + ContextualHelpInteraction, + TutorialAnalyticsEvent, +} from './entities'; + +// Services +import { + TutorialService, + TutorialProgressService, + ContextualHelpService, + TutorialAnalyticsService, + LocalizationService, +} from './services'; + +// Controllers +import { + TutorialController, + TutorialProgressController, + ContextualHelpController, + TutorialAnalyticsController, +} from './controllers'; + +// External modules +import { UsersModule } from '../users/users.module'; +import { NotificationsModule } from '../notifications/notifications.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Tutorial, + TutorialStep, + UserTutorialProgress, + ContextualHelp, + ContextualHelpInteraction, + TutorialAnalyticsEvent, + ]), + forwardRef(() => UsersModule), + forwardRef(() => NotificationsModule), + ], + controllers: [ + TutorialController, + TutorialProgressController, + ContextualHelpController, + TutorialAnalyticsController, + ], + providers: [ + TutorialService, + TutorialProgressService, + ContextualHelpService, + TutorialAnalyticsService, + LocalizationService, + ], + exports: [ + TutorialService, + TutorialProgressService, + ContextualHelpService, + TutorialAnalyticsService, + LocalizationService, + ], +}) +export class TutorialModule {}