New_Micro_Learning/backend/app/Modules/LearningPaths/Http/LearningPathController.php

252 خطوط
14 KiB
PHP

<?php
namespace App\Modules\LearningPaths\Http;
use App\Http\Controllers\Controller;
use App\Modules\Courses\Domain\CourseVersion;
use App\Modules\Courses\Domain\Enums\CourseVersionStatus;
use App\Modules\Identity\Application\RolePermissions;
use App\Modules\Identity\Domain\Enums\Permission;
use App\Modules\LearningPaths\Domain\LearningPath;
use App\Modules\LearningPaths\Domain\LearningPathItem;
use App\Modules\LearningPaths\Domain\LearningPathVersion;
use App\Modules\Tenancy\Application\TenantContext;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
final class LearningPathController extends Controller
{
public function __construct(private readonly TenantContext $tenant, private readonly RolePermissions $permissions) {}
public function index(Request $request): JsonResponse
{
$this->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<string, mixed> */
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<string, mixed> */
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<string, mixed> */
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);
}
}