authorize($request); $paths = LearningPath::query()->where('organization_id', $this->tenant->id())->with(['latestVersion' => fn ($query) => $query->withCount('items')])->latest()->get()->map(fn (LearningPath $path) => $this->summary($path)); return response()->json(['data' => $paths]); } public function contexts(Request $request): JsonResponse { $this->authorize($request); $courses = CourseVersion::query()->with('course:id,title')->where('organization_id', $this->tenant->id())->where('status', CourseVersionStatus::Published)->whereNull('unpublished_at')->orderBy('title')->get()->map(fn (CourseVersion $version) => [ 'id' => $version->getKey(), 'title' => $version->course->title, 'version' => $version->version_number, ]); return response()->json(['data' => $courses]); } public function store(Request $request): JsonResponse { $this->authorize($request); $data = $request->validate(['title' => ['required', 'string', 'max:240'], 'description' => ['nullable', 'string', 'max:3000'], 'enforceOrder' => ['required', 'boolean']]); $path = DB::transaction(function () use ($request, $data): LearningPath { $base = Str::slug($data['title']) ?: 'path-'.Str::lower(Str::random(8)); $slug = $base; $i = 2; while (LearningPath::query()->where('organization_id', $this->tenant->id())->where('slug', $slug)->exists()) { $slug = $base.'-'.$i++; } $path = LearningPath::query()->create(['organization_id' => $this->tenant->id(), 'title' => $data['title'], 'slug' => $slug, 'status' => 'draft', 'created_by' => $request->user()->getKey()]); LearningPathVersion::query()->create(['organization_id' => $this->tenant->id(), 'learning_path_id' => $path->getKey(), 'version_number' => 1, 'status' => CourseVersionStatus::Draft, 'title' => $data['title'], 'description' => $data['description'] ?? null, 'settings' => ['enforceOrder' => $data['enforceOrder']]]); return $path; }); return response()->json(['data' => $this->summary($path->load(['latestVersion' => fn ($query) => $query->withCount('items')]))], 201); } public function show(Request $request, string $path): JsonResponse { $this->authorize($request); $model = $this->path($path); $versionId = $request->string('versionId')->toString(); $version = LearningPathVersion::query()->where('organization_id', $this->tenant->id())->where('learning_path_id', $model->getKey()) ->when($versionId !== '', fn ($query) => $query->whereKey($versionId))->when($versionId === '', fn ($query) => $query->latest('version_number'))->firstOrFail(); $version->load(['items.courseVersion.course:id,title']); $versions = LearningPathVersion::query()->where('learning_path_id', $model->getKey())->orderByDesc('version_number')->get(); return response()->json(['data' => ['path' => ['id' => $model->getKey(), 'title' => $model->title, 'status' => $model->status], 'version' => $this->versionPayload($version), 'versions' => $versions->map(fn (LearningPathVersion $item) => $this->versionPayload($item))]]); } public function update(Request $request, string $path, string $version): JsonResponse { $this->authorize($request); $model = $this->version($path, $version); $this->draft($model); $data = $request->validate(['title' => ['sometimes', 'string', 'max:240'], 'description' => ['nullable', 'string', 'max:3000'], 'enforceOrder' => ['sometimes', 'boolean']]); $settings = $model->settings ?? []; if (array_key_exists('enforceOrder', $data)) { $settings['enforceOrder'] = $data['enforceOrder']; } $model->update([...collect($data)->only(['title', 'description'])->all(), 'settings' => $settings]); if (isset($data['title'])) { $model->path()->update(['title' => $data['title']]); } return response()->json(['data' => $this->versionPayload($model->fresh('items.courseVersion.course'))]); } public function addItem(Request $request, string $path, string $version): JsonResponse { $this->authorize($request); $model = $this->version($path, $version); $this->draft($model); $data = $request->validate(['courseVersionId' => ['required', 'string'], 'prerequisiteItemId' => ['nullable', 'string'], 'completionType' => ['required', Rule::in(['course_completed', 'minimum_score'])], 'minimumScore' => ['nullable', 'integer', 'between:0,100']]); $courseVersion = CourseVersion::query()->where('organization_id', $this->tenant->id())->where('status', CourseVersionStatus::Published)->whereNull('unpublished_at')->findOrFail($data['courseVersionId']); if ($model->items()->where('course_version_id', $courseVersion->getKey())->exists()) { throw ValidationException::withMessages(['courseVersionId' => ['این دوره قبلاً در مسیر وجود دارد.']]); } $prerequisite = null; if ($data['prerequisiteItemId'] ?? null) { $prerequisite = $model->items()->find($data['prerequisiteItemId']); if (! $prerequisite) { throw ValidationException::withMessages(['prerequisiteItemId' => ['پیش‌نیاز متعلق به این مسیر نیست.']]); } } $item = LearningPathItem::query()->create(['organization_id' => $this->tenant->id(), 'learning_path_version_id' => $model->getKey(), 'course_version_id' => $courseVersion->getKey(), 'prerequisite_item_id' => $prerequisite?->getKey(), 'position' => $model->items()->count() + 1, 'completion_rules' => ['type' => $data['completionType'], 'value' => $data['minimumScore'] ?? null]]); return response()->json(['data' => $this->itemPayload($item->load('courseVersion.course'))], 201); } public function reorder(Request $request, string $path, string $version): JsonResponse { $this->authorize($request); $model = $this->version($path, $version); $this->draft($model); $ids = $request->validate(['itemIds' => ['required', 'array'], 'itemIds.*' => ['string']])['itemIds']; $existing = $model->items()->pluck('id')->map(fn ($id) => (string) $id)->sort()->values()->all(); $incoming = collect($ids)->unique()->sort()->values()->all(); if ($existing !== $incoming) { throw ValidationException::withMessages(['itemIds' => ['فهرست کامل و بدون تکرار مراحل لازم است.']]); } DB::transaction(function () use ($model, $ids): void { foreach ($ids as $index => $id) { $model->items()->whereKey($id)->update(['position' => $index + 1001]); } foreach ($ids as $index => $id) { $model->items()->whereKey($id)->update(['position' => $index + 1]); } }); return response()->json(['data' => ['reordered' => true]]); } public function removeItem(Request $request, string $item): JsonResponse { $this->authorize($request); $model = LearningPathItem::query()->where('organization_id', $this->tenant->id())->findOrFail($item); $version = LearningPathVersion::query()->findOrFail($model->learning_path_version_id); $this->draft($version); DB::transaction(function () use ($model, $version): void { LearningPathItem::query()->where('prerequisite_item_id', $model->getKey())->update(['prerequisite_item_id' => null]); $model->delete(); $version->items()->orderBy('position')->get()->each(fn (LearningPathItem $item, int $index) => $item->update(['position' => $index + 1])); }); return response()->json(status: 204); } public function submitReview(Request $request, string $path, string $version): JsonResponse { $this->authorize($request); $model = $this->version($path, $version); $this->draft($model); if ($model->items()->count() === 0) { throw ValidationException::withMessages(['items' => ['مسیر بدون دوره قابل بازبینی نیست.']]); } $model->update(['status' => CourseVersionStatus::InReview, 'review_submitted_at' => now()]); return response()->json(['data' => $this->versionPayload($model->fresh('items.courseVersion.course'))]); } public function publish(Request $request, string $path, string $version): JsonResponse { $this->authorize($request); $model = $this->version($path, $version); if ($model->status !== CourseVersionStatus::InReview || $model->items()->count() === 0) { throw ValidationException::withMessages(['version' => ['مسیر باید در حال بازبینی و دارای حداقل یک دوره باشد.']]); } $model->update(['status' => CourseVersionStatus::Published, 'published_at' => now(), 'scheduled_publish_at' => null]); $model->path()->update(['status' => 'published']); return response()->json(['data' => $this->versionPayload($model->fresh('items.courseVersion.course'))]); } public function fork(Request $request, string $path, string $version): JsonResponse { $this->authorize($request); $source = $this->version($path, $version); if ($source->status !== CourseVersionStatus::Published) { throw ValidationException::withMessages(['version' => ['فقط نسخه منتشرشده قابل نسخه‌برداری است.']]); } $target = DB::transaction(function () use ($source): LearningPathVersion { $existing = LearningPathVersion::query()->where('source_version_id', $source->getKey())->where('status', CourseVersionStatus::Draft)->first(); if ($existing) { return $existing; } $target = LearningPathVersion::query()->create(['organization_id' => $source->organization_id, 'learning_path_id' => $source->learning_path_id, 'source_version_id' => $source->getKey(), 'version_number' => LearningPathVersion::query()->where('learning_path_id', $source->learning_path_id)->max('version_number') + 1, 'status' => CourseVersionStatus::Draft, 'title' => $source->title, 'description' => $source->description, 'settings' => $source->settings]); $map = []; foreach ($source->items()->orderBy('position')->get() as $item) { $copy = LearningPathItem::query()->create(['organization_id' => $source->organization_id, 'learning_path_version_id' => $target->getKey(), 'course_version_id' => $item->course_version_id, 'position' => $item->position, 'completion_rules' => $item->completion_rules]); $map[$item->getKey()] = $copy->getKey(); } foreach ($source->items()->whereNotNull('prerequisite_item_id')->get() as $item) { LearningPathItem::query()->whereKey($map[$item->getKey()])->update(['prerequisite_item_id' => $map[$item->prerequisite_item_id] ?? null]); } return $target; }); return response()->json(['data' => $this->versionPayload($target->load('items.courseVersion.course'))], 201); } private function path(string $id): LearningPath { return LearningPath::query()->where('organization_id', $this->tenant->id())->findOrFail($id); } private function version(string $path, string $id): LearningPathVersion { $this->path($path); return LearningPathVersion::query()->where('organization_id', $this->tenant->id())->where('learning_path_id', $path)->findOrFail($id); } private function draft(LearningPathVersion $version): void { if ($version->status !== CourseVersionStatus::Draft) { throw ValidationException::withMessages(['version' => ['فقط Draft قابل ویرایش است.']]); } } /** @return array */ private function summary(LearningPath $path): array { $version = $path->latestVersion; return ['id' => $path->getKey(), 'title' => $path->title, 'slug' => $path->slug, 'status' => $path->status, 'versionId' => $version?->getKey(), 'versionNumber' => $version?->version_number, 'versionStatus' => $version?->status?->value, 'itemCount' => $version?->items_count ?? 0, 'updatedAt' => $path->updated_at?->toISOString()]; } /** @return array */ private function versionPayload(LearningPathVersion $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(), 'items' => $version->relationLoaded('items') ? $version->items->map(fn (LearningPathItem $item) => $this->itemPayload($item))->values() : []]; } /** @return array */ private function itemPayload(LearningPathItem $item): array { return ['id' => $item->getKey(), 'courseVersionId' => $item->course_version_id, 'courseTitle' => $item->courseVersion->course->title, 'courseVersion' => $item->courseVersion->version_number, 'prerequisiteItemId' => $item->prerequisite_item_id, 'position' => $item->position, 'completionRules' => $item->completion_rules ?? []]; } private function authorize(Request $request): void { abort_unless($this->permissions->allows($request->user(), Permission::CoursesAuthor), 403); } }