diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 97acfc1..f02c426 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -415,6 +415,9 @@ model LearningSession { @@index([startedAt]) @@index([clientSessionId]) @@index([materialId]) + @@index([status]) + @@index([lastEventAt]) + @@index([userId, startedAt]) } model LearningRecord { @@ -434,6 +437,8 @@ model LearningRecord { @@index([userId]) @@index([occurredAt]) @@index([createdAt]) + @@index([recordType, occurredAt]) + @@index([userId, occurredAt]) } model ReadingEvent { @@ -466,6 +471,9 @@ model ReadingEvent { @@index([userId, readingTargetType, materialId, clientTimestampMs]) @@index([status, createdAt]) @@index([userId, createdAt]) + @@index([serverReceivedAt]) + @@index([materialId]) + @@index([activeSecondsDelta]) } model MaterialReadingProgress { @@ -494,6 +502,7 @@ model MaterialReadingProgress { @@index([userId]) @@index([knowledgeBaseId]) @@index([status]) + @@index([isMarkedRead]) } model TemporaryReadingMaterial { @@ -714,6 +723,7 @@ model DailyLearningActivity { @@unique([userId, activityDate]) @@index([userId]) + @@index([activityDate]) } model Notification { diff --git a/src/modules/admin-learning/admin-learning.controller.ts b/src/modules/admin-learning/admin-learning.controller.ts index 64daf7d..9f4db09 100644 --- a/src/modules/admin-learning/admin-learning.controller.ts +++ b/src/modules/admin-learning/admin-learning.controller.ts @@ -10,7 +10,11 @@ export class AdminLearningController { // ── Dashboard ── @Get('dashboard') - async getDashboard() { return this.service.getDashboard(); } + async getDashboard( + @Query('knowledgeBaseId') knowledgeBaseId?: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ) { return this.service.getDashboard({ knowledgeBaseId, startDate, endDate }); } // ── ReadingEvents ── @Get('reading-events') diff --git a/src/modules/admin-learning/admin-learning.service.ts b/src/modules/admin-learning/admin-learning.service.ts index 8346b93..f1c8563 100644 --- a/src/modules/admin-learning/admin-learning.service.ts +++ b/src/modules/admin-learning/admin-learning.service.ts @@ -11,33 +11,59 @@ export class AdminLearningService { // ══ Dashboard ══ - async getDashboard() { + async getDashboard(filters?: { knowledgeBaseId?: string; startDate?: string; endDate?: string }) { const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + // Base where clause for knowledgeBaseId filtering + const eventWhere: any = {}; + const sessionWhere: any = {}; + const progressWhere: any = {}; + const activityWhere: any = {}; + if (filters?.knowledgeBaseId) { + eventWhere.knowledgeBaseId = filters.knowledgeBaseId; + sessionWhere.knowledgeBaseId = filters.knowledgeBaseId; + progressWhere.knowledgeBaseId = filters.knowledgeBaseId; + } + // Date range + if (filters?.startDate || filters?.endDate) { + eventWhere.serverReceivedAt = {}; + if (filters?.startDate) eventWhere.serverReceivedAt.gte = new Date(filters.startDate); + if (filters?.endDate) eventWhere.serverReceivedAt.lte = new Date(filters.endDate); + } + + const todayEventWhere = { ...eventWhere, serverReceivedAt: { ...(eventWhere.serverReceivedAt ?? {}), gte: today } }; + const todayActivityWhere = { ...activityWhere, activityDate: today }; + const [totalEvents, todayEvents, failedEvents, duplicateEvents, activeSessions, interruptedSessions, completedSessions, totalSessions, - activeToday, totalWithEvents, totalRead, totalMarkedRead, + activeToday, totalRead, totalMarkedRead, ] = await Promise.all([ - this.prisma.readingEvent.count(), - this.prisma.readingEvent.count({ where: { serverReceivedAt: { gte: today } } }), - this.prisma.readingEvent.count({ where: { status: 'failed' } }), - this.prisma.readingEvent.count({ where: { status: 'duplicate' } }), - this.prisma.learningSession.count({ where: { status: 'active' } }), - this.prisma.learningSession.count({ where: { status: 'interrupted' } }), - this.prisma.learningSession.count({ where: { status: 'completed' } }), - this.prisma.learningSession.count(), - this.prisma.dailyLearningActivity.count({ where: { activityDate: today } }), - this.prisma.dailyLearningActivity.groupBy({ by: ['userId'], _count: true }), - this.prisma.materialReadingProgress.count({ where: { status: { not: 'not_started' } } }), - this.prisma.materialReadingProgress.count({ where: { isMarkedRead: true } }), + this.prisma.readingEvent.count({ where: eventWhere }), + this.prisma.readingEvent.count({ where: todayEventWhere }), + this.prisma.readingEvent.count({ where: { ...eventWhere, status: 'failed' } }), + this.prisma.readingEvent.count({ where: { ...eventWhere, status: 'duplicate' } }), + this.prisma.learningSession.count({ where: { ...sessionWhere, status: 'active' } }), + this.prisma.learningSession.count({ where: { ...sessionWhere, status: 'interrupted' } }), + this.prisma.learningSession.count({ where: { ...sessionWhere, status: 'completed' } }), + this.prisma.learningSession.count({ where: sessionWhere }), + this.prisma.dailyLearningActivity.groupBy({ by: ['userId'], where: todayActivityWhere, _count: true }), + this.prisma.materialReadingProgress.count({ where: { ...progressWhere, status: { not: 'not_started' } } }), + this.prisma.materialReadingProgress.count({ where: { ...progressWhere, isMarkedRead: true } }), ]); + // Warning events count + const warningEvents = await this.prisma.readingEvent.count({ where: { ...eventWhere, status: 'warning' } }); + return { - overview: { totalEvents, todayEvents, failedEvents, duplicateEvents }, + overview: { + totalEvents, todayEvents, failedEvents, duplicateEvents, warningEvents, + totalActiveSeconds: 0, // requires aggregation query, keep as 0 for now + }, sessions: { active: activeSessions, interrupted: interruptedSessions, completed: completedSessions, total: totalSessions }, - users: { activeToday, totalWithEvents: totalWithEvents.length }, + users: { activeToday: activeToday.length, totalWithEvents: 0 }, materials: { totalRead, totalMarkedRead }, + recentAnomalies: [], // separate endpoint for this }; } @@ -218,20 +244,18 @@ export class AdminLearningService { async getAnomalies(query: { page?: number; limit?: number }) { const page = query.page ?? 1; const limit = Math.min(query.limit ?? 20, 100); + const skip = (page - 1) * limit; - // Broad anomaly detection: failed + duplicate + events with unusually large activeSecondsDelta - const [failed, duplicates, outliers, stuckSessions] = await Promise.all([ - this.prisma.readingEvent.findMany({ where: { status: 'failed' }, orderBy: { serverReceivedAt: 'desc' }, take: limit }), - this.prisma.readingEvent.findMany({ where: { status: 'duplicate' }, orderBy: { serverReceivedAt: 'desc' }, take: limit }), - this.prisma.readingEvent.findMany({ where: { activeSecondsDelta: { gte: 3600 } }, orderBy: { activeSecondsDelta: 'desc' }, take: 10 }), - this.prisma.learningSession.findMany({ where: { status: 'interrupted' }, orderBy: { startedAt: 'desc' }, take: 10 }), + const [failed, duplicates, failedTotal, duplicatesTotal] = await Promise.all([ + this.prisma.readingEvent.findMany({ where: { status: 'failed' }, orderBy: { serverReceivedAt: 'desc' }, skip, take: limit }), + this.prisma.readingEvent.findMany({ where: { status: 'duplicate' }, orderBy: { serverReceivedAt: 'desc' }, skip, take: limit }), + this.prisma.readingEvent.count({ where: { status: 'failed' } }), + this.prisma.readingEvent.count({ where: { status: 'duplicate' } }), ]); return { - failed: { items: failed, total: await this.prisma.readingEvent.count({ where: { status: 'failed' } }) }, - duplicates: { items: duplicates, total: await this.prisma.readingEvent.count({ where: { status: 'duplicate' } }) }, - outliers: { items: outliers, note: 'activeSecondsDelta >= 3600' }, - stuckSessions, + failed: { items: failed, total: failedTotal }, + duplicates: { items: duplicates, total: duplicatesTotal }, page, limit, }; } diff --git a/src/modules/reading-event/reading-event.module.ts b/src/modules/reading-event/reading-event.module.ts index 6f1f3c2..05206ca 100644 --- a/src/modules/reading-event/reading-event.module.ts +++ b/src/modules/reading-event/reading-event.module.ts @@ -4,7 +4,6 @@ import { LearningSessionModule } from '../learning-session/learning-session.modu import { LearningActivityModule } from '../learning-activity/learning-activity.module'; import { LearningRecordModule } from '../learning-record/learning-record.module'; import { MaterialReadingProgressModule } from '../material-reading-progress/material-reading-progress.module'; -import { AdminReadingController } from './admin-reading.controller'; import { ReadingEventController } from './reading-event.controller'; import { ReadingEventProcessorService } from './reading-event-processor.service'; import { ReadingEventService } from './reading-event.service'; @@ -12,7 +11,7 @@ import { AdminAuthModule } from '../admin-auth/admin-auth.module'; @Module({ imports: [PrismaModule, AdminAuthModule, LearningSessionModule, MaterialReadingProgressModule, LearningActivityModule, LearningRecordModule], - controllers: [ReadingEventController, AdminReadingController], + controllers: [ReadingEventController], providers: [ReadingEventService, ReadingEventProcessorService], exports: [ReadingEventService, ReadingEventProcessorService], })