New_Micro_Learning/backend/app/Modules/Courses/Http/CourseController.php

291 خطوط
14 KiB
PHP

<?php
namespace App\Modules\Courses\Http;
use App\Http\Controllers\Controller;
use App\Modules\Assets\Domain\Asset;
use App\Modules\Courses\Application\CourseVersionEditor;
use App\Modules\Courses\Domain\Course;
use App\Modules\Courses\Domain\CourseModule;
use App\Modules\Courses\Domain\CourseVersion;
use App\Modules\Courses\Domain\Enums\CourseVersionStatus;
use App\Modules\Courses\Domain\Lesson;
use App\Modules\Courses\Http\Requests\StoreCourseRequest;
use App\Modules\Courses\Http\Requests\UpdateCourseVersionRequest;
use App\Modules\Identity\Application\RolePermissions;
use App\Modules\Identity\Domain\Enums\Permission;
use App\Modules\Tenancy\Application\TenantContext;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
final class CourseController extends Controller
{
public function __construct(
private readonly TenantContext $tenant,
private readonly RolePermissions $permissions,
private readonly CourseVersionEditor $versions,
) {}
public function index(Request $request): JsonResponse
{
$this->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);
}
}