164 خطوط
11 KiB
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');
|
|
}
|
|
}
|