authorize($request); $versions = CourseVersion::query()->with(['course:id,title', 'lessons:id,course_version_id,title']) ->where('organization_id', $this->tenant->id())->where('status', CourseVersionStatus::Draft) ->latest()->get()->map(fn (CourseVersion $version) => [ 'id' => $version->getKey(), 'title' => $version->title, 'course' => ['id' => $version->course->getKey(), 'title' => $version->course->title], 'lessons' => $version->lessons->map(fn (Lesson $lesson) => ['id' => $lesson->getKey(), 'title' => $lesson->title])->values(), ]); return response()->json(['data' => $versions]); } public function index(Request $request): JsonResponse { $this->authorize($request); $items = Assessment::query()->with('questions') ->where('organization_id', $this->tenant->id())->latest()->get() ->map(fn (Assessment $assessment) => $this->assessmentPayload($assessment)); return response()->json(['data' => $items]); } public function store(Request $request): JsonResponse { $this->authorize($request); $data = $this->assessmentData($request); $version = $this->draftVersion($data['courseVersionId']); $lesson = isset($data['lessonId']) ? Lesson::query()->where('organization_id', $this->tenant->id())->where('course_version_id', $version->getKey())->findOrFail($data['lessonId']) : null; $assessment = Assessment::query()->create([ 'organization_id' => $this->tenant->id(), 'course_version_id' => $version->getKey(), 'lesson_id' => $lesson?->getKey(), 'title' => $data['title'], 'settings' => $data['settings'], ]); return response()->json(['data' => $this->assessmentPayload($assessment->load('questions'))], 201); } public function show(Request $request, string $assessment): JsonResponse { $this->authorize($request); $model = $this->assessment($assessment)->load('questions'); return response()->json(['data' => $this->assessmentPayload($model)]); } public function update(Request $request, string $assessment): JsonResponse { $this->authorize($request); $model = $this->assessment($assessment); $this->assertDraft($model->course_version_id); $rules = ['title' => ['sometimes', 'required', 'string', 'max:240'], 'settings' => ['sometimes', 'array']]; if ($request->has('settings')) { $rules = [...$rules, ...$this->settingRules('settings.')]; } $data = $request->validate($rules); $model->update($data); return response()->json(['data' => $this->assessmentPayload($model->fresh('questions'))]); } public function destroy(Request $request, string $assessment): JsonResponse { $this->authorize($request); $model = $this->assessment($assessment); $this->assertDraft($model->course_version_id); abort_if($model->questions()->whereHas('questionResults')->exists(), 409, 'Assessment with learner results cannot be deleted.'); $model->delete(); return response()->json(status: 204); } public function storeQuestion(Request $request, string $assessment): JsonResponse { $this->authorize($request); $model = $this->assessment($assessment); $version = $this->draftVersion((string) $model->course_version_id); $data = $request->validate([ 'sourceQuestionId' => ['nullable', 'string'], 'type' => ['required_without:sourceQuestionId', Rule::in($this->schema->types())], 'prompt' => ['required_without:sourceQuestionId', 'string', 'max:5000'], 'configuration' => ['required_without:sourceQuestionId', 'array'], 'difficulty' => ['nullable', Rule::in(['beginner', 'intermediate', 'advanced'])], 'topic' => ['nullable', 'string', 'max:160'], 'tags' => ['nullable', 'array', 'max:20'], 'tags.*' => ['string', 'max:80'], 'explanation' => ['nullable', 'string', 'max:5000'], 'saveToBank' => ['nullable', 'boolean'], ]); $source = isset($data['sourceQuestionId']) ? Question::query()->where('organization_id', $this->tenant->id())->where('is_bank_item', true)->findOrFail($data['sourceQuestionId']) : null; $type = $source?->type ?? $data['type']; $configuration = $this->schema->validate($type, $source?->configuration ?? $data['configuration']); $question = Question::query()->create([ 'organization_id' => $this->tenant->id(), 'course_version_id' => $version->getKey(), 'assessment_id' => $model->getKey(), 'source_question_id' => $source?->getKey(), 'type' => $type, 'prompt' => $source?->prompt ?? $data['prompt'], 'configuration' => $configuration, 'difficulty' => $source?->difficulty ?? ($data['difficulty'] ?? null), 'topic' => $source?->topic ?? ($data['topic'] ?? null), 'tags' => $source?->tags ?? ($data['tags'] ?? []), 'explanation' => $source?->explanation ?? ($data['explanation'] ?? null), 'position' => ($model->questions()->max('position') ?? 0) + 1, ]); if (($data['saveToBank'] ?? false) && ! $source) { $bank = $question->replicate(['assessment_id', 'position']); $bank->assessment_id = null; $bank->position = 0; $bank->is_bank_item = true; $bank->save(); $question->update(['source_question_id' => $bank->getKey()]); } return response()->json(['data' => $this->questionPayload($question->fresh())], 201); } public function updateQuestion(Request $request, string $question): JsonResponse { $this->authorize($request); $model = $this->question($question); if ($model->course_version_id) { $this->assertDraft($model->course_version_id); } $data = $request->validate([ 'prompt' => ['sometimes', 'required', 'string', 'max:5000'], 'configuration' => ['sometimes', 'array'], 'difficulty' => ['sometimes', 'nullable', Rule::in(['beginner', 'intermediate', 'advanced'])], 'topic' => ['sometimes', 'nullable', 'string', 'max:160'], 'tags' => ['sometimes', 'array', 'max:20'], 'tags.*' => ['string', 'max:80'], 'explanation' => ['sometimes', 'nullable', 'string', 'max:5000'], ]); if (isset($data['configuration'])) { $data['configuration'] = $this->schema->validate($model->type, $data['configuration']); } $model->update($data); return response()->json(['data' => $this->questionPayload($model->fresh())]); } public function destroyQuestion(Request $request, string $question): JsonResponse { $this->authorize($request); $model = $this->question($question); if ($model->course_version_id) { $this->assertDraft($model->course_version_id); } abort_if(QuestionResult::query()->where('question_id', $model->getKey())->exists(), 409, 'Question with learner results cannot be deleted.'); DB::transaction(function () use ($model): void { $assessmentId = $model->assessment_id; $position = $model->position; $model->delete(); if ($assessmentId) { Question::query()->where('assessment_id', $assessmentId)->where('position', '>', $position)->decrement('position'); } }); return response()->json(status: 204); } public function reorderQuestions(Request $request, string $assessment): JsonResponse { $this->authorize($request); $model = $this->assessment($assessment); $this->assertDraft($model->course_version_id); $ids = $request->validate(['questionIds' => ['required', 'array'], 'questionIds.*' => ['string']])['questionIds']; $current = $model->questions()->pluck('id')->map(fn ($id): string => (string) $id)->all(); if (count($ids) !== count($current) || array_diff($ids, $current) || array_diff($current, $ids)) { throw ValidationException::withMessages(['questionIds' => ['فهرست کامل سؤال‌ها الزامی است.']]); } DB::transaction(fn () => collect($ids)->each(fn (string $id, int $index) => Question::query()->whereKey($id)->update(['position' => 10000 + $index]))); DB::transaction(fn () => collect($ids)->each(fn (string $id, int $index) => Question::query()->whereKey($id)->update(['position' => $index + 1]))); return response()->json(['data' => $model->fresh('questions')->questions->map(fn (Question $item) => $this->questionPayload($item))]); } /** @return array */ private function assessmentData(Request $request): array { return $request->validate([ 'courseVersionId' => ['required', 'string'], 'lessonId' => ['nullable', 'string'], 'title' => ['required', 'string', 'max:240'], 'settings' => ['required', 'array'], ...$this->settingRules('settings.'), ]); } /** @return array> */ private function settingRules(string $prefix): array { return [ $prefix.'randomSelection' => ['required', 'boolean'], $prefix.'questionPoolSize' => ['nullable', 'integer', 'min:1', 'max:500'], $prefix.'shuffleQuestions' => ['required', 'boolean'], $prefix.'shuffleOptions' => ['required', 'boolean'], $prefix.'passingScore' => ['required', 'integer', 'between:0,100'], $prefix.'attemptLimit' => ['nullable', 'integer', 'min:1', 'max:100'], $prefix.'feedbackMode' => ['required', Rule::in(['immediate', 'after_submission', 'none'])], $prefix.'timeLimitSeconds' => ['nullable', 'integer', 'min:30', 'max:86400'], ]; } private function draftVersion(string $id): CourseVersion { $version = CourseVersion::query()->where('organization_id', $this->tenant->id())->findOrFail($id); $this->assertDraft($version->getKey()); return $version; } private function assertDraft(string $versionId): void { $status = CourseVersion::query()->where('organization_id', $this->tenant->id())->whereKey($versionId)->value('status'); $statusValue = $status instanceof CourseVersionStatus ? $status->value : $status; if ($statusValue !== CourseVersionStatus::Draft->value) { throw ValidationException::withMessages(['courseVersionId' => ['Published course version assessments are immutable.']]); } } private function assessment(string $id): Assessment { return Assessment::query()->where('organization_id', $this->tenant->id())->findOrFail($id); } private function question(string $id): Question { return Question::query()->where('organization_id', $this->tenant->id())->findOrFail($id); } private function authorize(Request $request): void { abort_unless($this->permissions->allows($request->user(), Permission::CoursesAuthor), 403); } /** @return array */ private function assessmentPayload(Assessment $assessment): array { return [ 'id' => $assessment->getKey(), 'courseVersionId' => $assessment->course_version_id, 'lessonId' => $assessment->lesson_id, 'title' => $assessment->title, 'settings' => $assessment->settings ?? [], 'questionCount' => $assessment->questions->count(), 'questions' => $assessment->questions->map(fn (Question $question) => $this->questionPayload($question))->values(), 'updatedAt' => $assessment->updated_at?->toISOString(), ]; } /** @return array */ private function questionPayload(Question $question): array { $usage = $question->is_bank_item ? Question::query()->where('source_question_id', $question->getKey())->count() : 0; $performance = QuestionResult::query()->where('question_id', $question->getKey())->avg('normalized_value'); return [ 'id' => $question->getKey(), 'courseVersionId' => $question->course_version_id, 'assessmentId' => $question->assessment_id, 'sourceQuestionId' => $question->source_question_id, 'isBankItem' => $question->is_bank_item, 'type' => $question->type, 'schemaVersion' => $question->schema_version, 'prompt' => $question->prompt, 'configuration' => $question->configuration, 'difficulty' => $question->difficulty, 'topic' => $question->topic, 'tags' => $question->tags ?? [], 'explanation' => $question->explanation, 'position' => $question->position, 'usageCount' => $usage, 'performance' => $performance === null ? null : round(((float) $performance) * 100, 1), ]; } }