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 $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 $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 */ 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(), ]; } }