New_Micro_Learning/backend/app/Modules/Learner/Http/LearnerController.php

272 خطوط
22 KiB
PHP

<?php
namespace App\Modules\Learner\Http;
use App\Http\Controllers\Controller;
use App\Modules\Assets\Application\AssetUsage;
use App\Modules\Assets\Domain\Asset;
use App\Modules\Assignments\Domain\Assignment;
use App\Modules\Certificates\Application\IssueCertificate;
use App\Modules\Courses\Domain\Block;
use App\Modules\Courses\Domain\CourseVersion;
use App\Modules\Courses\Domain\Lesson;
use App\Modules\Events\Domain\EventTaxonomy;
use App\Modules\Identity\Application\RolePermissions;
use App\Modules\Identity\Domain\Enums\Permission;
use App\Modules\Learner\Application\LearnerProgressService;
use App\Modules\Learner\Application\LearnerProgressReport;
use App\Modules\Learner\Domain\BlockProgress;
use App\Modules\Learner\Domain\LearnerBookmark;
use App\Modules\Learner\Domain\LearnerNote;
use App\Modules\Learner\Domain\LessonProgress;
use App\Modules\LearningPaths\Domain\LearningPathVersion;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
final class LearnerController extends Controller
{
public function __construct(
private readonly LearnerProgressService $progress,
private readonly LearnerProgressReport $progressReport,
private readonly AssetUsage $assets,
private readonly IssueCertificate $certificates,
private readonly RolePermissions $permissions,
) {}
public function home(Request $request): JsonResponse
{
$learner = $this->learner($request);
$assignments = $this->assignments($learner->getKey());
$assignmentIds = $assignments->pluck('id');
$activities = LessonProgress::query()
->join('lessons', 'lessons.id', '=', 'lesson_progress.lesson_id')
->where('lesson_progress.learner_id', $learner->getKey())
->whereIn('lesson_progress.assignment_id', $assignmentIds)
->orderByDesc('lesson_progress.last_activity_at')
->get(['lesson_progress.assignment_id as assignmentId', 'lesson_progress.last_activity_at as lastActivityAt', 'lessons.title as lessonTitle'])
->unique('assignmentId')->keyBy('assignmentId');
$scores = BlockProgress::query()->where('learner_id', $learner->getKey())->whereIn('assignment_id', $assignmentIds)->whereNotNull('score')
->selectRaw('assignment_id, avg(score) * 100 as final_score')->groupBy('assignment_id')->pluck('final_score', 'assignment_id');
$favorites = DB::table('learner_favorites')->where('learner_id', $learner->getKey())->whereIn('assignment_id', $assignmentIds)->pluck('assignment_id')->flip();
$certificates = DB::table('certificates')->where('user_id', $learner->getKey())->get(['course_version_id', 'verification_code'])->keyBy('course_version_id');
$items = $assignments->map(fn (Assignment $assignment) => $this->assignmentPayload($assignment, $activities->get($assignment->getKey()), $scores->get($assignment->getKey()), $favorites->has($assignment->getKey()), $certificates));
$completed = $items->where('status', 'completed')->count();
return response()->json(['data' => [
'continueLearning' => $items->whereIn('status', ['in_progress', 'assigned'])->sortByDesc(fn ($item) => $item['progress'])->first(),
'assigned' => $items->values(), 'dueSoon' => $items->filter(fn ($item) => $item['dueAt'] && now()->diffInDays($item['dueAt'], false) <= 7 && $item['status'] !== 'completed')->values(),
'summary' => ['total' => $items->count(), 'completed' => $completed, 'inProgress' => $items->where('status', 'in_progress')->count(), 'averageProgress' => $items->count() ? (int) round($items->avg('progress')) : 0],
]]);
}
public function progress(Request $request): JsonResponse
{
$learner = $this->learner($request);
$assignments = $this->assignments($learner->getKey());
return response()->json(['data' => $this->progressReport->build($learner, $assignments)]);
}
public function show(Request $request, string $assignment): JsonResponse
{
$learner = $this->learner($request);
$model = $this->progress->assignment($learner, $assignment);
$courseVersionId = $request->string('courseVersionId')->toString() ?: (string) $model->assignable_id;
$version = $this->progress->courseVersion($model, $courseVersionId);
$version->load(['course.coverAsset', 'modules.lessons.blocks']);
$lessonId = $request->string('lessonId')->toString();
$lesson = $version->lessons()->when($lessonId, fn ($query) => $query->whereKey($lessonId))->orderBy('position')->firstOrFail();
$lesson->load('blocks');
$summary = $this->progress->courseProgress($learner, $model, $version);
$progress = LessonProgress::query()->where('assignment_id', $model->getKey())->where('learner_id', $learner->getKey())->where('course_version_id', $version->getKey())->get()->keyBy('lesson_id');
$blockProgress = BlockProgress::query()->where('assignment_id', $model->getKey())->where('learner_id', $learner->getKey())->where('lesson_id', $lesson->getKey())->get()->keyBy('block_id');
return response()->json(['data' => [
'assignment' => ['id' => $model->getKey(), 'dueAt' => $model->due_at?->toISOString(), 'mandatory' => $model->mandatory],
'course' => ['id' => $version->course_id, 'versionId' => $version->getKey(), 'title' => $version->course->title, 'description' => $version->description, 'progress' => $summary['progress']],
'lesson' => ['id' => $lesson->getKey(), 'title' => $lesson->title, 'presentationMode' => $lesson->presentation_mode, 'progress' => (int) ($progress->get($lesson->getKey())?->progress ?? 0)],
'structure' => $version->modules->sortBy('position')->map(fn ($module) => ['id' => $module->getKey(), 'title' => $module->title, 'lessons' => $module->lessons->sortBy('position')->map(fn (Lesson $item) => ['id' => $item->getKey(), 'title' => $item->title, 'position' => $item->position, 'status' => $progress->get($item->getKey())?->status ?? 'not_started', 'progress' => (int) ($progress->get($item->getKey())?->progress ?? 0)])->values()])->values(),
'blocks' => $lesson->blocks->sortBy('position')->map(fn (Block $block) => ['id' => $block->getKey(), 'type' => $block->type, 'data' => $this->assetUrls($block->data, $block->organization_id), 'style' => $block->style, 'behavior' => $block->behavior, 'accessibility' => $block->accessibility, 'position' => $block->position, 'status' => $blockProgress->get($block->getKey())?->status ?? 'not_started'])->values(),
'notes' => LearnerNote::query()->where('learner_id', $learner->getKey())->where('course_version_id', $version->getKey())->where('lesson_id', $lesson->getKey())->latest()->get()->map(fn ($note) => ['id' => $note->getKey(), 'body' => $note->body, 'updatedAt' => $note->updated_at?->toISOString()]),
'bookmarks' => LearnerBookmark::query()->where('learner_id', $learner->getKey())->where('lesson_id', $lesson->getKey())->pluck('block_id')->filter()->values(),
'highlights' => DB::table('learner_highlights')->where('learner_id', $learner->getKey())->where('lesson_id', $lesson->getKey())->latest()->get(['id', 'block_id as blockId', 'quote', 'color', 'created_at as createdAt']),
'favorite' => DB::table('learner_favorites')->where('learner_id', $learner->getKey())->where('assignment_id', $model->getKey())->exists(),
'discussions' => DB::table('lesson_discussions')->join('users', 'users.id', '=', 'lesson_discussions.learner_id')->where('lesson_discussions.organization_id', $learner->organization_id)->where('lesson_id', $lesson->getKey())->orderBy('lesson_discussions.created_at')->get(['lesson_discussions.id', 'lesson_discussions.parent_id as parentId', 'lesson_discussions.body', 'lesson_discussions.created_at as createdAt', 'users.name as author'])->map(function ($comment) use ($learner) {
$comment->reactions = DB::table('discussion_reactions')->where('discussion_id', $comment->id)->select('reaction', DB::raw('count(*) as count'))->groupBy('reaction')->get();
$comment->myReactions = DB::table('discussion_reactions')->where('discussion_id', $comment->id)->where('learner_id', $learner->getKey())->pluck('reaction');
return $comment;
}),
'offlineManifest' => $this->manifest($model, $version),
]]);
}
public function sync(Request $request): JsonResponse
{
$learner = $this->learner($request);
$events = $request->validate(['events' => ['required', 'array', 'min:1', 'max:100'], 'events.*.id' => ['required', 'uuid'], 'events.*.type' => ['required', Rule::in(EventTaxonomy::learning())], 'events.*.schemaVersion' => ['nullable', 'integer', Rule::in([EventTaxonomy::SCHEMA_VERSION])], 'events.*.sessionId' => ['nullable', 'string', 'max:120'], 'events.*.correlationId' => ['nullable', 'uuid'], 'events.*.causationId' => ['nullable', 'uuid'], 'events.*.deviceContext' => ['nullable', 'array'], 'events.*.assignmentId' => ['required', 'string'], 'events.*.courseVersionId' => ['required', 'string'], 'events.*.lessonId' => ['nullable', 'string'], 'events.*.blockId' => ['nullable', 'string'], 'events.*.payload' => ['nullable', 'array'], 'events.*.occurredAt' => ['required', 'date', 'before_or_equal:now']])['events'];
$results = [];
foreach ($events as $event) {
$assignment = $this->progress->assignment($learner, $event['assignmentId']);
$version = $this->progress->courseVersion($assignment, $event['courseVersionId']);
$lesson = isset($event['lessonId']) ? Lesson::query()->where('course_version_id', $version->getKey())->findOrFail($event['lessonId']) : null;
$block = isset($event['blockId']) && $lesson ? Block::query()->where('lesson_id', $lesson->getKey())->findOrFail($event['blockId']) : null;
$context = ['schemaVersion' => $event['schemaVersion'] ?? EventTaxonomy::SCHEMA_VERSION, 'sessionId' => $event['sessionId'] ?? null, 'correlationId' => $event['correlationId'] ?? $event['id'], 'causationId' => $event['causationId'] ?? null, 'deviceContext' => EventTaxonomy::deviceContext($event['deviceContext'] ?? [])];
$result = $this->progress->record($learner, $assignment, $version, $event['id'], $event['type'], $lesson, $block, $event['payload'] ?? [], new \DateTimeImmutable($event['occurredAt']), $context);
if (($result['completed'] ?? false) && $assignment->assignable_type === 'course') {
$this->certificates->issue($learner, $version);
}
$results[] = ['id' => $event['id'], ...$result];
}
return response()->json(['data' => ['events' => $results]]);
}
public function note(Request $request, string $assignment): JsonResponse
{
$learner = $this->learner($request);
$model = $this->progress->assignment($learner, $assignment);
$data = $request->validate(['courseVersionId' => ['required', 'string'], 'lessonId' => ['required', 'string'], 'body' => ['required', 'string', 'max:10000']]);
$version = $this->progress->courseVersion($model, $data['courseVersionId']);
Lesson::query()->where('course_version_id', $version->getKey())->findOrFail($data['lessonId']);
$note = LearnerNote::query()->create(['organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'course_version_id' => $version->getKey(), 'lesson_id' => $data['lessonId'], 'body' => $data['body']]);
return response()->json(['data' => ['id' => $note->getKey(), 'body' => $note->body, 'updatedAt' => $note->updated_at?->toISOString()]], 201);
}
public function bookmark(Request $request, string $assignment): JsonResponse
{
$learner = $this->learner($request);
$model = $this->progress->assignment($learner, $assignment);
$data = $request->validate(['courseVersionId' => ['required', 'string'], 'lessonId' => ['required', 'string'], 'blockId' => ['required', 'string']]);
$version = $this->progress->courseVersion($model, $data['courseVersionId']);
$block = Block::query()->where('course_version_id', $version->getKey())->where('lesson_id', $data['lessonId'])->findOrFail($data['blockId']);
$bookmark = LearnerBookmark::query()->where('learner_id', $learner->getKey())->where('lesson_id', $data['lessonId'])->where('block_id', $block->getKey())->first();
if ($bookmark) {
$bookmark->delete();
} else {
LearnerBookmark::query()->create(['organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'course_version_id' => $version->getKey(), 'lesson_id' => $data['lessonId'], 'block_id' => $block->getKey()]);
}
return response()->json(['data' => ['bookmarked' => ! $bookmark]]);
}
public function highlight(Request $request, string $assignment): JsonResponse
{
$learner = $this->learner($request);
$model = $this->progress->assignment($learner, $assignment);
$data = $request->validate(['courseVersionId' => ['required', 'string'], 'lessonId' => ['required', 'string'], 'blockId' => ['required', 'string'], 'quote' => ['required', 'string', 'max:2000'], 'color' => ['required', Rule::in(['yellow', 'green', 'blue'])]]);
$version = $this->progress->courseVersion($model, $data['courseVersionId']);
Block::query()->where('course_version_id', $version->getKey())->where('lesson_id', $data['lessonId'])->findOrFail($data['blockId']);
$id = (string) Str::ulid();
DB::table('learner_highlights')->insert(['id' => $id, 'organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'course_version_id' => $version->getKey(), 'lesson_id' => $data['lessonId'], 'block_id' => $data['blockId'], 'quote' => $data['quote'], 'color' => $data['color'], 'created_at' => now(), 'updated_at' => now()]);
return response()->json(['data' => ['id' => $id]], 201);
}
public function favorite(Request $request, string $assignment): JsonResponse
{
$learner = $this->learner($request);
$model = $this->progress->assignment($learner, $assignment);
$existing = DB::table('learner_favorites')->where('learner_id', $learner->getKey())->where('assignment_id', $model->getKey());
$favorite = ! $existing->exists();
if ($favorite) {
DB::table('learner_favorites')->insert(['id' => (string) Str::ulid(), 'organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'assignment_id' => $model->getKey(), 'created_at' => now(), 'updated_at' => now()]);
} else {
$existing->delete();
}
return response()->json(['data' => ['favorite' => $favorite]]);
}
public function discuss(Request $request, string $assignment): JsonResponse
{
$learner = $this->learner($request);
$model = $this->progress->assignment($learner, $assignment);
$data = $request->validate(['courseVersionId' => ['required', 'string'], 'lessonId' => ['required', 'string'], 'body' => ['required', 'string', 'max:5000'], 'parentId' => ['nullable', 'string']]);
$version = $this->progress->courseVersion($model, $data['courseVersionId']);
Lesson::query()->where('course_version_id', $version->getKey())->findOrFail($data['lessonId']);
if (($data['parentId'] ?? null) && ! DB::table('lesson_discussions')->where('organization_id', $learner->organization_id)->where('lesson_id', $data['lessonId'])->where('id', $data['parentId'])->exists()) {
abort(422);
} $id = (string) Str::ulid();
DB::table('lesson_discussions')->insert(['id' => $id, 'organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'course_version_id' => $version->getKey(), 'lesson_id' => $data['lessonId'], 'parent_id' => $data['parentId'] ?? null, 'body' => $data['body'], 'created_at' => now(), 'updated_at' => now()]);
return response()->json(['data' => ['id' => $id]], 201);
}
public function react(Request $request, string $discussion): JsonResponse
{
$learner = $this->learner($request);
$data = $request->validate(['reaction' => ['required', Rule::in(['helpful', 'like'])]]);
abort_unless(DB::table('lesson_discussions')->where('organization_id', $learner->organization_id)->where('id', $discussion)->exists(), 404);
$query = DB::table('discussion_reactions')->where('learner_id', $learner->getKey())->where('discussion_id', $discussion)->where('reaction', $data['reaction']);
$active = ! $query->exists();
if ($active) {
DB::table('discussion_reactions')->insert(['id' => (string) Str::ulid(), 'organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'discussion_id' => $discussion, 'reaction' => $data['reaction'], 'created_at' => now(), 'updated_at' => now()]);
} else {
$query->delete();
}
return response()->json(['data' => ['active' => $active]]);
}
private function assignments(string $learnerId)
{
return Assignment::query()->where('organization_id', auth()->user()->organization_id)->where('status', 'active')->whereHas('users', fn ($query) => $query->where('users.id', $learnerId)->whereNot('assignment_users.status', 'cancelled'))->with(['users' => fn ($query) => $query->where('users.id', $learnerId)])->latest()->get();
}
private function assignmentPayload(Assignment $assignment, mixed $activity, mixed $score, bool $favorite, mixed $certificates): array
{
$pivot = $assignment->users->first()?->pivot;
$version = $assignment->assignable_type === 'course' ? CourseVersion::query()->with(['course.coverAsset', 'lessons'])->find($assignment->assignable_id) : null;
$path = $assignment->assignable_type === 'learning_path' ? LearningPathVersion::query()->with(['path', 'items.courseVersion.course.coverAsset', 'items.courseVersion.lessons'])->find($assignment->assignable_id) : null;
$firstVersion = $version ?? $path?->items?->first()?->courseVersion;
$firstLesson = $firstVersion?->lessons()->orderBy('position')->first();
$lessons = $version?->lessons ?? $path?->items?->flatMap(fn ($item) => $item->courseVersion?->lessons ?? collect()) ?? collect();
$totalMinutes = (int) $lessons->sum(fn (Lesson $lesson) => (int) data_get($lesson->settings, 'durationMinutes', 0));
$progress = (int) ($pivot?->progress ?? 0);
$taxonomy = $version?->taxonomy_snapshot ?? [];
$skills = collect(data_get($taxonomy, 'skills', []))->map(fn ($skill) => is_array($skill) ? ($skill['name'] ?? $skill['title'] ?? null) : $skill)->filter(fn ($skill) => is_string($skill) && trim($skill) !== '')->values();
$certificate = $firstVersion ? $certificates->get($firstVersion->getKey()) : null;
return ['id' => $assignment->getKey(), 'type' => $assignment->assignable_type, 'title' => $version?->course?->title ?? $path?->path?->title ?? 'محتوای یادگیری', 'description' => $version?->description ?? $path?->description, 'courseVersionId' => $firstVersion?->getKey(), 'lessonId' => $firstLesson?->getKey(), 'coverUrl' => $firstVersion?->course?->coverAsset ? URL::temporarySignedRoute('assets.content', now()->addHours(), ['asset' => $firstVersion->course->coverAsset->getKey()], absolute: false) : null, 'progress' => $progress, 'status' => $pivot?->status ?? 'assigned', 'mandatory' => $assignment->mandatory, 'dueAt' => $assignment->due_at?->toISOString(),
'startsAt' => $assignment->starts_at?->toISOString(), 'assignedAt' => $pivot?->assigned_at ? Carbon::parse($pivot->assigned_at)->toISOString() : null, 'completedAt' => $pivot?->completed_at ? Carbon::parse($pivot->completed_at)->toISOString() : null,
'lastActivityAt' => $activity?->lastActivityAt ? Carbon::parse($activity->lastActivityAt)->toISOString() : null, 'currentLessonTitle' => $activity?->lessonTitle ?? $firstLesson?->title,
'lessonCount' => $lessons->count(), 'remainingMinutes' => $totalMinutes > 0 ? (int) ceil($totalMinutes * (100 - $progress) / 100) : null, 'skills' => $skills,
'finalScore' => $score !== null ? (int) round((float) $score) : null, 'favorite' => $favorite, 'certificateUrl' => $certificate ? '/certificate/verify/'.$certificate->verification_code : null];
}
private function manifest(Assignment $assignment, CourseVersion $version): array
{
$version->loadMissing('modules.lessons.blocks');
$assetIds = $version->blocks->flatMap(fn (Block $block) => $this->assets->assetIds($block->data))->unique();
$assets = Asset::query()->where('organization_id', $version->organization_id)->whereIn('id', $assetIds)->get()->map(fn (Asset $asset) => ['id' => $asset->getKey(), 'kind' => $asset->kind, 'size' => $asset->size, 'downloadAllowed' => $asset->kind !== 'video' || $asset->size <= 200 * 1024 * 1024, 'url' => URL::temporarySignedRoute('assets.content', now()->addHours(), ['asset' => $asset->getKey()], absolute: false)]);
return ['version' => 1, 'assignmentId' => $assignment->getKey(), 'courseVersionId' => $version->getKey(), 'generatedAt' => now()->toISOString(), 'assets' => $assets];
}
private function assetUrls(array $data, string $organizationId): array
{
foreach ($this->assets->assetIds($data) as $id) {
$asset = Asset::query()->where('organization_id', $organizationId)->find($id);
if ($asset) {
$data['_assets'][$id] = URL::temporarySignedRoute('assets.content', now()->addHours(), ['asset' => $asset->getKey()], absolute: false);
}
}
return $data;
}
private function learner(Request $request)
{
abort_unless($request->user() && $this->permissions->allows($request->user(), Permission::PersonalLearningView), 403);
return $request->user();
}
}