authorizeAuthor($request); $filters = $request->validate([ 'search' => ['nullable', 'string', 'max:180'], 'status' => ['nullable', Rule::in(['draft', 'in_review', 'published', 'archived'])], 'sort' => ['nullable', Rule::in(['updatedAt', 'createdAt', 'title'])], 'direction' => ['nullable', Rule::in(['asc', 'desc'])], 'page' => ['nullable', 'integer', 'min:1'], 'perPage' => ['nullable', 'integer', 'min:1', 'max:100'], ]); $sort = match ($filters['sort'] ?? 'updatedAt') { 'createdAt' => 'created_at', 'title' => 'title', default => 'updated_at' }; $courses = Course::query() ->where('organization_id', $this->tenant->id()) ->when(! isset($filters['status']), fn ($query) => $query->where('status', '!=', 'archived')) ->when($filters['search'] ?? null, fn ($query, string $search) => $query->where('title', 'like', '%'.$search.'%')) ->when($filters['status'] ?? null, fn ($query, string $status) => $query->where('status', $status)) ->with(['creator:id,name', 'coverAsset', 'latestVersion' => fn ($query) => $query->withCount(['modules', 'lessons'])]) ->orderBy($sort, $filters['direction'] ?? 'desc') ->paginate($filters['perPage'] ?? 25) ->through(fn (Course $course) => $this->summary($course, (string) $request->user()->getKey())); return response()->json(['data' => $courses->items(), 'meta' => [ 'currentPage' => $courses->currentPage(), 'lastPage' => $courses->lastPage(), 'total' => $courses->total(), ]]); } public function store(StoreCourseRequest $request): JsonResponse { $this->authorizeAuthor($request); $data = $request->validated(); $course = DB::transaction(function () use ($request, $data) { $course = Course::query()->create([ 'organization_id' => $this->tenant->id(), 'title' => $data['title'], 'slug' => $this->uniqueSlug($data['title']), 'status' => 'draft', 'created_by' => $request->user()->getKey(), ]); $version = CourseVersion::query()->create([ 'organization_id' => $this->tenant->id(), 'course_id' => $course->getKey(), 'created_by' => $request->user()->getKey(), 'version_number' => 1, 'status' => CourseVersionStatus::Draft, 'title' => $data['title'], 'description' => $data['description'] ?? null, 'settings' => ['language' => $data['language'], 'difficulty' => $data['difficulty'] ?? null], ]); $module = CourseModule::query()->create([ 'organization_id' => $this->tenant->id(), 'course_version_id' => $version->getKey(), 'title' => 'ماژول اول', 'position' => 1, ]); Lesson::query()->create([ 'organization_id' => $this->tenant->id(), 'course_version_id' => $version->getKey(), 'course_module_id' => $module->getKey(), 'title' => 'درس اول', 'position' => 1, ]); return $course; }); return response()->json(['data' => ['id' => $course->getKey(), 'workspaceUrl' => '/app/courses/'.$course->getKey()]], 201); } public function show(Request $request, string $course): JsonResponse { $this->authorizeAuthor($request); $model = $this->tenantCourse($course); $versionQuery = CourseVersion::query()->where('organization_id', $this->tenant->id())->where('course_id', $model->getKey()); $version = $request->filled('versionId') ? (clone $versionQuery)->findOrFail($request->string('versionId')->toString()) : (clone $versionQuery)->latest('version_number')->firstOrFail(); $version->load(['modules' => fn ($query) => $query->orderBy('position')->with([ 'lessons' => fn ($lessons) => $lessons->orderBy('position')->with(['blocks:id,lesson_id,type'])->withCount(['blocks', 'assessments']), ])]); $history = $versionQuery->orderByDesc('version_number')->get(); $model->load(['creator:id,name', 'coverAsset']); return response()->json(['data' => [ 'course' => ['id' => $model->getKey(), 'title' => $model->title, 'slug' => $model->slug, 'status' => $model->status, 'owner' => $model->creator?->name, 'cover' => $this->coverPayload($model), 'updatedAt' => $model->updated_at?->toISOString()], 'version' => $this->versionPayload($version), 'versions' => $history->map(fn (CourseVersion $item) => $this->versionPayload($item))->values(), 'modules' => $version->modules->map(fn (CourseModule $module) => [ 'id' => $module->getKey(), 'title' => $module->title, 'position' => $module->position, 'locked' => $module->is_locked, 'lessons' => $module->lessons->map(fn (Lesson $lesson) => $this->lessonPayload($lesson))->values(), ])->values(), ]]); } public function updateVersion(UpdateCourseVersionRequest $request, string $course, string $version): JsonResponse { $this->authorizeAuthor($request); $courseModel = $this->tenantCourse($course); $model = $this->tenantVersion($courseModel, $version); $this->versions->assertDraft($model); $data = $request->validated(); $settings = $model->settings ?? []; foreach (['language', 'difficulty'] as $field) { if (array_key_exists($field, $data)) { $settings[$field] = $data[$field]; } } $model->update([ ...collect($data)->only(['title', 'description'])->all(), 'settings' => $settings, ]); if (isset($data['title']) && $model->version_number === $courseModel->versions()->max('version_number')) { $courseModel->update(['title' => $data['title']]); } return response()->json(['data' => $this->versionPayload($model->fresh())]); } public function update(Request $request, string $course): JsonResponse { $this->authorizeAuthor($request); $data = $request->validate(['coverAssetId' => ['nullable', 'string']]); $model = $this->tenantCourse($course); if ($data['coverAssetId'] ?? null) { $asset = Asset::query()->where('organization_id', $this->tenant->id())->findOrFail($data['coverAssetId']); if ($asset->kind !== 'image') { throw ValidationException::withMessages(['coverAssetId' => ['Course cover must be an image asset.']]); } } $model->update(['cover_asset_id' => $data['coverAssetId'] ?? null]); $model->load('coverAsset'); return response()->json(['data' => ['id' => $model->getKey(), 'cover' => $this->coverPayload($model)]]); } public function fork(Request $request, string $course, string $version): JsonResponse { $this->authorizeAuthor($request); $courseModel = $this->tenantCourse($course); $source = $this->tenantVersion($courseModel, $version); $draft = $this->versions->forkPublished($courseModel, $source, (string) $request->user()->getKey()); return response()->json(['data' => $this->versionPayload($draft)], 201); } public function toggleBookmark(Request $request, string $course): JsonResponse { $this->authorizeAuthor($request); $model = $this->tenantCourse($course); $key = ['course_id' => $model->getKey(), 'user_id' => $request->user()->getKey()]; $bookmarked = ! DB::table('course_bookmarks')->where($key)->exists(); if ($bookmarked) { DB::table('course_bookmarks')->insert([...$key, 'created_at' => now(), 'updated_at' => now()]); } else { DB::table('course_bookmarks')->where($key)->delete(); } return response()->json(['data' => ['bookmarked' => $bookmarked]]); } public function archive(Request $request, string $course): JsonResponse { $this->authorizeAuthor($request); $model = $this->tenantCourse($course); $model->update(['status' => 'archived']); return response()->json(['data' => ['id' => $model->getKey(), 'status' => 'archived']]); } private function tenantCourse(string $id): Course { return Course::query()->where('organization_id', $this->tenant->id())->findOrFail($id); } private function tenantVersion(Course $course, string $id): CourseVersion { return CourseVersion::query()->where('organization_id', $this->tenant->id())->where('course_id', $course->getKey())->findOrFail($id); } private function uniqueSlug(string $title): string { $base = Str::slug($title) ?: 'course-'.Str::lower(Str::random(8)); $slug = $base; $suffix = 2; while (Course::query()->where('organization_id', $this->tenant->id())->where('slug', $slug)->exists()) { $slug = $base.'-'.$suffix++; } return $slug; } private function summary(Course $course, string $userId): array { $version = $course->latestVersion; return [ 'id' => $course->getKey(), 'title' => $course->title, 'slug' => $course->slug, 'status' => $course->status, 'owner' => $course->creator?->name, 'versionId' => $version?->getKey(), 'versionNumber' => $version?->version_number, 'versionStatus' => $version?->status->value, 'moduleCount' => $version?->modules_count ?? 0, 'lessonCount' => $version?->lessons_count ?? 0, 'updatedAt' => $course->updated_at?->toISOString(), 'cover' => $this->coverPayload($course), 'bookmarked' => DB::table('course_bookmarks')->where('course_id', $course->getKey())->where('user_id', $userId)->exists(), ]; } private function coverPayload(Course $course): ?array { $asset = $course->coverAsset; if (! $asset) { return null; } return [ 'assetId' => $asset->getKey(), 'altText' => $asset->alt_text, 'contentUrl' => URL::temporarySignedRoute('assets.content', now()->addMinutes(10), ['asset' => $asset->getKey()], absolute: false), ]; } private function versionPayload(CourseVersion $version): array { return [ 'id' => $version->getKey(), 'number' => $version->version_number, 'status' => $version->status->value, 'title' => $version->title, 'description' => $version->description, 'settings' => $version->settings ?? [], 'sourceVersionId' => $version->source_version_id, 'publishedAt' => $version->published_at?->toISOString(), 'createdAt' => $version->created_at?->toISOString(), 'updatedAt' => $version->updated_at?->toISOString(), ]; } private function lessonPayload(Lesson $lesson): array { $settings = $lesson->settings ?? []; $types = $lesson->blocks->pluck('type')->unique()->values(); $contentType = match (true) { ($lesson->assessments_count ?? 0) > 0 => 'assessment', $types->contains(fn (string $type) => str_contains($type, 'scenario') || str_contains($type, 'branch')) => 'scenario', $types->contains('video') => 'video', $types->contains('audio') => 'audio', $types->contains(fn (string $type) => str_contains($type, 'pdf')) => 'pdf', $types->contains(fn (string $type) => in_array($type, ['file', 'document', 'download'], true)) => 'file', $types->contains(fn (string $type) => str_contains($type, 'exercise') || str_contains($type, 'practice')) => 'exercise', default => 'block', }; return [ 'id' => $lesson->getKey(), 'title' => $lesson->title, 'position' => $lesson->position, 'presentationMode' => $lesson->presentation_mode, 'blockCount' => $lesson->blocks_count, 'locked' => $lesson->is_locked, 'contentType' => $settings['contentType'] ?? $contentType, 'durationMinutes' => isset($settings['durationMinutes']) ? (int) $settings['durationMinutes'] : null, 'description' => $settings['description'] ?? null, 'status' => $settings['status'] ?? null, 'assetCount' => $types->filter(fn (string $type) => in_array($type, ['image', 'video', 'audio', 'file', 'document', 'pdf'], true))->count(), 'assessmentCount' => (int) ($lesson->assessments_count ?? 0), 'prerequisites' => array_values($settings['prerequisites'] ?? []), 'blockTypes' => $types, ]; } private function authorizeAuthor(Request $request): void { abort_unless($this->permissions->allows($request->user(), Permission::CoursesAuthor), 403); } }