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