From 6c8ae3ed0e75c536d05eb31916bff3e8f395ce2e Mon Sep 17 00:00:00 2001 From: "NITRO\\fabia" Date: Fri, 31 Jul 2026 15:13:35 -0300 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20adiciona=20libera=C3=A7=C3=A3o=20po?= =?UTF-8?q?r=20progresso=20e=20conclus=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/MemberAreaAppController.php | 101 +++++++- .../Controllers/MemberBuilderController.php | 231 ++++++++++++++++-- app/Models/MemberLesson.php | 10 + app/Models/MemberLessonReleaseDependency.php | 16 ++ app/Models/MemberModule.php | 5 + app/Models/MemberModuleReleaseDependency.php | 26 ++ ...mber_module_release_dependencies_table.php | 34 +++ ...mber_lesson_release_dependencies_table.php | 27 ++ .../Produtos/MemberBuilder/Standalone.vue | 148 ++++++++++- .../MemberBuilderLessonEditor.vue | 30 +++ .../MemberBuilderModuleEditor.vue | 74 +++++- .../MemberBuilderModulesTab.vue | 12 + .../MemberLessonReleaseDependencyTest.php | 95 +++++++ .../MemberModuleReleaseDependencyTest.php | 111 +++++++++ 14 files changed, 881 insertions(+), 39 deletions(-) create mode 100644 app/Models/MemberLessonReleaseDependency.php create mode 100644 app/Models/MemberModuleReleaseDependency.php create mode 100644 database/migrations/2026_07_31_000001_create_member_module_release_dependencies_table.php create mode 100644 database/migrations/2026_07_31_000002_create_member_lesson_release_dependencies_table.php create mode 100644 tests/Feature/MemberLessonReleaseDependencyTest.php create mode 100644 tests/Feature/MemberModuleReleaseDependencyTest.php diff --git a/app/Http/Controllers/MemberAreaAppController.php b/app/Http/Controllers/MemberAreaAppController.php index 33fbd972..78d60b2a 100644 --- a/app/Http/Controllers/MemberAreaAppController.php +++ b/app/Http/Controllers/MemberAreaAppController.php @@ -2,13 +2,13 @@ namespace App\Http\Controllers; +use App\Models\MemberActivityLog; use App\Models\MemberCertificateIssued; use App\Models\MemberComment; use App\Models\MemberCommunityPage; use App\Models\MemberCommunityPost; use App\Models\MemberCommunityPostComment; use App\Models\MemberCommunityPostLike; -use App\Models\MemberActivityLog; use App\Models\MemberInternalProduct; use App\Models\MemberLesson; use App\Models\MemberLessonLike; @@ -31,7 +31,6 @@ use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Http; use Inertia\Inertia; use Inertia\Response; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; @@ -73,7 +72,7 @@ public function show(Request $request, string $slug): Response $user = $request->user(); $this->logMemberActivity($request, $product, $user, 'member_area.open', [ - 'path' => '/' . ltrim($request->path(), '/'), + 'path' => '/'.ltrim($request->path(), '/'), ]); $accessStartAt = $this->userAccessStartAt($product, $user); @@ -355,6 +354,7 @@ public function lesson(Request $request, string $slug, MemberLesson $lesson): Re return redirect()->route($this->memberAreaModuleRouteName($request), ['slug' => $slug, 'module' => $moduleRouteId]) ->with('error', $lessonLock['lock_message'] ?? 'Aula ainda não liberada.'); } + return redirect()->route($this->memberAreaModulosRouteName($request), ['slug' => $slug]) ->with('error', $lessonLock['lock_message'] ?? 'Aula ainda não liberada.'); } @@ -1430,6 +1430,7 @@ public function openRelatedProduct(Request $request): RedirectResponse if (! $user instanceof User) { // In host-based member areas, GET /login is handled by the platform login controller. $isHost = str_ends_with(($request->route()?->getName() ?? ''), '.host'); + return $isHost ? redirect()->to('/login')->with('error', 'Faça login para acessar a área de membros.') : redirect()->route('member-area.login', ['slug' => $slug])->with('error', 'Faça login para acessar a área de membros.'); @@ -1487,6 +1488,7 @@ public function openRelatedProductDeliverable(Request $request): RedirectRespons if (! $user instanceof User) { $isHost = str_ends_with(($request->route()?->getName() ?? ''), '.host'); + return $isHost ? redirect()->to('/login')->with('error', 'Faça login para acessar a área de membros.') : redirect()->route('member-area.login', ['slug' => $slug])->with('error', 'Faça login para acessar a área de membros.'); @@ -1532,8 +1534,9 @@ public function openRelatedProductDeliverable(Request $request): RedirectRespons if ($link !== '') { return str_starts_with($link, 'http://') || str_starts_with($link, 'https://') ? redirect()->away($link) - : redirect('/' . ltrim($link, '/')); + : redirect('/'.ltrim($link, '/')); } + return redirect()->to($this->baseUrlForRequest($host, $request)) ->with('error', 'Este produto está como tipo Link, mas o link de entrega não foi configurado.'); } @@ -1547,12 +1550,14 @@ public function openRelatedProductDeliverable(Request $request): RedirectRespons private function memberAreaProductsOpenRouteName(Request $request): string { $name = $request->route()?->getName() ?? ''; + return str_ends_with($name, '.host') ? 'member-area-app.products.open.host' : 'member-area-app.products.open'; } private function memberAreaProductsDeliverableRouteName(Request $request): string { $name = $request->route()?->getName() ?? ''; + return str_ends_with($name, '.host') ? 'member-area-app.products.deliverable.host' : 'member-area-app.products.deliverable'; } @@ -1562,6 +1567,7 @@ private function memberAreaProductsDeliverableRouteName(Request $request): strin private function memberAreaProductsRouteParams(Request $request, string $slug, string $productId): array { $isHost = str_ends_with(($request->route()?->getName() ?? ''), '.host'); + return $isHost ? ['relatedProduct' => $productId] : ['slug' => $slug, 'relatedProduct' => $productId]; } @@ -1571,6 +1577,7 @@ private function memberAreaProductsRouteParams(Request $request, string $slug, s private function memberAreaModuleRouteParams(Request $request, string $slug, string $moduleId): array { $isHost = str_ends_with(($request->route()?->getName() ?? ''), '.host'); + return $isHost ? ['module' => $moduleId] : ['slug' => $slug, 'module' => $moduleId]; } @@ -1639,7 +1646,7 @@ private function assertEmbeddedProductLinkAccess(MemberModule $wrapper, User $us if ($link !== '') { return str_starts_with($link, 'http://') || str_starts_with($link, 'https://') ? redirect()->away($link) - : redirect('/' . ltrim($link, '/')); + : redirect('/'.ltrim($link, '/')); } } @@ -1768,6 +1775,7 @@ private function userAccessStartAt(Product $product, User $user): Carbon if ($createdAt) { return Carbon::parse($createdAt); } + return now(); } @@ -1782,6 +1790,7 @@ private function scheduleMeta(?int $afterDays, mixed $atDate, Carbon $accessStar if (is_int($afterDays) && $afterDays > 0) { return ['available_at' => $accessStartAt->copy()->addDays($afterDays), 'mode' => 'days']; } + return ['available_at' => null, 'mode' => null]; } @@ -1827,12 +1836,14 @@ private function lockPayload(?Carbon $availableAt, ?Carbon $expiresAt, Carbon $n } else { $message = 'Disponível em '.$availableAt->format('d/m/Y H:i'); } + return [ ...$payload, 'is_locked' => true, 'lock_message' => $message, 'lock_reason' => 'scheduled', ]; + } private function moduleLockPayload(MemberModule $module, Carbon $accessStartAt, Carbon $now): array @@ -1840,7 +1851,53 @@ private function moduleLockPayload(MemberModule $module, Carbon $accessStartAt, $meta = $this->scheduleMeta($module->release_after_days, $module->release_at_date, $accessStartAt); $expiresAt = $this->accessExpiresAt($module->access_duration_days, $accessStartAt); - return $this->lockPayload($meta['available_at'], $expiresAt, $now, $meta['mode']); + $scheduleLock = $this->lockPayload($meta['available_at'], $expiresAt, $now, $meta['mode']); + if (($scheduleLock['is_locked'] ?? false) === true) { + return $scheduleLock; + } + + $user = request()->user(); + if (! $user instanceof User) { + return $scheduleLock; + } + + $dependencies = $module->releaseDependencies() + ->with(['requiredModule.lessons:id,member_module_id']) + ->get(); + if ($dependencies->isEmpty()) { + return $scheduleLock; + } + + $lessonIds = $dependencies + ->flatMap(fn ($dependency) => $dependency->requiredModule?->lessons->pluck('id') ?? []) + ->unique() + ->values(); + $completedLessonIds = MemberLessonProgress::query() + ->where('user_id', $user->id) + ->whereNotNull('completed_at') + ->whereIn('member_lesson_id', $lessonIds) + ->pluck('member_lesson_id') + ->flip(); + + foreach ($dependencies as $dependency) { + $requiredModule = $dependency->requiredModule; + $requiredLessons = $requiredModule?->lessons ?? collect(); + $completedCount = $requiredLessons->filter(fn (MemberLesson $lesson) => isset($completedLessonIds[$lesson->id]))->count(); + $progressPercent = $requiredLessons->isEmpty() + ? 0 + : (int) floor(($completedCount / $requiredLessons->count()) * 100); + $minimumProgressPercent = (int) $dependency->minimum_progress_percent; + if ($progressPercent < $minimumProgressPercent) { + return [ + ...$scheduleLock, + 'is_locked' => true, + 'lock_message' => 'Atinja '.$minimumProgressPercent.'% no módulo '.($requiredModule?->title ?? 'anterior').' para liberar este módulo.', + 'lock_reason' => 'prerequisite', + ]; + } + } + + return $scheduleLock; } private function lessonLockPayload(MemberLesson $lesson, ?MemberModule $module, Carbon $accessStartAt, Carbon $now): array @@ -1878,7 +1935,37 @@ private function lessonLockPayload(MemberLesson $lesson, ?MemberModule $module, $expiresAt = $lessonExpiresAt ?? $moduleExpiresAt; } - return $this->lockPayload($availableAt, $expiresAt, $now, $mode); + $scheduleLock = $this->lockPayload($availableAt, $expiresAt, $now, $mode); + if (($scheduleLock['is_locked'] ?? false) === true) { + return $scheduleLock; + } + + $user = request()->user(); + if (! $user instanceof User) { + return $scheduleLock; + } + $dependencies = $lesson->releaseDependencies()->with('requiredLesson')->get(); + if ($dependencies->isEmpty()) { + return $scheduleLock; + } + $completedIds = MemberLessonProgress::query() + ->where('user_id', $user->id) + ->whereNotNull('completed_at') + ->whereIn('member_lesson_id', $dependencies->pluck('required_member_lesson_id')) + ->pluck('member_lesson_id') + ->flip(); + foreach ($dependencies as $dependency) { + if (! isset($completedIds[$dependency->required_member_lesson_id])) { + return [ + ...$scheduleLock, + 'is_locked' => true, + 'lock_message' => 'Conclua a aula '.($dependency->requiredLesson?->title ?? 'anterior').' para liberar esta aula.', + 'lock_reason' => 'prerequisite', + ]; + } + } + + return $scheduleLock; } private function moduleThumbnailUrl(MemberModule $module, Product $product, ?MemberModule $fallback = null): ?string diff --git a/app/Http/Controllers/MemberBuilderController.php b/app/Http/Controllers/MemberBuilderController.php index 6cca6289..009f574d 100644 --- a/app/Http/Controllers/MemberBuilderController.php +++ b/app/Http/Controllers/MemberBuilderController.php @@ -5,40 +5,39 @@ use App\Models\MemberAreaDomain; use App\Models\MemberComment; use App\Models\MemberCommunityPage; -use App\Models\MemberCommunityPost; use App\Models\MemberInternalProduct; use App\Models\MemberLesson; use App\Models\MemberLessonProgress; use App\Models\MemberModule; +use App\Models\MemberNotification; +use App\Models\MemberPushSubscription; use App\Models\MemberSection; use App\Models\MemberTurma; use App\Models\Product; use App\Models\User; -use App\Models\MemberNotification; -use App\Models\MemberPushSubscription; use App\Rules\StorageOrHttpUrl; -use App\Support\StoredFileUrl; -use Illuminate\Support\Facades\Hash; +use App\Services\GamificationService; use App\Services\MemberAreaResolver; use App\Services\MemberCommentService; -use App\Services\StorageService; -use App\Services\GamificationService; use App\Services\MemberProgressService; +use App\Services\StorageService; use App\Services\TeamAccessService; +use App\Support\StoredFileUrl; +use App\Support\VapidKeyGenerator; +use Illuminate\Database\QueryException; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Database\QueryException; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; use Illuminate\View\View; use Inertia\Inertia; use Inertia\Response; -use App\Support\VapidKeyGenerator; -use Minishlink\WebPush\WebPush; use Minishlink\WebPush\Subscription; +use Minishlink\WebPush\WebPush; class MemberBuilderController extends Controller { @@ -55,6 +54,7 @@ private function normalizeLessonContentFiles(mixed $input): array if ($url !== '' && StoredFileUrl::isValid($url)) { $out[] = ['url' => StoredFileUrl::normalize($url), 'name' => 'Material']; } + continue; } if (! is_array($item)) { @@ -116,7 +116,9 @@ public function index(Product $produto): View|RedirectResponse $produto->load([ 'memberAreaDomain', 'memberSections.modules.lessons', + 'memberSections.modules.lessons.releaseDependencies', 'memberSections.modules.relatedProduct', + 'memberSections.modules.releaseDependencies', 'memberInternalProducts.relatedProduct', 'memberTurmas.users:id,name,email', 'memberCommunityPages', @@ -213,6 +215,10 @@ public function index(Product $produto): View|RedirectResponse 'release_after_days' => $m->release_after_days, 'release_at_date' => $m->release_at_date?->format('Y-m-d'), 'access_duration_days' => $m->access_duration_days, + 'release_dependencies' => $m->releaseDependencies->map(fn ($dependency) => [ + 'module_id' => (int) $dependency->required_member_module_id, + 'minimum_progress_percent' => (int) $dependency->minimum_progress_percent, + ])->values()->all(), 'lessons' => $m->lessons->map(fn (MemberLesson $l) => [ 'id' => $l->id, 'title' => $l->title, @@ -226,6 +232,7 @@ public function index(Product $produto): View|RedirectResponse 'release_after_days' => $l->release_after_days, 'release_at_date' => $l->release_at_date?->format('Y-m-d'), 'access_duration_days' => $l->access_duration_days, + 'release_dependency_lesson_ids' => $l->releaseDependencies->pluck('required_member_lesson_id')->map(fn ($id) => (int) $id)->values()->all(), 'content_text' => \App\Support\HtmlSanitizer::sanitize($l->content_text), 'duration_seconds' => $l->duration_seconds, 'is_free' => $l->is_free, @@ -243,6 +250,7 @@ public function index(Product $produto): View|RedirectResponse 'image_url' => $m->relatedProduct->image ? app(StorageService::class)->url($m->relatedProduct->image) : null, ] : null, ]); + return array_merge($base, $extra); })->values()->all(), ])->values()->all(), @@ -370,6 +378,7 @@ public function updateConfig(Request $request, Product $produto): JsonResponse|R 'message' => 'O segmento do path deve ter entre 6 e 16 caracteres (apenas letras minúsculas e números).', ], 422); } + return back()->with('error', 'O segmento do path deve ter entre 6 e 16 caracteres (apenas letras minúsculas e números).'); } $conflictProduct = Product::where('checkout_slug', $pathSegment) @@ -386,6 +395,7 @@ public function updateConfig(Request $request, Product $produto): JsonResponse|R 'message' => 'Este segmento de URL já está em uso por outra área de membros.', ], 422); } + return back()->with('error', 'Este segmento de URL já está em uso por outra área de membros.'); } } @@ -455,6 +465,7 @@ public function updateConfig(Request $request, Product $produto): JsonResponse|R 'message' => 'Informe um domínio ou subdomínio válido (apenas o host, sem https:// e sem /path).', ], 422); } + return back()->with('error', 'Informe um domínio ou subdomínio válido (apenas o host, sem https:// e sem /path).'); } $conflictCustom = MemberAreaDomain::where('type', MemberAreaDomain::TYPE_CUSTOM) @@ -467,6 +478,7 @@ public function updateConfig(Request $request, Product $produto): JsonResponse|R 'message' => 'Este domínio já está vinculado a outra área de membros.', ], 422); } + return back()->with('error', 'Este domínio já está vinculado a outra área de membros.'); } } @@ -481,6 +493,7 @@ public function updateConfig(Request $request, Product $produto): JsonResponse|R if ($vapidWarning !== null) { $json['warning'] = $vapidWarning; } + return response()->json($json); } @@ -499,7 +512,8 @@ public function uploadImage(Request $request, Product $produto): JsonResponse 'file.max' => 'A imagem deve ter no máximo '.(int) max(1, floor($maxKb / 1024)).' MB.', ]); $storage = app(StorageService::class); - $path = $storage->putFile('member-area/' . $produto->id, $request->file('file')); + $path = $storage->putFile('member-area/'.$produto->id, $request->file('file')); + return response()->json(['url' => $storage->url($path), 'path' => $path]); } @@ -516,9 +530,10 @@ public function uploadPdf(Request $request, Product $produto): JsonResponse ]); $file = $request->file('file'); $name = $file->getClientOriginalName(); - $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', pathinfo($name, PATHINFO_FILENAME)) . '.pdf'; + $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', pathinfo($name, PATHINFO_FILENAME)).'.pdf'; $storage = app(StorageService::class); - $path = $storage->putFileAs('member-area/' . $produto->id, $file, $safeName); + $path = $storage->putFileAs('member-area/'.$produto->id, $file, $safeName); + return response()->json(['url' => $storage->url($path), 'path' => $path]); } @@ -537,7 +552,8 @@ public function uploadBadge(Request $request, Product $produto): JsonResponse 'file.max' => 'A imagem da badge deve ter no máximo '.(int) max(1, floor($maxKb / 1024)).' MB.', ]); $storage = app(StorageService::class); - $path = $storage->putFile('member-area-gamification/' . $produto->id . '/badges', $request->file('file')); + $path = $storage->putFile('member-area-gamification/'.$produto->id.'/badges', $request->file('file')); + return response()->json(['url' => $storage->url($path), 'path' => $path]); } @@ -561,6 +577,7 @@ public function storeSection(Request $request, Product $produto): JsonResponse|R if ($request->expectsJson()) { return response()->json(['message' => 'Seção criada.']); } + return back()->with('success', 'Seção criada.'); } @@ -580,6 +597,7 @@ public function updateSection(Request $request, Product $produto, MemberSection if ($request->expectsJson()) { return response()->json(['message' => 'Seção atualizada.']); } + return back()->with('success', 'Seção atualizada.'); } @@ -593,6 +611,7 @@ public function destroySection(Request $request, Product $produto, MemberSection if ($request->expectsJson()) { return response()->json(['message' => 'Seção removida.']); } + return back()->with('success', 'Seção removida.'); } @@ -785,6 +804,9 @@ public function storeModule(Request $request, Product $produto, MemberSection $s 'release_after_days' => ['nullable', 'integer', 'min:1', 'max:3650'], 'release_at_date' => ['nullable', 'date_format:Y-m-d'], 'access_duration_days' => ['nullable', 'integer', 'min:1', 'max:3650'], + 'release_dependencies' => ['nullable', 'array'], + 'release_dependencies.*.module_id' => ['required', 'integer', 'distinct'], + 'release_dependencies.*.minimum_progress_percent' => ['required', 'integer', 'min:1', 'max:100'], ]); if (! empty($validated['release_at_date'] ?? null)) { $validated['release_after_days'] = null; @@ -794,6 +816,7 @@ public function storeModule(Request $request, Product $produto, MemberSection $s } else { $validated['release_at_date'] = null; } + $this->assertNewModuleReleaseDependenciesAreValid($produto, $validated['release_dependencies'] ?? []); $max = MemberModule::where('member_section_id', $section->id)->max('position') ?? 0; $module = MemberModule::create([ 'member_section_id' => $section->id, @@ -805,6 +828,7 @@ public function storeModule(Request $request, Product $produto, MemberSection $s 'release_at_date' => $validated['release_at_date'] ?? null, 'access_duration_days' => $validated['access_duration_days'] ?? null, ]); + $this->syncModuleReleaseDependencies($produto, $module, $validated['release_dependencies'] ?? []); } elseif ($sectionType === 'products') { $validated = $request->validate([ 'title' => ['required', 'string', 'max:255'], @@ -821,6 +845,7 @@ public function storeModule(Request $request, Product $produto, MemberSection $s if ($request->expectsJson()) { return response()->json(['message' => 'Não é possível referenciar o próprio produto.'], 422); } + return back()->with('error', 'Não é possível referenciar o próprio produto.'); } $max = MemberModule::where('member_section_id', $section->id)->max('position') ?? 0; @@ -917,6 +942,7 @@ public function storeModule(Request $request, Product $produto, MemberSection $s return response()->json(['message' => 'Módulo criado.', 'module' => $payload, 'modules' => [$payload]]); } + return back()->with('success', 'Módulo criado.'); } @@ -934,6 +960,10 @@ private function moduleJsonPayloadForStore(MemberModule $module): array 'release_after_days' => $module->release_after_days, 'release_at_date' => $module->release_at_date?->format('Y-m-d'), 'access_duration_days' => $module->access_duration_days, + 'release_dependencies' => $module->releaseDependencies()->get()->map(fn ($dependency) => [ + 'module_id' => (int) $dependency->required_member_module_id, + 'minimum_progress_percent' => (int) $dependency->minimum_progress_percent, + ])->values()->all(), 'lessons' => $module->relationLoaded('lessons') ? $module->lessons->map(fn (MemberLesson $l) => [ 'id' => $l->id, 'title' => $l->title, @@ -967,6 +997,99 @@ private function moduleJsonPayloadForStore(MemberModule $module): array return $payload; } + /** + * @param array $dependencies + */ + private function syncModuleReleaseDependencies(Product $product, MemberModule $module, array $dependencies): void + { + if ($dependencies === []) { + $module->releaseDependencies()->delete(); + + return; + } + $ids = array_map(fn (array $dependency) => (int) $dependency['module_id'], $dependencies); + $orderedModules = $this->orderedCourseModules($product); + $currentIndex = $orderedModules->search(fn (MemberModule $candidate) => $candidate->id === $module->id); + $allowedIds = $currentIndex === false + ? [] + : $orderedModules->take($currentIndex)->pluck('id')->map(fn ($id) => (int) $id)->all(); + + if (array_diff($ids, $allowedIds) !== []) { + throw ValidationException::withMessages([ + 'release_dependencies' => ['Selecione ao menos um módulo anterior válido.'], + ]); + } + + $module->releaseDependencies()->delete(); + $module->releaseDependencies()->createMany(array_map(fn (array $dependency) => [ + 'required_member_module_id' => (int) $dependency['module_id'], + 'minimum_progress_percent' => (int) $dependency['minimum_progress_percent'], + ], $dependencies)); + } + + /** @param array $dependencies */ + private function assertNewModuleReleaseDependenciesAreValid(Product $product, array $dependencies): void + { + $ids = array_values(array_unique(array_map(fn (array $dependency) => (int) $dependency['module_id'], $dependencies))); + $allowedIds = $this->orderedCourseModules($product)->pluck('id')->map(fn ($id) => (int) $id)->all(); + if (array_diff($ids, $allowedIds) !== []) { + throw ValidationException::withMessages([ + 'release_dependencies' => ['Selecione apenas módulos anteriores válidos.'], + ]); + } + } + + /** @return \Illuminate\Support\Collection */ + private function orderedCourseModules(Product $product): \Illuminate\Support\Collection + { + return $product->memberSections() + ->where('section_type', 'courses') + ->orderBy('position') + ->with(['modules' => fn ($query) => $query->orderBy('position')]) + ->get() + ->flatMap(fn (MemberSection $section) => $section->modules) + ->values(); + } + + /** @param array $dependencyIds */ + private function syncLessonReleaseDependencies(MemberModule $module, MemberLesson $lesson, array $dependencyIds): void + { + $this->assertLessonReleaseDependenciesAreValid($module, $lesson, $dependencyIds); + + $ids = array_values(array_unique(array_map('intval', $dependencyIds))); + $lesson->releaseDependencies()->delete(); + $lesson->releaseDependencies()->createMany(array_map(fn (int $id) => [ + 'required_member_lesson_id' => $id, + ], $ids)); + } + + /** @param array $dependencyIds */ + private function assertLessonReleaseDependenciesAreValid(MemberModule $module, MemberLesson $lesson, array $dependencyIds): void + { + $ids = array_values(array_unique(array_map('intval', $dependencyIds))); + $orderedLessons = $module->lessons()->orderBy('position')->get(); + $currentIndex = $orderedLessons->search(fn (MemberLesson $candidate) => $candidate->id === $lesson->id); + $allowedIds = $currentIndex === false ? [] : $orderedLessons->take($currentIndex)->pluck('id')->map(fn ($id) => (int) $id)->all(); + if ($ids !== [] && array_diff($ids, $allowedIds) !== []) { + throw ValidationException::withMessages([ + 'release_dependency_lesson_ids' => ['Selecione apenas aulas anteriores deste módulo.'], + ]); + } + } + + /** @param array $dependencyIds */ + private function assertNewLessonReleaseDependenciesAreValid(MemberModule $module, array $dependencyIds): void + { + $ids = array_values(array_unique(array_map('intval', $dependencyIds))); + $allowedIds = $module->lessons()->pluck('id')->map(fn ($id) => (int) $id)->all(); + + if ($ids !== [] && array_diff($ids, $allowedIds) !== []) { + throw ValidationException::withMessages([ + 'release_dependency_lesson_ids' => ['Selecione apenas aulas anteriores deste módulo.'], + ]); + } + } + public function updateModule(Request $request, Product $produto, MemberModule $module): JsonResponse|RedirectResponse { $this->authorizeProduct($produto); @@ -985,6 +1108,9 @@ public function updateModule(Request $request, Product $produto, MemberModule $m 'release_after_days' => ['nullable', 'integer', 'min:1', 'max:3650'], 'release_at_date' => ['nullable', 'date_format:Y-m-d'], 'access_duration_days' => ['nullable', 'integer', 'min:1', 'max:3650'], + 'release_dependencies' => ['nullable', 'array'], + 'release_dependencies.*.module_id' => ['required', 'integer', 'distinct'], + 'release_dependencies.*.minimum_progress_percent' => ['required', 'integer', 'min:1', 'max:100'], ]); if (array_key_exists('release_at_date', $validated) || array_key_exists('release_after_days', $validated)) { $date = $validated['release_at_date'] ?? null; @@ -1018,6 +1144,7 @@ public function updateModule(Request $request, Product $produto, MemberModule $m if ($request->expectsJson()) { return response()->json(['message' => 'Não é possível referenciar o próprio produto.'], 422); } + return back()->with('error', 'Não é possível referenciar o próprio produto.'); } } @@ -1030,10 +1157,16 @@ public function updateModule(Request $request, Product $produto, MemberModule $m 'show_title_on_cover' => ['sometimes', 'boolean'], ]); } + $releaseDependencies = $validated['release_dependencies'] ?? null; + unset($validated['release_dependencies']); $module->update($validated); + if ($sectionType === 'courses' && $releaseDependencies !== null) { + $this->syncModuleReleaseDependencies($produto, $module, $releaseDependencies); + } if ($request->expectsJson()) { return response()->json(['message' => 'Módulo atualizado.']); } + return back()->with('success', 'Módulo atualizado.'); } @@ -1047,6 +1180,7 @@ public function destroyModule(Request $request, Product $produto, MemberModule $ if ($request->expectsJson()) { return response()->json(['message' => 'Módulo removido.']); } + return back()->with('success', 'Módulo removido.'); } @@ -1100,17 +1234,19 @@ public function storeLesson(Request $request, Product $produto, MemberModule $mo 'content_url' => ['nullable', 'string', 'max:2000'], 'link_title' => ['nullable', 'string', 'max:255'], 'content_files' => ['nullable', 'array', 'max:30'], - 'content_files.*.url' => ['nullable', 'string', 'max:2000', new StorageOrHttpUrl()], + 'content_files.*.url' => ['nullable', 'string', 'max:2000', new StorageOrHttpUrl], 'content_files.*.name' => ['nullable', 'string', 'max:255'], 'support_files' => ['nullable', 'array', 'max:30'], - 'support_files.*.url' => ['nullable', 'string', 'max:2000', new StorageOrHttpUrl()], + 'support_files.*.url' => ['nullable', 'string', 'max:2000', new StorageOrHttpUrl], 'support_files.*.name' => ['nullable', 'string', 'max:255'], 'useful_links' => ['nullable', 'array', 'max:20'], 'useful_links.*.title' => ['nullable', 'string', 'max:255'], - 'useful_links.*.url' => ['nullable', 'string', 'max:2000', new StorageOrHttpUrl()], + 'useful_links.*.url' => ['nullable', 'string', 'max:2000', new StorageOrHttpUrl], 'release_after_days' => ['nullable', 'integer', 'min:1', 'max:3650'], 'release_at_date' => ['nullable', 'date_format:Y-m-d'], 'access_duration_days' => ['nullable', 'integer', 'min:1', 'max:3650'], + 'release_dependency_lesson_ids' => ['nullable', 'array'], + 'release_dependency_lesson_ids.*' => ['integer', 'distinct'], 'content_text' => ['nullable', 'string'], 'duration_seconds' => ['nullable', 'integer', 'min:0'], 'is_free' => ['boolean'], @@ -1153,8 +1289,12 @@ public function storeLesson(Request $request, Product $produto, MemberModule $mo 'is_free' => $request->boolean('is_free', false), 'watermark_enabled' => $request->boolean('watermark_enabled', false), ]); + $dependencyLessonIds = $validated['release_dependency_lesson_ids'] ?? []; + $this->assertNewLessonReleaseDependenciesAreValid($module, $dependencyLessonIds); + try { - MemberLesson::create($attributes); + $lesson = MemberLesson::create($attributes); + $this->syncLessonReleaseDependencies($module, $lesson, $dependencyLessonIds); } catch (QueryException $e) { Log::warning('member_lesson create failed', ['error' => $e->getMessage(), 'product_id' => $produto->id]); @@ -1168,6 +1308,7 @@ public function storeLesson(Request $request, Product $produto, MemberModule $mo if ($request->expectsJson()) { return response()->json(['message' => 'Aula criada.']); } + return back()->with('success', 'Aula criada.'); } @@ -1184,17 +1325,19 @@ public function updateLesson(Request $request, Product $produto, MemberLesson $l 'content_url' => ['nullable', 'string', 'max:2000'], 'link_title' => ['nullable', 'string', 'max:255'], 'content_files' => ['nullable', 'array', 'max:30'], - 'content_files.*.url' => ['nullable', 'string', 'max:2000', new StorageOrHttpUrl()], + 'content_files.*.url' => ['nullable', 'string', 'max:2000', new StorageOrHttpUrl], 'content_files.*.name' => ['nullable', 'string', 'max:255'], 'support_files' => ['nullable', 'array', 'max:30'], - 'support_files.*.url' => ['nullable', 'string', 'max:2000', new StorageOrHttpUrl()], + 'support_files.*.url' => ['nullable', 'string', 'max:2000', new StorageOrHttpUrl], 'support_files.*.name' => ['nullable', 'string', 'max:255'], 'useful_links' => ['nullable', 'array', 'max:20'], 'useful_links.*.title' => ['nullable', 'string', 'max:255'], - 'useful_links.*.url' => ['nullable', 'string', 'max:2000', new StorageOrHttpUrl()], + 'useful_links.*.url' => ['nullable', 'string', 'max:2000', new StorageOrHttpUrl], 'release_after_days' => ['nullable', 'integer', 'min:1', 'max:3650'], 'release_at_date' => ['nullable', 'date_format:Y-m-d'], 'access_duration_days' => ['nullable', 'integer', 'min:1', 'max:3650'], + 'release_dependency_lesson_ids' => ['nullable', 'array'], + 'release_dependency_lesson_ids.*' => ['integer', 'distinct'], 'content_text' => ['nullable', 'string'], 'duration_seconds' => ['nullable', 'integer', 'min:0'], 'is_free' => ['boolean'], @@ -1244,8 +1387,17 @@ public function updateLesson(Request $request, Product $produto, MemberLesson $l if ($request->has('useful_links')) { $validated['useful_links'] = $usefulLinks !== [] ? $usefulLinks : null; } + $dependencyLessonIds = $validated['release_dependency_lesson_ids'] ?? null; + if ($dependencyLessonIds !== null) { + $this->assertLessonReleaseDependenciesAreValid($lesson->module, $lesson, $dependencyLessonIds); + } + unset($validated['release_dependency_lesson_ids']); + try { $lesson->update(MemberLesson::onlyExistingColumns($validated)); + if ($dependencyLessonIds !== null) { + $this->syncLessonReleaseDependencies($lesson->module, $lesson, $dependencyLessonIds); + } } catch (QueryException $e) { Log::warning('member_lesson update failed', ['error' => $e->getMessage(), 'lesson_id' => $lesson->id]); @@ -1259,6 +1411,7 @@ public function updateLesson(Request $request, Product $produto, MemberLesson $l if ($request->expectsJson()) { return response()->json(['message' => 'Aula atualizada.']); } + return back()->with('success', 'Aula atualizada.'); } @@ -1272,6 +1425,7 @@ public function destroyLesson(Request $request, Product $produto, MemberLesson $ if ($request->expectsJson()) { return response()->json(['message' => 'Aula removida.']); } + return back()->with('success', 'Aula removida.'); } @@ -1319,6 +1473,7 @@ public function storeInternalProduct(Request $request, Product $produto): Redire ['product_id' => $produto->id, 'related_product_id' => $validated['related_product_id']], ['position' => $max + 1] ); + return back()->with('success', 'Produto adicionado à loja interna.'); } @@ -1329,6 +1484,7 @@ public function destroyInternalProduct(Product $produto, MemberInternalProduct $ abort(404); } $internalProduct->delete(); + return back()->with('success', 'Produto removido da loja interna.'); } @@ -1351,6 +1507,7 @@ public function storeTurma(Request $request, Product $produto): RedirectResponse 'end_date' => $validated['end_date'] ?? null, 'position' => $max + 1, ]); + return back()->with('success', 'Turma criada.'); } @@ -1368,6 +1525,7 @@ public function updateTurma(Request $request, Product $produto, MemberTurma $tur 'position' => ['sometimes', 'integer', 'min:0'], ]); $turma->update($validated); + return back()->with('success', 'Turma atualizada.'); } @@ -1378,6 +1536,7 @@ public function destroyTurma(Product $produto, MemberTurma $turma): RedirectResp abort(404); } $turma->delete(); + return back()->with('success', 'Turma removida.'); } @@ -1392,6 +1551,7 @@ public function attachTurmaUser(Request $request, Product $produto, MemberTurma if ($request->expectsJson()) { return response()->json(['message' => 'Aluno adicionado à turma.']); } + return back()->with('success', 'Aluno adicionado à turma.'); } @@ -1405,6 +1565,7 @@ public function detachTurmaUser(Product $produto, MemberTurma $turma, int $userI if ($request->expectsJson()) { return response()->json(['message' => 'Aluno removido da turma.']); } + return back()->with('success', 'Aluno removido da turma.'); } @@ -1427,6 +1588,7 @@ public function storeNewAluno(Request $request, Product $produto): JsonResponse| if ($request->expectsJson()) { return response()->json(['message' => 'Turma inválida.'], 422); } + return back()->with('error', 'Turma inválida.'); } } @@ -1440,6 +1602,7 @@ public function storeNewAluno(Request $request, Product $produto): JsonResponse| if ($request->expectsJson()) { return response()->json(['message' => $msg, 'errors' => ['email' => [$msg]]], 422); } + return back()->with('error', $msg); } if (! $existing->isAluno()) { @@ -1447,6 +1610,7 @@ public function storeNewAluno(Request $request, Product $produto): JsonResponse| if ($request->expectsJson()) { return response()->json(['message' => $msg, 'errors' => ['email' => [$msg]]], 422); } + return back()->with('error', $msg); } @@ -1472,6 +1636,7 @@ public function storeNewAluno(Request $request, Product $produto): JsonResponse| if ($request->expectsJson()) { return response()->json(['message' => $msg, 'errors' => ['password' => [$msg]]], 422); } + return back()->with('error', $msg); } @@ -1493,6 +1658,7 @@ public function storeNewAluno(Request $request, Product $produto): JsonResponse| 'user' => ['id' => $user->id, 'name' => $user->name, 'email' => $user->email], ]); } + return back()->with('success', 'Aluno criado e adicionado.'); } @@ -1506,6 +1672,7 @@ public function commentsIndex(Request $request, Product $produto): Response ->with(['user:id,name,email', 'lesson:id,title', 'reviewer:id,name']) ->latest() ->paginate(20); + return Inertia::render('Produtos/MemberBuilder/Comments', [ 'produto' => ['id' => $produto->id, 'name' => $produto->name], 'comments' => $comments, @@ -1529,6 +1696,7 @@ public function updateComment(Request $request, Product $produto, MemberComment if ($request->expectsJson()) { return response()->json(['success' => true, 'message' => 'Comentário atualizado.']); } + return back()->with('success', 'Comentário atualizado.'); } @@ -1567,6 +1735,7 @@ public function storeCommunityPage(Request $request, Product $produto): JsonResp 'community_pages' => $this->buildCommunityPagesPayload($produto), ]); } + return back()->with('success', 'Página da comunidade criada.'); } @@ -1597,6 +1766,7 @@ public function updateCommunityPage(Request $request, Product $produto, MemberCo 'community_pages' => $this->buildCommunityPagesPayload($produto), ]); } + return back()->with('success', 'Página atualizada.'); } @@ -1614,6 +1784,7 @@ public function destroyCommunityPage(Request $request, Product $produto, MemberC 'community_pages' => $this->buildCommunityPagesPayload($produto), ]); } + return back()->with('success', 'Página removida.'); } @@ -1623,6 +1794,7 @@ public function destroyCommunityPage(Request $request, Product $produto, MemberC private function buildCommunityPagesPayload(Product $produto): array { $produto->load('memberCommunityPages'); + return $produto->memberCommunityPages->map(fn (MemberCommunityPage $p) => [ 'id' => $p->id, 'title' => $p->title, @@ -1659,7 +1831,7 @@ public function sendPushNotification(Request $request, Product $produto): JsonRe } $subscriptions = MemberPushSubscription::where('product_id', $produto->id)->get(); $sent = 0; - $subject = 'mailto:' . (config('mail.from.address') ?: 'noreply@' . parse_url(config('app.url'), PHP_URL_HOST)); + $subject = 'mailto:'.(config('mail.from.address') ?: 'noreply@'.parse_url(config('app.url'), PHP_URL_HOST)); $auth = [ 'VAPID' => [ 'subject' => $subject, @@ -1706,8 +1878,9 @@ public function sendPushNotification(Request $request, Product $produto): JsonRe ]); } } catch (\Throwable $e) { - return response()->json(['message' => 'Erro ao enviar: ' . $e->getMessage()], 500); + return response()->json(['message' => 'Erro ao enviar: '.$e->getMessage()], 500); } + return response()->json([ 'success' => true, 'sent' => $sent, @@ -1726,6 +1899,7 @@ private function normalizeBase64KeyForPush(string $key): string if (str_contains($key, '+') || str_contains($key, '/')) { return strtr($key, ['+' => '-', '/' => '_']); } + return $key; } @@ -1800,7 +1974,7 @@ private function ipInCidr(string $ip, string $cidr): bool $mask = $bitsInt === 0 ? 0 : (-1 << (32 - $bitsInt)); - return (($ipLong & $mask) === ($subnetLong & $mask)); + return ($ipLong & $mask) === ($subnetLong & $mask); } /** Para o front: exibe só "path" ou "custom". Subdomínio vira custom com valor = host completo. */ @@ -1815,8 +1989,10 @@ private function memberAreaDomainValueForFront(MemberAreaDomain $domain, Product if ($domain->type === MemberAreaDomain::TYPE_SUBDOMAIN && config('members.subdomain_enabled')) { $base = config('members.subdomain_base', ''); $slug = $domain->value ?: $product->checkout_slug; - return $base !== '' ? $slug . '.' . $base : (string) $domain->value; + + return $base !== '' ? $slug.'.'.$base : (string) $domain->value; } + return (string) ($domain->value ?? ''); } @@ -1904,6 +2080,8 @@ private function duplicateMemberLessonRecord( */ private function serializeMemberLessonForBuilder(MemberLesson $lesson): array { + $lesson->loadMissing('releaseDependencies'); + return [ 'id' => $lesson->id, 'title' => $lesson->title, @@ -1917,6 +2095,7 @@ private function serializeMemberLessonForBuilder(MemberLesson $lesson): array 'release_after_days' => $lesson->release_after_days, 'release_at_date' => $lesson->release_at_date?->format('Y-m-d'), 'access_duration_days' => $lesson->access_duration_days, + 'release_dependency_lesson_ids' => $lesson->releaseDependencies->pluck('required_member_lesson_id')->map(fn ($id) => (int) $id)->values()->all(), 'content_text' => \App\Support\HtmlSanitizer::sanitize($lesson->content_text), 'duration_seconds' => $lesson->duration_seconds, 'is_free' => (bool) $lesson->is_free, @@ -1929,7 +2108,7 @@ private function serializeMemberLessonForBuilder(MemberLesson $lesson): array */ private function serializeMemberModuleForBuilder(MemberModule $module): array { - $module->loadMissing(['lessons' => fn ($q) => $q->orderBy('position'), 'relatedProduct']); + $module->loadMissing(['lessons' => fn ($q) => $q->orderBy('position'), 'lessons.releaseDependencies', 'relatedProduct']); $payload = [ 'id' => $module->id, diff --git a/app/Models/MemberLesson.php b/app/Models/MemberLesson.php index 920073f2..a0728336 100644 --- a/app/Models/MemberLesson.php +++ b/app/Models/MemberLesson.php @@ -10,12 +10,17 @@ class MemberLesson extends Model { public const TYPE_VIDEO = 'video'; + public const TYPE_LINK = 'link'; + public const TYPE_PDF = 'pdf'; + /** Slides / leitura em tela com navegação por páginas (pdf.js), distinto de "Material" (download). */ public const TYPE_PDF_PRESENTATION = 'pdf_presentation'; + /** Leitor de PDF completo (marcações, miniaturas, curtidas) — mesma origem de arquivo que pdf_presentation. */ public const TYPE_PDF_READER = 'pdf_reader'; + public const TYPE_TEXT = 'text'; protected $fillable = [ @@ -80,6 +85,11 @@ public function pdfAnnotations(): HasMany return $this->hasMany(MemberLessonPdfAnnotation::class, 'member_lesson_id'); } + public function releaseDependencies(): HasMany + { + return $this->hasMany(MemberLessonReleaseDependency::class, 'member_lesson_id'); + } + /** * @param array $attributes * @return array diff --git a/app/Models/MemberLessonReleaseDependency.php b/app/Models/MemberLessonReleaseDependency.php new file mode 100644 index 00000000..3a55834f --- /dev/null +++ b/app/Models/MemberLessonReleaseDependency.php @@ -0,0 +1,16 @@ +belongsTo(MemberLesson::class, 'required_member_lesson_id'); + } +} diff --git a/app/Models/MemberModule.php b/app/Models/MemberModule.php index f3f059ac..40f4d388 100644 --- a/app/Models/MemberModule.php +++ b/app/Models/MemberModule.php @@ -59,4 +59,9 @@ public function lessons(): HasMany { return $this->hasMany(MemberLesson::class, 'member_module_id')->orderBy('position'); } + + public function releaseDependencies(): HasMany + { + return $this->hasMany(MemberModuleReleaseDependency::class, 'member_module_id'); + } } diff --git a/app/Models/MemberModuleReleaseDependency.php b/app/Models/MemberModuleReleaseDependency.php new file mode 100644 index 00000000..b33cd976 --- /dev/null +++ b/app/Models/MemberModuleReleaseDependency.php @@ -0,0 +1,26 @@ + 'integer']; + } + + public function module(): BelongsTo + { + return $this->belongsTo(MemberModule::class, 'member_module_id'); + } + + public function requiredModule(): BelongsTo + { + return $this->belongsTo(MemberModule::class, 'required_member_module_id'); + } +} diff --git a/database/migrations/2026_07_31_000001_create_member_module_release_dependencies_table.php b/database/migrations/2026_07_31_000001_create_member_module_release_dependencies_table.php new file mode 100644 index 00000000..fd25aa09 --- /dev/null +++ b/database/migrations/2026_07_31_000001_create_member_module_release_dependencies_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('member_module_id'); + $table->foreignId('required_member_module_id'); + $table->unsignedTinyInteger('minimum_progress_percent')->default(100); + $table->timestamps(); + + $table->unique(['member_module_id', 'required_member_module_id'], 'member_module_release_dependency_unique'); + $table->foreign('member_module_id', 'mm_release_dep_module_fk') + ->references('id')->on('member_modules')->cascadeOnDelete(); + $table->foreign('required_member_module_id', 'mm_release_dep_required_fk') + ->references('id')->on('member_modules')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('member_module_release_dependencies'); + } +}; diff --git a/database/migrations/2026_07_31_000002_create_member_lesson_release_dependencies_table.php b/database/migrations/2026_07_31_000002_create_member_lesson_release_dependencies_table.php new file mode 100644 index 00000000..e3af6b80 --- /dev/null +++ b/database/migrations/2026_07_31_000002_create_member_lesson_release_dependencies_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('member_lesson_id'); + $table->foreignId('required_member_lesson_id'); + $table->timestamps(); + $table->unique(['member_lesson_id', 'required_member_lesson_id'], 'ml_release_dependency_unique'); + $table->foreign('member_lesson_id', 'ml_release_dep_lesson_fk')->references('id')->on('member_lessons')->cascadeOnDelete(); + $table->foreign('required_member_lesson_id', 'ml_release_dep_required_fk')->references('id')->on('member_lessons')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('member_lesson_release_dependencies'); + } +}; diff --git a/resources/js/Pages/Produtos/MemberBuilder/Standalone.vue b/resources/js/Pages/Produtos/MemberBuilder/Standalone.vue index f1e92df4..476ac31f 100644 --- a/resources/js/Pages/Produtos/MemberBuilder/Standalone.vue +++ b/resources/js/Pages/Produtos/MemberBuilder/Standalone.vue @@ -229,6 +229,10 @@ function cloneMemberSectionsStructure(sections) { const courseStructureSections = ref(cloneMemberSectionsStructure(props.produto.sections)); +const initialModuleSelection = typeof window === 'undefined' + ? null + : new URLSearchParams(window.location.search); + watch( () => props.produto.sections, (next) => { @@ -237,8 +241,8 @@ watch( { deep: true }, ); -const modulosSelectedSectionId = ref(null); -const modulosSelectedModuleId = ref(null); +const modulosSelectedSectionId = ref(Number(initialModuleSelection?.get('member_section')) || null); +const modulosSelectedModuleId = ref(Number(initialModuleSelection?.get('member_module')) || null); const effectiveShowPreview = computed(() => { if (!showPreview.value) return false; @@ -600,6 +604,7 @@ const editingModuleId = ref(null); // Módulos: painel direito (aulas do módulo + formulário nova/editar) const modulosLessonForm = ref(null); const modulosLessonFormSaving = ref(false); +const modulosModuleFormSaving = ref(false); const lessonPdfFileInput = ref(null); const lessonPdfUploading = ref(false); const lessonSupportFileInput = ref(null); @@ -680,6 +685,8 @@ function openModulosLessonForm(lesson) { release_after_days: lesson.release_after_days ? String(lesson.release_after_days) : '', release_at_date: lesson.release_at_date || '', access_duration_days: lesson.access_duration_days ? String(lesson.access_duration_days) : '', + release_dependency_lesson_ids: Array.isArray(lesson.release_dependency_lesson_ids) ? lesson.release_dependency_lesson_ids.map(Number) : [], + requires_previous_lessons: Array.isArray(lesson.release_dependency_lesson_ids) && lesson.release_dependency_lesson_ids.length > 0, }; } else { modulosLessonForm.value = { @@ -696,6 +703,8 @@ function openModulosLessonForm(lesson) { release_after_days: '', release_at_date: '', access_duration_days: '', + release_dependency_lesson_ids: [], + requires_previous_lessons: false, }; } } @@ -842,6 +851,9 @@ function lessonPayload(form) { release_after_days, release_at_date, access_duration_days: Number.isFinite(accessDurationDays) && accessDurationDays > 0 ? accessDurationDays : null, + release_dependency_lesson_ids: form.requires_previous_lessons && Array.isArray(form.release_dependency_lesson_ids) + ? form.release_dependency_lesson_ids.map(Number) + : [], content_text: form.content_text ?? '', duration_seconds: 0, is_free: false, @@ -853,6 +865,10 @@ async function saveLessonFromSidebar() { const form = modulosLessonForm.value; const moduleId = modulosSelectedModuleId.value; if (!form || !moduleId) return; + if (form.requires_previous_lessons && !(form.release_dependency_lesson_ids?.length > 0)) { + alert('Selecione ao menos uma aula anterior concluída.'); + return; + } modulosLessonFormSaving.value = true; try { const payload = lessonPayload(form); @@ -924,6 +940,8 @@ const editingModuleReleaseMode = ref('none'); // none | days | date const editingModuleReleaseAfterDays = ref(''); const editingModuleReleaseAtDate = ref(''); const editingModuleAccessDurationDays = ref(''); +const editingModuleRequiresPreviousModules = ref(false); +const editingModuleReleaseDependencies = ref([]); const sectionModalOpen = ref(false); const sectionModalTitle = ref(''); @@ -948,6 +966,52 @@ const moduleModalReleaseMode = ref('none'); // none | days | date const moduleModalReleaseAfterDays = ref(''); const moduleModalReleaseAtDate = ref(''); const moduleModalAccessDurationDays = ref(''); +const moduleModalRequiresPreviousModules = ref(false); +const moduleModalReleaseDependencies = ref([]); +const moduleModalDependencyToAdd = ref(''); + +function releaseDependencyModuleOptions(currentModuleId = null) { + // A ordem dos arrays é a fonte da verdade no editor: o draggable a altera + // imediatamente, antes de a API persistir as novas posições. + const modules = (courseStructureSections.value ?? []).flatMap((section) => + (section.section_type ?? 'courses') === 'courses' ? (section.modules ?? []) : [], + ); + if (!currentModuleId) return modules.reverse(); + const index = modules.findIndex((module) => Number(module.id) === Number(currentModuleId)); + return index < 0 ? [] : modules.slice(0, index).reverse(); +} + +const editingModuleReleaseDependencyOptions = computed(() => + releaseDependencyModuleOptions(editingModuleId.value), +); + +watch(editingModuleReleaseDependencyOptions, (options) => { + const validIds = new Set(options.map((module) => Number(module.id))); + const validDependencies = editingModuleReleaseDependencies.value.filter((dependency) => + validIds.has(Number(dependency.module_id)), + ); + if (validDependencies.length !== editingModuleReleaseDependencies.value.length) { + editingModuleReleaseDependencies.value = validDependencies; + } + if (validDependencies.length === 0) { + editingModuleRequiresPreviousModules.value = false; + } +}); + +function addReleaseDependency(dependencies, moduleId) { + const id = Number(moduleId); + if (!id || dependencies.some((dependency) => Number(dependency.module_id) === id)) return; + dependencies.push({ module_id: id, minimum_progress_percent: 100 }); +} + +function removeReleaseDependency(dependencies, moduleId) { + const index = dependencies.findIndex((dependency) => Number(dependency.module_id) === Number(moduleId)); + if (index >= 0) dependencies.splice(index, 1); +} + +function moduleTitle(moduleId) { + return releaseDependencyModuleOptions().find((module) => Number(module.id) === Number(moduleId))?.title ?? 'Módulo'; +} function openSectionEdit(section) { editingSectionTitle.value = section.title; @@ -974,6 +1038,13 @@ function openModuleEdit(mod) { editingModuleRelatedProductId.value = mod.related_product_id ?? null; editingModuleAccessType.value = mod.access_type ?? 'paid'; editingModuleExternalUrl.value = mod.external_url ?? ''; + editingModuleReleaseDependencies.value = Array.isArray(mod.release_dependencies) + ? mod.release_dependencies.map((dependency) => ({ + module_id: Number(dependency.module_id), + minimum_progress_percent: Number(dependency.minimum_progress_percent ?? 100), + })) + : []; + editingModuleRequiresPreviousModules.value = editingModuleReleaseDependencies.value.length > 0; if (mod.release_at_date) { editingModuleReleaseMode.value = 'date'; editingModuleReleaseAtDate.value = mod.release_at_date; @@ -1012,6 +1083,14 @@ async function saveModuleTitle() { payload.release_after_days = null; payload.release_at_date = null; } + if (editingModuleRequiresPreviousModules.value + && editingModuleReleaseDependencies.value.length === 0) { + alert('Selecione ao menos um módulo anterior concluído.'); + return; + } + payload.release_dependencies = editingModuleRequiresPreviousModules.value + ? editingModuleReleaseDependencies.value + : []; } else if (sectionType === 'products') { payload.related_product_id = editingModuleRelatedProductId.value; payload.access_type = editingModuleAccessType.value; @@ -1020,11 +1099,15 @@ async function saveModuleTitle() { payload.external_url = editingModuleExternalUrl.value?.trim() ?? ''; payload.show_title_on_cover = editingModuleShowTitleOnCover.value; } + modulosModuleFormSaving.value = true; try { await axios.put(`${base.value}/modules/${id}`, payload, { headers: headers() }); cancelEdit(); reload(); - } catch (_) {} + } catch (_) { + } finally { + modulosModuleFormSaving.value = false; + } } async function setModuleShowTitleOnCover(value) { @@ -1351,9 +1434,16 @@ function removeLoginBackground() { function reload() { const url = new URL(window.location.href); url.searchParams.set('tab', activeTab.value); + if (activeTab.value === 'modulos') { + if (modulosSelectedSectionId.value) url.searchParams.set('member_section', modulosSelectedSectionId.value); + else url.searchParams.delete('member_section'); + if (modulosSelectedModuleId.value) url.searchParams.set('member_module', modulosSelectedModuleId.value); + else url.searchParams.delete('member_module'); + } window.location.href = url.toString(); } + // Modal nativo para pedir um texto (substitui prompt()) const promptModal = ref({ show: false, title: '', placeholder: '', value: '', callback: null }); const promptModalInputRef = ref(null); @@ -1527,6 +1617,9 @@ function openModuleModal(sectionId) { moduleModalReleaseAfterDays.value = ''; moduleModalReleaseAtDate.value = ''; moduleModalAccessDurationDays.value = ''; + moduleModalRequiresPreviousModules.value = false; + moduleModalReleaseDependencies.value = []; + moduleModalDependencyToAdd.value = ''; clearModuleModalFile(); moduleModalOpen.value = true; } @@ -1560,6 +1653,11 @@ async function confirmNewModule() { const sectionType = moduleModalSectionType.value; if (sectionType === 'products' && !moduleModalRelatedProductId.value) return; if (sectionType === 'external_links' && !moduleModalExternalUrl.value?.trim()) return; + if (moduleModalRequiresPreviousModules.value + && moduleModalReleaseDependencies.value.length === 0) { + alert('Selecione ao menos um módulo anterior concluído.'); + return; + } moduleModalSaving.value = true; try { let payload = { title }; @@ -1578,6 +1676,9 @@ async function confirmNewModule() { payload.release_after_days = null; payload.release_at_date = null; } + payload.release_dependencies = moduleModalRequiresPreviousModules.value + ? moduleModalReleaseDependencies.value + : []; } else if (sectionType === 'products') { payload.related_product_id = moduleModalRelatedProductId.value; payload.access_type = moduleModalAccessType.value; @@ -2405,6 +2506,7 @@ const inputClass = 'block w-full rounded-lg border border-zinc-300 bg-white px-3 :section-type-label="sectionTypeLabel" :lesson-form="modulosLessonForm" :lesson-form-saving="modulosLessonFormSaving" + :module-form-saving="modulosModuleFormSaving" :lesson-pdf-uploading="lessonPdfUploading" :lesson-support-uploading="lessonSupportUploading" :is-lesson-pdf-content-type="isLessonPdfContentType" @@ -2422,6 +2524,9 @@ const inputClass = 'block w-full rounded-lg border border-zinc-300 bg-white px-3 :editing-module-release-after-days="editingModuleReleaseAfterDays" :editing-module-release-at-date="editingModuleReleaseAtDate" :editing-module-access-duration-days="editingModuleAccessDurationDays" + :editing-module-requires-previous-modules="editingModuleRequiresPreviousModules" + :editing-module-release-dependencies="editingModuleReleaseDependencies" + :editing-module-release-dependency-options="editingModuleReleaseDependencyOptions" :editing-module-thumbnail="editingModule?.thumbnail" :module-thumbnail-uploading="moduleThumbnailUploading" @open-section-modal="openSectionModal" @@ -2465,6 +2570,8 @@ const inputClass = 'block w-full rounded-lg border border-zinc-300 bg-white px-3 @update:editing-module-release-after-days="editingModuleReleaseAfterDays = $event" @update:editing-module-release-at-date="editingModuleReleaseAtDate = $event" @update:editing-module-access-duration-days="editingModuleAccessDurationDays = $event" + @update:editing-module-requires-previous-modules="editingModuleRequiresPreviousModules = $event" + @update:editing-module-release-dependencies="editingModuleReleaseDependencies = $event" /> @@ -3106,7 +3213,7 @@ const inputClass = 'block w-full rounded-lg border border-zinc-300 bg-white px-3 class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" @click.self="cancelPrompt" > -
+

{{ promptModal.title }}

@@ -3305,6 +3412,39 @@ const inputClass = 'block w-full rounded-lg border border-zinc-300 bg-white px-3

Em dias após a compra. Deixe vazio para acesso ilimitado.

+ + +

Crie um módulo anterior para usar esta condição.

diff --git a/resources/js/components/member-builder/MemberBuilderLessonEditor.vue b/resources/js/components/member-builder/MemberBuilderLessonEditor.vue index 59dd39e5..0841885f 100644 --- a/resources/js/components/member-builder/MemberBuilderLessonEditor.vue +++ b/resources/js/components/member-builder/MemberBuilderLessonEditor.vue @@ -1,4 +1,5 @@ diff --git a/resources/js/components/member-builder/MemberBuilderModulesTab.vue b/resources/js/components/member-builder/MemberBuilderModulesTab.vue index 69020fa0..4806d644 100644 --- a/resources/js/components/member-builder/MemberBuilderModulesTab.vue +++ b/resources/js/components/member-builder/MemberBuilderModulesTab.vue @@ -21,6 +21,7 @@ const props = defineProps({ sectionTypeLabel: { type: Function, required: true }, lessonForm: { type: Object, default: null }, lessonFormSaving: { type: Boolean, default: false }, + moduleFormSaving: { type: Boolean, default: false }, lessonPdfUploading: { type: Boolean, default: false }, lessonSupportUploading: { type: Boolean, default: false }, isLessonPdfContentType: { type: Function, required: true }, @@ -38,6 +39,9 @@ const props = defineProps({ editingModuleReleaseAfterDays: { type: String, default: '' }, editingModuleReleaseAtDate: { type: String, default: '' }, editingModuleAccessDurationDays: { type: String, default: '' }, + editingModuleRequiresPreviousModules: { type: Boolean, default: false }, + editingModuleReleaseDependencies: { type: Array, default: () => [] }, + editingModuleReleaseDependencyOptions: { type: Array, default: () => [] }, editingModuleThumbnail: { type: String, default: null }, moduleThumbnailUploading: { type: Boolean, default: false }, }); @@ -84,6 +88,8 @@ const emit = defineEmits([ 'update:editingModuleReleaseAfterDays', 'update:editingModuleReleaseAtDate', 'update:editingModuleAccessDurationDays', + 'update:editingModuleRequiresPreviousModules', + 'update:editingModuleReleaseDependencies', ]); const mobileStep = ref('sections'); @@ -415,8 +421,12 @@ const columnClass = (step) => [ :editing-release-after-days="editingModuleReleaseAfterDays" :editing-release-at-date="editingModuleReleaseAtDate" :editing-access-duration-days="editingModuleAccessDurationDays" + :editing-requires-previous-modules="editingModuleRequiresPreviousModules" + :editing-release-dependencies="editingModuleReleaseDependencies" + :editing-release-dependency-options="editingModuleReleaseDependencyOptions" :editing-thumbnail="editingModuleThumbnail" :thumbnail-uploading="moduleThumbnailUploading" + :saving="moduleFormSaving" @update:editing-title="emit('update:editingModuleTitle', $event)" @update:editing-show-title-on-cover="emit('update:editingModuleShowTitleOnCover', $event)" @update:editing-related-product-id="emit('update:editingModuleRelatedProductId', $event)" @@ -426,6 +436,8 @@ const columnClass = (step) => [ @update:editing-release-after-days="emit('update:editingModuleReleaseAfterDays', $event)" @update:editing-release-at-date="emit('update:editingModuleReleaseAtDate', $event)" @update:editing-access-duration-days="emit('update:editingModuleAccessDurationDays', $event)" + @update:editing-requires-previous-modules="emit('update:editingModuleRequiresPreviousModules', $event)" + @update:editing-release-dependencies="emit('update:editingModuleReleaseDependencies', $event)" @save="emit('save-module')" @pick-thumbnail="emit('pick-module-thumbnail')" @remove-thumbnail="emit('remove-module-thumbnail')" diff --git a/tests/Feature/MemberLessonReleaseDependencyTest.php b/tests/Feature/MemberLessonReleaseDependencyTest.php new file mode 100644 index 00000000..8643a5b3 --- /dev/null +++ b/tests/Feature/MemberLessonReleaseDependencyTest.php @@ -0,0 +1,95 @@ +withoutMiddleware(EnsureInstalled::class); + + $owner = User::factory()->create(['role' => User::ROLE_INFOPRODUTOR, 'tenant_id' => 1]); + $student = User::factory()->create(['role' => User::ROLE_ALUNO, 'tenant_id' => 1]); + $product = $this->createTestProduct([ + 'type' => Product::TYPE_AREA_MEMBROS, + 'checkout_slug' => 'lesson-release-dependency-'.substr(uniqid('', true), -8), + ]); + $section = MemberSection::create([ + 'product_id' => $product->id, + 'title' => 'Aulas', + 'position' => 1, + 'section_type' => 'courses', + ]); + $module = MemberModule::create([ + 'member_section_id' => $section->id, + 'product_id' => $product->id, + 'title' => 'Módulo imediato', + 'position' => 1, + ]); + $firstLesson = MemberLesson::create([ + 'member_module_id' => $module->id, + 'product_id' => $product->id, + 'title' => 'Primeira aula', + 'position' => 1, + 'type' => MemberLesson::TYPE_TEXT, + 'content_text' => '

Conteúdo

', + ]); + $secondLesson = MemberLesson::create([ + 'member_module_id' => $module->id, + 'product_id' => $product->id, + 'title' => 'Segunda aula', + 'position' => 2, + 'type' => MemberLesson::TYPE_TEXT, + 'content_text' => '

Conteúdo

', + ]); + + $this->actingAs($owner)->putJson( + route('member-builder.lessons.update', ['produto' => $product, 'lesson' => $secondLesson]), + ['release_dependency_lesson_ids' => [$firstLesson->id]], + )->assertOk(); + + $this->assertDatabaseHas('member_lesson_release_dependencies', [ + 'member_lesson_id' => $secondLesson->id, + 'required_member_lesson_id' => $firstLesson->id, + ]); + + $product->users()->attach($student->id); + $locked = $this->actingAs($student)->get('/m/'.$product->checkout_slug); + $locked->assertOk()->assertInertia(fn ($page) => $page + ->where('sections.0.modules.0.lessons.1.is_locked', true) + ->where('sections.0.modules.0.lessons.1.lock_message', 'Conclua a aula Primeira aula para liberar esta aula.')); + + $this->actingAs($student)->postJson('/m/'.$product->checkout_slug.'/aula/'.$firstLesson->id.'/complete') + ->assertOk() + ->assertJsonPath('success', true); + + $unlocked = $this->actingAs($student)->get('/m/'.$product->checkout_slug); + $unlocked->assertOk()->assertInertia(fn ($page) => $page + ->where('sections.0.modules.0.lessons.1.is_locked', false)); + } + + public function test_lesson_cannot_depend_on_itself_or_a_later_lesson(): void + { + $this->withoutMiddleware(EnsureInstalled::class); + + $owner = User::factory()->create(['role' => User::ROLE_INFOPRODUTOR, 'tenant_id' => 1]); + $product = $this->createTestProduct(['type' => Product::TYPE_AREA_MEMBROS]); + $section = MemberSection::create(['product_id' => $product->id, 'title' => 'Aulas', 'position' => 1, 'section_type' => 'courses']); + $module = MemberModule::create(['member_section_id' => $section->id, 'product_id' => $product->id, 'title' => 'Módulo', 'position' => 1]); + $firstLesson = MemberLesson::create(['member_module_id' => $module->id, 'product_id' => $product->id, 'title' => 'Primeira', 'position' => 1, 'type' => MemberLesson::TYPE_TEXT]); + $secondLesson = MemberLesson::create(['member_module_id' => $module->id, 'product_id' => $product->id, 'title' => 'Segunda', 'position' => 2, 'type' => MemberLesson::TYPE_TEXT]); + + $this->actingAs($owner)->putJson( + route('member-builder.lessons.update', ['produto' => $product, 'lesson' => $firstLesson]), + ['release_dependency_lesson_ids' => [$secondLesson->id]], + )->assertUnprocessable()->assertJsonValidationErrors('release_dependency_lesson_ids'); + } +} diff --git a/tests/Feature/MemberModuleReleaseDependencyTest.php b/tests/Feature/MemberModuleReleaseDependencyTest.php new file mode 100644 index 00000000..bad5a0f0 --- /dev/null +++ b/tests/Feature/MemberModuleReleaseDependencyTest.php @@ -0,0 +1,111 @@ +withoutMiddleware(EnsureInstalled::class); + + $owner = User::factory()->create(['role' => User::ROLE_INFOPRODUTOR, 'tenant_id' => 1]); + $student = User::factory()->create(['role' => User::ROLE_ALUNO, 'tenant_id' => 1]); + $product = $this->createTestProduct([ + 'type' => Product::TYPE_AREA_MEMBROS, + 'checkout_slug' => 'release-dependency-'.substr(uniqid('', true), -8), + ]); + $section = MemberSection::create([ + 'product_id' => $product->id, + 'title' => 'Aulas', + 'position' => 1, + 'section_type' => 'courses', + ]); + $firstModule = MemberModule::create([ + 'member_section_id' => $section->id, + 'product_id' => $product->id, + 'title' => 'Fundamentos', + 'position' => 1, + ]); + $firstLesson = MemberLesson::create([ + 'member_module_id' => $firstModule->id, + 'product_id' => $product->id, + 'title' => 'Aula inicial', + 'position' => 1, + 'type' => MemberLesson::TYPE_TEXT, + 'content_text' => '

Conteúdo

', + ]); + MemberLesson::create([ + 'member_module_id' => $firstModule->id, + 'product_id' => $product->id, + 'title' => 'Aula complementar', + 'position' => 2, + 'type' => MemberLesson::TYPE_TEXT, + 'content_text' => '

Conteúdo

', + ]); + + $response = $this->actingAs($owner)->postJson( + route('member-builder.modules.store', ['produto' => $product, 'section' => $section]), + [ + 'title' => 'Avançado', + 'release_at_date' => now()->subDay()->format('Y-m-d'), + 'release_dependencies' => [[ + 'module_id' => $firstModule->id, + 'minimum_progress_percent' => 50, + ]], + ], + ); + + $response->assertOk() + ->assertJsonPath('module.release_dependencies.0.module_id', $firstModule->id) + ->assertJsonPath('module.release_dependencies.0.minimum_progress_percent', 50); + $secondModule = MemberModule::where('title', 'Avançado')->firstOrFail(); + $this->assertDatabaseHas('member_module_release_dependencies', [ + 'member_module_id' => $secondModule->id, + 'required_member_module_id' => $firstModule->id, + 'minimum_progress_percent' => 50, + ]); + + $product->users()->attach($student->id); + $locked = $this->actingAs($student)->get('/m/'.$product->checkout_slug); + $locked->assertOk()->assertInertia(fn ($page) => $page->where('sections.0.modules.1.is_locked', true)); + + $this->actingAs($student)->postJson('/m/'.$product->checkout_slug.'/aula/'.$firstLesson->id.'/complete') + ->assertOk() + ->assertJsonPath('success', true); + + $unlocked = $this->actingAs($student)->get('/m/'.$product->checkout_slug); + $unlocked->assertOk()->assertInertia(fn ($page) => $page->where('sections.0.modules.1.is_locked', false)); + } + + public function test_module_cannot_depend_on_itself_or_a_later_module(): void + { + $this->withoutMiddleware(EnsureInstalled::class); + + $owner = User::factory()->create(['role' => User::ROLE_INFOPRODUTOR, 'tenant_id' => 1]); + $product = $this->createTestProduct(['type' => Product::TYPE_AREA_MEMBROS]); + $section = MemberSection::create([ + 'product_id' => $product->id, + 'title' => 'Aulas', + 'position' => 1, + 'section_type' => 'courses', + ]); + $firstModule = MemberModule::create(['member_section_id' => $section->id, 'product_id' => $product->id, 'title' => 'Primeiro', 'position' => 1]); + $secondModule = MemberModule::create(['member_section_id' => $section->id, 'product_id' => $product->id, 'title' => 'Segundo', 'position' => 2]); + + $this->actingAs($owner)->putJson( + route('member-builder.modules.update', ['produto' => $product, 'module' => $firstModule]), + ['release_after_days' => 1, 'release_dependencies' => [[ + 'module_id' => $secondModule->id, + 'minimum_progress_percent' => 100, + ]]], + )->assertUnprocessable()->assertJsonValidationErrors('release_dependencies'); + } +} From ef10cfbbb142494ed2ec07713b9e0c31a804b69d Mon Sep 17 00:00:00 2001 From: "NITRO\\fabia" Date: Mon, 10 Aug 2026 16:53:48 -0300 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20mant=C3=A9m=20depend=C3=AAncias=20v?= =?UTF-8?q?=C3=A1lidas=20ap=C3=B3s=20reordena=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/MemberBuilderController.php | 38 ++ .../MemberLessonReleaseDependencyTest.php | 2 +- .../MemberModuleReleaseDependencyTest.php | 2 +- .../MemberReleaseDependencyMatrixTest.php | 326 ++++++++++++++++++ 4 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/MemberReleaseDependencyMatrixTest.php diff --git a/app/Http/Controllers/MemberBuilderController.php b/app/Http/Controllers/MemberBuilderController.php index 009f574d..ff19c12c 100644 --- a/app/Http/Controllers/MemberBuilderController.php +++ b/app/Http/Controllers/MemberBuilderController.php @@ -713,6 +713,8 @@ private function applyMemberSectionReorder(Product $produto, array $orderedIds): foreach ($orderedIds as $index => $id) { MemberSection::query()->where('product_id', $produto->id)->whereKey($id)->update(['position' => $index + 1]); } + + $this->pruneInvalidModuleReleaseDependencies($produto); } /** @param array $orderedIds */ @@ -738,6 +740,8 @@ private function applyMemberModuleReorder(Product $produto, int $sectionId, arra ->whereKey($id) ->update(['position' => $index + 1]); } + + $this->pruneInvalidModuleReleaseDependencies($produto); } /** @param array $orderedIds */ @@ -763,6 +767,40 @@ private function applyMemberLessonReorder(Product $produto, int $moduleId, array ->whereKey($id) ->update(['position' => $index + 1]); } + + $this->pruneInvalidLessonReleaseDependencies($module); + } + + private function pruneInvalidModuleReleaseDependencies(Product $product): void + { + $previousModuleIds = []; + + foreach ($this->orderedCourseModules($product) as $module) { + $dependencies = $module->releaseDependencies(); + if ($previousModuleIds === []) { + $dependencies->delete(); + } else { + $dependencies->whereNotIn('required_member_module_id', $previousModuleIds)->delete(); + } + + $previousModuleIds[] = (int) $module->id; + } + } + + private function pruneInvalidLessonReleaseDependencies(MemberModule $module): void + { + $previousLessonIds = []; + + foreach ($module->lessons()->orderBy('position')->get() as $lesson) { + $dependencies = $lesson->releaseDependencies(); + if ($previousLessonIds === []) { + $dependencies->delete(); + } else { + $dependencies->whereNotIn('required_member_lesson_id', $previousLessonIds)->delete(); + } + + $previousLessonIds[] = (int) $lesson->id; + } } /** diff --git a/tests/Feature/MemberLessonReleaseDependencyTest.php b/tests/Feature/MemberLessonReleaseDependencyTest.php index 8643a5b3..cf638796 100644 --- a/tests/Feature/MemberLessonReleaseDependencyTest.php +++ b/tests/Feature/MemberLessonReleaseDependencyTest.php @@ -20,7 +20,7 @@ public function test_immediate_lesson_can_require_a_previous_lesson_to_be_comple $student = User::factory()->create(['role' => User::ROLE_ALUNO, 'tenant_id' => 1]); $product = $this->createTestProduct([ 'type' => Product::TYPE_AREA_MEMBROS, - 'checkout_slug' => 'lesson-release-dependency-'.substr(uniqid('', true), -8), + 'checkout_slug' => 'lesson-rel-'.substr(uniqid('', true), -5), ]); $section = MemberSection::create([ 'product_id' => $product->id, diff --git a/tests/Feature/MemberModuleReleaseDependencyTest.php b/tests/Feature/MemberModuleReleaseDependencyTest.php index bad5a0f0..9a412662 100644 --- a/tests/Feature/MemberModuleReleaseDependencyTest.php +++ b/tests/Feature/MemberModuleReleaseDependencyTest.php @@ -20,7 +20,7 @@ public function test_module_release_can_require_a_previous_module_to_be_complete $student = User::factory()->create(['role' => User::ROLE_ALUNO, 'tenant_id' => 1]); $product = $this->createTestProduct([ 'type' => Product::TYPE_AREA_MEMBROS, - 'checkout_slug' => 'release-dependency-'.substr(uniqid('', true), -8), + 'checkout_slug' => 'module-rel-'.substr(uniqid('', true), -5), ]); $section = MemberSection::create([ 'product_id' => $product->id, diff --git a/tests/Feature/MemberReleaseDependencyMatrixTest.php b/tests/Feature/MemberReleaseDependencyMatrixTest.php new file mode 100644 index 00000000..9621eae8 --- /dev/null +++ b/tests/Feature/MemberReleaseDependencyMatrixTest.php @@ -0,0 +1,326 @@ +withoutMiddleware(EnsureInstalled::class); + } + + public function test_module_progress_thresholds_use_each_required_modules_lesson_count(): void + { + [$product, $section, $student] = $this->course(); + $required = $this->module($product, $section, 'Base com quatro aulas', 1, 4); + $quarter = $this->module($product, $section, 'Libera com 25%', 2, 1); + $half = $this->module($product, $section, 'Libera com 50%', 3, 1); + $quarter->releaseDependencies()->create([ + 'required_member_module_id' => $required->id, + 'minimum_progress_percent' => 25, + ]); + $half->releaseDependencies()->create([ + 'required_member_module_id' => $required->id, + 'minimum_progress_percent' => 50, + ]); + + $this->assertModuleLocked($product, $student, 1, true); + $this->assertModuleLocked($product, $student, 2, true); + + $this->complete($product, $student, $required->lessons[0]); + + $this->assertModuleLocked($product, $student, 1, false); + $this->assertModuleLocked($product, $student, 2, true); + + $this->complete($product, $student, $required->lessons[1]); + + $this->assertModuleLocked($product, $student, 2, false); + + $otherStudent = User::factory()->create(['role' => User::ROLE_ALUNO, 'tenant_id' => 1]); + $product->users()->attach($otherStudent->id); + $this->assertModuleLocked($product, $otherStudent, 1, true); + } + + public function test_all_selected_module_dependencies_must_reach_their_own_thresholds(): void + { + [$product, $section, $student] = $this->course(); + $first = $this->module($product, $section, 'Primeiro', 1, 4); + $second = $this->module($product, $section, 'Segundo', 2, 2); + $target = $this->module($product, $section, 'Destino', 3, 1); + $target->releaseDependencies()->createMany([ + ['required_member_module_id' => $first->id, 'minimum_progress_percent' => 50], + ['required_member_module_id' => $second->id, 'minimum_progress_percent' => 100], + ]); + + $this->complete($product, $student, $first->lessons[0]); + $this->complete($product, $student, $first->lessons[1]); + $this->complete($product, $student, $second->lessons[0]); + + $this->assertModuleLocked($product, $student, 2, true); + + $this->complete($product, $student, $second->lessons[1]); + + $this->assertModuleLocked($product, $student, 2, false); + } + + public function test_schedule_and_progress_conditions_are_both_required(): void + { + [$product, $section, $student] = $this->course(now()->subDays(8)); + $required = $this->module($product, $section, 'Base', 1, 2); + $target = $this->module($product, $section, 'Após sete dias e 50%', 2, 1, 7); + $target->releaseDependencies()->create([ + 'required_member_module_id' => $required->id, + 'minimum_progress_percent' => 50, + ]); + + $this->assertModuleLocked($product, $student, 1, true, 'Atinja 50%'); + + $this->complete($product, $student, $required->lessons[0]); + + $this->assertModuleLocked($product, $student, 1, false); + + $newStudent = User::factory()->create(['role' => User::ROLE_ALUNO, 'tenant_id' => 1]); + $product->users()->attach($newStudent->id); + $this->complete($product, $newStudent, $required->lessons[0]); + $this->assertModuleLocked($product, $newStudent, 1, true, 'Disponível em 7 dias'); + } + + public function test_a_dependency_on_an_empty_module_never_counts_as_completed(): void + { + [$product, $section, $student] = $this->course(); + $empty = $this->module($product, $section, 'Módulo vazio', 1, 0); + $target = $this->module($product, $section, 'Destino', 2, 1); + $target->releaseDependencies()->create([ + 'required_member_module_id' => $empty->id, + 'minimum_progress_percent' => 1, + ]); + + $this->assertModuleLocked($product, $student, 1, true, 'Atinja 1%'); + } + + public function test_fractional_progress_is_compared_using_the_completed_whole_percent(): void + { + [$product, $section, $student] = $this->course(); + $required = $this->module($product, $section, 'Três aulas', 1, 3); + $at34 = $this->module($product, $section, 'Exige 34%', 2, 1); + $at67 = $this->module($product, $section, 'Exige 67%', 3, 1); + $at34->releaseDependencies()->create([ + 'required_member_module_id' => $required->id, + 'minimum_progress_percent' => 34, + ]); + $at67->releaseDependencies()->create([ + 'required_member_module_id' => $required->id, + 'minimum_progress_percent' => 67, + ]); + + $this->complete($product, $student, $required->lessons[0]); + $this->assertModuleLocked($product, $student, 1, true); + + $this->complete($product, $student, $required->lessons[1]); + $this->assertModuleLocked($product, $student, 1, false); + $this->assertModuleLocked($product, $student, 2, true); + + $this->complete($product, $student, $required->lessons[2]); + $this->assertModuleLocked($product, $student, 2, false); + } + + public function test_release_date_and_progress_conditions_are_both_required(): void + { + [$product, $section, $student] = $this->course(); + $required = $this->module($product, $section, 'Base', 1, 2); + $target = $this->module($product, $section, 'Data e progresso', 2, 1); + $target->update(['release_at_date' => now()->addDay()->format('Y-m-d')]); + $target->releaseDependencies()->create([ + 'required_member_module_id' => $required->id, + 'minimum_progress_percent' => 50, + ]); + $this->complete($product, $student, $required->lessons[0]); + + $this->assertModuleLocked($product, $student, 1, true, 'Disponível em'); + + $target->update(['release_at_date' => now()->subDay()->format('Y-m-d')]); + + $this->assertModuleLocked($product, $student, 1, false); + } + + public function test_lesson_schedule_and_previous_lesson_completion_are_both_required(): void + { + [$product, $section, $student] = $this->course(); + $module = $this->module($product, $section, 'Aulas', 1, 2); + [$first, $second] = $module->lessons->all(); + $second->update(['release_at_date' => now()->addDay()->format('Y-m-d')]); + $second->releaseDependencies()->create(['required_member_lesson_id' => $first->id]); + $this->complete($product, $student, $first); + + $this->actingAs($student)->get('/m/'.$product->checkout_slug) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->where('sections.0.modules.0.lessons.1.is_locked', true) + ->where('sections.0.modules.0.lessons.1.lock_message', fn ($message) => str_contains((string) $message, 'Disponível em'))); + + $second->update(['release_at_date' => now()->subDay()->format('Y-m-d')]); + + $this->actingAs($student)->get('/m/'.$product->checkout_slug) + ->assertOk() + ->assertInertia(fn ($page) => $page->where('sections.0.modules.0.lessons.1.is_locked', false)); + } + + public function test_reordering_modules_and_sections_removes_dependencies_that_are_no_longer_previous(): void + { + [$product, $firstSection, $owner] = $this->course(owner: true); + $secondSection = MemberSection::create([ + 'product_id' => $product->id, + 'title' => 'Segunda seção', + 'position' => 2, + 'section_type' => 'courses', + ]); + $first = $this->module($product, $firstSection, 'Primeiro', 1, 1); + $second = $this->module($product, $firstSection, 'Segundo', 2, 1); + $third = $this->module($product, $secondSection, 'Terceiro', 1, 1); + $second->releaseDependencies()->create([ + 'required_member_module_id' => $first->id, + 'minimum_progress_percent' => 100, + ]); + $third->releaseDependencies()->create([ + 'required_member_module_id' => $second->id, + 'minimum_progress_percent' => 100, + ]); + + $this->actingAs($owner)->postJson(route('member-builder.reorder', $product), [ + 'scope' => 'modules', + 'section_id' => $firstSection->id, + 'ordered_ids' => [$second->id, $first->id], + ])->assertOk(); + + $this->assertDatabaseMissing('member_module_release_dependencies', [ + 'member_module_id' => $second->id, + 'required_member_module_id' => $first->id, + ]); + $this->assertDatabaseHas('member_module_release_dependencies', [ + 'member_module_id' => $third->id, + 'required_member_module_id' => $second->id, + ]); + + $this->actingAs($owner)->postJson(route('member-builder.reorder', $product), [ + 'scope' => 'sections', + 'ordered_ids' => [$secondSection->id, $firstSection->id], + ])->assertOk(); + + $this->assertDatabaseMissing('member_module_release_dependencies', [ + 'member_module_id' => $third->id, + 'required_member_module_id' => $second->id, + ]); + } + + public function test_reordering_lessons_removes_dependencies_that_are_no_longer_previous(): void + { + [$product, $section, $owner] = $this->course(owner: true); + $module = $this->module($product, $section, 'Módulo', 1, 2); + [$first, $second] = $module->lessons->all(); + $second->releaseDependencies()->create(['required_member_lesson_id' => $first->id]); + + $this->actingAs($owner)->postJson(route('member-builder.reorder', $product), [ + 'scope' => 'lessons', + 'module_id' => $module->id, + 'ordered_ids' => [$second->id, $first->id], + ])->assertOk(); + + $this->assertDatabaseMissing('member_lesson_release_dependencies', [ + 'member_lesson_id' => $second->id, + 'required_member_lesson_id' => $first->id, + ]); + } + + /** @return array{Product, MemberSection, User} */ + private function course($accessStartedAt = null, bool $owner = false): array + { + $user = User::factory()->create([ + 'role' => $owner ? User::ROLE_INFOPRODUTOR : User::ROLE_ALUNO, + 'tenant_id' => 1, + ]); + $product = $this->createTestProduct([ + 'type' => Product::TYPE_AREA_MEMBROS, + 'checkout_slug' => 'matrix-'.substr(uniqid('', true), -8), + ]); + $section = MemberSection::create([ + 'product_id' => $product->id, + 'title' => 'Trilha', + 'position' => 1, + 'section_type' => 'courses', + ]); + if (! $owner) { + $timestamp = $accessStartedAt ?? now(); + $product->users()->attach($user->id, ['created_at' => $timestamp, 'updated_at' => $timestamp]); + } + + return [$product, $section, $user]; + } + + private function module( + Product $product, + MemberSection $section, + string $title, + int $position, + int $lessonCount, + ?int $releaseAfterDays = null, + ): MemberModule { + $module = MemberModule::create([ + 'member_section_id' => $section->id, + 'product_id' => $product->id, + 'title' => $title, + 'position' => $position, + 'release_after_days' => $releaseAfterDays, + ]); + for ($position = 1; $position <= $lessonCount; $position++) { + MemberLesson::create([ + 'member_module_id' => $module->id, + 'product_id' => $product->id, + 'title' => $title.' - Aula '.$position, + 'position' => $position, + 'type' => MemberLesson::TYPE_TEXT, + 'content_text' => '

Conteúdo

', + ]); + } + + return $module->load('lessons'); + } + + private function complete(Product $product, User $student, MemberLesson $lesson): void + { + MemberLessonProgress::updateOrCreate( + ['user_id' => $student->id, 'member_lesson_id' => $lesson->id], + ['product_id' => $product->id, 'completed_at' => now(), 'progress_percent' => 100], + ); + } + + private function assertModuleLocked( + Product $product, + User $student, + int $moduleIndex, + bool $locked, + ?string $messageContains = null, + ): void { + $assertion = fn ($page) => $page->where("sections.0.modules.{$moduleIndex}.is_locked", $locked); + if ($messageContains !== null) { + $assertion = fn ($page) => $page + ->where("sections.0.modules.{$moduleIndex}.is_locked", $locked) + ->where("sections.0.modules.{$moduleIndex}.lock_message", fn ($message) => str_contains((string) $message, $messageContains)); + } + + $this->actingAs($student) + ->get('/m/'.$product->checkout_slug) + ->assertOk() + ->assertInertia($assertion); + } +} From 1254f3b27988fb0bbf137674d1db2f304f6567b0 Mon Sep 17 00:00:00 2001 From: Fabiano Cruz Date: Fri, 14 Aug 2026 23:34:16 -0300 Subject: [PATCH 3/3] remove redundant module prerequisite flow --- .../Controllers/MemberAreaAppController.php | 41 ---- .../Controllers/MemberBuilderController.php | 82 -------- app/Models/MemberModule.php | 4 - app/Models/MemberModuleReleaseDependency.php | 26 --- ...mber_module_release_dependencies_table.php | 34 ++++ .../Produtos/MemberBuilder/Standalone.vue | 113 ----------- .../MemberBuilderModuleEditor.vue | 66 ------- .../MemberBuilderModulesTab.vue | 10 - .../MemberModuleReleaseDependencyTest.php | 111 ----------- .../MemberReleaseDependencyMatrixTest.php | 181 ------------------ 10 files changed, 34 insertions(+), 634 deletions(-) delete mode 100644 app/Models/MemberModuleReleaseDependency.php create mode 100644 database/migrations/2026_08_14_000000_drop_member_module_release_dependencies_table.php delete mode 100644 tests/Feature/MemberModuleReleaseDependencyTest.php diff --git a/app/Http/Controllers/MemberAreaAppController.php b/app/Http/Controllers/MemberAreaAppController.php index e9d85b7e..6fa679df 100644 --- a/app/Http/Controllers/MemberAreaAppController.php +++ b/app/Http/Controllers/MemberAreaAppController.php @@ -1988,47 +1988,6 @@ private function moduleLockPayload(MemberModule $module, Carbon $accessStartAt, return $scheduleLock; } - $user = request()->user(); - if (! $user instanceof User) { - return $scheduleLock; - } - - $dependencies = $module->releaseDependencies() - ->with(['requiredModule.lessons:id,member_module_id']) - ->get(); - if ($dependencies->isEmpty()) { - return $scheduleLock; - } - - $lessonIds = $dependencies - ->flatMap(fn ($dependency) => $dependency->requiredModule?->lessons->pluck('id') ?? []) - ->unique() - ->values(); - $completedLessonIds = MemberLessonProgress::query() - ->where('user_id', $user->id) - ->whereNotNull('completed_at') - ->whereIn('member_lesson_id', $lessonIds) - ->pluck('member_lesson_id') - ->flip(); - - foreach ($dependencies as $dependency) { - $requiredModule = $dependency->requiredModule; - $requiredLessons = $requiredModule?->lessons ?? collect(); - $completedCount = $requiredLessons->filter(fn (MemberLesson $lesson) => isset($completedLessonIds[$lesson->id]))->count(); - $progressPercent = $requiredLessons->isEmpty() - ? 0 - : (int) floor(($completedCount / $requiredLessons->count()) * 100); - $minimumProgressPercent = (int) $dependency->minimum_progress_percent; - if ($progressPercent < $minimumProgressPercent) { - return [ - ...$scheduleLock, - 'is_locked' => true, - 'lock_message' => 'Atinja '.$minimumProgressPercent.'% no módulo '.($requiredModule?->title ?? 'anterior').' para liberar este módulo.', - 'lock_reason' => 'prerequisite', - ]; - } - } - return $scheduleLock; } diff --git a/app/Http/Controllers/MemberBuilderController.php b/app/Http/Controllers/MemberBuilderController.php index ecfc54a8..6d485cac 100644 --- a/app/Http/Controllers/MemberBuilderController.php +++ b/app/Http/Controllers/MemberBuilderController.php @@ -118,7 +118,6 @@ public function index(Product $produto): View|RedirectResponse 'memberSections.modules.lessons', 'memberSections.modules.lessons.releaseDependencies', 'memberSections.modules.relatedProduct', - 'memberSections.modules.releaseDependencies', 'memberInternalProducts.relatedProduct', 'memberTurmas.users:id,name,email', 'memberCommunityPages', @@ -219,10 +218,6 @@ public function index(Product $produto): View|RedirectResponse ? array_values(array_map('intval', $m->release_required_module_ids)) : [], 'access_duration_days' => $m->access_duration_days, - 'release_dependencies' => $m->releaseDependencies->map(fn ($dependency) => [ - 'module_id' => (int) $dependency->required_member_module_id, - 'minimum_progress_percent' => (int) $dependency->minimum_progress_percent, - ])->values()->all(), 'lessons' => $m->lessons->map(fn (MemberLesson $l) => [ 'id' => $l->id, 'title' => $l->title, @@ -726,7 +721,6 @@ private function applyMemberSectionReorder(Product $produto, array $orderedIds): MemberSection::query()->where('product_id', $produto->id)->whereKey($id)->update(['position' => $index + 1]); } - $this->pruneInvalidModuleReleaseDependencies($produto); } /** @param array $orderedIds */ @@ -753,7 +747,6 @@ private function applyMemberModuleReorder(Product $produto, int $sectionId, arra ->update(['position' => $index + 1]); } - $this->pruneInvalidModuleReleaseDependencies($produto); } /** @param array $orderedIds */ @@ -783,22 +776,6 @@ private function applyMemberLessonReorder(Product $produto, int $moduleId, array $this->pruneInvalidLessonReleaseDependencies($module); } - private function pruneInvalidModuleReleaseDependencies(Product $product): void - { - $previousModuleIds = []; - - foreach ($this->orderedCourseModules($product) as $module) { - $dependencies = $module->releaseDependencies(); - if ($previousModuleIds === []) { - $dependencies->delete(); - } else { - $dependencies->whereNotIn('required_member_module_id', $previousModuleIds)->delete(); - } - - $previousModuleIds[] = (int) $module->id; - } - } - private function pruneInvalidLessonReleaseDependencies(MemberModule $module): void { $previousLessonIds = []; @@ -857,12 +834,8 @@ public function storeModule(Request $request, Product $produto, MemberSection $s 'release_required_module_ids' => ['nullable', 'array'], 'release_required_module_ids.*' => ['integer', 'exists:member_modules,id'], 'access_duration_days' => ['nullable', 'integer', 'min:1', 'max:3650'], - 'release_dependencies' => ['nullable', 'array'], - 'release_dependencies.*.module_id' => ['required', 'integer', 'distinct'], - 'release_dependencies.*.minimum_progress_percent' => ['required', 'integer', 'min:1', 'max:100'], ]); $validated = $this->normalizeModuleReleaseFields($validated, (string) $produto->id, null); - $this->assertNewModuleReleaseDependenciesAreValid($produto, $validated['release_dependencies'] ?? []); $max = MemberModule::where('member_section_id', $section->id)->max('position') ?? 0; $module = MemberModule::create([ 'member_section_id' => $section->id, @@ -876,7 +849,6 @@ public function storeModule(Request $request, Product $produto, MemberSection $s 'release_required_module_ids' => $validated['release_required_module_ids'] ?? null, 'access_duration_days' => $validated['access_duration_days'] ?? null, ]); - $this->syncModuleReleaseDependencies($produto, $module, $validated['release_dependencies'] ?? []); } elseif ($sectionType === 'products') { $validated = $request->validate([ 'title' => ['required', 'string', 'max:255'], @@ -1012,10 +984,6 @@ private function moduleJsonPayloadForStore(MemberModule $module): array ? array_values(array_map('intval', $module->release_required_module_ids)) : [], 'access_duration_days' => $module->access_duration_days, - 'release_dependencies' => $module->releaseDependencies()->get()->map(fn ($dependency) => [ - 'module_id' => (int) $dependency->required_member_module_id, - 'minimum_progress_percent' => (int) $dependency->minimum_progress_percent, - ])->values()->all(), 'lessons' => $module->relationLoaded('lessons') ? $module->lessons->map(fn (MemberLesson $l) => [ 'id' => $l->id, 'title' => $l->title, @@ -1049,48 +1017,6 @@ private function moduleJsonPayloadForStore(MemberModule $module): array return $payload; } - /** - * @param array $dependencies - */ - private function syncModuleReleaseDependencies(Product $product, MemberModule $module, array $dependencies): void - { - if ($dependencies === []) { - $module->releaseDependencies()->delete(); - - return; - } - $ids = array_map(fn (array $dependency) => (int) $dependency['module_id'], $dependencies); - $orderedModules = $this->orderedCourseModules($product); - $currentIndex = $orderedModules->search(fn (MemberModule $candidate) => $candidate->id === $module->id); - $allowedIds = $currentIndex === false - ? [] - : $orderedModules->take($currentIndex)->pluck('id')->map(fn ($id) => (int) $id)->all(); - - if (array_diff($ids, $allowedIds) !== []) { - throw ValidationException::withMessages([ - 'release_dependencies' => ['Selecione ao menos um módulo anterior válido.'], - ]); - } - - $module->releaseDependencies()->delete(); - $module->releaseDependencies()->createMany(array_map(fn (array $dependency) => [ - 'required_member_module_id' => (int) $dependency['module_id'], - 'minimum_progress_percent' => (int) $dependency['minimum_progress_percent'], - ], $dependencies)); - } - - /** @param array $dependencies */ - private function assertNewModuleReleaseDependenciesAreValid(Product $product, array $dependencies): void - { - $ids = array_values(array_unique(array_map(fn (array $dependency) => (int) $dependency['module_id'], $dependencies))); - $allowedIds = $this->orderedCourseModules($product)->pluck('id')->map(fn ($id) => (int) $id)->all(); - if (array_diff($ids, $allowedIds) !== []) { - throw ValidationException::withMessages([ - 'release_dependencies' => ['Selecione apenas módulos anteriores válidos.'], - ]); - } - } - /** @return \Illuminate\Support\Collection */ private function orderedCourseModules(Product $product): \Illuminate\Support\Collection { @@ -1163,9 +1089,6 @@ public function updateModule(Request $request, Product $produto, MemberModule $m 'release_required_module_ids' => ['nullable', 'array'], 'release_required_module_ids.*' => ['integer', 'exists:member_modules,id'], 'access_duration_days' => ['nullable', 'integer', 'min:1', 'max:3650'], - 'release_dependencies' => ['nullable', 'array'], - 'release_dependencies.*.module_id' => ['required', 'integer', 'distinct'], - 'release_dependencies.*.minimum_progress_percent' => ['required', 'integer', 'min:1', 'max:100'], ]); if (array_key_exists('release_at_date', $validated) || array_key_exists('release_after_days', $validated) @@ -1205,12 +1128,7 @@ public function updateModule(Request $request, Product $produto, MemberModule $m 'show_title_on_cover' => ['sometimes', 'boolean'], ]); } - $releaseDependencies = $validated['release_dependencies'] ?? null; - unset($validated['release_dependencies']); $module->update($validated); - if ($sectionType === 'courses' && $releaseDependencies !== null) { - $this->syncModuleReleaseDependencies($produto, $module, $releaseDependencies); - } if ($request->expectsJson()) { return response()->json(['message' => 'Módulo atualizado.']); } diff --git a/app/Models/MemberModule.php b/app/Models/MemberModule.php index 8c68e6f3..25acf967 100644 --- a/app/Models/MemberModule.php +++ b/app/Models/MemberModule.php @@ -64,8 +64,4 @@ public function lessons(): HasMany return $this->hasMany(MemberLesson::class, 'member_module_id')->orderBy('position'); } - public function releaseDependencies(): HasMany - { - return $this->hasMany(MemberModuleReleaseDependency::class, 'member_module_id'); - } } diff --git a/app/Models/MemberModuleReleaseDependency.php b/app/Models/MemberModuleReleaseDependency.php deleted file mode 100644 index b33cd976..00000000 --- a/app/Models/MemberModuleReleaseDependency.php +++ /dev/null @@ -1,26 +0,0 @@ - 'integer']; - } - - public function module(): BelongsTo - { - return $this->belongsTo(MemberModule::class, 'member_module_id'); - } - - public function requiredModule(): BelongsTo - { - return $this->belongsTo(MemberModule::class, 'required_member_module_id'); - } -} diff --git a/database/migrations/2026_08_14_000000_drop_member_module_release_dependencies_table.php b/database/migrations/2026_08_14_000000_drop_member_module_release_dependencies_table.php new file mode 100644 index 00000000..5195cff2 --- /dev/null +++ b/database/migrations/2026_08_14_000000_drop_member_module_release_dependencies_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('member_module_id'); + $table->foreignId('required_member_module_id'); + $table->unsignedTinyInteger('minimum_progress_percent')->default(100); + $table->timestamps(); + + $table->unique(['member_module_id', 'required_member_module_id'], 'member_module_release_dependency_unique'); + $table->foreign('member_module_id', 'mm_release_dep_module_fk') + ->references('id')->on('member_modules')->cascadeOnDelete(); + $table->foreign('required_member_module_id', 'mm_release_dep_required_fk') + ->references('id')->on('member_modules')->cascadeOnDelete(); + }); + } +}; diff --git a/resources/js/Pages/Produtos/MemberBuilder/Standalone.vue b/resources/js/Pages/Produtos/MemberBuilder/Standalone.vue index 6b1f40f9..943db3df 100644 --- a/resources/js/Pages/Produtos/MemberBuilder/Standalone.vue +++ b/resources/js/Pages/Produtos/MemberBuilder/Standalone.vue @@ -1002,8 +1002,6 @@ const editingModuleReleaseAtDate = ref(''); const editingModuleReleaseProgressPercent = ref(''); const editingModuleReleaseRequiredModuleIds = ref([]); const editingModuleAccessDurationDays = ref(''); -const editingModuleRequiresPreviousModules = ref(false); -const editingModuleReleaseDependencies = ref([]); const sectionModalOpen = ref(false); const sectionModalTitle = ref(''); @@ -1030,52 +1028,6 @@ const moduleModalReleaseAtDate = ref(''); const moduleModalReleaseProgressPercent = ref(''); const moduleModalReleaseRequiredModuleIds = ref([]); const moduleModalAccessDurationDays = ref(''); -const moduleModalRequiresPreviousModules = ref(false); -const moduleModalReleaseDependencies = ref([]); -const moduleModalDependencyToAdd = ref(''); - -function releaseDependencyModuleOptions(currentModuleId = null) { - // A ordem dos arrays é a fonte da verdade no editor: o draggable a altera - // imediatamente, antes de a API persistir as novas posições. - const modules = (courseStructureSections.value ?? []).flatMap((section) => - (section.section_type ?? 'courses') === 'courses' ? (section.modules ?? []) : [], - ); - if (!currentModuleId) return modules.reverse(); - const index = modules.findIndex((module) => Number(module.id) === Number(currentModuleId)); - return index < 0 ? [] : modules.slice(0, index).reverse(); -} - -const editingModuleReleaseDependencyOptions = computed(() => - releaseDependencyModuleOptions(editingModuleId.value), -); - -watch(editingModuleReleaseDependencyOptions, (options) => { - const validIds = new Set(options.map((module) => Number(module.id))); - const validDependencies = editingModuleReleaseDependencies.value.filter((dependency) => - validIds.has(Number(dependency.module_id)), - ); - if (validDependencies.length !== editingModuleReleaseDependencies.value.length) { - editingModuleReleaseDependencies.value = validDependencies; - } - if (validDependencies.length === 0) { - editingModuleRequiresPreviousModules.value = false; - } -}); - -function addReleaseDependency(dependencies, moduleId) { - const id = Number(moduleId); - if (!id || dependencies.some((dependency) => Number(dependency.module_id) === id)) return; - dependencies.push({ module_id: id, minimum_progress_percent: 100 }); -} - -function removeReleaseDependency(dependencies, moduleId) { - const index = dependencies.findIndex((dependency) => Number(dependency.module_id) === Number(moduleId)); - if (index >= 0) dependencies.splice(index, 1); -} - -function moduleTitle(moduleId) { - return releaseDependencyModuleOptions().find((module) => Number(module.id) === Number(moduleId))?.title ?? 'Módulo'; -} function openSectionEdit(section) { editingSectionTitle.value = section.title; @@ -1102,13 +1054,6 @@ function openModuleEdit(mod) { editingModuleRelatedProductId.value = mod.related_product_id ?? null; editingModuleAccessType.value = mod.access_type ?? 'paid'; editingModuleExternalUrl.value = mod.external_url ?? ''; - editingModuleReleaseDependencies.value = Array.isArray(mod.release_dependencies) - ? mod.release_dependencies.map((dependency) => ({ - module_id: Number(dependency.module_id), - minimum_progress_percent: Number(dependency.minimum_progress_percent ?? 100), - })) - : []; - editingModuleRequiresPreviousModules.value = editingModuleReleaseDependencies.value.length > 0; editingModuleReleaseProgressPercent.value = ''; editingModuleReleaseRequiredModuleIds.value = []; if (mod.release_at_date) { @@ -1182,14 +1127,6 @@ async function saveModuleTitle() { payload.release_progress_percent = null; payload.release_required_module_ids = []; } - if (editingModuleRequiresPreviousModules.value - && editingModuleReleaseDependencies.value.length === 0) { - alert('Selecione ao menos um módulo anterior concluído.'); - return; - } - payload.release_dependencies = editingModuleRequiresPreviousModules.value - ? editingModuleReleaseDependencies.value - : []; } else if (sectionType === 'products') { payload.related_product_id = editingModuleRelatedProductId.value; payload.access_type = editingModuleAccessType.value; @@ -1207,7 +1144,6 @@ async function saveModuleTitle() { const msg = err?.response?.data?.message || err?.response?.data?.errors?.release_required_module_ids?.[0] - || err?.response?.data?.errors?.release_dependencies?.[0] || err?.message || 'Não foi possível salvar o módulo.'; window.alert(msg); @@ -1762,9 +1698,6 @@ function openModuleModal(sectionId) { moduleModalReleaseProgressPercent.value = ''; moduleModalReleaseRequiredModuleIds.value = []; moduleModalAccessDurationDays.value = ''; - moduleModalRequiresPreviousModules.value = false; - moduleModalReleaseDependencies.value = []; - moduleModalDependencyToAdd.value = ''; clearModuleModalFile(); moduleModalOpen.value = true; } @@ -1798,11 +1731,6 @@ async function confirmNewModule() { const sectionType = moduleModalSectionType.value; if (sectionType === 'products' && !moduleModalRelatedProductId.value) return; if (sectionType === 'external_links' && !moduleModalExternalUrl.value?.trim()) return; - if (moduleModalRequiresPreviousModules.value - && moduleModalReleaseDependencies.value.length === 0) { - alert('Selecione ao menos um módulo anterior concluído.'); - return; - } moduleModalSaving.value = true; try { let payload = { title }; @@ -1838,9 +1766,6 @@ async function confirmNewModule() { payload.release_progress_percent = null; payload.release_required_module_ids = []; } - payload.release_dependencies = moduleModalRequiresPreviousModules.value - ? moduleModalReleaseDependencies.value - : []; } else if (sectionType === 'products') { payload.related_product_id = moduleModalRelatedProductId.value; payload.access_type = moduleModalAccessType.value; @@ -2781,9 +2706,6 @@ const inputClass = 'block w-full rounded-lg border border-zinc-300 bg-white px-3 :editing-module-release-progress-percent="editingModuleReleaseProgressPercent" :editing-module-release-required-module-ids="editingModuleReleaseRequiredModuleIds" :editing-module-access-duration-days="editingModuleAccessDurationDays" - :editing-module-requires-previous-modules="editingModuleRequiresPreviousModules" - :editing-module-release-dependencies="editingModuleReleaseDependencies" - :editing-module-release-dependency-options="editingModuleReleaseDependencyOptions" :editing-module-thumbnail="editingModule?.thumbnail" :module-thumbnail-uploading="moduleThumbnailUploading" @open-section-modal="openSectionModal" @@ -2829,8 +2751,6 @@ const inputClass = 'block w-full rounded-lg border border-zinc-300 bg-white px-3 @update:editing-module-release-progress-percent="editingModuleReleaseProgressPercent = $event" @update:editing-module-release-required-module-ids="editingModuleReleaseRequiredModuleIds = $event" @update:editing-module-access-duration-days="editingModuleAccessDurationDays = $event" - @update:editing-module-requires-previous-modules="editingModuleRequiresPreviousModules = $event" - @update:editing-module-release-dependencies="editingModuleReleaseDependencies = $event" /> @@ -3710,39 +3630,6 @@ const inputClass = 'block w-full rounded-lg border border-zinc-300 bg-white px-3

Em dias após a compra. Deixe vazio para acesso ilimitado.

- - -

Crie um módulo anterior para usar esta condição.

diff --git a/resources/js/components/member-builder/MemberBuilderModuleEditor.vue b/resources/js/components/member-builder/MemberBuilderModuleEditor.vue index 1a26f086..30567b55 100644 --- a/resources/js/components/member-builder/MemberBuilderModuleEditor.vue +++ b/resources/js/components/member-builder/MemberBuilderModuleEditor.vue @@ -22,9 +22,6 @@ const props = defineProps({ editingReleaseProgressPercent: { type: String, default: '' }, editingReleaseRequiredModuleIds: { type: Array, default: () => [] }, editingAccessDurationDays: { type: String, default: '' }, - editingRequiresPreviousModules: { type: Boolean, default: false }, - editingReleaseDependencies: { type: Array, default: () => [] }, - editingReleaseDependencyOptions: { type: Array, default: () => [] }, editingThumbnail: { type: String, default: null }, thumbnailUploading: { type: Boolean, default: false }, saving: { type: Boolean, default: false }, @@ -42,42 +39,12 @@ const emit = defineEmits([ 'update:editingReleaseProgressPercent', 'update:editingReleaseRequiredModuleIds', 'update:editingAccessDurationDays', - 'update:editingRequiresPreviousModules', - 'update:editingReleaseDependencies', 'save', 'pick-thumbnail', 'remove-thumbnail', 'set-show-title-on-cover', ]); -const availableDependencyOptions = computed(() => props.editingReleaseDependencyOptions.filter( - (candidate) => !props.editingReleaseDependencies.some((dependency) => Number(dependency.module_id) === Number(candidate.id)), -)); - -function emitDependencies(dependencies) { - emit('update:editingReleaseDependencies', dependencies); -} - -function addDependency(moduleId) { - const id = Number(moduleId); - if (!id || props.editingReleaseDependencies.some((dependency) => Number(dependency.module_id) === id)) return; - emitDependencies([...props.editingReleaseDependencies, { module_id: id, minimum_progress_percent: 100 }]); -} - -function removeDependency(moduleId) { - emitDependencies(props.editingReleaseDependencies.filter((dependency) => Number(dependency.module_id) !== Number(moduleId))); -} - -function updateDependencyProgress(moduleId, value) { - emitDependencies(props.editingReleaseDependencies.map((dependency) => Number(dependency.module_id) === Number(moduleId) - ? { ...dependency, minimum_progress_percent: Number(value) } - : dependency)); -} - -function dependencyTitle(moduleId) { - return props.editingReleaseDependencyOptions.find((candidate) => Number(candidate.id) === Number(moduleId))?.title ?? 'Módulo'; -} - const prerequisiteOptions = computed(() => { const selfId = props.module?.id; return (props.productModules || []).filter((m) => m.id !== selfId); @@ -247,39 +214,6 @@ function toggleRequiredModule(id) {

Em dias após a compra. Deixe vazio para acesso ilimitado.

- - -
-
- {{ dependencyTitle(dependency.module_id) }} - - -
-
-

A primeira opção é o módulo imediatamente anterior. O padrão é 100%.

-

Não há módulo anterior para selecionar.

diff --git a/resources/js/components/member-builder/MemberBuilderModulesTab.vue b/resources/js/components/member-builder/MemberBuilderModulesTab.vue index d494b47f..339fd28f 100644 --- a/resources/js/components/member-builder/MemberBuilderModulesTab.vue +++ b/resources/js/components/member-builder/MemberBuilderModulesTab.vue @@ -41,9 +41,6 @@ const props = defineProps({ editingModuleReleaseProgressPercent: { type: String, default: '' }, editingModuleReleaseRequiredModuleIds: { type: Array, default: () => [] }, editingModuleAccessDurationDays: { type: String, default: '' }, - editingModuleRequiresPreviousModules: { type: Boolean, default: false }, - editingModuleReleaseDependencies: { type: Array, default: () => [] }, - editingModuleReleaseDependencyOptions: { type: Array, default: () => [] }, editingModuleThumbnail: { type: String, default: null }, moduleThumbnailUploading: { type: Boolean, default: false }, }); @@ -92,8 +89,6 @@ const emit = defineEmits([ 'update:editingModuleReleaseProgressPercent', 'update:editingModuleReleaseRequiredModuleIds', 'update:editingModuleAccessDurationDays', - 'update:editingModuleRequiresPreviousModules', - 'update:editingModuleReleaseDependencies', ]); const mobileStep = ref('sections'); @@ -442,9 +437,6 @@ const columnClass = (step) => [ :editing-release-progress-percent="editingModuleReleaseProgressPercent" :editing-release-required-module-ids="editingModuleReleaseRequiredModuleIds" :editing-access-duration-days="editingModuleAccessDurationDays" - :editing-requires-previous-modules="editingModuleRequiresPreviousModules" - :editing-release-dependencies="editingModuleReleaseDependencies" - :editing-release-dependency-options="editingModuleReleaseDependencyOptions" :editing-thumbnail="editingModuleThumbnail" :thumbnail-uploading="moduleThumbnailUploading" :saving="moduleFormSaving" @@ -459,8 +451,6 @@ const columnClass = (step) => [ @update:editing-release-progress-percent="emit('update:editingModuleReleaseProgressPercent', $event)" @update:editing-release-required-module-ids="emit('update:editingModuleReleaseRequiredModuleIds', $event)" @update:editing-access-duration-days="emit('update:editingModuleAccessDurationDays', $event)" - @update:editing-requires-previous-modules="emit('update:editingModuleRequiresPreviousModules', $event)" - @update:editing-release-dependencies="emit('update:editingModuleReleaseDependencies', $event)" @save="emit('save-module')" @pick-thumbnail="emit('pick-module-thumbnail')" @remove-thumbnail="emit('remove-module-thumbnail')" diff --git a/tests/Feature/MemberModuleReleaseDependencyTest.php b/tests/Feature/MemberModuleReleaseDependencyTest.php deleted file mode 100644 index 9a412662..00000000 --- a/tests/Feature/MemberModuleReleaseDependencyTest.php +++ /dev/null @@ -1,111 +0,0 @@ -withoutMiddleware(EnsureInstalled::class); - - $owner = User::factory()->create(['role' => User::ROLE_INFOPRODUTOR, 'tenant_id' => 1]); - $student = User::factory()->create(['role' => User::ROLE_ALUNO, 'tenant_id' => 1]); - $product = $this->createTestProduct([ - 'type' => Product::TYPE_AREA_MEMBROS, - 'checkout_slug' => 'module-rel-'.substr(uniqid('', true), -5), - ]); - $section = MemberSection::create([ - 'product_id' => $product->id, - 'title' => 'Aulas', - 'position' => 1, - 'section_type' => 'courses', - ]); - $firstModule = MemberModule::create([ - 'member_section_id' => $section->id, - 'product_id' => $product->id, - 'title' => 'Fundamentos', - 'position' => 1, - ]); - $firstLesson = MemberLesson::create([ - 'member_module_id' => $firstModule->id, - 'product_id' => $product->id, - 'title' => 'Aula inicial', - 'position' => 1, - 'type' => MemberLesson::TYPE_TEXT, - 'content_text' => '

Conteúdo

', - ]); - MemberLesson::create([ - 'member_module_id' => $firstModule->id, - 'product_id' => $product->id, - 'title' => 'Aula complementar', - 'position' => 2, - 'type' => MemberLesson::TYPE_TEXT, - 'content_text' => '

Conteúdo

', - ]); - - $response = $this->actingAs($owner)->postJson( - route('member-builder.modules.store', ['produto' => $product, 'section' => $section]), - [ - 'title' => 'Avançado', - 'release_at_date' => now()->subDay()->format('Y-m-d'), - 'release_dependencies' => [[ - 'module_id' => $firstModule->id, - 'minimum_progress_percent' => 50, - ]], - ], - ); - - $response->assertOk() - ->assertJsonPath('module.release_dependencies.0.module_id', $firstModule->id) - ->assertJsonPath('module.release_dependencies.0.minimum_progress_percent', 50); - $secondModule = MemberModule::where('title', 'Avançado')->firstOrFail(); - $this->assertDatabaseHas('member_module_release_dependencies', [ - 'member_module_id' => $secondModule->id, - 'required_member_module_id' => $firstModule->id, - 'minimum_progress_percent' => 50, - ]); - - $product->users()->attach($student->id); - $locked = $this->actingAs($student)->get('/m/'.$product->checkout_slug); - $locked->assertOk()->assertInertia(fn ($page) => $page->where('sections.0.modules.1.is_locked', true)); - - $this->actingAs($student)->postJson('/m/'.$product->checkout_slug.'/aula/'.$firstLesson->id.'/complete') - ->assertOk() - ->assertJsonPath('success', true); - - $unlocked = $this->actingAs($student)->get('/m/'.$product->checkout_slug); - $unlocked->assertOk()->assertInertia(fn ($page) => $page->where('sections.0.modules.1.is_locked', false)); - } - - public function test_module_cannot_depend_on_itself_or_a_later_module(): void - { - $this->withoutMiddleware(EnsureInstalled::class); - - $owner = User::factory()->create(['role' => User::ROLE_INFOPRODUTOR, 'tenant_id' => 1]); - $product = $this->createTestProduct(['type' => Product::TYPE_AREA_MEMBROS]); - $section = MemberSection::create([ - 'product_id' => $product->id, - 'title' => 'Aulas', - 'position' => 1, - 'section_type' => 'courses', - ]); - $firstModule = MemberModule::create(['member_section_id' => $section->id, 'product_id' => $product->id, 'title' => 'Primeiro', 'position' => 1]); - $secondModule = MemberModule::create(['member_section_id' => $section->id, 'product_id' => $product->id, 'title' => 'Segundo', 'position' => 2]); - - $this->actingAs($owner)->putJson( - route('member-builder.modules.update', ['produto' => $product, 'module' => $firstModule]), - ['release_after_days' => 1, 'release_dependencies' => [[ - 'module_id' => $secondModule->id, - 'minimum_progress_percent' => 100, - ]]], - )->assertUnprocessable()->assertJsonValidationErrors('release_dependencies'); - } -} diff --git a/tests/Feature/MemberReleaseDependencyMatrixTest.php b/tests/Feature/MemberReleaseDependencyMatrixTest.php index 9621eae8..578736fa 100644 --- a/tests/Feature/MemberReleaseDependencyMatrixTest.php +++ b/tests/Feature/MemberReleaseDependencyMatrixTest.php @@ -20,140 +20,6 @@ protected function setUp(): void $this->withoutMiddleware(EnsureInstalled::class); } - public function test_module_progress_thresholds_use_each_required_modules_lesson_count(): void - { - [$product, $section, $student] = $this->course(); - $required = $this->module($product, $section, 'Base com quatro aulas', 1, 4); - $quarter = $this->module($product, $section, 'Libera com 25%', 2, 1); - $half = $this->module($product, $section, 'Libera com 50%', 3, 1); - $quarter->releaseDependencies()->create([ - 'required_member_module_id' => $required->id, - 'minimum_progress_percent' => 25, - ]); - $half->releaseDependencies()->create([ - 'required_member_module_id' => $required->id, - 'minimum_progress_percent' => 50, - ]); - - $this->assertModuleLocked($product, $student, 1, true); - $this->assertModuleLocked($product, $student, 2, true); - - $this->complete($product, $student, $required->lessons[0]); - - $this->assertModuleLocked($product, $student, 1, false); - $this->assertModuleLocked($product, $student, 2, true); - - $this->complete($product, $student, $required->lessons[1]); - - $this->assertModuleLocked($product, $student, 2, false); - - $otherStudent = User::factory()->create(['role' => User::ROLE_ALUNO, 'tenant_id' => 1]); - $product->users()->attach($otherStudent->id); - $this->assertModuleLocked($product, $otherStudent, 1, true); - } - - public function test_all_selected_module_dependencies_must_reach_their_own_thresholds(): void - { - [$product, $section, $student] = $this->course(); - $first = $this->module($product, $section, 'Primeiro', 1, 4); - $second = $this->module($product, $section, 'Segundo', 2, 2); - $target = $this->module($product, $section, 'Destino', 3, 1); - $target->releaseDependencies()->createMany([ - ['required_member_module_id' => $first->id, 'minimum_progress_percent' => 50], - ['required_member_module_id' => $second->id, 'minimum_progress_percent' => 100], - ]); - - $this->complete($product, $student, $first->lessons[0]); - $this->complete($product, $student, $first->lessons[1]); - $this->complete($product, $student, $second->lessons[0]); - - $this->assertModuleLocked($product, $student, 2, true); - - $this->complete($product, $student, $second->lessons[1]); - - $this->assertModuleLocked($product, $student, 2, false); - } - - public function test_schedule_and_progress_conditions_are_both_required(): void - { - [$product, $section, $student] = $this->course(now()->subDays(8)); - $required = $this->module($product, $section, 'Base', 1, 2); - $target = $this->module($product, $section, 'Após sete dias e 50%', 2, 1, 7); - $target->releaseDependencies()->create([ - 'required_member_module_id' => $required->id, - 'minimum_progress_percent' => 50, - ]); - - $this->assertModuleLocked($product, $student, 1, true, 'Atinja 50%'); - - $this->complete($product, $student, $required->lessons[0]); - - $this->assertModuleLocked($product, $student, 1, false); - - $newStudent = User::factory()->create(['role' => User::ROLE_ALUNO, 'tenant_id' => 1]); - $product->users()->attach($newStudent->id); - $this->complete($product, $newStudent, $required->lessons[0]); - $this->assertModuleLocked($product, $newStudent, 1, true, 'Disponível em 7 dias'); - } - - public function test_a_dependency_on_an_empty_module_never_counts_as_completed(): void - { - [$product, $section, $student] = $this->course(); - $empty = $this->module($product, $section, 'Módulo vazio', 1, 0); - $target = $this->module($product, $section, 'Destino', 2, 1); - $target->releaseDependencies()->create([ - 'required_member_module_id' => $empty->id, - 'minimum_progress_percent' => 1, - ]); - - $this->assertModuleLocked($product, $student, 1, true, 'Atinja 1%'); - } - - public function test_fractional_progress_is_compared_using_the_completed_whole_percent(): void - { - [$product, $section, $student] = $this->course(); - $required = $this->module($product, $section, 'Três aulas', 1, 3); - $at34 = $this->module($product, $section, 'Exige 34%', 2, 1); - $at67 = $this->module($product, $section, 'Exige 67%', 3, 1); - $at34->releaseDependencies()->create([ - 'required_member_module_id' => $required->id, - 'minimum_progress_percent' => 34, - ]); - $at67->releaseDependencies()->create([ - 'required_member_module_id' => $required->id, - 'minimum_progress_percent' => 67, - ]); - - $this->complete($product, $student, $required->lessons[0]); - $this->assertModuleLocked($product, $student, 1, true); - - $this->complete($product, $student, $required->lessons[1]); - $this->assertModuleLocked($product, $student, 1, false); - $this->assertModuleLocked($product, $student, 2, true); - - $this->complete($product, $student, $required->lessons[2]); - $this->assertModuleLocked($product, $student, 2, false); - } - - public function test_release_date_and_progress_conditions_are_both_required(): void - { - [$product, $section, $student] = $this->course(); - $required = $this->module($product, $section, 'Base', 1, 2); - $target = $this->module($product, $section, 'Data e progresso', 2, 1); - $target->update(['release_at_date' => now()->addDay()->format('Y-m-d')]); - $target->releaseDependencies()->create([ - 'required_member_module_id' => $required->id, - 'minimum_progress_percent' => 50, - ]); - $this->complete($product, $student, $required->lessons[0]); - - $this->assertModuleLocked($product, $student, 1, true, 'Disponível em'); - - $target->update(['release_at_date' => now()->subDay()->format('Y-m-d')]); - - $this->assertModuleLocked($product, $student, 1, false); - } - public function test_lesson_schedule_and_previous_lesson_completion_are_both_required(): void { [$product, $section, $student] = $this->course(); @@ -176,53 +42,6 @@ public function test_lesson_schedule_and_previous_lesson_completion_are_both_req ->assertInertia(fn ($page) => $page->where('sections.0.modules.0.lessons.1.is_locked', false)); } - public function test_reordering_modules_and_sections_removes_dependencies_that_are_no_longer_previous(): void - { - [$product, $firstSection, $owner] = $this->course(owner: true); - $secondSection = MemberSection::create([ - 'product_id' => $product->id, - 'title' => 'Segunda seção', - 'position' => 2, - 'section_type' => 'courses', - ]); - $first = $this->module($product, $firstSection, 'Primeiro', 1, 1); - $second = $this->module($product, $firstSection, 'Segundo', 2, 1); - $third = $this->module($product, $secondSection, 'Terceiro', 1, 1); - $second->releaseDependencies()->create([ - 'required_member_module_id' => $first->id, - 'minimum_progress_percent' => 100, - ]); - $third->releaseDependencies()->create([ - 'required_member_module_id' => $second->id, - 'minimum_progress_percent' => 100, - ]); - - $this->actingAs($owner)->postJson(route('member-builder.reorder', $product), [ - 'scope' => 'modules', - 'section_id' => $firstSection->id, - 'ordered_ids' => [$second->id, $first->id], - ])->assertOk(); - - $this->assertDatabaseMissing('member_module_release_dependencies', [ - 'member_module_id' => $second->id, - 'required_member_module_id' => $first->id, - ]); - $this->assertDatabaseHas('member_module_release_dependencies', [ - 'member_module_id' => $third->id, - 'required_member_module_id' => $second->id, - ]); - - $this->actingAs($owner)->postJson(route('member-builder.reorder', $product), [ - 'scope' => 'sections', - 'ordered_ids' => [$secondSection->id, $firstSection->id], - ])->assertOk(); - - $this->assertDatabaseMissing('member_module_release_dependencies', [ - 'member_module_id' => $third->id, - 'required_member_module_id' => $second->id, - ]); - } - public function test_reordering_lessons_removes_dependencies_that_are_no_longer_previous(): void { [$product, $section, $owner] = $this->course(owner: true);