authorizeAnalytics($request, $permissions); $filters = $this->filters($request); $courseModel = Course::query()->where('organization_id', $user->organization_id)->findOrFail($course); $version = $this->version($courseModel->getKey(), $user->organization_id, $filters['version'] ?? null); $team = $this->team($user->organization_id, $filters['team'] ?? null); [$from, $to] = $this->range($filters); $data = $dashboard->build($version, $from, $to, $team, $filters['sort'] ?? 'position', $filters['direction'] ?? 'asc', (int) ($filters['page'] ?? 1), (int) ($filters['pageSize'] ?? 10)); $data['course'] = ['id' => $courseModel->getKey(), 'title' => $courseModel->title, 'versionId' => $version->getKey(), 'version' => $version->version_number, 'publishedAt' => $version->published_at?->toISOString()]; $data['filters'] = [ 'versions' => CourseVersion::query()->where('organization_id', $user->organization_id)->where('course_id', $courseModel->getKey())->orderByDesc('version_number')->get(['id', 'version_number', 'published_at'])->map(fn (CourseVersion $item) => ['id' => $item->getKey(), 'version' => $item->version_number, 'publishedAt' => $item->published_at?->toISOString()]), 'teams' => DB::table('teams')->where('organization_id', $user->organization_id)->orderBy('name')->get(['id', 'name']), ]; return response()->json(['data' => $data]); } public function report(Request $request, string $course, RolePermissions $permissions, CourseAnalyticsDashboard $dashboard): StreamedResponse { $user = $this->authorizeAnalytics($request, $permissions); $filters = $this->filters($request); $courseModel = Course::query()->where('organization_id', $user->organization_id)->findOrFail($course); $version = $this->version($courseModel->getKey(), $user->organization_id, $filters['version'] ?? null); $team = $this->team($user->organization_id, $filters['team'] ?? null); [$from, $to] = $this->range($filters); $data = $dashboard->build($version, $from, $to, $team, $filters['sort'] ?? 'position', $filters['direction'] ?? 'asc', 1, 5000); $filename = (preg_replace('/[^\pL\pN_-]+/u', '-', $courseModel->title) ?: 'course').'-تحلیل-نسخه-'.$version->version_number.'.csv'; return response()->streamDownload(function () use ($data): void { $stream = fopen('php://output', 'wb'); if (! $stream) { return; } fwrite($stream, "\xEF\xBB\xBF"); fputcsv($stream, ['شاخص', 'مقدار', 'واحد', 'تعریف']); $labels = ['learners' => 'یادگیرندگان', 'starts' => 'شروع دوره', 'completions' => 'تکمیل', 'averageProgress' => 'میانگین پیشرفت', 'averageScore' => 'میانگین امتیاز', 'dropOff' => 'نرخ ریزش']; foreach ($data['metrics'] as $key => $metric) { fputcsv($stream, [$labels[$key] ?? $key, $metric['value'] ?? '—', $metric['unit'], $data['definitions'][$key]]); } fputcsv($stream, []); fputcsv($stream, ['درس', 'ماژول', 'بازدید', 'میانگین زمان (دقیقه)', 'نرخ تکمیل', 'امتیاز ارزیابی', 'وضعیت']); foreach ($data['content']['items'] as $lesson) { fputcsv($stream, [$lesson['title'], $lesson['moduleTitle'], $lesson['views'], $lesson['averageTimeMinutes'] ?? '—', $lesson['completionRate'] ?? '—', $lesson['assessmentScore'] ?? '—', $lesson['status']]); } fclose($stream); }, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']); } private function authorizeAnalytics(Request $request, RolePermissions $permissions): User { $user = $request->user(); abort_unless($user instanceof User && $permissions->allows($user, Permission::OrganizationAnalyticsView), 403); return $user; } /** @return array */ private function filters(Request $request): array { return $request->validate([ 'from' => ['nullable', 'date'], 'to' => ['nullable', 'date', 'after_or_equal:from'], 'version' => ['nullable', 'string'], 'team' => ['nullable', 'string'], 'sort' => ['nullable', 'in:position,title,views,time,completion,score,status'], 'direction' => ['nullable', 'in:asc,desc'], 'page' => ['nullable', 'integer', 'min:1'], 'pageSize' => ['nullable', 'integer', 'in:5,10,25,50'], ]); } /** @param array $filters @return array{CarbonImmutable, CarbonImmutable} */ private function range(array $filters): array { $from = CarbonImmutable::parse($filters['from'] ?? now()->subDays(29)->toDateString())->startOfDay(); $to = CarbonImmutable::parse($filters['to'] ?? now()->toDateString())->endOfDay(); abort_if($from->diffInDays($to) > 366, 422, 'Date range cannot exceed 366 days.'); return [$from, $to]; } private function version(string $courseId, string $organizationId, ?string $versionId): CourseVersion { $query = CourseVersion::query()->where('organization_id', $organizationId)->where('course_id', $courseId); return $versionId ? $query->findOrFail($versionId) : $query->orderByDesc('version_number')->firstOrFail(); } private function team(string $organizationId, ?string $teamId): ?string { if ($teamId) { abort_unless(DB::table('teams')->where('organization_id', $organizationId)->where('id', $teamId)->exists(), 404); } return $teamId; } }