New_Micro_Learning/backend/app/Modules/Publishing/Application/CoursePublication.php

164 خطوط
11 KiB
PHP

<?php
namespace App\Modules\Publishing\Application;
use App\Modules\Assessments\Application\QuestionSchema;
use App\Modules\Assets\Application\AssetUsage;
use App\Modules\Assets\Domain\Asset;
use App\Modules\Courses\Application\BlockRegistry;
use App\Modules\Courses\Domain\Course;
use App\Modules\Courses\Domain\CourseVersion;
use App\Modules\Courses\Domain\Enums\CourseVersionStatus;
use App\Modules\Taxonomy\Domain\ContentTaxonomyMapping;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class CoursePublication
{
public function __construct(private readonly BlockRegistry $blocks, private readonly QuestionSchema $questions, private readonly AssetUsage $assets) {}
/** @return array{ready: bool, checks: list<array<string, mixed>>} */
public function readiness(CourseVersion $version): array
{
$version->load(['modules.lessons.blocks', 'lessons', 'blocks', 'course', 'assessments.questions']);
$checks = [];
$this->check($checks, 'metadata', trim($version->title) !== '' && trim((string) $version->description) !== '', 'مشخصات دوره', 'عنوان و توضیح دوره کامل است.', 'عنوان و توضیح دوره را کامل کنید.', ['type' => 'course', 'tab' => 'overview']);
$this->check($checks, 'structure', $version->modules->isNotEmpty() && $version->lessons->isNotEmpty(), 'ساختار محتوا', 'ساختار دوره دارای ماژول و درس است.', 'حداقل یک ماژول و یک درس لازم است.', ['type' => 'course', 'tab' => 'content']);
$emptyLessons = $version->lessons->filter(fn ($lesson) => $lesson->blocks()->count() === 0);
$emptyLesson = $emptyLessons->first();
$this->check($checks, 'lesson_content', $emptyLessons->isEmpty(), 'محتوای درس‌ها', 'همه درس‌ها محتوا دارند.', $emptyLessons->isEmpty() ? '' : 'درس‌های بدون محتوا: '.$emptyLessons->pluck('title')->implode('، '), $emptyLesson ? ['type' => 'lesson', 'lessonId' => $emptyLesson->getKey()] : ['type' => 'course', 'tab' => 'content']);
$invalidBlocks = [];
foreach ($version->blocks as $block) {
try {
$this->blocks->validate($block->type, $block->schema_version, $block->data);
} catch (ValidationException) {
$invalidBlocks[] = ['id' => $block->getKey(), 'lessonId' => $block->lesson_id];
}
}
$invalidBlock = $invalidBlocks[0] ?? null;
$this->check($checks, 'blocks', $invalidBlocks === [], 'اعتبار بلوک‌ها', 'همه بلوک‌ها معتبر هستند.', $invalidBlocks === [] ? '' : count($invalidBlocks).' بلوک نامعتبر است.', $invalidBlock ? ['type' => 'block', 'lessonId' => $invalidBlock['lessonId'], 'blockId' => $invalidBlock['id']] : ['type' => 'course', 'tab' => 'content']);
$assetIds = $version->blocks->flatMap(fn ($block) => $this->assets->assetIds($block->data))->unique()->values();
$existingAssetIds = Asset::query()->where('organization_id', $version->organization_id)->whereIn('id', $assetIds)->pluck('id');
$missingAssetIds = $assetIds->diff($existingAssetIds);
$assetBlock = $version->blocks->first(fn ($block) => collect($this->assets->assetIds($block->data))->intersect($missingAssetIds)->isNotEmpty());
$this->check($checks, 'assets', $missingAssetIds->isEmpty(), 'فایل‌های وابسته', 'همه فایل‌های استفاده‌شده در دسترس‌اند.', 'یک یا چند فایل استفاده‌شده حذف یا خارج از سازمان است.', $assetBlock ? ['type' => 'block', 'lessonId' => $assetBlock->lesson_id, 'blockId' => $assetBlock->getKey()] : ['type' => 'assets']);
$invalidQuestions = [];
foreach ($version->assessments->flatMap->questions as $question) {
try {
$this->questions->validate($question->type, $question->configuration);
} catch (ValidationException) {
$invalidQuestions[] = $question->getKey();
}
}
$emptyAssessments = $version->assessments->filter(fn ($assessment) => $assessment->questions->isEmpty());
$assessmentContentReady = $invalidQuestions === [] && $emptyAssessments->isEmpty();
$rules = $version->completion_rules ?? [];
$rulesReady = isset($rules['mode'], $rules['rules']) && in_array($rules['mode'], ['all', 'any'], true) && is_array($rules['rules']) && $rules['rules'] !== [];
$assessmentReady = $assessmentContentReady && $rulesReady;
$assessmentFailure = ! $assessmentContentReady ? 'ارزیابی بدون سؤال یا سؤال/سناریوی نامعتبر وجود دارد.' : (! $rulesReady ? 'حداقل یک قانون تکمیل تعریف کنید.' : '');
$this->check($checks, 'assessments', $assessmentReady, 'ارزیابی‌ها و سناریوها', 'ارزیابی‌ها، سناریوها و قانون تکمیل معتبر هستند.', $assessmentFailure, $assessmentContentReady ? ['type' => 'completion'] : ['type' => 'assessments'], $assessmentContentReady && ! $rulesReady ? 'warning' : 'error');
return ['ready' => collect($checks)->every(fn (array $check): bool => $check['passed']), 'checks' => $checks];
}
public function submitReview(CourseVersion $version): CourseVersion
{
if ($version->status !== CourseVersionStatus::Draft) {
throw ValidationException::withMessages(['version' => ['فقط Draft را می‌توان برای بازبینی ارسال کرد.']]);
}
$readiness = $this->readiness($version);
if (! $readiness['ready']) {
throw ValidationException::withMessages(['readiness' => collect($readiness['checks'])->where('passed', false)->pluck('failure')->values()->all()]);
}
$version->update(['status' => CourseVersionStatus::InReview, 'review_submitted_at' => now()]);
return $version->fresh();
}
public function returnToDraft(CourseVersion $version): CourseVersion
{
if ($version->status !== CourseVersionStatus::InReview || $version->scheduled_publish_at) {
throw ValidationException::withMessages(['version' => ['نسخه زمان‌بندی‌شده را ابتدا از زمان‌بندی خارج کنید.']]);
}
$version->update(['status' => CourseVersionStatus::Draft, 'review_submitted_at' => null]);
return $version->fresh();
}
/** @param array<string, mixed> $completionRules */
public function configure(CourseVersion $version, array $completionRules): CourseVersion
{
if (! in_array($version->status, [CourseVersionStatus::Draft, CourseVersionStatus::InReview], true)) {
throw ValidationException::withMessages(['version' => ['نسخه منتشرشده قابل تغییر نیست.']]);
}
$version->update(['completion_rules' => $completionRules]);
return $version->fresh();
}
public function publish(CourseVersion $version, ?string $publishedBy = null): CourseVersion
{
if ($version->status !== CourseVersionStatus::InReview) {
throw ValidationException::withMessages(['version' => ['نسخه باید ابتدا در وضعیت بازبینی باشد.']]);
}
$readiness = $this->readiness($version);
if (! $readiness['ready']) {
throw ValidationException::withMessages(['readiness' => collect($readiness['checks'])->where('passed', false)->pluck('failure')->values()->all()]);
}
return DB::transaction(function () use ($version, $publishedBy): CourseVersion {
$snapshot = ContentTaxonomyMapping::query()->with('taxonomyNode.type')->where('course_version_id', $version->getKey())->get()->map(fn (ContentTaxonomyMapping $mapping) => [
'mappingId' => $mapping->getKey(), 'mappableType' => $mapping->mappable_type->value, 'mappableId' => $mapping->mappable_id,
'mappingType' => $mapping->mapping_type->value, 'weight' => (float) $mapping->weight,
'taxonomyNode' => ['id' => $mapping->taxonomy_node_id, 'name' => $mapping->taxonomyNode->name, 'code' => $mapping->taxonomyNode->code, 'type' => $mapping->taxonomyNode->type->key],
])->values()->all();
$version->update(['status' => CourseVersionStatus::Published, 'published_at' => now(), 'published_by' => $publishedBy, 'scheduled_publish_at' => null, 'taxonomy_snapshot' => $snapshot]);
$version->course()->update(['status' => 'published']);
return $version->fresh();
});
}
public function schedule(CourseVersion $version, \DateTimeInterface $publishAt, ?\DateTimeInterface $unpublishAt): CourseVersion
{
if ($version->status !== CourseVersionStatus::InReview) {
throw ValidationException::withMessages(['version' => ['فقط نسخه در حال بازبینی قابل زمان‌بندی است.']]);
}
if ($unpublishAt && $unpublishAt <= $publishAt) {
throw ValidationException::withMessages(['scheduledUnpublishAt' => ['زمان توقف انتشار باید بعد از انتشار باشد.']]);
}
$version->update(['scheduled_publish_at' => $publishAt, 'scheduled_unpublish_at' => $unpublishAt]);
return $version->fresh();
}
public function cancelSchedule(CourseVersion $version): CourseVersion
{
if ($version->status !== CourseVersionStatus::InReview) {
throw ValidationException::withMessages(['version' => ['زمان‌بندی این نسخه قابل لغو نیست.']]);
}
$version->update(['scheduled_publish_at' => null, 'scheduled_unpublish_at' => null]);
return $version->fresh();
}
public function unpublish(CourseVersion $version): void
{
if ($version->status !== CourseVersionStatus::Published || $version->unpublished_at) {
throw ValidationException::withMessages(['version' => ['این نسخه منتشرشده و فعال نیست.']]);
}
DB::table('course_versions')->where('id', $version->getKey())->update(['unpublished_at' => now(), 'updated_at' => now()]);
$hasActive = CourseVersion::query()->where('course_id', $version->course_id)->where('status', CourseVersionStatus::Published)->whereNull('unpublished_at')->exists();
if (! $hasActive) {
Course::query()->whereKey($version->course_id)->update(['status' => 'archived']);
}
}
/** @param list<array<string, mixed>> $checks */
private function check(array &$checks, string $key, bool $passed, string $label, string $success, string $failure, array $target, string $failureStatus = 'error'): void
{
$status = $passed ? 'success' : $failureStatus;
$checks[] = compact('key', 'passed', 'label', 'success', 'failure', 'status', 'target');
}
}