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

284 خطوط
14 KiB
PHP

<?php
namespace App\Modules\Courses\Http;
use App\Http\Controllers\Controller;
use App\Modules\Assets\Application\AssetUsage;
use App\Modules\Assets\Domain\Asset;
use App\Modules\Collaboration\Application\SoftLockService;
use App\Modules\Courses\Application\BlockContractValidator;
use App\Modules\Courses\Application\BlockRegistry;
use App\Modules\Courses\Domain\Block;
use App\Modules\Courses\Domain\Course;
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\ReorderBlocksRequest;
use App\Modules\Courses\Http\Requests\StoreBlockRequest;
use App\Modules\Courses\Http\Requests\UpdateBlockRequest;
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\Validation\ValidationException;
final class CourseBuilderController extends Controller
{
public function __construct(
private readonly TenantContext $tenant,
private readonly RolePermissions $permissions,
private readonly BlockRegistry $registry,
private readonly BlockContractValidator $contract,
private readonly AssetUsage $assetUsage,
private readonly SoftLockService $softLocks,
) {}
public function registry(Request $request): JsonResponse
{
$this->authorizeAuthor($request);
return response()->json(['data' => $this->registry->metadata()]);
}
public function show(Request $request, string $course, string $version, string $lesson): JsonResponse
{
$this->authorizeAuthor($request);
[$courseModel, $versionModel, $lessonModel] = $this->scope($course, $version, $lesson);
$modules = $versionModel->modules()->with(['lessons' => fn ($query) => $query->orderBy('position')])->orderBy('position')->get();
return response()->json(['data' => [
'course' => ['id' => $courseModel->getKey(), 'title' => $courseModel->title],
'version' => ['id' => $versionModel->getKey(), 'number' => $versionModel->version_number, 'status' => $versionModel->status->value],
'lesson' => ['id' => $lessonModel->getKey(), 'title' => $lessonModel->title, 'presentationMode' => $lessonModel->presentation_mode],
'structure' => $modules->map(fn ($module) => [
'id' => $module->getKey(), 'title' => $module->title, 'position' => $module->position, 'locked' => $module->is_locked,
'lessons' => $module->lessons->map(fn (Lesson $item) => ['id' => $item->getKey(), 'title' => $item->title, 'position' => $item->position, 'locked' => $item->is_locked])->values(),
])->values(),
'blocks' => $lessonModel->blocks()->orderBy('position')->get()->map(fn (Block $block) => $this->blockPayload($block))->values(),
]]);
}
public function store(StoreBlockRequest $request, string $course, string $version, string $lesson): JsonResponse
{
$this->authorizeAuthor($request);
[, $versionModel, $lessonModel] = $this->scope($course, $version, $lesson);
$this->assertDraft($versionModel);
$this->assertStructureUnlocked($lessonModel);
$input = $request->validated();
$data = $this->registry->validate($input['type'], (int) $input['schemaVersion'], $input['data']);
$contract = $this->contract->validate($input);
$this->assertAssetReferences($data);
$block = DB::transaction(function () use ($versionModel, $lessonModel, $input, $data, $contract) {
$position = min((int) ($input['insertionPosition'] ?? ($lessonModel->blocks()->count() + 1)), $lessonModel->blocks()->count() + 1);
$lessonModel->blocks()->where('position', '>=', $position)->orderByDesc('position')->get()->each(fn (Block $item) => $item->increment('position'));
return Block::query()->create([
'organization_id' => $this->tenant->id(),
'course_version_id' => $versionModel->getKey(),
'lesson_id' => $lessonModel->getKey(),
'type' => $input['type'],
'schema_version' => $input['schemaVersion'],
'data' => $data,
'style' => $contract['style'] ?? null,
'behavior' => $contract['behavior'] ?? null,
'responsive' => $contract['responsive'] ?? null,
'accessibility' => $contract['accessibility'] ?? null,
'position' => $position,
'revision' => 1,
]);
});
return response()->json(['data' => $this->blockPayload($block)], 201);
}
public function update(UpdateBlockRequest $request, string $block): JsonResponse
{
$this->authorizeAuthor($request);
$model = $this->tenantBlock($block);
$this->softLocks->assertEditable($model, $request->user());
$version = $this->tenantVersion($model->course_version_id);
$this->assertDraft($version);
$this->assertStructureUnlocked(Lesson::query()->findOrFail($model->lesson_id));
$input = $request->validated();
$this->assertBlockUpdateAllowed($model, $input);
$data = $this->registry->validate($model->type, $model->schema_version, $input['data']);
$contract = $this->contract->validate($input);
$this->assertAssetReferences($data);
$attributes = ['data' => $data, 'revision' => DB::raw('revision + 1'), 'updated_at' => now()];
foreach (['style', 'behavior', 'responsive', 'accessibility'] as $field) {
if (array_key_exists($field, $contract)) {
$attributes[$field] = $contract[$field];
}
}
$updated = Block::query()->whereKey($model->getKey())->where('revision', $input['expectedRevision'])->update($attributes);
if ($updated !== 1) {
return response()->json(['error' => ['code' => 'revision_conflict', 'message' => 'This block was changed elsewhere.', 'current' => $this->blockPayload($model->fresh())]], 409);
}
return response()->json(['data' => $this->blockPayload($model->fresh())]);
}
public function reorder(ReorderBlocksRequest $request, string $lesson): JsonResponse
{
$this->authorizeAuthor($request);
$lessonModel = Lesson::query()->where('organization_id', $this->tenant->id())->findOrFail($lesson);
$this->assertDraft($this->tenantVersion($lessonModel->course_version_id));
$this->assertStructureUnlocked($lessonModel);
if ($lessonModel->blocks()->get()->contains(fn (Block $block) => (bool) data_get($block->behavior, 'locked', false))) {
throw ValidationException::withMessages(['locked' => ['Locked blocks cannot be reordered.']]);
}
$ids = $request->validated('blockIds');
$actual = $lessonModel->blocks()->pluck('id')->all();
if (count($ids) !== count($actual) || array_diff($ids, $actual) || array_diff($actual, $ids)) {
throw ValidationException::withMessages(['blockIds' => ['The order must contain every lesson block exactly once.']]);
}
DB::transaction(function () use ($ids, $lessonModel) {
$lessonModel->blocks()->update(['position' => DB::raw('position + 100000')]);
foreach ($ids as $index => $id) {
Block::query()->whereKey($id)->update(['position' => $index + 1]);
}
});
return response()->json(['data' => ['blockIds' => $ids]]);
}
public function destroy(Request $request, string $block): JsonResponse
{
$this->authorizeAuthor($request);
$model = $this->tenantBlock($block);
$this->softLocks->assertEditable($model, $request->user());
$this->assertDraft($this->tenantVersion($model->course_version_id));
$this->assertStructureUnlocked(Lesson::query()->findOrFail($model->lesson_id));
$this->assertBlockUnlocked($model);
DB::transaction(function () use ($model) {
$lessonId = $model->lesson_id;
$position = $model->position;
$model->delete();
Block::query()->where('lesson_id', $lessonId)->where('position', '>', $position)->orderBy('position')->each(fn (Block $item) => $item->decrement('position'));
});
return response()->json(status: 204);
}
public function duplicate(Request $request, string $block): JsonResponse
{
$this->authorizeAuthor($request);
$source = $this->tenantBlock($block);
$this->softLocks->assertEditable($source, $request->user());
$this->assertDraft($this->tenantVersion($source->course_version_id));
$lesson = Lesson::query()->where('organization_id', $this->tenant->id())->findOrFail($source->lesson_id);
$this->assertStructureUnlocked($lesson);
$this->assertBlockUnlocked($source);
$copy = DB::transaction(function () use ($source, $lesson) {
$position = $source->position + 1;
$lesson->blocks()->where('position', '>=', $position)->orderByDesc('position')->get()->each(fn (Block $item) => $item->increment('position'));
return Block::query()->create([
'organization_id' => $source->organization_id, 'course_version_id' => $source->course_version_id,
'lesson_id' => $source->lesson_id, 'type' => $source->type, 'schema_version' => $source->schema_version,
'data' => $source->data, 'style' => $source->style, 'behavior' => $source->behavior,
'responsive' => $source->responsive, 'accessibility' => $source->accessibility,
'position' => $position, 'revision' => 1,
]);
});
return response()->json(['data' => $this->blockPayload($copy)], 201);
}
/** @return array{Course, CourseVersion, Lesson} */
private function scope(string $course, string $version, string $lesson): array
{
$courseModel = Course::query()->where('organization_id', $this->tenant->id())->findOrFail($course);
$versionModel = CourseVersion::query()->where('organization_id', $this->tenant->id())->where('course_id', $courseModel->getKey())->findOrFail($version);
$lessonModel = Lesson::query()->where('organization_id', $this->tenant->id())->where('course_version_id', $versionModel->getKey())->findOrFail($lesson);
return [$courseModel, $versionModel, $lessonModel];
}
private function tenantBlock(string $id): Block
{
return Block::query()->where('organization_id', $this->tenant->id())->findOrFail($id);
}
private function tenantVersion(string $id): CourseVersion
{
return CourseVersion::query()->where('organization_id', $this->tenant->id())->findOrFail($id);
}
private function assertDraft(CourseVersion $version): void
{
if ($version->status !== CourseVersionStatus::Draft) {
throw ValidationException::withMessages(['version' => ['Only draft course versions can be edited.']]);
}
}
private function assertStructureUnlocked(Lesson $lesson): void
{
$module = $lesson->courseModule()->firstOrFail();
if ($lesson->is_locked || $module->is_locked) {
throw ValidationException::withMessages(['locked' => ['The lesson or its module is locked.']]);
}
}
private function assertBlockUnlocked(Block $block): void
{
if ((bool) data_get($block->behavior, 'locked', false)) {
throw ValidationException::withMessages(['locked' => ['The block is locked.']]);
}
}
/** @param array<string, mixed> $input */
private function assertBlockUpdateAllowed(Block $block, array $input): void
{
if (! (bool) data_get($block->behavior, 'locked', false)) {
return;
}
$expectedBehavior = $block->behavior ?? [];
$expectedBehavior['locked'] = false;
$unlockOnly = data_get($input, 'behavior.locked') === false
&& ($input['data'] ?? null) == $block->data
&& ($input['behavior'] ?? []) == $expectedBehavior
&& ($input['style'] ?? $block->style ?? []) == ($block->style ?? [])
&& ($input['responsive'] ?? $block->responsive ?? []) == ($block->responsive ?? [])
&& ($input['accessibility'] ?? $block->accessibility ?? []) == ($block->accessibility ?? []);
if (! $unlockOnly) {
$this->assertBlockUnlocked($block);
}
}
/** @param array<string, mixed> $data */
private function assertAssetReferences(array $data): void
{
$ids = $this->assetUsage->assetIds($data);
if ($ids === []) {
return;
}
$count = Asset::query()->where('organization_id', $this->tenant->id())->whereIn('id', $ids)->count();
if ($count !== count($ids)) {
throw ValidationException::withMessages(['data.assetId' => ['Every referenced asset must belong to the current organization.']]);
}
}
private function authorizeAuthor(Request $request): void
{
abort_unless($this->permissions->allows($request->user(), Permission::CoursesAuthor), 403);
}
/** @return array<string, mixed> */
private function blockPayload(Block $block): array
{
return [
'id' => $block->getKey(), 'type' => $block->type, 'schemaVersion' => $block->schema_version,
'data' => $block->data, 'style' => $block->style, 'behavior' => $block->behavior,
'responsive' => $block->responsive, 'accessibility' => $block->accessibility,
'position' => $block->position, 'revision' => $block->revision, 'updatedAt' => $block->updated_at?->toISOString(),
];
}
}