252 خطوط
14 KiB
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);
|
|
}
|
|
}
|