authorize($request); $versionModel = $this->version($version); $this->versions->assertDraft($versionModel); $module = CourseModule::query()->create(['organization_id' => $this->tenant->id(), 'course_version_id' => $versionModel->getKey(), 'title' => $request->validated('title'), 'position' => ((int) $versionModel->modules()->max('position')) + 1]); return response()->json(['data' => $this->modulePayload($module)], 201); } public function updateModule(UpdateModuleRequest $request, string $module): JsonResponse { $this->authorize($request); $model = $this->module($module); $this->versions->assertDraft($this->version($model->course_version_id)); $data = $request->validated(); if ($model->is_locked && (($data['locked'] ?? null) !== false || count($data) !== 1)) { $this->locked('module'); } $attributes = []; if (isset($data['title'])) { $attributes['title'] = $data['title']; } if (array_key_exists('locked', $data)) { $attributes['is_locked'] = $data['locked']; } $model->update($attributes); return response()->json(['data' => $this->modulePayload($model)]); } public function destroyModule(Request $request, string $module): JsonResponse { $this->authorize($request); $model = $this->module($module); $this->versions->assertDraft($this->version($model->course_version_id)); $this->assertUnlocked($model); if ($model->lessons()->where('is_locked', true)->exists()) { $this->locked('lesson'); } DB::transaction(function () use ($model) { $versionId = $model->course_version_id; $position = $model->position; $model->delete(); CourseModule::query()->where('course_version_id', $versionId)->where('position', '>', $position)->decrement('position'); }); return response()->json(status: 204); } public function reorderModules(ReorderStructureRequest $request, string $version): JsonResponse { $this->authorize($request); $model = $this->version($version); $this->versions->assertDraft($model); $ids = $request->validated('ids'); $this->assertComplete($ids, $model->modules()->pluck('id')->all()); if ($model->modules()->where('is_locked', true)->exists()) { $this->locked('module order'); } $this->reorder(CourseModule::class, 'course_version_id', $model->getKey(), $ids); return response()->json(['data' => ['ids' => $ids]]); } public function storeLesson(StoreLessonRequest $request, string $module): JsonResponse { $this->authorize($request); $moduleModel = $this->module($module); $version = $this->version($moduleModel->course_version_id); $this->versions->assertDraft($version); $this->assertUnlocked($moduleModel); $lesson = Lesson::query()->create(['organization_id' => $this->tenant->id(), 'course_version_id' => $version->getKey(), 'course_module_id' => $moduleModel->getKey(), 'title' => $request->validated('title'), 'presentation_mode' => $request->validated('presentationMode', 'flow'), 'position' => ((int) $moduleModel->lessons()->max('position')) + 1]); return response()->json(['data' => $this->lessonPayload($lesson)], 201); } public function updateLesson(UpdateLessonRequest $request, string $lesson): JsonResponse { $this->authorize($request); $model = $this->lesson($lesson); $version = $this->version($model->course_version_id); $this->versions->assertDraft($version); $data = $request->validated(); $currentModule = $this->module($model->course_module_id); $this->assertUnlocked($currentModule); if ($model->is_locked && (($data['locked'] ?? null) !== false || count($data) !== 1)) { $this->locked('lesson'); } DB::transaction(function () use ($model, $version, $data) { $attributes = []; if (isset($data['title'])) { $attributes['title'] = $data['title']; } if (isset($data['presentationMode'])) { $attributes['presentation_mode'] = $data['presentationMode']; } if (array_key_exists('locked', $data)) { $attributes['is_locked'] = $data['locked']; } if (isset($data['moduleId']) && $data['moduleId'] !== $model->course_module_id) { $target = CourseModule::query()->where('organization_id', $this->tenant->id())->where('course_version_id', $version->getKey())->findOrFail($data['moduleId']); $this->assertUnlocked($target); $oldModule = $model->course_module_id; $oldPosition = $model->position; $attributes['course_module_id'] = $target->getKey(); $attributes['position'] = ((int) $target->lessons()->max('position')) + 1; $model->update($attributes); Lesson::query()->where('course_module_id', $oldModule)->where('position', '>', $oldPosition)->decrement('position'); return; } $model->update($attributes); }); return response()->json(['data' => $this->lessonPayload($model->fresh())]); } public function destroyLesson(Request $request, string $lesson): JsonResponse { $this->authorize($request); $model = $this->lesson($lesson); $this->versions->assertDraft($this->version($model->course_version_id)); $this->assertUnlocked($this->module($model->course_module_id)); $this->assertUnlocked($model); DB::transaction(function () use ($model) { $moduleId = $model->course_module_id; $position = $model->position; $model->delete(); Lesson::query()->where('course_module_id', $moduleId)->where('position', '>', $position)->decrement('position'); }); return response()->json(status: 204); } public function reorderLessons(ReorderStructureRequest $request, string $module): JsonResponse { $this->authorize($request); $moduleModel = $this->module($module); $this->versions->assertDraft($this->version($moduleModel->course_version_id)); $ids = $request->validated('ids'); $this->assertComplete($ids, $moduleModel->lessons()->pluck('id')->all()); if ($moduleModel->is_locked || $moduleModel->lessons()->where('is_locked', true)->exists()) { $this->locked('lesson order'); } $this->reorder(Lesson::class, 'course_module_id', $moduleModel->getKey(), $ids); return response()->json(['data' => ['ids' => $ids]]); } private function reorder(string $model, string $parentKey, string $parentId, array $ids): void { DB::transaction(function () use ($model, $parentKey, $parentId, $ids) { $model::query()->where($parentKey, $parentId)->update(['position' => DB::raw('position + 100000')]); foreach ($ids as $index => $id) { $model::query()->whereKey($id)->update(['position' => $index + 1]); } }); } private function assertComplete(array $ids, array $actual): void { if (count($ids) !== count($actual) || array_diff($ids, $actual) || array_diff($actual, $ids)) { throw ValidationException::withMessages(['ids' => ['The order must contain every item exactly once.']]); } } public function duplicateModule(Request $request, string $module): JsonResponse { $this->authorize($request); $source = $this->module($module); $version = $this->version($source->course_version_id); $this->versions->assertDraft($version); $this->assertUnlocked($source); $copy = DB::transaction(function () use ($source, $version) { $source->load('lessons.blocks'); $module = CourseModule::query()->create([ 'organization_id' => $this->tenant->id(), 'course_version_id' => $version->getKey(), 'title' => $source->title.' — کپی', 'position' => ((int) $version->modules()->max('position')) + 1, 'settings' => $source->settings, 'is_locked' => false, ]); foreach ($source->lessons->sortBy('position') as $lesson) { $newLesson = Lesson::query()->create([ 'organization_id' => $this->tenant->id(), 'course_version_id' => $version->getKey(), 'course_module_id' => $module->getKey(), 'title' => $lesson->title, 'position' => $lesson->position, 'presentation_mode' => $lesson->presentation_mode, 'settings' => $lesson->settings, 'is_locked' => false, ]); $this->copyBlocks($lesson, $newLesson); } return $module; }); return response()->json(['data' => $this->modulePayload($copy)], 201); } public function duplicateLesson(Request $request, string $lesson): JsonResponse { $this->authorize($request); $source = $this->lesson($lesson); $version = $this->version($source->course_version_id); $this->versions->assertDraft($version); $module = $this->module($source->course_module_id); $this->assertUnlocked($module); $this->assertUnlocked($source); $copy = DB::transaction(function () use ($source, $version, $module) { $lesson = Lesson::query()->create([ 'organization_id' => $this->tenant->id(), 'course_version_id' => $version->getKey(), 'course_module_id' => $module->getKey(), 'title' => $source->title.' — کپی', 'position' => ((int) $module->lessons()->max('position')) + 1, 'presentation_mode' => $source->presentation_mode, 'settings' => $source->settings, 'is_locked' => false, ]); $this->copyBlocks($source, $lesson); return $lesson; }); return response()->json(['data' => $this->lessonPayload($copy)], 201); } private function copyBlocks(Lesson $source, Lesson $target): void { $source->loadMissing('blocks'); foreach ($source->blocks->sortBy('position') as $block) { Block::query()->create([ 'organization_id' => $this->tenant->id(), 'course_version_id' => $target->course_version_id, 'lesson_id' => $target->getKey(), 'type' => $block->type, 'schema_version' => $block->schema_version, 'data' => $block->data, 'style' => $block->style, 'behavior' => $block->behavior, 'responsive' => $block->responsive, 'accessibility' => $block->accessibility, 'position' => $block->position, 'revision' => 1, ]); } } private function assertUnlocked(CourseModule|Lesson $model): void { if ($model->is_locked) { $this->locked($model instanceof CourseModule ? 'module' : 'lesson'); } } private function locked(string $target): never { throw ValidationException::withMessages(['locked' => ["The {$target} is locked."]]); } private function version(string $id): CourseVersion { return CourseVersion::query()->where('organization_id', $this->tenant->id())->findOrFail($id); } private function module(string $id): CourseModule { return CourseModule::query()->where('organization_id', $this->tenant->id())->findOrFail($id); } private function lesson(string $id): Lesson { return Lesson::query()->where('organization_id', $this->tenant->id())->findOrFail($id); } private function modulePayload(CourseModule $module): array { return ['id' => $module->getKey(), 'title' => $module->title, 'position' => $module->position, 'locked' => $module->is_locked, 'lessons' => []]; } private function lessonPayload(Lesson $lesson): array { return ['id' => $lesson->getKey(), 'title' => $lesson->title, 'position' => $lesson->position, 'presentationMode' => $lesson->presentation_mode, 'locked' => $lesson->is_locked, 'blockCount' => $lesson->blocks()->count()]; } private function authorize(Request $request): void { abort_unless($this->permissions->allows($request->user(), Permission::CoursesAuthor), 403); } }