From b17ac703e640a17e31876cfdb2dcadd0be073758 Mon Sep 17 00:00:00 2001 From: John Wesely Date: Tue, 8 Sep 2026 10:14:48 -0600 Subject: [PATCH 1/8] feat: add optional course certificate templates Hosts can assign a certificate-builder template per course without making the builder a hard dependency. Award Blade certificates stay the fallback. Co-authored-by: Cursor --- README.md | 28 +++++- composer.json | 1 + config/filament-lms.php | 11 +++ ..._template_id_to_lms_courses_table.php.stub | 36 ++++++++ phpstan.neon.dist | 4 + src/FilamentLmsServiceProvider.php | 1 + .../Controllers/CertificateController.php | 59 ++++++++++++- src/Models/Course.php | 12 +++ src/Pages/CourseCompleted.php | 2 +- src/Resources/CourseResource.php | 17 ++++ .../CourseResource/Pages/EditCourse.php | 59 +++++++++++++ src/Support/CertificateBuilder.php | 88 +++++++++++++++++++ tests/Feature/CertificateControllerTest.php | 70 +++++++++++++++ tests/TestCase.php | 1 + tests/Unit/CertificateBuilderTest.php | 47 ++++++++++ 15 files changed, 431 insertions(+), 5 deletions(-) create mode 100644 database/migrations/add_certificate_template_id_to_lms_courses_table.php.stub create mode 100644 src/Support/CertificateBuilder.php create mode 100644 tests/Feature/CertificateControllerTest.php create mode 100644 tests/Unit/CertificateBuilderTest.php diff --git a/README.md b/README.md index 3243918e..455e857d 100644 --- a/README.md +++ b/README.md @@ -423,7 +423,33 @@ When `multipart_upload.enabled` is `true` **and** `spykapps/filament-uppy-upload ## Certificate Customization -The LMS package generates PDF certificates when users complete courses. You can customize the appearance and content of certificates using the following configuration options: +The LMS package generates PDF certificates when users complete courses. You can customize the appearance and content of certificates using the following configuration options. + +### Optional certificate-builder templates + +Install [tapp/filament-certificate-builder](https://github.com/TappNetwork/filament-certificate-builder) and enable the integration to assign a custom template per course. When a course has no template (or the package is not installed), the existing award Blade certificate is used. + +```bash +composer require tapp/filament-certificate-builder +php artisan vendor:publish --tag=filament-lms-migrations +php artisan migrate +``` + +```php +// config/filament-lms.php +'integrations' => [ + 'certificate_builder' => [ + 'enabled' => true, + 'token_set' => 'course', + // Point this at your host CertificateTemplateResource if you do not register the package resource. + 'template_resource' => \Tapp\FilamentCertificateBuilder\Filament\Resources\CertificateTemplates\CertificateTemplateResource::class, + ], +], +``` + +Add a matching `course` token set in `config/certificate-builder.php`. LMS resolves tokens with context `['course' => $course, 'user' => $user]` and keeps the `filament-lms::certificates.show` / `filament-lms::certificates.download` routes. + +On **Edit Course**, **Create Certificate Template** creates a template for that token set, sets `certificate_template_id` on the course, and redirects to the designer. When the course already has a template, the action is **Edit Certificate Template** and opens the designer for that template. ### certificate_logo diff --git a/composer.json b/composer.json index 0c4ef4d8..86eb8dbe 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ }, "suggest": { "tapp/filament-library": "Required for Library file / Library link step materials (set filament-lms.integrations.filament_library.enabled for the admin material picker).", + "tapp/filament-certificate-builder": "Optional. Enables custom certificate templates on courses (set filament-lms.integrations.certificate_builder.enabled).", "spykapps/filament-uppy-upload": "Optional. Enables Uppy chunked (multipart) uploads for SCORM package import so large ZIPs stay under Cloudflare's ~100MB limit (set filament-lms.common_cartridge_import.multipart_upload.enabled)." }, "require-dev": { diff --git a/config/filament-lms.php b/config/filament-lms.php index 80a80548..81ce7ac4 100644 --- a/config/filament-lms.php +++ b/config/filament-lms.php @@ -186,6 +186,17 @@ // Max rows returned when searching library files or links on the Step form. 'material_select_limit' => 200, ], + + 'certificate_builder' => [ + // When true (and tapp/filament-certificate-builder is installed), courses may use a custom template. + 'enabled' => false, + + // Token set key from config/certificate-builder.php used for new course templates. + 'token_set' => 'course', + + // Filament resource used after creating a template from Edit Course. + 'template_resource' => \Tapp\FilamentCertificateBuilder\Filament\Resources\CertificateTemplates\CertificateTemplateResource::class, + ], ], /* diff --git a/database/migrations/add_certificate_template_id_to_lms_courses_table.php.stub b/database/migrations/add_certificate_template_id_to_lms_courses_table.php.stub new file mode 100644 index 00000000..80cfd1ee --- /dev/null +++ b/database/migrations/add_certificate_template_id_to_lms_courses_table.php.stub @@ -0,0 +1,36 @@ +unsignedBigInteger('certificate_template_id') + ->nullable() + ->after('award') + ->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('lms_courses', function (Blueprint $table) { + $table->dropIndex(['certificate_template_id']); + $table->dropColumn('certificate_template_id'); + }); + } +}; diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 439c110b..8eaebe17 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -40,3 +40,7 @@ parameters: message: '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model::(getFirstMedia|getSecureUrl)\(\)#' paths: - src/Livewire/LibraryFileStep.php + - src/Livewire/LibraryLinkStep.php + # Optional filament-certificate-builder integration + - '#Class Tapp\\FilamentCertificateBuilder\\#' + - '#unknown class Tapp\\FilamentCertificateBuilder\\#' diff --git a/src/FilamentLmsServiceProvider.php b/src/FilamentLmsServiceProvider.php index 52f235ae..5d56975b 100644 --- a/src/FilamentLmsServiceProvider.php +++ b/src/FilamentLmsServiceProvider.php @@ -72,6 +72,7 @@ public function configurePackage(Package $package): void 'create_lms_course_user_group_table', 'create_lms_user_group_memberships_table', 'add_is_explicitly_assigned_to_lms_course_user_table', + 'add_certificate_template_id_to_lms_courses_table', ]) ->hasCommand(BackfillCourseCompletedAt::class) ->hasCommand(BackfillEmbeddedPlayerCourses::class) diff --git a/src/Http/Controllers/CertificateController.php b/src/Http/Controllers/CertificateController.php index bc8d5136..c5e12271 100644 --- a/src/Http/Controllers/CertificateController.php +++ b/src/Http/Controllers/CertificateController.php @@ -10,10 +10,10 @@ use Illuminate\Support\Str; use Illuminate\View\View; use InvalidArgumentException; -// TODO get from config use Spatie\Browsershot\Browsershot; use Symfony\Component\HttpFoundation\StreamedResponse; use Tapp\FilamentLms\Models\Course; +use Tapp\FilamentLms\Support\CertificateBuilder; class CertificateController extends Controller { @@ -35,10 +35,16 @@ public function show($courseId, $userId): View if (! request()->hasValidSignature() && ! $course->completedByUserAt($userId) && - ! Auth::user()->can('update', $course)) { + ! Auth::user()?->can('update', $course)) { abort(403); } + $builderView = $this->builderCertificateView($course, $user); + + if ($builderView !== null) { + return $builderView; + } + $view = 'filament-lms::certificates.'.$course->award; if (! view()->exists($view)) { @@ -55,7 +61,7 @@ public function show($courseId, $userId): View public function download(Course $course): StreamedResponse { - if (! $course->completedByUserAt(Auth::id()) && ! Auth::user()->can('update', $course)) { + if (! $course->completedByUserAt(Auth::id()) && ! Auth::user()?->can('update', $course)) { abort(403); } @@ -85,4 +91,51 @@ public function browsershot(string $url): Browsershot ->showBackground() ->landscape(); } + + private function builderCertificateView(Course $course, Authenticatable $user): ?View + { + if (! CertificateBuilder::enabled() || $course->certificate_template_id === null) { + return null; + } + + $templateClass = CertificateBuilder::TEMPLATE_MODEL; + $template = $templateClass::query()->find($course->certificate_template_id); + + if ($template === null) { + return null; + } + + return view('filament-certificate-builder::certificate', [ + 'template' => $template, + 'tokens' => $this->tokensForTemplate($template, $course, $user), + ]); + } + + /** + * @return array + */ + private function tokensForTemplate(object $template, Course $course, Authenticatable $user): array + { + $layoutClass = CertificateBuilder::LAYOUT_CLASS; + $resolves = CertificateBuilder::RESOLVES_TOKENS; + + if (! class_exists($layoutClass)) { + return []; + } + + $tokenSet = method_exists($template, 'tokenSet') + ? $template->tokenSet() + : CertificateBuilder::tokenSet(); + + $resolver = app($layoutClass::resolverClass($tokenSet)); + + if (! is_object($resolver) || ! $resolver instanceof $resolves) { + return $layoutClass::sampleTokens($tokenSet); + } + + return $resolver->resolve([ + 'course' => $course, + 'user' => $user, + ]); + } } diff --git a/src/Models/Course.php b/src/Models/Course.php index 2d5e0757..763831c4 100644 --- a/src/Models/Course.php +++ b/src/Models/Course.php @@ -41,6 +41,7 @@ * @property string $external_id * @property string|null $image * @property string|null $award + * @property int|null $certificate_template_id * @property array $award_content * @property string|null $description * @property int|null $required_test_percentage @@ -110,6 +111,17 @@ public function evaluationCourse(): BelongsTo return $this->belongsTo(self::class, 'evaluation_course_id'); } + /** + * @return BelongsTo<\Illuminate\Database\Eloquent\Model, $this> + */ + public function certificateTemplate(): BelongsTo + { + /** @var class-string<\Illuminate\Database\Eloquent\Model> $related */ + $related = 'Tapp\\FilamentCertificateBuilder\\Models\\CertificateTemplate'; + + return $this->belongsTo($related, 'certificate_template_id'); + } + public function hasEvaluation(): bool { return app(CourseEvaluationService::class)->hasEvaluation($this); diff --git a/src/Pages/CourseCompleted.php b/src/Pages/CourseCompleted.php index b860aa49..55ff743a 100644 --- a/src/Pages/CourseCompleted.php +++ b/src/Pages/CourseCompleted.php @@ -172,7 +172,7 @@ public function mount($courseSlug) public function downloadCertificate() { - return response()->download(route('certificates.download', [auth()->user()->id, $this->course->id])); + return response()->download(route('filament-lms::certificates.download', [$this->course->id])); } /** diff --git a/src/Resources/CourseResource.php b/src/Resources/CourseResource.php index 5861d544..a12f7ac9 100644 --- a/src/Resources/CourseResource.php +++ b/src/Resources/CourseResource.php @@ -34,6 +34,7 @@ use Tapp\FilamentLms\Resources\CourseResource\Pages\ListCourses; use Tapp\FilamentLms\Resources\CourseResource\RelationManagers\LessonsRelationManager; use Tapp\FilamentLms\Services\CourseEvaluationService; +use Tapp\FilamentLms\Support\CertificateBuilder; class CourseResource extends Resource { @@ -130,6 +131,22 @@ public static function form(Schema $schema): Schema return null; }) ->helperText('Form must be saved before previewing.'), + Select::make('certificate_template_id') + ->label('Certificate Template') + ->helperText('Optional custom layout. Leave blank to use the award certificate.') + ->options(function (): array { + $model = CertificateBuilder::TEMPLATE_MODEL; + + return $model::query() + ->where('token_set', CertificateBuilder::tokenSet()) + ->orderBy('name') + ->pluck('name', 'id') + ->all(); + }) + ->searchable() + ->preload() + ->nullable() + ->visible(fn (): bool => CertificateBuilder::enabled()), Checkbox::make('embedded_player') ->label('Embedded player mode') ->helperText('Hides LMS step sidebar and uses the SCORM/HTML5 package as the primary navigation.'), diff --git a/src/Resources/CourseResource/Pages/EditCourse.php b/src/Resources/CourseResource/Pages/EditCourse.php index 2c97e68a..ee94fecc 100644 --- a/src/Resources/CourseResource/Pages/EditCourse.php +++ b/src/Resources/CourseResource/Pages/EditCourse.php @@ -2,9 +2,13 @@ namespace Tapp\FilamentLms\Resources\CourseResource\Pages; +use Filament\Actions\Action; use Filament\Actions\DeleteAction; +use Filament\Forms\Components\TextInput; use Filament\Resources\Pages\EditRecord; +use Tapp\FilamentLms\Models\Course; use Tapp\FilamentLms\Resources\CourseResource; +use Tapp\FilamentLms\Support\CertificateBuilder; class EditCourse extends EditRecord { @@ -13,7 +17,62 @@ class EditCourse extends EditRecord protected function getHeaderActions(): array { return [ + Action::make('create_certificate_template') + ->label('Create Certificate Template') + ->icon('heroicon-o-document-duplicate') + ->visible(fn (): bool => CertificateBuilder::canCreateTemplate( + auth()->user(), + $this->getRecord(), + )) + ->schema([ + TextInput::make('name') + ->required() + ->maxLength(255) + ->default(fn (): string => $this->getRecord()->name.' Certificate'), + ]) + ->action(function (array $data): void { + $this->createAndAssociateCertificateTemplate((string) $data['name']); + }), + Action::make('edit_certificate_template') + ->label('Edit Certificate Template') + ->icon('heroicon-o-pencil-square') + ->url(fn (): string => CertificateBuilder::templateEditUrl($this->getRecord()) ?? '#') + ->visible(fn (): bool => CertificateBuilder::canEditTemplate( + auth()->user(), + $this->getRecord(), + )), DeleteAction::make(), ]; } + + public function createAndAssociateCertificateTemplate(string $name): void + { + if (! CertificateBuilder::enabled()) { + return; + } + + $templateClass = CertificateBuilder::TEMPLATE_MODEL; + $layoutClass = CertificateBuilder::LAYOUT_CLASS; + $tokenSet = CertificateBuilder::tokenSet(); + + $layout = class_exists($layoutClass) ? $layoutClass::default($tokenSet) : []; + + $template = $templateClass::query()->create([ + 'name' => $name, + 'token_set' => $tokenSet, + 'layout' => $layout, + ]); + + /** @var Course $course */ + $course = $this->getRecord(); + $course->update([ + 'certificate_template_id' => $template->getKey(), + ]); + + $resource = CertificateBuilder::templateResource(); + + if ($resource !== null) { + $this->redirect($resource::getUrl('edit', ['record' => $template])); + } + } } diff --git a/src/Support/CertificateBuilder.php b/src/Support/CertificateBuilder.php new file mode 100644 index 00000000..1f42b4bc --- /dev/null +++ b/src/Support/CertificateBuilder.php @@ -0,0 +1,88 @@ +can('update', $course) === true; + } + + public static function assignedTemplateId(Course $course): int|string|null + { + $templateId = $course->certificate_template_id; + + if ($templateId === null || ! class_exists(self::TEMPLATE_MODEL)) { + return null; + } + + $exists = self::TEMPLATE_MODEL::query()->whereKey($templateId)->exists(); + + return $exists ? $templateId : null; + } + + public static function templateEditUrl(Course $course): ?string + { + $resource = self::templateResource(); + $templateId = self::assignedTemplateId($course); + + if ($resource === null || $templateId === null) { + return null; + } + + return $resource::getUrl('edit', ['record' => $templateId]); + } + + public static function tokenSet(): string + { + $tokenSet = config('filament-lms.integrations.certificate_builder.token_set', 'course'); + + return is_string($tokenSet) && $tokenSet !== '' ? $tokenSet : 'course'; + } + + /** + * @return class-string|null + */ + public static function templateResource(): ?string + { + $resource = config('filament-lms.integrations.certificate_builder.template_resource'); + + if (! is_string($resource) || $resource === '' || ! class_exists($resource)) { + return null; + } + + return $resource; + } +} diff --git a/tests/Feature/CertificateControllerTest.php b/tests/Feature/CertificateControllerTest.php new file mode 100644 index 00000000..db623d33 --- /dev/null +++ b/tests/Feature/CertificateControllerTest.php @@ -0,0 +1,70 @@ + TestUser::class, + 'auth.providers.users.model' => TestUser::class, + ]); +}); + +it('renders the default award certificate when no template is assigned', function () { + $user = TestUser::query()->create([ + 'name' => 'Jane Doe', + 'email' => 'jane-cert@example.com', + 'password' => bcrypt('password'), + ]); + + $course = Course::factory()->create([ + 'name' => 'Intro to CHW', + 'award' => 'default', + ]); + + $lesson = Lesson::factory()->create(['course_id' => $course->id]); + Step::factory()->create(['lesson_id' => $lesson->id]); + + $course->users()->attach($user->id, ['completed_at' => now()]); + + $this->actingAs($user) + ->get(route('filament-lms::certificates.show', ['course' => $course->id, 'user' => $user->id])) + ->assertSuccessful() + ->assertSee('CERTIFICATE', false) + ->assertSee('Intro to CHW', false) + ->assertSee('Jane Doe', false); +}); + +it('forbids download when the user has not completed the course', function () { + $user = TestUser::query()->create([ + 'name' => 'Pat Learner', + 'email' => 'pat-cert@example.com', + 'password' => bcrypt('password'), + ]); + + $course = Course::factory()->create(['award' => 'default']); + + $this->actingAs($user) + ->get(route('filament-lms::certificates.download', $course)) + ->assertForbidden(); +}); + +it('does not expose the create certificate template action when the builder is disabled', function () { + config(['filament-lms.integrations.certificate_builder.enabled' => false]); + + $user = TestUser::query()->create([ + 'name' => 'Pat Admin', + 'email' => 'pat-admin-cert@example.com', + 'password' => bcrypt('password'), + ]); + $course = Course::factory()->create(['award' => 'default']); + + expect(CertificateBuilder::enabled())->toBeFalse() + ->and(CertificateBuilder::canCreateTemplate($user, $course))->toBeFalse() + ->and(CertificateBuilder::canEditTemplate($user, $course))->toBeFalse(); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 0e924bf2..494df70a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -102,6 +102,7 @@ protected function setUpDatabase($app) $table->string('external_id')->unique(); $table->text('image')->nullable(); $table->string('award')->nullable(); + $table->unsignedBigInteger('certificate_template_id')->nullable()->index(); $table->text('description')->nullable(); $table->unsignedTinyInteger('required_test_percentage')->nullable(); $table->boolean('is_private')->default(false); diff --git a/tests/Unit/CertificateBuilderTest.php b/tests/Unit/CertificateBuilderTest.php new file mode 100644 index 00000000..07ce7e6e --- /dev/null +++ b/tests/Unit/CertificateBuilderTest.php @@ -0,0 +1,47 @@ +toBeFalse(); +}); + +it('is disabled when enabled in config but the builder class is missing', function () { + config(['filament-lms.integrations.certificate_builder.enabled' => true]); + + expect(class_exists(CertificateBuilder::TEMPLATE_MODEL))->toBeFalse() + ->and(CertificateBuilder::enabled())->toBeFalse(); +}); + +it('does not allow creating a template when the builder is disabled', function () { + $user = TestUser::query()->create([ + 'name' => 'Admin', + 'email' => 'admin-cert-builder@example.com', + 'password' => bcrypt('password'), + ]); + + $course = Course::factory()->create(); + + expect(CertificateBuilder::canCreateTemplate($user, $course))->toBeFalse() + ->and(CertificateBuilder::canEditTemplate($user, $course))->toBeFalse(); +}); + +it('returns null for the template resource when the configured class is missing', function () { + expect(CertificateBuilder::templateResource())->toBeNull(); +}); + +it('uses the configured token set and falls back to course', function () { + expect(CertificateBuilder::tokenSet())->toBe('course'); + + config(['filament-lms.integrations.certificate_builder.token_set' => 'training']); + + expect(CertificateBuilder::tokenSet())->toBe('training'); + + config(['filament-lms.integrations.certificate_builder.token_set' => '']); + + expect(CertificateBuilder::tokenSet())->toBe('course'); +}); From 362a3279a7e819e2e1d82a93afadc3ca5d9cf388 Mon Sep 17 00:00:00 2001 From: johnwesely <29612767+johnwesely@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:15:24 +0000 Subject: [PATCH 2/8] Fix styling --- config/filament-lms.php | 3 ++- src/Models/Course.php | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/config/filament-lms.php b/config/filament-lms.php index 81ce7ac4..3bfaa978 100644 --- a/config/filament-lms.php +++ b/config/filament-lms.php @@ -1,6 +1,7 @@ 'default', @@ -195,7 +196,7 @@ 'token_set' => 'course', // Filament resource used after creating a template from Edit Course. - 'template_resource' => \Tapp\FilamentCertificateBuilder\Filament\Resources\CertificateTemplates\CertificateTemplateResource::class, + 'template_resource' => CertificateTemplateResource::class, ], ], diff --git a/src/Models/Course.php b/src/Models/Course.php index 763831c4..1c781442 100644 --- a/src/Models/Course.php +++ b/src/Models/Course.php @@ -112,11 +112,11 @@ public function evaluationCourse(): BelongsTo } /** - * @return BelongsTo<\Illuminate\Database\Eloquent\Model, $this> + * @return BelongsTo */ public function certificateTemplate(): BelongsTo { - /** @var class-string<\Illuminate\Database\Eloquent\Model> $related */ + /** @var class-string $related */ $related = 'Tapp\\FilamentCertificateBuilder\\Models\\CertificateTemplate'; return $this->belongsTo($related, 'certificate_template_id'); From 348504f482f390089a1c34c015cab55f8042c2e7 Mon Sep 17 00:00:00 2001 From: John Wesely Date: Tue, 8 Sep 2026 10:29:58 -0600 Subject: [PATCH 3/8] fix: satisfy PHPStan on course templates Optional builder class-strings and EditRecord typing tripped level 4 analysis. Co-authored-by: Cursor --- src/Models/Course.php | 9 ++------- .../CourseResource/Pages/EditCourse.php | 20 ++++++++++++------- src/Support/CertificateBuilder.php | 4 ++-- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/Models/Course.php b/src/Models/Course.php index 1c781442..669c284a 100644 --- a/src/Models/Course.php +++ b/src/Models/Course.php @@ -31,6 +31,7 @@ use Tapp\FilamentLms\Pages\Dashboard; use Tapp\FilamentLms\Pages\Step as StepPage; use Tapp\FilamentLms\Services\CourseEvaluationService; +use Tapp\FilamentLms\Support\CertificateBuilder; use Tapp\FilamentLms\Traits\HasMediaUrl; use Tapp\FilamentLms\UserGroups\CourseAccessResolver; @@ -111,15 +112,9 @@ public function evaluationCourse(): BelongsTo return $this->belongsTo(self::class, 'evaluation_course_id'); } - /** - * @return BelongsTo - */ public function certificateTemplate(): BelongsTo { - /** @var class-string $related */ - $related = 'Tapp\\FilamentCertificateBuilder\\Models\\CertificateTemplate'; - - return $this->belongsTo($related, 'certificate_template_id'); + return $this->belongsTo(CertificateBuilder::TEMPLATE_MODEL, 'certificate_template_id'); } public function hasEvaluation(): bool diff --git a/src/Resources/CourseResource/Pages/EditCourse.php b/src/Resources/CourseResource/Pages/EditCourse.php index ee94fecc..f7008d2f 100644 --- a/src/Resources/CourseResource/Pages/EditCourse.php +++ b/src/Resources/CourseResource/Pages/EditCourse.php @@ -22,13 +22,13 @@ protected function getHeaderActions(): array ->icon('heroicon-o-document-duplicate') ->visible(fn (): bool => CertificateBuilder::canCreateTemplate( auth()->user(), - $this->getRecord(), + $this->courseRecord(), )) ->schema([ TextInput::make('name') ->required() ->maxLength(255) - ->default(fn (): string => $this->getRecord()->name.' Certificate'), + ->default(fn (): string => $this->courseRecord()->name.' Certificate'), ]) ->action(function (array $data): void { $this->createAndAssociateCertificateTemplate((string) $data['name']); @@ -36,10 +36,10 @@ protected function getHeaderActions(): array Action::make('edit_certificate_template') ->label('Edit Certificate Template') ->icon('heroicon-o-pencil-square') - ->url(fn (): string => CertificateBuilder::templateEditUrl($this->getRecord()) ?? '#') + ->url(fn (): string => CertificateBuilder::templateEditUrl($this->courseRecord()) ?? '#') ->visible(fn (): bool => CertificateBuilder::canEditTemplate( auth()->user(), - $this->getRecord(), + $this->courseRecord(), )), DeleteAction::make(), ]; @@ -63,9 +63,7 @@ public function createAndAssociateCertificateTemplate(string $name): void 'layout' => $layout, ]); - /** @var Course $course */ - $course = $this->getRecord(); - $course->update([ + $this->courseRecord()->update([ 'certificate_template_id' => $template->getKey(), ]); @@ -75,4 +73,12 @@ public function createAndAssociateCertificateTemplate(string $name): void $this->redirect($resource::getUrl('edit', ['record' => $template])); } } + + private function courseRecord(): Course + { + /** @var Course $course */ + $course = $this->getRecord(); + + return $course; + } } diff --git a/src/Support/CertificateBuilder.php b/src/Support/CertificateBuilder.php index 1f42b4bc..3d35c054 100644 --- a/src/Support/CertificateBuilder.php +++ b/src/Support/CertificateBuilder.php @@ -40,7 +40,7 @@ public static function canManageTemplates(?object $user, Course $course): bool && $user->can('update', $course) === true; } - public static function assignedTemplateId(Course $course): int|string|null + public static function assignedTemplateId(Course $course): ?int { $templateId = $course->certificate_template_id; @@ -50,7 +50,7 @@ public static function assignedTemplateId(Course $course): int|string|null $exists = self::TEMPLATE_MODEL::query()->whereKey($templateId)->exists(); - return $exists ? $templateId : null; + return $exists ? (int) $templateId : null; } public static function templateEditUrl(Course $course): ?string From 05a9a158359fcec74b4a113d3f0d53b7a1c5e243 Mon Sep 17 00:00:00 2001 From: John Wesely Date: Tue, 8 Sep 2026 12:43:24 -0600 Subject: [PATCH 4/8] fix: allow template actions without policy LMS ships no Course policy; Laravel denies update and hid Create/Edit Certificate Template on Edit Course. Co-authored-by: Cursor --- src/Support/CertificateBuilder.php | 26 +++++++++-- tests/Unit/CertificateBuilderTest.php | 64 +++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/Support/CertificateBuilder.php b/src/Support/CertificateBuilder.php index 3d35c054..fee17a0f 100644 --- a/src/Support/CertificateBuilder.php +++ b/src/Support/CertificateBuilder.php @@ -4,6 +4,7 @@ namespace Tapp\FilamentLms\Support; +use Illuminate\Support\Facades\Gate; use Tapp\FilamentLms\Models\Course; final class CertificateBuilder @@ -35,9 +36,28 @@ public static function canEditTemplate(?object $user, Course $course): bool public static function canManageTemplates(?object $user, Course $course): bool { return self::enabled() - && $user !== null - && method_exists($user, 'can') - && $user->can('update', $course) === true; + && self::userCanUpdateCourse($user, $course); + } + + public static function userCanUpdateCourse(?object $user, Course $course): bool + { + if ($user === null) { + return false; + } + + $policy = Gate::getPolicyFor($course); + + if ($policy === null) { + return true; + } + + $policyClass = is_object($policy) ? $policy::class : $policy; + + if (! method_exists($policyClass, 'update')) { + return true; + } + + return method_exists($user, 'can') && $user->can('update', $course) === true; } public static function assignedTemplateId(Course $course): ?int diff --git a/tests/Unit/CertificateBuilderTest.php b/tests/Unit/CertificateBuilderTest.php index 07ce7e6e..84e14017 100644 --- a/tests/Unit/CertificateBuilderTest.php +++ b/tests/Unit/CertificateBuilderTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Illuminate\Support\Facades\Gate; use Tapp\FilamentLms\Models\Course; use Tapp\FilamentLms\Support\CertificateBuilder; use Tapp\FilamentLms\Tests\TestUser; @@ -30,6 +31,53 @@ ->and(CertificateBuilder::canEditTemplate($user, $course))->toBeFalse(); }); +it('allows course updates when no policy is registered', function () { + $user = TestUser::query()->create([ + 'name' => 'Admin', + 'email' => 'admin-no-policy@example.com', + 'password' => bcrypt('password'), + ]); + + $course = Course::factory()->create(); + + expect(CertificateBuilder::userCanUpdateCourse(null, $course))->toBeFalse() + ->and(CertificateBuilder::userCanUpdateCourse($user, $course))->toBeTrue(); +}); + +it('defers to a course policy when one exists', function () { + Gate::policy(Course::class, CertificateBuilderCourseUpdatePolicy::class); + + $denied = TestUser::query()->create([ + 'name' => 'Denied', + 'email' => 'denied-course-update@example.com', + 'password' => bcrypt('password'), + ]); + $allowed = TestUser::query()->create([ + 'name' => 'Allowed', + 'email' => 'allowed@example.com', + 'password' => bcrypt('password'), + ]); + + $course = Course::factory()->create(); + + expect(CertificateBuilder::userCanUpdateCourse($denied, $course))->toBeFalse() + ->and(CertificateBuilder::userCanUpdateCourse($allowed, $course))->toBeTrue(); +}); + +it('allows course updates when a policy exists without an update method', function () { + Gate::policy(Course::class, CertificateBuilderCourseViewPolicy::class); + + $user = TestUser::query()->create([ + 'name' => 'Viewer', + 'email' => 'viewer-no-update@example.com', + 'password' => bcrypt('password'), + ]); + + $course = Course::factory()->create(); + + expect(CertificateBuilder::userCanUpdateCourse($user, $course))->toBeTrue(); +}); + it('returns null for the template resource when the configured class is missing', function () { expect(CertificateBuilder::templateResource())->toBeNull(); }); @@ -45,3 +93,19 @@ expect(CertificateBuilder::tokenSet())->toBe('course'); }); + +class CertificateBuilderCourseUpdatePolicy +{ + public function update(object $user, Course $course): bool + { + return $user->email === 'allowed@example.com'; + } +} + +class CertificateBuilderCourseViewPolicy +{ + public function view(object $user, Course $course): bool + { + return true; + } +} From b0144f87cbd6e13e9d7aca3697e3435c2ed7a42e Mon Sep 17 00:00:00 2001 From: John Wesely Date: Wed, 9 Sep 2026 10:34:04 -0600 Subject: [PATCH 5/8] feat: migrate award blades to templates Hosts can convert existing LMS award certificates into builder templates without waiting on pixel-perfect layouts. Co-authored-by: Cursor --- README.md | 10 + ...ateAwardsToCertificateTemplatesCommand.php | 63 +++ src/FilamentLmsServiceProvider.php | 2 + .../MigrateAwardsToCertificateTemplates.php | 176 ++++++++ src/Support/AwardCertificateBlueprint.php | 22 + .../AwardCertificateBlueprintFactory.php | 405 ++++++++++++++++++ src/Support/AwardCertificateLayoutApplier.php | 48 +++ tests/Fakes/FakeCertificateLayout.php | 46 ++ tests/Fakes/FakeCertificateTemplate.php | 35 ++ ...wardsToCertificateTemplatesCommandTest.php | 201 +++++++++ tests/TestCase.php | 8 + .../AwardCertificateBlueprintFactoryTest.php | 107 +++++ .../AwardCertificateLayoutApplierTest.php | 35 ++ .../certificates/custom-award.blade.php | 13 + .../certificates/no-course-name.blade.php | 10 + 15 files changed, 1181 insertions(+) create mode 100644 src/Console/Commands/MigrateAwardsToCertificateTemplatesCommand.php create mode 100644 src/Services/MigrateAwardsToCertificateTemplates.php create mode 100644 src/Support/AwardCertificateBlueprint.php create mode 100644 src/Support/AwardCertificateBlueprintFactory.php create mode 100644 src/Support/AwardCertificateLayoutApplier.php create mode 100644 tests/Fakes/FakeCertificateLayout.php create mode 100644 tests/Fakes/FakeCertificateTemplate.php create mode 100644 tests/Feature/MigrateAwardsToCertificateTemplatesCommandTest.php create mode 100644 tests/Unit/AwardCertificateBlueprintFactoryTest.php create mode 100644 tests/Unit/AwardCertificateLayoutApplierTest.php create mode 100644 tests/fixtures/certificates/custom-award.blade.php create mode 100644 tests/fixtures/certificates/no-course-name.blade.php diff --git a/README.md b/README.md index 455e857d..bfb8f65c 100644 --- a/README.md +++ b/README.md @@ -451,6 +451,16 @@ Add a matching `course` token set in `config/certificate-builder.php`. LMS resol On **Edit Course**, **Create Certificate Template** creates a template for that token set, sets `certificate_template_id` on the course, and redirects to the designer. When the course already has a template, the action is **Edit Certificate Template** and opens the designer for that template. +Migrate existing award Blade certificates into builder templates (logos and copy only; layouts are not pixel-perfect): + +```bash +php artisan filament-lms:migrate-awards-to-templates --dry-run +php artisan filament-lms:migrate-awards-to-templates +php artisan filament-lms:migrate-awards-to-templates --award=safb +``` + +The command reads `config('filament-lms.awards')` plus any `award` values already stored on courses, creates one template per award from the matching `filament-lms::certificates.{award}` view, and assigns it to courses that do not already have a `certificate_template_id`. Background/header images are skipped; `` logos and static text are applied to the default builder layout. Use `--force` to refresh those layouts and reassign courses that already have a template. + ### certificate_logo Specify a custom logo to display on certificates. If not set, it falls back to the `brand_logo` setting. diff --git a/src/Console/Commands/MigrateAwardsToCertificateTemplatesCommand.php b/src/Console/Commands/MigrateAwardsToCertificateTemplatesCommand.php new file mode 100644 index 00000000..8f3e6a94 --- /dev/null +++ b/src/Console/Commands/MigrateAwardsToCertificateTemplatesCommand.php @@ -0,0 +1,63 @@ +error('Certificate builder is not enabled. Install tapp/filament-certificate-builder and set filament-lms.integrations.certificate_builder.enabled to true.'); + + return self::FAILURE; + } + + if ($this->option('dry-run')) { + $this->info('DRY RUN - no changes will be written.'); + } + + $award = $this->option('award'); + + $summary = $migrator->handle( + award: is_string($award) && $award !== '' ? $award : null, + dryRun: (bool) $this->option('dry-run'), + force: (bool) $this->option('force'), + ); + + $this->table( + ['Award', 'Template', 'Created', 'Courses', 'Logos'], + array_map( + fn (array $row): array => [ + $row['award'], + $row['template'], + $row['created'] ? 'yes' : 'no', + $row['courses'], + $row['logos'], + ], + $summary['awards'], + ), + ); + + $this->info('Migration summary: '.json_encode([ + 'templates_created' => $summary['templates_created'], + 'templates_reused' => $summary['templates_reused'], + 'courses_updated' => $summary['courses_updated'], + 'logos_attached' => $summary['logos_attached'], + ], JSON_THROW_ON_ERROR)); + + return self::SUCCESS; + } +} diff --git a/src/FilamentLmsServiceProvider.php b/src/FilamentLmsServiceProvider.php index 5d56975b..bd6eb1a4 100644 --- a/src/FilamentLmsServiceProvider.php +++ b/src/FilamentLmsServiceProvider.php @@ -14,6 +14,7 @@ use Tapp\FilamentLms\Console\Commands\BackfillCourseCompletedAt; use Tapp\FilamentLms\Console\Commands\BackfillEmbeddedPlayerCourses; use Tapp\FilamentLms\Console\Commands\ImportCartridgesCommand; +use Tapp\FilamentLms\Console\Commands\MigrateAwardsToCertificateTemplatesCommand; use Tapp\FilamentLms\Console\Commands\ReconcileUserGroupMemberships; use Tapp\FilamentLms\Livewire\DocumentStep; use Tapp\FilamentLms\Livewire\FormStep; @@ -77,6 +78,7 @@ public function configurePackage(Package $package): void ->hasCommand(BackfillCourseCompletedAt::class) ->hasCommand(BackfillEmbeddedPlayerCourses::class) ->hasCommand(ImportCartridgesCommand::class) + ->hasCommand(MigrateAwardsToCertificateTemplatesCommand::class) ->hasCommand(ReconcileUserGroupMemberships::class) ->hasInstallCommand(function (InstallCommand $command) { $command diff --git a/src/Services/MigrateAwardsToCertificateTemplates.php b/src/Services/MigrateAwardsToCertificateTemplates.php new file mode 100644 index 00000000..7a3678ef --- /dev/null +++ b/src/Services/MigrateAwardsToCertificateTemplates.php @@ -0,0 +1,176 @@ +|null $templateClass + * @param class-string|null $layoutClass + * @return array{ + * templates_created: int, + * templates_reused: int, + * courses_updated: int, + * logos_attached: int, + * awards: list + * } + */ + public function handle( + ?string $award = null, + bool $dryRun = false, + bool $force = false, + ?string $templateClass = null, + ?string $layoutClass = null, + ): array { + $templateClass ??= CertificateBuilder::TEMPLATE_MODEL; + $layoutClass ??= CertificateBuilder::LAYOUT_CLASS; + + if (! class_exists($templateClass)) { + throw new RuntimeException('Certificate builder is not installed.'); + } + + $tokenSet = CertificateBuilder::tokenSet(); + $summary = [ + 'templates_created' => 0, + 'templates_reused' => 0, + 'courses_updated' => 0, + 'logos_attached' => 0, + 'awards' => [], + ]; + + foreach ($this->blueprints->awardKeys($award) as $awardKey) { + $blueprint = $this->blueprints->make($awardKey); + $existing = $templateClass::query() + ->where('name', $blueprint->templateName) + ->first(); + + $created = $existing === null; + $logoCount = 0; + $courseCount = $this->coursesToUpdate($awardKey, $force)->count(); + + if (! $dryRun) { + $template = $existing ?? $templateClass::query()->create([ + 'name' => $blueprint->templateName, + 'token_set' => $tokenSet, + 'layout' => $this->layoutFor($blueprint, $layoutClass, $tokenSet), + ]); + + if ($existing !== null && $force) { + $template->update([ + 'token_set' => $tokenSet, + 'layout' => $this->layoutFor($blueprint, $layoutClass, $tokenSet), + ]); + } + + $logoCount = $this->attachLogos($template, $blueprint, $force); + $courseCount = $this->coursesToUpdate($awardKey, $force)->update([ + 'certificate_template_id' => $template->getKey(), + ]); + } + + $summary['templates_created'] += $created ? 1 : 0; + $summary['templates_reused'] += $created ? 0 : 1; + $summary['courses_updated'] += $courseCount; + $summary['logos_attached'] += $logoCount; + $summary['awards'][] = [ + 'award' => $awardKey, + 'template' => $blueprint->templateName, + 'created' => $created, + 'courses' => $courseCount, + 'logos' => $logoCount, + ]; + } + + return $summary; + } + + /** + * @return Builder + */ + private function coursesToUpdate(string $awardKey, bool $force): Builder + { + $query = Course::query() + ->withoutTenantScope() + ->where('award', $awardKey); + + if (! $force) { + $query->whereNull('certificate_template_id'); + } + + return $query; + } + + /** + * @param class-string|null $layoutClass + * @return array + */ + private function layoutFor(AwardCertificateBlueprint $blueprint, ?string $layoutClass, string $tokenSet): array + { + $layout = class_exists((string) $layoutClass) && method_exists((string) $layoutClass, 'default') + ? $layoutClass::default($tokenSet) + : [ + 'width' => 1050, + 'height' => 774, + 'signature_count' => 2, + 'elements' => [], + ]; + + if (! is_array($layout)) { + $layout = []; + } + + return $this->layouts->apply($layout, $blueprint); + } + + private function attachLogos(Model $template, AwardCertificateBlueprint $blueprint, bool $force): int + { + if (! method_exists($template, 'addMedia')) { + return 0; + } + + $attached = 0; + + foreach (array_slice($blueprint->logoPaths, 0, 3) as $index => $path) { + $absolutePath = $this->blueprints->resolveLogoAbsolutePath($path); + $collection = 'logo_'.($index + 1); + + if ($absolutePath === null) { + continue; + } + + if (! $force && method_exists($template, 'getFirstMedia') && $template->getFirstMedia($collection) !== null) { + continue; + } + + $template->addMedia($absolutePath) + ->preservingOriginal() + ->toMediaCollection($collection); + + $attached++; + } + + return $attached; + } +} diff --git a/src/Support/AwardCertificateBlueprint.php b/src/Support/AwardCertificateBlueprint.php new file mode 100644 index 00000000..e247a596 --- /dev/null +++ b/src/Support/AwardCertificateBlueprint.php @@ -0,0 +1,22 @@ + $logoPaths + */ + public function __construct( + public string $key, + public string $label, + public string $templateName, + public array $logoPaths, + public string $certifyingLine, + public string $completedLine, + public string $description, + public bool $includeCourseName, + ) {} +} diff --git a/src/Support/AwardCertificateBlueprintFactory.php b/src/Support/AwardCertificateBlueprintFactory.php new file mode 100644 index 00000000..210f6b2e --- /dev/null +++ b/src/Support/AwardCertificateBlueprintFactory.php @@ -0,0 +1,405 @@ + + */ + public function awardKeys(?string $only = null): array + { + if (is_string($only) && $only !== '') { + return [$only]; + } + + $configured = array_keys($this->configuredAwards()); + $used = $this->courseQuery() + ->whereNotNull('award') + ->where('award', '!=', '') + ->distinct() + ->orderBy('award') + ->pluck('award') + ->all(); + + $keys = array_values(array_unique([ + ...$configured, + ...array_map(strval(...), $used), + ])); + + sort($keys); + + return $keys; + } + + public function make(string $awardKey): AwardCertificateBlueprint + { + $label = $this->labelFor($awardKey); + $source = $this->viewSource($awardKey); + $copy = $this->copyFrom($source); + + return new AwardCertificateBlueprint( + key: $awardKey, + label: $label, + templateName: $label.' Certificate', + logoPaths: $this->logoPaths($source), + certifyingLine: $copy['certifying_line'], + completedLine: $copy['completed_line'], + description: $copy['description'], + includeCourseName: $this->includesCourseName($source), + ); + } + + public function resolveLogoAbsolutePath(string $path): ?string + { + $parsed = parse_url($path, PHP_URL_PATH); + $relative = ltrim(is_string($parsed) && $parsed !== '' ? $parsed : $path, '/'); + + if ($relative === '') { + return null; + } + + $candidates = [ + public_path($relative), + base_path('public/'.$relative), + ]; + + foreach ($candidates as $candidate) { + if (is_file($candidate)) { + return $candidate; + } + } + + return null; + } + + /** + * @return array + */ + private function configuredAwards(): array + { + $awards = config('filament-lms.awards', ['default' => 'Default']); + + if (! is_array($awards) || $awards === []) { + return ['default' => 'Default']; + } + + $normalized = []; + + foreach ($awards as $key => $label) { + $normalized[(string) $key] = is_string($label) && $label !== '' + ? $label + : Str::headline(str_replace(['-', '_'], ' ', (string) $key)); + } + + return $normalized; + } + + private function labelFor(string $awardKey): string + { + return $this->configuredAwards()[$awardKey] + ?? Str::headline(str_replace(['-', '_'], ' ', $awardKey)); + } + + private function viewSource(string $awardKey): ?string + { + foreach ([$awardKey, 'default'] as $name) { + $contents = $this->contentsFromPath($this->publishedViewPath($name)) + ?? $this->contentsFromView('filament-lms::certificates.'.$name); + + if ($contents !== null) { + return $contents; + } + } + + return null; + } + + private function publishedViewPath(string $awardKey): string + { + return resource_path('views/vendor/filament-lms/certificates/'.$awardKey.'.blade.php'); + } + + private function contentsFromPath(string $path): ?string + { + if (! is_file($path)) { + return null; + } + + $contents = file_get_contents($path); + + return is_string($contents) ? $contents : null; + } + + private function contentsFromView(string $view): ?string + { + if (! view()->exists($view)) { + return null; + } + + try { + $path = view($view)->getPath(); + } catch (Throwable) { + return null; + } + + return $this->contentsFromPath($path); + } + + /** + * @return list + */ + private function logoPaths(?string $source): array + { + $paths = []; + + if (is_string($source) && $source !== '') { + $backgrounds = $this->backgroundAssetPaths($source); + + foreach ($this->imageAssetPaths($source) as $path) { + if (in_array($path, $backgrounds, true) || $this->looksLikeHeaderAsset($path)) { + continue; + } + + $paths[] = $path; + } + } + + if ($paths === []) { + $configured = config('filament-lms.certificate_logo') ?: config('filament-lms.brand_logo'); + + if (is_string($configured) && $configured !== '') { + $paths[] = $configured; + } + } + + return array_values(array_unique($paths)); + } + + /** + * @return list + */ + private function imageAssetPaths(string $source): array + { + preg_match_all( + '/]*\bsrc\s*=\s*[\'"]\s*\{\{\s*asset\(\s*[\'"]([^\'"]+)[\'"]/i', + $source, + $matches, + ); + + return $this->normalizeAssetPaths($matches[1]); + } + + /** + * @return list + */ + private function backgroundAssetPaths(string $source): array + { + preg_match_all( + '/background-image\s*:\s*url\(\s*\{\{\s*asset\(\s*[\'"]([^\'"]+)[\'"]/i', + $source, + $matches, + ); + + return $this->normalizeAssetPaths($matches[1]); + } + + /** + * @param list $paths + * @return list + */ + private function normalizeAssetPaths(array $paths): array + { + $normalized = []; + + foreach ($paths as $path) { + $path = trim($path); + + if ($path === '') { + continue; + } + + $normalized[] = $path; + } + + return $normalized; + } + + private function looksLikeHeaderAsset(string $path): bool + { + return str_contains(Str::lower($path), 'header'); + } + + /** + * @return array{certifying_line: string, completed_line: string, description: string} + */ + private function copyFrom(?string $source): array + { + $defaults = $this->defaultCopy(); + + if (! is_string($source) || $source === '') { + return $defaults; + } + + $certifying = $defaults['certifying_line']; + $completed = $defaults['completed_line']; + $description = $defaults['description']; + $foundCompleted = false; + + foreach ($this->extractCopyStrings($source) as $string) { + if ($this->isTitleCopy($string)) { + continue; + } + + if ($this->isCertifyingCopy($string)) { + $certifying = $string; + + continue; + } + + if ($this->isCompletedCopy($string)) { + $completed = $this->normalizeCompletedCopy($string); + $foundCompleted = true; + + continue; + } + + if (Str::length($string) > 40) { + $description = $string; + } + } + + if ($description !== '' && ! $foundCompleted) { + $completed = ''; + } + + return [ + 'certifying_line' => $certifying, + 'completed_line' => $completed, + 'description' => $description, + ]; + } + + /** + * @return array{certifying_line: string, completed_line: string, description: string} + */ + private function defaultCopy(): array + { + $tokenSet = CertificateBuilder::tokenSet(); + $copy = config('certificate-builder.token_sets.'.$tokenSet.'.default_copy', []); + + if (! is_array($copy)) { + $copy = []; + } + + return [ + 'certifying_line' => is_string($copy['certifying_line'] ?? null) && $copy['certifying_line'] !== '' + ? $copy['certifying_line'] + : 'This certifies that', + 'completed_line' => is_string($copy['completed_line'] ?? null) && $copy['completed_line'] !== '' + ? $copy['completed_line'] + : 'has successfully completed', + 'description' => is_string($copy['description'] ?? null) ? $copy['description'] : '', + ]; + } + + /** + * @return list + */ + private function extractCopyStrings(string $source): array + { + $strings = []; + + preg_match_all('/__\(\s*[\'"](.+?)[\'"]\s*\)/s', $source, $matches); + + foreach ($matches[1] as $string) { + $decoded = trim(stripcslashes((string) $string)); + + if ($decoded !== '') { + $strings[] = $decoded; + } + } + + if (preg_match_all('/]*>(.*?)<\/p>/si', $source, $matches)) { + foreach ($matches[1] as $html) { + $text = $this->plainText((string) $html); + + if ($text !== '') { + $strings[] = $text; + } + } + } + + return $strings; + } + + private function plainText(string $html): string + { + $html = preg_replace('/\{\{.*?\}\}/s', '', $html) ?? $html; + $html = preg_replace('/\{!!.*?!!\}/s', '', $html) ?? $html; + $html = preg_replace('/@\w+(?:\([^)]*\))?/', '', $html) ?? $html; + $text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5); + + return trim(preg_replace('/\s+/', ' ', $text) ?? $text); + } + + private function isTitleCopy(string $string): bool + { + $normalized = Str::upper(trim($string)); + + return in_array($normalized, [ + 'CERTIFICATE', + 'CERTIFICATE OF COMPLETION', + 'OF COMPLETION', + ], true); + } + + private function isCertifyingCopy(string $string): bool + { + $normalized = Str::lower($string); + + return str_contains($normalized, 'awarded to') + || str_contains($normalized, 'certifies that'); + } + + private function isCompletedCopy(string $string): bool + { + $normalized = Str::lower($string); + + return str_contains($normalized, 'successfully completed') + || str_contains($normalized, 'has completed'); + } + + private function normalizeCompletedCopy(string $string): string + { + $normalized = trim(preg_replace('/\s+on\s*\.?$/i', '', $string) ?? $string); + $normalized = trim($normalized, " \t\n\r\0\x0B."); + + return $normalized !== '' ? $normalized : 'has successfully completed'; + } + + private function includesCourseName(?string $source): bool + { + if (! is_string($source) || $source === '') { + return true; + } + + return str_contains($source, '$course->name') + || str_contains($source, '$course[\'name\']'); + } + + /** + * @return Builder + */ + private function courseQuery(): Builder + { + return Course::query()->withoutTenantScope(); + } +} diff --git a/src/Support/AwardCertificateLayoutApplier.php b/src/Support/AwardCertificateLayoutApplier.php new file mode 100644 index 00000000..bf12a21e --- /dev/null +++ b/src/Support/AwardCertificateLayoutApplier.php @@ -0,0 +1,48 @@ + $layout + * @return array + */ + public function apply(array $layout, AwardCertificateBlueprint $blueprint): array + { + $elements = is_array($layout['elements'] ?? null) ? $layout['elements'] : []; + + foreach ($elements as $index => $element) { + if (! is_array($element)) { + continue; + } + + $id = isset($element['id']) && is_string($element['id']) ? $element['id'] : null; + + if ($id === 'certifying_line') { + $elements[$index]['text'] = $blueprint->certifyingLine; + $elements[$index]['visible'] = $blueprint->certifyingLine !== ''; + } + + if ($id === 'completed_line') { + $elements[$index]['text'] = $blueprint->completedLine; + $elements[$index]['visible'] = $blueprint->completedLine !== ''; + } + + if ($id === 'description') { + $elements[$index]['text'] = $blueprint->description; + $elements[$index]['visible'] = $blueprint->description !== ''; + } + + if ($id === 'course_name') { + $elements[$index]['visible'] = $blueprint->includeCourseName; + } + } + + $layout['elements'] = array_values($elements); + + return $layout; + } +} diff --git a/tests/Fakes/FakeCertificateLayout.php b/tests/Fakes/FakeCertificateLayout.php new file mode 100644 index 00000000..aad8fb66 --- /dev/null +++ b/tests/Fakes/FakeCertificateLayout.php @@ -0,0 +1,46 @@ +>} + */ + public static function default(?string $tokenSet = null): array + { + return [ + 'width' => 1050, + 'height' => 774, + 'signature_count' => 2, + 'elements' => [ + [ + 'id' => 'certifying_line', + 'type' => 'text', + 'text' => 'This certifies that', + 'visible' => true, + ], + [ + 'id' => 'completed_line', + 'type' => 'text', + 'text' => 'has successfully completed', + 'visible' => true, + ], + [ + 'id' => 'course_name', + 'type' => 'text', + 'bind' => 'course_name', + 'visible' => true, + ], + [ + 'id' => 'description', + 'type' => 'text', + 'text' => '', + 'visible' => false, + ], + ], + ]; + } +} diff --git a/tests/Fakes/FakeCertificateTemplate.php b/tests/Fakes/FakeCertificateTemplate.php new file mode 100644 index 00000000..c30525f8 --- /dev/null +++ b/tests/Fakes/FakeCertificateTemplate.php @@ -0,0 +1,35 @@ + + */ + protected function casts(): array + { + return [ + 'layout' => 'array', + ]; + } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('logo_1')->singleFile(); + $this->addMediaCollection('logo_2')->singleFile(); + $this->addMediaCollection('logo_3')->singleFile(); + } +} diff --git a/tests/Feature/MigrateAwardsToCertificateTemplatesCommandTest.php b/tests/Feature/MigrateAwardsToCertificateTemplatesCommandTest.php new file mode 100644 index 00000000..9d31fd21 --- /dev/null +++ b/tests/Feature/MigrateAwardsToCertificateTemplatesCommandTest.php @@ -0,0 +1,201 @@ +artisan('filament-lms:migrate-awards-to-templates') + ->expectsOutputToContain('Certificate builder is not enabled') + ->assertFailed(); +}); + +it('creates a template per award and assigns untemplated courses', function () { + config([ + 'filament-lms.awards' => [ + 'default' => 'Default', + 'decan' => 'Delaware Contraceptive Access Network', + ], + ]); + + publishAwardFixture('decan', 'custom-award.blade.php'); + seedAwardLogo('img/DE_DHSS-logo-red-wide.png'); + + $defaultCourse = Course::factory()->create([ + 'name' => 'Default Award Course', + 'external_id' => 'default_award_course', + 'award' => 'default', + 'certificate_template_id' => null, + ]); + $decanCourse = Course::factory()->create([ + 'name' => 'Decan Award Course', + 'external_id' => 'decan_award_course', + 'award' => 'decan', + 'certificate_template_id' => null, + ]); + $alreadyAssigned = Course::factory()->create([ + 'name' => 'Already Assigned Course', + 'external_id' => 'already_assigned_course', + 'award' => 'decan', + 'certificate_template_id' => 99, + ]); + + $summary = app(MigrateAwardsToCertificateTemplates::class)->handle( + templateClass: FakeCertificateTemplate::class, + layoutClass: FakeCertificateLayout::class, + ); + + $defaultTemplate = FakeCertificateTemplate::query()->where('name', 'Default Certificate')->first(); + $decanTemplate = FakeCertificateTemplate::query() + ->where('name', 'Delaware Contraceptive Access Network Certificate') + ->first(); + + expect($summary['templates_created'])->toBe(2) + ->and($summary['courses_updated'])->toBe(2) + ->and($defaultTemplate)->not->toBeNull() + ->and($decanTemplate)->not->toBeNull() + ->and($decanTemplate->token_set)->toBe('course') + ->and($decanTemplate->layout['elements'])->toContain( + ['id' => 'certifying_line', 'type' => 'text', 'text' => 'AWARDED TO:', 'visible' => true], + ) + ->and($decanTemplate->getFirstMedia('logo_1'))->not->toBeNull() + ->and($defaultCourse->refresh()->certificate_template_id)->toBe($defaultTemplate->id) + ->and($decanCourse->refresh()->certificate_template_id)->toBe($decanTemplate->id) + ->and($alreadyAssigned->refresh()->certificate_template_id)->toBe(99); +}); + +it('is idempotent', function () { + config(['filament-lms.awards' => [ + 'decan' => 'Delaware Contraceptive Access Network', + ]]); + + publishAwardFixture('decan', 'custom-award.blade.php'); + + Course::factory()->create([ + 'name' => 'Decan Course', + 'external_id' => 'decan_idempotent', + 'award' => 'decan', + ]); + + $migrator = app(MigrateAwardsToCertificateTemplates::class); + + $migrator->handle( + templateClass: FakeCertificateTemplate::class, + layoutClass: FakeCertificateLayout::class, + ); + $second = $migrator->handle( + templateClass: FakeCertificateTemplate::class, + layoutClass: FakeCertificateLayout::class, + ); + + expect(FakeCertificateTemplate::query()->count())->toBe(1) + ->and($second['templates_created'])->toBe(0) + ->and($second['templates_reused'])->toBe(1) + ->and($second['courses_updated'])->toBe(0); +}); + +it('does not write during a dry run', function () { + config(['filament-lms.awards' => [ + 'decan' => 'Delaware Contraceptive Access Network', + ]]); + + $course = Course::factory()->create([ + 'name' => 'Dry Run Course', + 'external_id' => 'dry_run_course', + 'award' => 'decan', + ]); + + $summary = app(MigrateAwardsToCertificateTemplates::class)->handle( + dryRun: true, + templateClass: FakeCertificateTemplate::class, + layoutClass: FakeCertificateLayout::class, + ); + + expect($summary['templates_created'])->toBe(1) + ->and($summary['courses_updated'])->toBe(1) + ->and(FakeCertificateTemplate::query()->count())->toBe(0) + ->and($course->refresh()->certificate_template_id)->toBeNull(); +}); + +it('can migrate a single award key', function () { + config(['filament-lms.awards' => [ + 'default' => 'Default', + 'decan' => 'Delaware Contraceptive Access Network', + ]]); + + Course::factory()->create([ + 'name' => 'Default Only Course', + 'external_id' => 'default_only_course', + 'award' => 'default', + ]); + $decanCourse = Course::factory()->create([ + 'name' => 'Decan Only Course', + 'external_id' => 'decan_only_course', + 'award' => 'decan', + ]); + + $summary = app(MigrateAwardsToCertificateTemplates::class)->handle( + award: 'decan', + templateClass: FakeCertificateTemplate::class, + layoutClass: FakeCertificateLayout::class, + ); + + expect($summary['awards'])->toHaveCount(1) + ->and($summary['awards'][0]['award'])->toBe('decan') + ->and(FakeCertificateTemplate::query()->count())->toBe(1) + ->and($decanCourse->refresh()->certificate_template_id)->not->toBeNull(); +}); + +it('reassigns existing templates when forced', function () { + config(['filament-lms.awards' => [ + 'decan' => 'Delaware Contraceptive Access Network', + ]]); + + publishAwardFixture('decan', 'custom-award.blade.php'); + + $existing = FakeCertificateTemplate::query()->create([ + 'name' => 'Delaware Contraceptive Access Network Certificate', + 'token_set' => 'training', + 'layout' => ['elements' => []], + ]); + + $course = Course::factory()->create([ + 'name' => 'Force Reassign Course', + 'external_id' => 'force_reassign_course', + 'award' => 'decan', + 'certificate_template_id' => 99, + ]); + + app(MigrateAwardsToCertificateTemplates::class)->handle( + force: true, + templateClass: FakeCertificateTemplate::class, + layoutClass: FakeCertificateLayout::class, + ); + + expect(FakeCertificateTemplate::query()->count())->toBe(1) + ->and($existing->refresh()->token_set)->toBe('course') + ->and($existing->layout['elements'])->not->toBeEmpty() + ->and($course->refresh()->certificate_template_id)->toBe($existing->id); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 494df70a..a4e1433a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -113,6 +113,14 @@ protected function setUpDatabase($app) $table->softDeletes(); }); + $app['db']->connection()->getSchemaBuilder()->create('certificate_templates', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('token_set')->default('default')->index(); + $table->json('layout')->nullable(); + $table->timestamps(); + }); + // Create lms_lessons table $app['db']->connection()->getSchemaBuilder()->create('lms_lessons', function (Blueprint $table) { $table->id(); diff --git a/tests/Unit/AwardCertificateBlueprintFactoryTest.php b/tests/Unit/AwardCertificateBlueprintFactoryTest.php new file mode 100644 index 00000000..fcbd4ffe --- /dev/null +++ b/tests/Unit/AwardCertificateBlueprintFactoryTest.php @@ -0,0 +1,107 @@ + [ + 'default' => 'Default', + 'safb' => 'Safe Arms For Babies', + ]]); + + Course::factory()->create([ + 'name' => 'Used Custom Award Course', + 'external_id' => 'used_custom_award', + 'award' => 'decan', + ]); + + $keys = app(AwardCertificateBlueprintFactory::class)->awardKeys(); + + expect($keys)->toBe(['decan', 'default', 'safb']); +}); + +it('limits discovery to an explicit award key', function () { + config(['filament-lms.awards' => [ + 'default' => 'Default', + 'safb' => 'Safe Arms For Babies', + ]]); + + expect(app(AwardCertificateBlueprintFactory::class)->awardKeys('safb'))->toBe(['safb']); +}); + +it('extracts logos and copy from a custom award blade', function () { + config(['filament-lms.awards' => [ + 'decan' => 'Delaware Contraceptive Access Network', + ]]); + + publishAwardView('decan', 'custom-award.blade.php'); + + $blueprint = app(AwardCertificateBlueprintFactory::class)->make('decan'); + + expect($blueprint->key)->toBe('decan') + ->and($blueprint->label)->toBe('Delaware Contraceptive Access Network') + ->and($blueprint->templateName)->toBe('Delaware Contraceptive Access Network Certificate') + ->and($blueprint->logoPaths)->toBe(['/img/DE_DHSS-logo-red-wide.png']) + ->and($blueprint->certifyingLine)->toBe('AWARDED TO:') + ->and($blueprint->completedLine)->toBe('') + ->and($blueprint->description)->toContain('Delaware Contraceptive Access Now') + ->and($blueprint->includeCourseName)->toBeTrue(); +}); + +it('hides the course name when the award blade does not print it', function () { + config(['filament-lms.awards' => [ + 'family-support-specialist' => 'Family Support Specialist Onboarding', + ]]); + + publishAwardView('family-support-specialist', 'no-course-name.blade.php'); + + $blueprint = app(AwardCertificateBlueprintFactory::class)->make('family-support-specialist'); + + expect($blueprint->includeCourseName)->toBeFalse() + ->and($blueprint->logoPaths)->toBe(['/img/DPH_logo_family.png']) + ->and($blueprint->certifyingLine)->toBe('AWARDED TO:') + ->and($blueprint->description)->toContain('Family Support Specialist'); +}); + +it('uses the default award copy and configured logo', function () { + config([ + 'filament-lms.certificate_logo' => 'images/certificate-logo.png', + 'certificate-builder.token_sets.course.default_copy' => [ + 'certifying_line' => 'This certifies that', + 'completed_line' => 'has successfully completed', + 'description' => '', + ], + ]); + + $blueprint = app(AwardCertificateBlueprintFactory::class)->make('default'); + + expect($blueprint->templateName)->toBe('Default Certificate') + ->and($blueprint->logoPaths)->toBe(['images/certificate-logo.png']) + ->and($blueprint->certifyingLine)->toBe('This certificate is awarded to') + ->and($blueprint->completedLine)->toBe('successfully completed') + ->and($blueprint->includeCourseName)->toBeTrue(); +}); + +it('resolves a logo that exists in the public directory', function () { + $relative = 'img/award-logo.png'; + $absolute = public_path($relative); + + File::ensureDirectoryExists(dirname($absolute)); + File::put($absolute, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==')); + + expect(app(AwardCertificateBlueprintFactory::class)->resolveLogoAbsolutePath('/'.$relative)) + ->toBe($absolute) + ->and(app(AwardCertificateBlueprintFactory::class)->resolveLogoAbsolutePath('missing/logo.png')) + ->toBeNull(); +}); diff --git a/tests/Unit/AwardCertificateLayoutApplierTest.php b/tests/Unit/AwardCertificateLayoutApplierTest.php new file mode 100644 index 00000000..27b68072 --- /dev/null +++ b/tests/Unit/AwardCertificateLayoutApplierTest.php @@ -0,0 +1,35 @@ +apply( + FakeCertificateLayout::default('course'), + $blueprint, + ); + + $byId = collect($layout['elements'])->keyBy('id'); + + expect($byId['certifying_line']['text'])->toBe('AWARDED TO:') + ->and($byId['certifying_line']['visible'])->toBeTrue() + ->and($byId['completed_line']['text'])->toBe('') + ->and($byId['completed_line']['visible'])->toBeFalse() + ->and($byId['description']['text'])->toBe('Award-specific description.') + ->and($byId['description']['visible'])->toBeTrue() + ->and($byId['course_name']['visible'])->toBeFalse(); +}); diff --git a/tests/fixtures/certificates/custom-award.blade.php b/tests/fixtures/certificates/custom-award.blade.php new file mode 100644 index 00000000..fed00ef0 --- /dev/null +++ b/tests/fixtures/certificates/custom-award.blade.php @@ -0,0 +1,13 @@ +
+

{{ $course->name }}

+

{{ __('CERTIFICATE OF COMPLETION') }}

+

{{ __('AWARDED TO:') }}

+

{{ $user->name }}

+

{{ $dateEarned }}

+

+ This certificate recognizes your commitment to promoting inclusive and accessible reproductive healthcare as a valued participant in the Delaware Contraceptive Access Now training. +

+

+ +

+
diff --git a/tests/fixtures/certificates/no-course-name.blade.php b/tests/fixtures/certificates/no-course-name.blade.php new file mode 100644 index 00000000..3b71300f --- /dev/null +++ b/tests/fixtures/certificates/no-course-name.blade.php @@ -0,0 +1,10 @@ +
+

{{ __('AWARDED TO:') }}

+

{{ $user->name }}

+

+ This certificate recognizes your successful completion of the Family Support Specialist onboarding program. +

+

+ +

+
From 78bbae41b91e73e3a54f5ad4181dc5e4eb09e89b Mon Sep 17 00:00:00 2001 From: John Wesely Date: Fri, 11 Sep 2026 12:12:22 -0600 Subject: [PATCH 6/8] feat!: require certificate templates on courses Award Blades become templates on upgrade, then award is dropped. Hosts run filament-lms:upgrade-awards and migrate. Co-authored-by: Cursor --- README.md | 23 +-- UPGRADING.md | 58 +++++++ composer.json | 2 +- config/filament-lms.php | 7 +- database/factories/CourseFactory.php | 10 +- ...drop_award_from_lms_courses_table.php.stub | 55 +++++++ ...ateAwardsToCertificateTemplatesCommand.php | 59 ++----- src/Console/Commands/UpgradeAwardsCommand.php | 94 +++++++++++ src/FilamentLmsServiceProvider.php | 3 + .../Controllers/CertificateController.php | 18 +-- src/Models/Course.php | 3 - src/Resources/CourseResource.php | 29 ++-- .../CommonCartridgeImportService.php | 6 +- .../MigrateAwardsToCertificateTemplates.php | 123 ++++++++++++-- src/Support/AwardCertificateBlueprint.php | 6 + .../AwardCertificateBlueprintFactory.php | 144 +++++++++++++++++ src/Support/AwardCertificateLayoutApplier.php | 152 +++++++++++++++++- src/Support/CertificateBuilder.php | 35 +++- tests/Fakes/FakeCertificateLayout.php | 12 ++ tests/Fakes/FakeCertificateTemplate.php | 1 + tests/Feature/CertificateControllerTest.php | 23 +-- ...wardsToCertificateTemplatesCommandTest.php | 77 +++++++-- tests/Feature/StepRenderedTextTest.php | 2 +- .../AwardCertificateBlueprintFactoryTest.php | 72 ++++++++- .../AwardCertificateLayoutApplierTest.php | 134 ++++++++++++++- .../certificates/custom-award.blade.php | 2 + .../header-banner-award.blade.php | 7 + .../certificates/signed-award.blade.php | 8 + .../certificates/solid-border-award.blade.php | 5 + upgrade.md | 1 + 30 files changed, 1005 insertions(+), 166 deletions(-) create mode 100644 UPGRADING.md create mode 100644 database/migrations/drop_award_from_lms_courses_table.php.stub create mode 100644 src/Console/Commands/UpgradeAwardsCommand.php create mode 100644 tests/fixtures/certificates/header-banner-award.blade.php create mode 100644 tests/fixtures/certificates/signed-award.blade.php create mode 100644 tests/fixtures/certificates/solid-border-award.blade.php create mode 100644 upgrade.md diff --git a/README.md b/README.md index bfb8f65c..3191cbd8 100644 --- a/README.md +++ b/README.md @@ -313,9 +313,7 @@ return [ 'vite_theme' => '', 'colors' => [], - 'awards' => [ - 'Default' => 'default', - ], + // awards was removed. Course certificates use certificate_template_id. 'top_navigation' => false, 'show_exit_lms_link' => true, ]; @@ -425,9 +423,9 @@ When `multipart_upload.enabled` is `true` **and** `spykapps/filament-uppy-upload The LMS package generates PDF certificates when users complete courses. You can customize the appearance and content of certificates using the following configuration options. -### Optional certificate-builder templates +### Certificate-builder templates -Install [tapp/filament-certificate-builder](https://github.com/TappNetwork/filament-certificate-builder) and enable the integration to assign a custom template per course. When a course has no template (or the package is not installed), the existing award Blade certificate is used. +Course certificates require [tapp/filament-certificate-builder](https://github.com/TappNetwork/filament-certificate-builder). See [UPGRADING.md](UPGRADING.md) to convert legacy `award` Blades. ```bash composer require tapp/filament-certificate-builder @@ -441,25 +439,14 @@ php artisan migrate 'certificate_builder' => [ 'enabled' => true, 'token_set' => 'course', - // Point this at your host CertificateTemplateResource if you do not register the package resource. 'template_resource' => \Tapp\FilamentCertificateBuilder\Filament\Resources\CertificateTemplates\CertificateTemplateResource::class, ], ], ``` -Add a matching `course` token set in `config/certificate-builder.php`. LMS resolves tokens with context `['course' => $course, 'user' => $user]` and keeps the `filament-lms::certificates.show` / `filament-lms::certificates.download` routes. - -On **Edit Course**, **Create Certificate Template** creates a template for that token set, sets `certificate_template_id` on the course, and redirects to the designer. When the course already has a template, the action is **Edit Certificate Template** and opens the designer for that template. - -Migrate existing award Blade certificates into builder templates (logos and copy only; layouts are not pixel-perfect): - -```bash -php artisan filament-lms:migrate-awards-to-templates --dry-run -php artisan filament-lms:migrate-awards-to-templates -php artisan filament-lms:migrate-awards-to-templates --award=safb -``` +Add a matching `course` token set in `config/certificate-builder.php`. LMS resolves tokens with context `['course' => $course, 'user' => $user]` on `filament-lms::certificates.show` / `filament-lms::certificates.download`. A course without a template returns 404. -The command reads `config('filament-lms.awards')` plus any `award` values already stored on courses, creates one template per award from the matching `filament-lms::certificates.{award}` view, and assigns it to courses that do not already have a `certificate_template_id`. Background/header images are skipped; `` logos and static text are applied to the default builder layout. Use `--force` to refresh those layouts and reassign courses that already have a template. +On **Edit Course**, **Create Certificate Template** creates a template for that token set, sets `certificate_template_id`, and opens the designer. **Edit Certificate Template** opens the assigned template. ### certificate_logo diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 00000000..989ee372 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,58 @@ +# Upgrading + +## Award certificates → certificate templates + +This is a breaking change. Course certificates are builder-only. The `lms_courses.award` column is removed. + +### Before you deploy + +1. Require the builder in the host app: + +```bash +composer require tapp/filament-certificate-builder +``` + +2. Keep `filament-lms.awards` in the host config until `php artisan migrate` has run. The upgrade reads those labels (and any `award` values already on courses) to name templates. After migrate succeeds you can delete the `awards` array. + +3. Keep published award Blades on disk until migrate has run (`resources/views/vendor/filament-lms/certificates/*.blade.php`). They are the source for logos, copy, borders, and headers. + +4. Enable the course token set in `config/certificate-builder.php` and point `filament-lms.integrations.certificate_builder.template_resource` at your Filament resource. + +### Upgrade + +```bash +php artisan filament-lms:upgrade-awards --dry-run +php artisan migrate +``` + +`php artisan migrate` runs `drop_award_from_lms_courses_table`, which: + +1. Creates or reuses one certificate template per award key +2. Assigns a template to every course that does not already have a living `certificate_template_id` (`award = null` uses **Default Certificate**) +3. Does **not** overwrite hand-edited templates or retarget courses that already point at a different template +4. Fails if any course still lacks a template +5. Makes `certificate_template_id` required (`restrictOnDelete`) and drops `award` + +Preview or force-refresh migrated layouts (not custom templates) with: + +```bash +php artisan filament-lms:upgrade-awards --dump=storage/logs/lms-award-upgrade.json +php artisan filament-lms:upgrade-awards --force +php artisan filament-lms:upgrade-awards --award=decan +``` + +`filament-lms:migrate-awards-to-templates` is a hidden alias and will be removed in a later release. + +### After migrate + +- Delete published LMS award Blades and any Tailwind `@source` aimed only at those views +- Remove `awards` from `config/filament-lms.php` +- Point SCORM imports and new courses at a certificate template (the course form and `CourseFactory` default to **Default Certificate**) +- Training / certification certificates (CHECK `awardCertification()`) are unchanged + +### Notes + +- Layouts are not pixel-perfect copies of the old Blades +- `--force` only refreshes templates named `{Award label} Certificate` +- Courses with `award` values that had no Blade (for example CHECK `chwad`) get the default Blade source +- Rollback cannot restore per-course `award` values unless you keep the `--dump` JSON diff --git a/composer.json b/composer.json index 86eb8dbe..17447690 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ }, "suggest": { "tapp/filament-library": "Required for Library file / Library link step materials (set filament-lms.integrations.filament_library.enabled for the admin material picker).", - "tapp/filament-certificate-builder": "Optional. Enables custom certificate templates on courses (set filament-lms.integrations.certificate_builder.enabled).", + "tapp/filament-certificate-builder": "Required for course certificates. Install before running filament-lms:upgrade-awards.", "spykapps/filament-uppy-upload": "Optional. Enables Uppy chunked (multipart) uploads for SCORM package import so large ZIPs stay under Cloudflare's ~100MB limit (set filament-lms.common_cartridge_import.multipart_upload.enabled)." }, "require-dev": { diff --git a/config/filament-lms.php b/config/filament-lms.php index 3bfaa978..64dd29bc 100644 --- a/config/filament-lms.php +++ b/config/filament-lms.php @@ -22,9 +22,6 @@ 'vite_theme' => '', 'colors' => [], - 'awards' => [ - 'default' => 'Default', - ], // Enable top navigation on the LMS dashboard (courses list page). // Note: This only affects the dashboard. Course pages always use sidebar navigation. 'top_navigation' => false, @@ -189,8 +186,8 @@ ], 'certificate_builder' => [ - // When true (and tapp/filament-certificate-builder is installed), courses may use a custom template. - 'enabled' => false, + // Required for course certificates. The builder package must be installed. + 'enabled' => true, // Token set key from config/certificate-builder.php used for new course templates. 'token_set' => 'course', diff --git a/database/factories/CourseFactory.php b/database/factories/CourseFactory.php index 1d472a7b..08568fdc 100644 --- a/database/factories/CourseFactory.php +++ b/database/factories/CourseFactory.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; use Tapp\FilamentLms\Models\Course; +use Tapp\FilamentLms\Support\CertificateBuilder; class CourseFactory extends Factory { @@ -23,8 +24,15 @@ public function definition(): array 'name' => $name, 'slug' => $slug, 'external_id' => $externalId, - 'award' => 'default', + 'certificate_template_id' => CertificateBuilder::defaultTemplateId(), 'description' => $this->faker->sentence(), ]; } + + public function withoutCertificateTemplate(): static + { + return $this->state([ + 'certificate_template_id' => null, + ]); + } } diff --git a/database/migrations/drop_award_from_lms_courses_table.php.stub b/database/migrations/drop_award_from_lms_courses_table.php.stub new file mode 100644 index 00000000..625e5aba --- /dev/null +++ b/database/migrations/drop_award_from_lms_courses_table.php.stub @@ -0,0 +1,55 @@ +handle(); + + $unverified = app(MigrateAwardsToCertificateTemplates::class)->unverifiedCourseIds(); + + if ($unverified !== []) { + throw new RuntimeException( + 'Cannot drop lms_courses.award while courses lack a certificate template (ids: '.implode(', ', $unverified).'). Run php artisan filament-lms:upgrade-awards.', + ); + } + + $hasForeignKey = collect(Schema::getForeignKeys('lms_courses')) + ->contains(fn (array $foreignKey): bool => in_array('certificate_template_id', $foreignKey['columns'], true)); + + if ($hasForeignKey) { + Schema::table('lms_courses', function (Blueprint $table): void { + $table->dropForeign(['certificate_template_id']); + }); + } + + Schema::table('lms_courses', function (Blueprint $table): void { + $table->unsignedBigInteger('certificate_template_id')->nullable(false)->change(); + }); + + Schema::table('lms_courses', function (Blueprint $table): void { + $table->foreign('certificate_template_id') + ->references('id') + ->on('certificate_templates') + ->restrictOnDelete(); + }); + + Schema::table('lms_courses', function (Blueprint $table): void { + $table->dropColumn('award'); + }); + } +}; diff --git a/src/Console/Commands/MigrateAwardsToCertificateTemplatesCommand.php b/src/Console/Commands/MigrateAwardsToCertificateTemplatesCommand.php index 8f3e6a94..c61b0736 100644 --- a/src/Console/Commands/MigrateAwardsToCertificateTemplatesCommand.php +++ b/src/Console/Commands/MigrateAwardsToCertificateTemplatesCommand.php @@ -5,59 +5,28 @@ namespace Tapp\FilamentLms\Console\Commands; use Illuminate\Console\Command; -use Tapp\FilamentLms\Services\MigrateAwardsToCertificateTemplates; -use Tapp\FilamentLms\Support\CertificateBuilder; final class MigrateAwardsToCertificateTemplatesCommand extends Command { + protected $hidden = true; + protected $signature = 'filament-lms:migrate-awards-to-templates {--award= : Only migrate this award key} {--dry-run : Show the plan without writing} - {--force : Recreate layouts and reassign courses that already have a template}'; + {--force : Recreate layouts for migrated template names} + {--dump= : Write a JSON summary to this path}'; - protected $description = 'Create certificate templates from LMS award Blade views and assign them to courses'; + protected $description = 'Deprecated alias of filament-lms:upgrade-awards'; - public function handle(MigrateAwardsToCertificateTemplates $migrator): int + public function handle(): int { - if (! CertificateBuilder::enabled()) { - $this->error('Certificate builder is not enabled. Install tapp/filament-certificate-builder and set filament-lms.integrations.certificate_builder.enabled to true.'); - - return self::FAILURE; - } - - if ($this->option('dry-run')) { - $this->info('DRY RUN - no changes will be written.'); - } - - $award = $this->option('award'); - - $summary = $migrator->handle( - award: is_string($award) && $award !== '' ? $award : null, - dryRun: (bool) $this->option('dry-run'), - force: (bool) $this->option('force'), - ); - - $this->table( - ['Award', 'Template', 'Created', 'Courses', 'Logos'], - array_map( - fn (array $row): array => [ - $row['award'], - $row['template'], - $row['created'] ? 'yes' : 'no', - $row['courses'], - $row['logos'], - ], - $summary['awards'], - ), - ); - - $this->info('Migration summary: '.json_encode([ - 'templates_created' => $summary['templates_created'], - 'templates_reused' => $summary['templates_reused'], - 'courses_updated' => $summary['courses_updated'], - 'logos_attached' => $summary['logos_attached'], - ], JSON_THROW_ON_ERROR)); - - return self::SUCCESS; + $this->warn('filament-lms:migrate-awards-to-templates is deprecated. Use filament-lms:upgrade-awards.'); + + return $this->call('filament-lms:upgrade-awards', [ + '--award' => $this->option('award'), + '--dry-run' => $this->option('dry-run'), + '--force' => $this->option('force'), + '--dump' => $this->option('dump'), + ]); } } diff --git a/src/Console/Commands/UpgradeAwardsCommand.php b/src/Console/Commands/UpgradeAwardsCommand.php new file mode 100644 index 00000000..00ca6e03 --- /dev/null +++ b/src/Console/Commands/UpgradeAwardsCommand.php @@ -0,0 +1,94 @@ +error('Certificate builder is not installed. Require tapp/filament-certificate-builder before upgrading.'); + + return 1; + } + + if ($this->option('dry-run')) { + $this->info('DRY RUN - no changes will be written.'); + } + + $award = $this->option('award'); + + try { + $summary = $migrator->handle( + award: is_string($award) && $award !== '' ? $award : null, + dryRun: (bool) $this->option('dry-run'), + force: (bool) $this->option('force'), + ); + } catch (\RuntimeException $exception) { + $this->error($exception->getMessage()); + + return 1; + } + + $this->table( + ['Award', 'Template', 'Created', 'Courses', 'Logos'], + array_map( + fn (array $row): array => [ + $row['award'], + $row['template'], + $row['created'] ? 'yes' : 'no', + $row['courses'], + $row['logos'], + ], + $summary['awards'], + ), + ); + + $payload = [ + 'templates_created' => $summary['templates_created'], + 'templates_reused' => $summary['templates_reused'], + 'courses_updated' => $summary['courses_updated'], + 'logos_attached' => $summary['logos_attached'], + 'awards' => $summary['awards'], + ]; + + $this->info('Upgrade summary: '.json_encode($payload, JSON_THROW_ON_ERROR)); + + $dump = $this->option('dump'); + + if (is_string($dump) && $dump !== '') { + File::ensureDirectoryExists(dirname($dump)); + File::put($dump, json_encode($payload, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); + $this->info('Wrote '.$dump); + } + + if ($this->option('dry-run')) { + return self::SUCCESS; + } + + $unverified = $migrator->unverifiedCourseIds(); + + if ($unverified !== []) { + $this->error('Courses still missing a certificate template: '.implode(', ', $unverified)); + + return 2; + } + + return self::SUCCESS; + } +} diff --git a/src/FilamentLmsServiceProvider.php b/src/FilamentLmsServiceProvider.php index bd6eb1a4..ef268b4d 100644 --- a/src/FilamentLmsServiceProvider.php +++ b/src/FilamentLmsServiceProvider.php @@ -16,6 +16,7 @@ use Tapp\FilamentLms\Console\Commands\ImportCartridgesCommand; use Tapp\FilamentLms\Console\Commands\MigrateAwardsToCertificateTemplatesCommand; use Tapp\FilamentLms\Console\Commands\ReconcileUserGroupMemberships; +use Tapp\FilamentLms\Console\Commands\UpgradeAwardsCommand; use Tapp\FilamentLms\Livewire\DocumentStep; use Tapp\FilamentLms\Livewire\FormStep; use Tapp\FilamentLms\Livewire\ImageStep; @@ -74,11 +75,13 @@ public function configurePackage(Package $package): void 'create_lms_user_group_memberships_table', 'add_is_explicitly_assigned_to_lms_course_user_table', 'add_certificate_template_id_to_lms_courses_table', + 'drop_award_from_lms_courses_table', ]) ->hasCommand(BackfillCourseCompletedAt::class) ->hasCommand(BackfillEmbeddedPlayerCourses::class) ->hasCommand(ImportCartridgesCommand::class) ->hasCommand(MigrateAwardsToCertificateTemplatesCommand::class) + ->hasCommand(UpgradeAwardsCommand::class) ->hasCommand(ReconcileUserGroupMemberships::class) ->hasInstallCommand(function (InstallCommand $command) { $command diff --git a/src/Http/Controllers/CertificateController.php b/src/Http/Controllers/CertificateController.php index c5e12271..82bbc780 100644 --- a/src/Http/Controllers/CertificateController.php +++ b/src/Http/Controllers/CertificateController.php @@ -2,7 +2,6 @@ namespace Tapp\FilamentLms\Http\Controllers; -use Carbon\Carbon; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Auth; @@ -41,22 +40,11 @@ public function show($courseId, $userId): View $builderView = $this->builderCertificateView($course, $user); - if ($builderView !== null) { - return $builderView; + if ($builderView === null) { + abort(404); } - $view = 'filament-lms::certificates.'.$course->award; - - if (! view()->exists($view)) { - $view = 'filament-lms::certificates.default'; - } - - $completedAt = $course->completedByUserAt($userId) ?? now(); - - return view($view) - ->with('dateEarned', $completedAt ? Carbon::parse($completedAt)->format(('F j, Y')) : null) - ->with('user', $user) - ->with('course', $course); + return $builderView; } public function download(Course $course): StreamedResponse diff --git a/src/Models/Course.php b/src/Models/Course.php index 669c284a..6af9dbf7 100644 --- a/src/Models/Course.php +++ b/src/Models/Course.php @@ -41,9 +41,7 @@ * @property string $slug * @property string $external_id * @property string|null $image - * @property string|null $award * @property int|null $certificate_template_id - * @property array $award_content * @property string|null $description * @property int|null $required_test_percentage * @property bool $is_private @@ -68,7 +66,6 @@ final class Course extends Model implements HasMedia protected $table = 'lms_courses'; protected $casts = [ - 'award_content' => 'array', 'is_private' => 'boolean', 'embedded_player' => 'boolean', 'completion_mode' => CompletionMode::class, diff --git a/src/Resources/CourseResource.php b/src/Resources/CourseResource.php index a12f7ac9..97599ccb 100644 --- a/src/Resources/CourseResource.php +++ b/src/Resources/CourseResource.php @@ -116,24 +116,9 @@ public static function form(Schema $schema): Schema ->maxValue(100) ->default(0) ->nullable(), - Select::make('award') - ->options(config('filament-lms.awards')) - ->required() - ->hint(function ($record) { - // @phpstan-ignore-next-line - if ($record && $record->id) { - // @phpstan-ignore-next-line - $link = route('filament-lms::certificates.show', ['course' => $record->id, 'user' => auth()->id()]); - - return new HtmlString("Click to Preview"); - } - - return null; - }) - ->helperText('Form must be saved before previewing.'), Select::make('certificate_template_id') ->label('Certificate Template') - ->helperText('Optional custom layout. Leave blank to use the award certificate.') + ->helperText('Required. Form must be saved before previewing.') ->options(function (): array { $model = CertificateBuilder::TEMPLATE_MODEL; @@ -143,9 +128,19 @@ public static function form(Schema $schema): Schema ->pluck('name', 'id') ->all(); }) + ->default(fn (): ?int => CertificateBuilder::defaultTemplateId()) + ->hint(function (?Course $record) { + if ($record && $record->id) { + $link = route('filament-lms::certificates.show', ['course' => $record->id, 'user' => auth()->id()]); + + return new HtmlString("Click to Preview"); + } + + return null; + }) ->searchable() ->preload() - ->nullable() + ->required() ->visible(fn (): bool => CertificateBuilder::enabled()), Checkbox::make('embedded_player') ->label('Embedded player mode') diff --git a/src/Services/CommonCartridge/CommonCartridgeImportService.php b/src/Services/CommonCartridge/CommonCartridgeImportService.php index 77ac507f..e04d3cb1 100644 --- a/src/Services/CommonCartridge/CommonCartridgeImportService.php +++ b/src/Services/CommonCartridge/CommonCartridgeImportService.php @@ -16,6 +16,7 @@ use Tapp\FilamentLms\Models\Lesson; use Tapp\FilamentLms\Models\Step; use Tapp\FilamentLms\Models\Test; +use Tapp\FilamentLms\Support\CertificateBuilder; final class CommonCartridgeImportService { @@ -156,16 +157,13 @@ private function createCourse(ParsedManifest $manifest, int|string|null $tenantI $externalId = $this->uniqueCourseColumn(Str::slug($manifest->courseTitle, '_'), 'external_id', $tenantId); $name = $this->uniqueCourseColumn($manifest->courseTitle, 'name', $tenantId); - $awards = config('filament-lms.awards', ['default' => 'Default']); - $defaultAward = array_key_first($awards); - $data = [ 'name' => $name, 'slug' => $slug, 'external_id' => $externalId, 'description' => $manifest->courseDescription, 'is_private' => false, - 'award' => $defaultAward, + 'certificate_template_id' => CertificateBuilder::defaultTemplateId(), ]; if (config('filament-lms.tenancy.enabled') && $tenantId !== null) { diff --git a/src/Services/MigrateAwardsToCertificateTemplates.php b/src/Services/MigrateAwardsToCertificateTemplates.php index 7a3678ef..df3968fd 100644 --- a/src/Services/MigrateAwardsToCertificateTemplates.php +++ b/src/Services/MigrateAwardsToCertificateTemplates.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Schema; use RuntimeException; use Tapp\FilamentLms\Models\Course; use Tapp\FilamentLms\Support\AwardCertificateBlueprint; @@ -51,6 +52,20 @@ public function handle( throw new RuntimeException('Certificate builder is not installed.'); } + if (! Schema::hasColumn('lms_courses', 'award')) { + return [ + 'templates_created' => 0, + 'templates_reused' => 0, + 'courses_updated' => 0, + 'logos_attached' => 0, + 'awards' => [], + ]; + } + + if (! Schema::hasColumn('lms_courses', 'certificate_template_id')) { + throw new RuntimeException('lms_courses.certificate_template_id is missing. Publish and run filament-lms migrations first.'); + } + $tokenSet = CertificateBuilder::tokenSet(); $summary = [ 'templates_created' => 0, @@ -68,7 +83,7 @@ public function handle( $created = $existing === null; $logoCount = 0; - $courseCount = $this->coursesToUpdate($awardKey, $force)->count(); + $courseCount = $this->coursesToUpdate($awardKey, $templateClass)->count(); if (! $dryRun) { $template = $existing ?? $templateClass::query()->create([ @@ -84,8 +99,9 @@ public function handle( ]); } - $logoCount = $this->attachLogos($template, $blueprint, $force); - $courseCount = $this->coursesToUpdate($awardKey, $force)->update([ + $logoCount = $this->attachLogos($template, $blueprint, $force) + + $this->attachHeader($template, $blueprint, $force); + $courseCount = $this->coursesToUpdate($awardKey, $templateClass)->update([ 'certificate_template_id' => $template->getKey(), ]); } @@ -103,23 +119,89 @@ public function handle( ]; } + if ($award === null) { + $orphanCount = $this->assignCoursesWithoutAward($templateClass, $layoutClass, $tokenSet, $dryRun); + $summary['courses_updated'] += $orphanCount; + } + return $summary; } /** + * @param class-string $templateClass + * @return list + */ + public function unverifiedCourseIds(?string $templateClass = null): array + { + $templateClass ??= CertificateBuilder::TEMPLATE_MODEL; + + return $this->coursesMissingTemplate($templateClass) + ->pluck('id') + ->all(); + } + + /** + * @param class-string $templateClass * @return Builder */ - private function coursesToUpdate(string $awardKey, bool $force): Builder + private function coursesToUpdate(string $awardKey, string $templateClass): Builder { - $query = Course::query() - ->withoutTenantScope() + return $this->coursesMissingTemplate($templateClass) ->where('award', $awardKey); + } + + /** + * @param class-string $templateClass + * @return Builder + */ + private function coursesMissingTemplate(string $templateClass): Builder + { + return Course::query() + ->withoutTenantScope() + ->where(function (Builder $query) use ($templateClass): void { + $query->whereNull('certificate_template_id') + ->orWhereNotIn('certificate_template_id', $templateClass::query()->select('id')); + }); + } - if (! $force) { - $query->whereNull('certificate_template_id'); + /** + * @param class-string $templateClass + * @param class-string|null $layoutClass + */ + private function assignCoursesWithoutAward( + string $templateClass, + ?string $layoutClass, + string $tokenSet, + bool $dryRun, + ): int { + $query = $this->coursesMissingTemplate($templateClass) + ->where(function (Builder $query): void { + $query->whereNull('award') + ->orWhere('award', ''); + }); + + $count = $query->count(); + + if ($dryRun || $count === 0) { + return $count; } - return $query; + $template = $templateClass::query() + ->where('name', 'Default Certificate') + ->first(); + + if ($template === null) { + $blueprint = $this->blueprints->make('default'); + $template = $templateClass::query()->create([ + 'name' => $blueprint->templateName, + 'token_set' => $tokenSet, + 'layout' => $this->layoutFor($blueprint, $layoutClass, $tokenSet), + ]); + } + + return $query->update([ + 'certificate_template_id' => $template->getKey(), + ]); } /** @@ -173,4 +255,27 @@ private function attachLogos(Model $template, AwardCertificateBlueprint $bluepri return $attached; } + + private function attachHeader(Model $template, AwardCertificateBlueprint $blueprint, bool $force): int + { + if ($blueprint->headerImagePath === null || ! method_exists($template, 'addMedia')) { + return 0; + } + + $absolutePath = $this->blueprints->resolveLogoAbsolutePath($blueprint->headerImagePath); + + if ($absolutePath === null) { + return 0; + } + + if (! $force && method_exists($template, 'getFirstMedia') && $template->getFirstMedia('header') !== null) { + return 0; + } + + $template->addMedia($absolutePath) + ->preservingOriginal() + ->toMediaCollection('header'); + + return 1; + } } diff --git a/src/Support/AwardCertificateBlueprint.php b/src/Support/AwardCertificateBlueprint.php index e247a596..815b7488 100644 --- a/src/Support/AwardCertificateBlueprint.php +++ b/src/Support/AwardCertificateBlueprint.php @@ -8,6 +8,8 @@ { /** * @param list $logoPaths + * @param array $border + * @param array $header */ public function __construct( public string $key, @@ -18,5 +20,9 @@ public function __construct( public string $completedLine, public string $description, public bool $includeCourseName, + public bool $includeSignatures, + public array $border, + public array $header, + public ?string $headerImagePath, ) {} } diff --git a/src/Support/AwardCertificateBlueprintFactory.php b/src/Support/AwardCertificateBlueprintFactory.php index 210f6b2e..1c83bd8c 100644 --- a/src/Support/AwardCertificateBlueprintFactory.php +++ b/src/Support/AwardCertificateBlueprintFactory.php @@ -30,6 +30,7 @@ public function awardKeys(?string $only = null): array ->all(); $keys = array_values(array_unique([ + 'default', ...$configured, ...array_map(strval(...), $used), ])); @@ -54,6 +55,10 @@ public function make(string $awardKey): AwardCertificateBlueprint completedLine: $copy['completed_line'], description: $copy['description'], includeCourseName: $this->includesCourseName($source), + includeSignatures: $this->includesSignatures($source), + border: $this->borderFrom($source), + header: $this->headerFrom($source), + headerImagePath: $this->headerImagePath($source), ); } @@ -395,6 +400,145 @@ private function includesCourseName(?string $source): bool || str_contains($source, '$course[\'name\']'); } + /** + * @return array + */ + private function borderFrom(?string $source): array + { + $border = [ + 'style' => 'double', + 'color' => '#a1a1aa', + 'width' => 8, + 'inner_color' => '#d4d4d8', + 'inner_width' => 4, + 'inner_inset' => 10, + 'gradient' => '', + ]; + + if (! is_string($source) || $source === '') { + return $border; + } + + if (preg_match('/bg-linear-to-r\s+from-([a-z0-9-]+)\s+via-([a-z0-9-]+)\s+to-([a-z0-9-]+)/', $source, $matches) === 1) { + $from = $this->tailwindColor($matches[1]); + $via = $this->tailwindColor($matches[2]); + $to = $this->tailwindColor($matches[3]); + + if ($from !== null && $via !== null && $to !== null) { + return [ + ...$border, + 'style' => 'gradient', + 'width' => 6, + 'color' => $from, + 'gradient' => 'linear-gradient(to right, '.$from.', '.$via.', '.$to.')', + ]; + } + } + + if (preg_match('/\bstyle\s*=\s*[\'"][^\'"]*background:\s*(#[0-9a-fA-F]{3,6})\b/', $source, $matches) === 1) { + return [ + ...$border, + 'style' => 'solid', + 'color' => $matches[1], + 'width' => 6, + ]; + } + + return $border; + } + + /** + * @return array + */ + private function headerFrom(?string $source): array + { + $header = [ + 'enabled' => false, + 'height' => 220, + 'background_color' => '', + 'background_size' => 'cover', + 'background_position' => 'center', + 'title_bind' => '', + 'title' => '', + 'title_color' => '#ffffff', + 'title_size' => 36, + 'title_transform' => 'none', + 'subtitle' => '', + 'subtitle_color' => '#111827', + 'subtitle_size' => 28, + ]; + + if (! is_string($source) || $source === '' || $this->headerImagePath($source) === null) { + return $header; + } + + $header['enabled'] = true; + + if (preg_match('/background-size\s*:\s*([^;]+)/i', $source, $matches) === 1) { + $header['background_size'] = trim($matches[1]); + } + + if (preg_match('/background-position\s*:\s*([^;]+)/i', $source, $matches) === 1) { + $header['background_position'] = trim($matches[1]); + } + + if (preg_match('/background-color\s*:\s*(#[0-9a-fA-F]{3,6})/i', $source, $matches) === 1) { + $header['background_color'] = $matches[1]; + } + + if (preg_match('/min-height\s*:\s*(\d+)px/i', $source, $matches) === 1) { + $header['height'] = max(40, (int) $matches[1]); + } + + if (str_contains($source, '$course->name') || str_contains($source, '$course[\'name\']')) { + $header['title_bind'] = 'course_name'; + $header['title_transform'] = str_contains($source, 'uppercase') ? 'uppercase' : 'none'; + } + + if (preg_match('/__\(\s*[\'"]CERTIFICATE OF COMPLETION[\'"]\s*\)/', $source) === 1) { + $header['subtitle'] = 'CERTIFICATE OF COMPLETION'; + } + + return $header; + } + + private function headerImagePath(?string $source): ?string + { + if (! is_string($source) || $source === '') { + return null; + } + + return $this->backgroundAssetPaths($source)[0] ?? null; + } + + private function tailwindColor(string $token): ?string + { + return match ($token) { + 'lime-400' => '#a3e635', + 'sky-500' => '#0ea5e9', + 'cyan-300' => '#67e8f9', + 'red-400' => '#f87171', + 'red-500' => '#ef4444', + 'red-300' => '#fca5a5', + 'zinc-400' => '#a1a1aa', + 'zinc-300' => '#d4d4d8', + default => null, + }; + } + + private function includesSignatures(?string $source): bool + { + if (! is_string($source) || $source === '') { + return false; + } + + if (str_contains($source, 'filament-lms.certificate_show_signatures')) { + return (bool) config('filament-lms.certificate_show_signatures', false); + } + + return (bool) preg_match('/signature/i', $source); + } + /** * @return Builder */ diff --git a/src/Support/AwardCertificateLayoutApplier.php b/src/Support/AwardCertificateLayoutApplier.php index bf12a21e..4f51fe3c 100644 --- a/src/Support/AwardCertificateLayoutApplier.php +++ b/src/Support/AwardCertificateLayoutApplier.php @@ -37,12 +37,162 @@ public function apply(array $layout, AwardCertificateBlueprint $blueprint): arra } if ($id === 'course_name') { - $elements[$index]['visible'] = $blueprint->includeCourseName; + $elements[$index]['visible'] = $blueprint->includeCourseName + && ($blueprint->header['title_bind'] ?? '') !== 'course_name'; } + + if ($id === 'recipient_name_display') { + $elements[$index]['visible'] = false; + } + } + + $elements = $this->placeElementsBelowHeader($elements, $blueprint->header); + $elements = $this->separateStackedText($elements, $blueprint->header); + + if (! $blueprint->includeSignatures) { + $layout['signature_count'] = 0; + $elements = array_values(array_filter( + $elements, + function (mixed $element): bool { + if (! is_array($element)) { + return true; + } + + $type = $element['type'] ?? null; + $id = $element['id'] ?? null; + + return $type !== 'signature' + && ! (is_string($id) && str_starts_with($id, 'signature_')); + }, + )); } $layout['elements'] = array_values($elements); + if ($blueprint->border !== []) { + $layout['border'] = $blueprint->border; + } + + if ($blueprint->header !== []) { + $layout['header'] = $blueprint->header; + } + return $layout; } + + /** + * @param list> $elements + * @param array $header + * @return list> + */ + private function placeElementsBelowHeader(array $elements, array $header): array + { + if (! ($header['enabled'] ?? false)) { + return $elements; + } + + $headerHeight = (int) ($header['height'] ?? 220); + $overlappingYs = []; + + foreach ($elements as $element) { + if (! is_array($element)) { + continue; + } + + $id = isset($element['id']) && is_string($element['id']) ? $element['id'] : null; + $y = (int) ($element['y'] ?? 0); + + if ( + ! ($element['visible'] ?? true) + || $y >= $headerHeight + || (is_string($id) && str_starts_with($id, 'logo_')) + ) { + continue; + } + + $overlappingYs[] = $y; + } + + $minOverlappingY = $overlappingYs === [] ? null : min($overlappingYs); + $shift = $minOverlappingY === null + ? 0 + : ($headerHeight + 16) - $minOverlappingY; + + foreach ($elements as $index => $element) { + if (! is_array($element)) { + continue; + } + + $id = isset($element['id']) && is_string($element['id']) ? $element['id'] : null; + $y = (int) ($element['y'] ?? 0); + + if (is_string($id) && str_starts_with($id, 'logo_') && $y < $headerHeight) { + $elements[$index]['y'] = 560; + + continue; + } + + if ($shift > 0 && $minOverlappingY !== null && $y >= $minOverlappingY) { + $elements[$index]['y'] = $y + $shift; + } + } + + return $elements; + } + + /** + * @param list> $elements + * @param array $header + * @return list> + */ + private function separateStackedText(array $elements, array $header): array + { + if (! ($header['enabled'] ?? false)) { + return $elements; + } + + $minGap = 28; + $indexes = []; + + foreach ($elements as $index => $element) { + if (! is_array($element) || ! ($element['visible'] ?? true)) { + continue; + } + + $id = isset($element['id']) && is_string($element['id']) ? $element['id'] : ''; + + if (($element['type'] ?? 'text') === 'image' || str_starts_with($id, 'logo_')) { + continue; + } + + $indexes[] = $index; + } + + usort($indexes, function (int $left, int $right) use ($elements): int { + $leftY = (int) ($elements[$left]['y'] ?? 0); + $rightY = (int) ($elements[$right]['y'] ?? 0); + + if ($leftY === $rightY) { + return $left <=> $right; + } + + return $leftY <=> $rightY; + }); + + $previousBottom = null; + + foreach ($indexes as $index) { + $y = (int) ($elements[$index]['y'] ?? 0); + $h = (int) ($elements[$index]['h'] ?? 30); + + if ($previousBottom !== null && $y < $previousBottom + $minGap) { + $y = $previousBottom + $minGap; + $elements[$index]['y'] = $y; + } + + $previousBottom = $y + $h; + } + + return $elements; + } } diff --git a/src/Support/CertificateBuilder.php b/src/Support/CertificateBuilder.php index fee17a0f..1a7942bb 100644 --- a/src/Support/CertificateBuilder.php +++ b/src/Support/CertificateBuilder.php @@ -17,14 +17,41 @@ final class CertificateBuilder public static function enabled(): bool { - return (bool) config('filament-lms.integrations.certificate_builder.enabled', false) - && class_exists(self::TEMPLATE_MODEL); + return class_exists(self::TEMPLATE_MODEL); } public static function canCreateTemplate(?object $user, Course $course): bool { - return self::canManageTemplates($user, $course) - && self::assignedTemplateId($course) === null; + return self::canManageTemplates($user, $course); + } + + public static function defaultTemplateId(): ?int + { + if (! class_exists(self::TEMPLATE_MODEL)) { + return null; + } + + $tokenSet = self::tokenSet(); + $query = self::TEMPLATE_MODEL::query()->where('token_set', $tokenSet); + $template = (clone $query)->where('name', 'Default Certificate')->first() + ?? $query->orderBy('name')->first(); + + if ($template !== null) { + return (int) $template->getKey(); + } + + $layoutClass = self::LAYOUT_CLASS; + $layout = class_exists($layoutClass) && method_exists($layoutClass, 'default') + ? $layoutClass::default($tokenSet) + : []; + + $created = self::TEMPLATE_MODEL::query()->create([ + 'name' => 'Default Certificate', + 'token_set' => $tokenSet, + 'layout' => is_array($layout) ? $layout : [], + ]); + + return (int) $created->getKey(); } public static function canEditTemplate(?object $user, Course $course): bool diff --git a/tests/Fakes/FakeCertificateLayout.php b/tests/Fakes/FakeCertificateLayout.php index aad8fb66..c2202428 100644 --- a/tests/Fakes/FakeCertificateLayout.php +++ b/tests/Fakes/FakeCertificateLayout.php @@ -16,6 +16,18 @@ public static function default(?string $tokenSet = null): array 'height' => 774, 'signature_count' => 2, 'elements' => [ + [ + 'id' => 'recipient_name_display', + 'type' => 'text', + 'bind' => 'recipient_name', + 'visible' => true, + ], + [ + 'id' => 'recipient_name', + 'type' => 'text', + 'bind' => 'recipient_name', + 'visible' => true, + ], [ 'id' => 'certifying_line', 'type' => 'text', diff --git a/tests/Fakes/FakeCertificateTemplate.php b/tests/Fakes/FakeCertificateTemplate.php index c30525f8..5d8e57b6 100644 --- a/tests/Fakes/FakeCertificateTemplate.php +++ b/tests/Fakes/FakeCertificateTemplate.php @@ -31,5 +31,6 @@ public function registerMediaCollections(): void $this->addMediaCollection('logo_1')->singleFile(); $this->addMediaCollection('logo_2')->singleFile(); $this->addMediaCollection('logo_3')->singleFile(); + $this->addMediaCollection('header')->singleFile(); } } diff --git a/tests/Feature/CertificateControllerTest.php b/tests/Feature/CertificateControllerTest.php index db623d33..28fc2544 100644 --- a/tests/Feature/CertificateControllerTest.php +++ b/tests/Feature/CertificateControllerTest.php @@ -3,8 +3,6 @@ declare(strict_types=1); use Tapp\FilamentLms\Models\Course; -use Tapp\FilamentLms\Models\Lesson; -use Tapp\FilamentLms\Models\Step; use Tapp\FilamentLms\Support\CertificateBuilder; use Tapp\FilamentLms\Tests\TestUser; @@ -15,29 +13,22 @@ ]); }); -it('renders the default award certificate when no template is assigned', function () { +it('returns not found when the course has no certificate template', function () { $user = TestUser::query()->create([ 'name' => 'Jane Doe', 'email' => 'jane-cert@example.com', 'password' => bcrypt('password'), ]); - $course = Course::factory()->create([ + $course = Course::factory()->withoutCertificateTemplate()->create([ 'name' => 'Intro to CHW', - 'award' => 'default', ]); - $lesson = Lesson::factory()->create(['course_id' => $course->id]); - Step::factory()->create(['lesson_id' => $lesson->id]); - $course->users()->attach($user->id, ['completed_at' => now()]); $this->actingAs($user) ->get(route('filament-lms::certificates.show', ['course' => $course->id, 'user' => $user->id])) - ->assertSuccessful() - ->assertSee('CERTIFICATE', false) - ->assertSee('Intro to CHW', false) - ->assertSee('Jane Doe', false); + ->assertNotFound(); }); it('forbids download when the user has not completed the course', function () { @@ -47,22 +38,20 @@ 'password' => bcrypt('password'), ]); - $course = Course::factory()->create(['award' => 'default']); + $course = Course::factory()->withoutCertificateTemplate()->create(); $this->actingAs($user) ->get(route('filament-lms::certificates.download', $course)) ->assertForbidden(); }); -it('does not expose the create certificate template action when the builder is disabled', function () { - config(['filament-lms.integrations.certificate_builder.enabled' => false]); - +it('does not expose template actions when the builder package is missing', function () { $user = TestUser::query()->create([ 'name' => 'Pat Admin', 'email' => 'pat-admin-cert@example.com', 'password' => bcrypt('password'), ]); - $course = Course::factory()->create(['award' => 'default']); + $course = Course::factory()->withoutCertificateTemplate()->create(); expect(CertificateBuilder::enabled())->toBeFalse() ->and(CertificateBuilder::canCreateTemplate($user, $course))->toBeFalse() diff --git a/tests/Feature/MigrateAwardsToCertificateTemplatesCommandTest.php b/tests/Feature/MigrateAwardsToCertificateTemplatesCommandTest.php index 9d31fd21..e5bd9515 100644 --- a/tests/Feature/MigrateAwardsToCertificateTemplatesCommandTest.php +++ b/tests/Feature/MigrateAwardsToCertificateTemplatesCommandTest.php @@ -26,10 +26,10 @@ function seedAwardLogo(string $relativePath): string return $absolute; } -it('fails when the certificate builder is not enabled', function () { - $this->artisan('filament-lms:migrate-awards-to-templates') - ->expectsOutputToContain('Certificate builder is not enabled') - ->assertFailed(); +it('fails when the certificate builder is not installed', function () { + $this->artisan('filament-lms:upgrade-awards') + ->expectsOutputToContain('Certificate builder is not installed') + ->assertExitCode(1); }); it('creates a template per award and assigns untemplated courses', function () { @@ -42,6 +42,7 @@ function seedAwardLogo(string $relativePath): string publishAwardFixture('decan', 'custom-award.blade.php'); seedAwardLogo('img/DE_DHSS-logo-red-wide.png'); + seedAwardLogo('img/header-green.jpg'); $defaultCourse = Course::factory()->create([ 'name' => 'Default Award Course', @@ -55,11 +56,16 @@ function seedAwardLogo(string $relativePath): string 'award' => 'decan', 'certificate_template_id' => null, ]); - $alreadyAssigned = Course::factory()->create([ + $customTemplate = FakeCertificateTemplate::query()->create([ + 'name' => 'Hand Built Template', + 'token_set' => 'course', + 'layout' => ['elements' => []], + ]); + $alreadyAssigned = Course::factory()->withoutCertificateTemplate()->create([ 'name' => 'Already Assigned Course', 'external_id' => 'already_assigned_course', 'award' => 'decan', - 'certificate_template_id' => 99, + 'certificate_template_id' => $customTemplate->id, ]); $summary = app(MigrateAwardsToCertificateTemplates::class)->handle( @@ -77,13 +83,21 @@ function seedAwardLogo(string $relativePath): string ->and($defaultTemplate)->not->toBeNull() ->and($decanTemplate)->not->toBeNull() ->and($decanTemplate->token_set)->toBe('course') - ->and($decanTemplate->layout['elements'])->toContain( - ['id' => 'certifying_line', 'type' => 'text', 'text' => 'AWARDED TO:', 'visible' => true], - ) + ->and($decanTemplate->layout['signature_count'])->toBe(0) + ->and($decanTemplate->layout['border']['style'])->toBe('gradient') + ->and($decanTemplate->layout['border']['gradient'])->toBe('linear-gradient(to right, #a3e635, #0ea5e9, #67e8f9)') + ->and(collect($decanTemplate->layout['elements'])->firstWhere('id', 'certifying_line')) + ->toMatchArray(['text' => 'AWARDED TO:', 'visible' => true]) + ->and(collect($decanTemplate->layout['elements'])->firstWhere('id', 'recipient_name_display')) + ->toMatchArray(['bind' => 'recipient_name', 'visible' => false]) + ->and(collect($decanTemplate->layout['elements'])->pluck('type'))->not->toContain('signature') + ->and($decanTemplate->layout['header']['enabled'])->toBeTrue() + ->and($decanTemplate->layout['header']['title_bind'])->toBe('course_name') ->and($decanTemplate->getFirstMedia('logo_1'))->not->toBeNull() + ->and($decanTemplate->getFirstMedia('header'))->not->toBeNull() ->and($defaultCourse->refresh()->certificate_template_id)->toBe($defaultTemplate->id) ->and($decanCourse->refresh()->certificate_template_id)->toBe($decanTemplate->id) - ->and($alreadyAssigned->refresh()->certificate_template_id)->toBe(99); + ->and($alreadyAssigned->refresh()->certificate_template_id)->toBe($customTemplate->id); }); it('is idempotent', function () { @@ -110,9 +124,9 @@ function seedAwardLogo(string $relativePath): string layoutClass: FakeCertificateLayout::class, ); - expect(FakeCertificateTemplate::query()->count())->toBe(1) + expect(FakeCertificateTemplate::query()->count())->toBe(2) ->and($second['templates_created'])->toBe(0) - ->and($second['templates_reused'])->toBe(1) + ->and($second['templates_reused'])->toBe(2) ->and($second['courses_updated'])->toBe(0); }); @@ -133,7 +147,7 @@ function seedAwardLogo(string $relativePath): string layoutClass: FakeCertificateLayout::class, ); - expect($summary['templates_created'])->toBe(1) + expect($summary['templates_created'])->toBe(2) ->and($summary['courses_updated'])->toBe(1) ->and(FakeCertificateTemplate::query()->count())->toBe(0) ->and($course->refresh()->certificate_template_id)->toBeNull(); @@ -181,11 +195,17 @@ function seedAwardLogo(string $relativePath): string 'layout' => ['elements' => []], ]); - $course = Course::factory()->create([ + $customTemplate = FakeCertificateTemplate::query()->create([ + 'name' => 'Hand Built Template', + 'token_set' => 'course', + 'layout' => ['elements' => []], + ]); + + $course = Course::factory()->withoutCertificateTemplate()->create([ 'name' => 'Force Reassign Course', 'external_id' => 'force_reassign_course', 'award' => 'decan', - 'certificate_template_id' => 99, + 'certificate_template_id' => $customTemplate->id, ]); app(MigrateAwardsToCertificateTemplates::class)->handle( @@ -194,8 +214,31 @@ function seedAwardLogo(string $relativePath): string layoutClass: FakeCertificateLayout::class, ); - expect(FakeCertificateTemplate::query()->count())->toBe(1) + expect(FakeCertificateTemplate::query()->count())->toBe(3) ->and($existing->refresh()->token_set)->toBe('course') ->and($existing->layout['elements'])->not->toBeEmpty() - ->and($course->refresh()->certificate_template_id)->toBe($existing->id); + ->and($course->refresh()->certificate_template_id)->toBe($customTemplate->id); +}); + +it('assigns courses with a null award to the default template', function () { + config(['filament-lms.awards' => [ + 'default' => 'Default', + ]]); + + $course = Course::factory()->withoutCertificateTemplate()->create([ + 'name' => 'Orphan Award Course', + 'external_id' => 'orphan_award_course', + 'award' => null, + ]); + + app(MigrateAwardsToCertificateTemplates::class)->handle( + templateClass: FakeCertificateTemplate::class, + layoutClass: FakeCertificateLayout::class, + ); + + $template = FakeCertificateTemplate::query()->where('name', 'Default Certificate')->first(); + + expect($template)->not->toBeNull() + ->and($course->refresh()->certificate_template_id)->toBe($template->id) + ->and(app(MigrateAwardsToCertificateTemplates::class)->unverifiedCourseIds(FakeCertificateTemplate::class))->toBe([]); }); diff --git a/tests/Feature/StepRenderedTextTest.php b/tests/Feature/StepRenderedTextTest.php index a6333483..9aed1e19 100644 --- a/tests/Feature/StepRenderedTextTest.php +++ b/tests/Feature/StepRenderedTextTest.php @@ -11,7 +11,7 @@ 'name' => 'Test Course', 'slug' => 'test-course', 'external_id' => 'test_course', - 'award' => 'default', + 'certificate_template_id' => null, ]); $this->lesson = Lesson::query()->create([ 'course_id' => $this->course->id, diff --git a/tests/Unit/AwardCertificateBlueprintFactoryTest.php b/tests/Unit/AwardCertificateBlueprintFactoryTest.php index fcbd4ffe..9824d023 100644 --- a/tests/Unit/AwardCertificateBlueprintFactoryTest.php +++ b/tests/Unit/AwardCertificateBlueprintFactoryTest.php @@ -56,7 +56,14 @@ function publishAwardView(string $award, string $fixture): void ->and($blueprint->certifyingLine)->toBe('AWARDED TO:') ->and($blueprint->completedLine)->toBe('') ->and($blueprint->description)->toContain('Delaware Contraceptive Access Now') - ->and($blueprint->includeCourseName)->toBeTrue(); + ->and($blueprint->includeCourseName)->toBeTrue() + ->and($blueprint->includeSignatures)->toBeFalse() + ->and($blueprint->border['style'])->toBe('gradient') + ->and($blueprint->border['gradient'])->toBe('linear-gradient(to right, #a3e635, #0ea5e9, #67e8f9)') + ->and($blueprint->header['enabled'])->toBeTrue() + ->and($blueprint->header['title_bind'])->toBe('course_name') + ->and($blueprint->header['subtitle'])->toBe('CERTIFICATE OF COMPLETION') + ->and($blueprint->headerImagePath)->toBe('/img/header-green.jpg'); }); it('hides the course name when the award blade does not print it', function () { @@ -71,7 +78,30 @@ function publishAwardView(string $award, string $fixture): void expect($blueprint->includeCourseName)->toBeFalse() ->and($blueprint->logoPaths)->toBe(['/img/DPH_logo_family.png']) ->and($blueprint->certifyingLine)->toBe('AWARDED TO:') - ->and($blueprint->description)->toContain('Family Support Specialist'); + ->and($blueprint->description)->toContain('Family Support Specialist') + ->and($blueprint->includeSignatures)->toBeFalse(); +}); + +it('keeps signatures when the award blade has signature fields', function () { + config(['filament-lms.awards' => [ + 'signed' => 'Signed Award', + ]]); + + publishAwardView('signed', 'signed-award.blade.php'); + + $blueprint = app(AwardCertificateBlueprintFactory::class)->make('signed'); + + expect($blueprint->includeSignatures)->toBeTrue(); +}); + +it('follows the LMS signature config for the default award blade', function () { + config(['filament-lms.certificate_show_signatures' => false]); + + expect(app(AwardCertificateBlueprintFactory::class)->make('default')->includeSignatures)->toBeFalse(); + + config(['filament-lms.certificate_show_signatures' => true]); + + expect(app(AwardCertificateBlueprintFactory::class)->make('default')->includeSignatures)->toBeTrue(); }); it('uses the default award copy and configured logo', function () { @@ -90,7 +120,43 @@ function publishAwardView(string $award, string $fixture): void ->and($blueprint->logoPaths)->toBe(['images/certificate-logo.png']) ->and($blueprint->certifyingLine)->toBe('This certificate is awarded to') ->and($blueprint->completedLine)->toBe('successfully completed') - ->and($blueprint->includeCourseName)->toBeTrue(); + ->and($blueprint->includeCourseName)->toBeTrue() + ->and($blueprint->border['style'])->toBe('double') + ->and($blueprint->border['color'])->toBe('#a1a1aa') + ->and($blueprint->header['enabled'])->toBeFalse() + ->and($blueprint->headerImagePath)->toBeNull(); +}); + +it('extracts a solid frame color from an award blade', function () { + config(['filament-lms.awards' => [ + 'family-support-specialist' => 'Family Support Specialist Onboarding', + ]]); + + publishAwardView('family-support-specialist', 'solid-border-award.blade.php'); + + $blueprint = app(AwardCertificateBlueprintFactory::class)->make('family-support-specialist'); + + expect($blueprint->border['style'])->toBe('solid') + ->and($blueprint->border['color'])->toBe('#B5498F') + ->and($blueprint->border['width'])->toBe(6) + ->and($blueprint->header['enabled'])->toBeFalse(); +}); + +it('extracts a banner header from an award blade', function () { + config(['filament-lms.awards' => [ + 'family-support-specialist' => 'Family Support Specialist Onboarding', + ]]); + + publishAwardView('family-support-specialist', 'header-banner-award.blade.php'); + + $blueprint = app(AwardCertificateBlueprintFactory::class)->make('family-support-specialist'); + + expect($blueprint->header['enabled'])->toBeTrue() + ->and($blueprint->header['height'])->toBe(300) + ->and($blueprint->header['background_color'])->toBe('#B5498F') + ->and($blueprint->header['background_size'])->toBe('60% auto') + ->and($blueprint->header['title_bind'])->toBe('') + ->and($blueprint->headerImagePath)->toBe('/img/home-visiting-header-certificate.png'); }); it('resolves a logo that exists in the public directory', function () { diff --git a/tests/Unit/AwardCertificateLayoutApplierTest.php b/tests/Unit/AwardCertificateLayoutApplierTest.php index 27b68072..890dd3a5 100644 --- a/tests/Unit/AwardCertificateLayoutApplierTest.php +++ b/tests/Unit/AwardCertificateLayoutApplierTest.php @@ -16,12 +16,41 @@ completedLine: '', description: 'Award-specific description.', includeCourseName: false, + includeSignatures: false, + border: [ + 'style' => 'gradient', + 'width' => 6, + 'color' => '#a3e635', + 'gradient' => 'linear-gradient(to right, #a3e635, #0ea5e9, #67e8f9)', + ], + header: [ + 'enabled' => true, + 'height' => 220, + 'title_bind' => 'course_name', + 'subtitle' => 'CERTIFICATE OF COMPLETION', + ], + headerImagePath: '/img/header-green.jpg', ); - $layout = app(AwardCertificateLayoutApplier::class)->apply( - FakeCertificateLayout::default('course'), - $blueprint, - ); + $layout = FakeCertificateLayout::default('course'); + $layout['elements'][] = [ + 'id' => 'logo_1', + 'type' => 'image', + 'y' => 30, + 'visible' => true, + ]; + $layout['elements'][] = [ + 'id' => 'signature_1', + 'type' => 'signature', + 'visible' => true, + ]; + $layout['elements'][] = [ + 'id' => 'signature_2', + 'type' => 'signature', + 'visible' => true, + ]; + + $layout = app(AwardCertificateLayoutApplier::class)->apply($layout, $blueprint); $byId = collect($layout['elements'])->keyBy('id'); @@ -31,5 +60,100 @@ ->and($byId['completed_line']['visible'])->toBeFalse() ->and($byId['description']['text'])->toBe('Award-specific description.') ->and($byId['description']['visible'])->toBeTrue() - ->and($byId['course_name']['visible'])->toBeFalse(); + ->and($byId['course_name']['visible'])->toBeFalse() + ->and($byId['recipient_name_display']['visible'])->toBeFalse() + ->and($byId['recipient_name']['visible'])->toBeTrue() + ->and($byId['logo_1']['y'])->toBe(560) + ->and($layout['signature_count'])->toBe(0) + ->and($byId->has('signature_1'))->toBeFalse() + ->and($byId->has('signature_2'))->toBeFalse() + ->and($layout['border']['style'])->toBe('gradient') + ->and($layout['header']['enabled'])->toBeTrue() + ->and($layout['header']['title_bind'])->toBe('course_name'); +}); + +it('keeps awarded-to text from overlapping the recipient name when a header is applied', function () { + $blueprint = new AwardCertificateBlueprint( + key: 'decan', + label: 'Delaware Contraceptive Access Network', + templateName: 'Delaware Contraceptive Access Network Certificate', + logoPaths: [], + certifyingLine: 'AWARDED TO:', + completedLine: '', + description: 'Award-specific description.', + includeCourseName: true, + includeSignatures: false, + border: [], + header: [ + 'enabled' => true, + 'height' => 220, + 'title_bind' => 'course_name', + ], + headerImagePath: '/img/header-green.jpg', + ); + + $layout = [ + 'elements' => [ + [ + 'id' => 'certifying_line', + 'type' => 'text', + 'text' => 'This certifies that', + 'y' => 200, + 'h' => 36, + 'visible' => true, + ], + [ + 'id' => 'recipient_name', + 'type' => 'text', + 'bind' => 'recipient_name', + 'y' => 245, + 'h' => 45, + 'visible' => true, + ], + [ + 'id' => 'description', + 'type' => 'text', + 'text' => '', + 'y' => 390, + 'h' => 80, + 'visible' => true, + ], + ], + ]; + + $layout = app(AwardCertificateLayoutApplier::class)->apply($layout, $blueprint); + $byId = collect($layout['elements'])->keyBy('id'); + + expect($byId['recipient_name']['y']) + ->toBeGreaterThanOrEqual($byId['certifying_line']['y'] + $byId['certifying_line']['h'] + 28) + ->and($byId['description']['y'])->toBeGreaterThan($byId['recipient_name']['y'] + $byId['recipient_name']['h']); +}); + +it('keeps signature fields when the award includes them', function () { + $blueprint = new AwardCertificateBlueprint( + key: 'default', + label: 'Default', + templateName: 'Default Certificate', + logoPaths: [], + certifyingLine: 'This certifies that', + completedLine: 'has successfully completed', + description: '', + includeCourseName: true, + includeSignatures: true, + border: [], + header: [], + headerImagePath: null, + ); + + $layout = FakeCertificateLayout::default('course'); + $layout['elements'][] = [ + 'id' => 'signature_1', + 'type' => 'signature', + 'visible' => true, + ]; + + $layout = app(AwardCertificateLayoutApplier::class)->apply($layout, $blueprint); + + expect($layout['signature_count'])->toBe(2) + ->and(collect($layout['elements'])->pluck('id'))->toContain('signature_1'); }); diff --git a/tests/fixtures/certificates/custom-award.blade.php b/tests/fixtures/certificates/custom-award.blade.php index fed00ef0..dae2babd 100644 --- a/tests/fixtures/certificates/custom-award.blade.php +++ b/tests/fixtures/certificates/custom-award.blade.php @@ -1,3 +1,4 @@ +

{{ $course->name }}

{{ __('CERTIFICATE OF COMPLETION') }}

@@ -11,3 +12,4 @@

+
diff --git a/tests/fixtures/certificates/header-banner-award.blade.php b/tests/fixtures/certificates/header-banner-award.blade.php new file mode 100644 index 00000000..13ef9e0c --- /dev/null +++ b/tests/fixtures/certificates/header-banner-award.blade.php @@ -0,0 +1,7 @@ +
+
+
+
+

{{ __('AWARDED TO:') }}

+

{{ $user->name }}

+
diff --git a/tests/fixtures/certificates/signed-award.blade.php b/tests/fixtures/certificates/signed-award.blade.php new file mode 100644 index 00000000..c15f94cd --- /dev/null +++ b/tests/fixtures/certificates/signed-award.blade.php @@ -0,0 +1,8 @@ +
+

{{ __('This certifies that') }}

+

{{ $user->name }}

+

{{ __('has successfully completed') }} {{ $course->name }}

+
+

Authorized Signature

+
+
diff --git a/tests/fixtures/certificates/solid-border-award.blade.php b/tests/fixtures/certificates/solid-border-award.blade.php new file mode 100644 index 00000000..38faf4ce --- /dev/null +++ b/tests/fixtures/certificates/solid-border-award.blade.php @@ -0,0 +1,5 @@ +
+

{{ __('AWARDED TO:') }}

+

{{ $user->name }}

+

{{ __('has successfully completed') }} {{ $course->name }}

+
diff --git a/upgrade.md b/upgrade.md new file mode 100644 index 00000000..955de0ac --- /dev/null +++ b/upgrade.md @@ -0,0 +1 @@ +See [UPGRADING.md](UPGRADING.md). From 79f8397d903827c2e8a2874099f37732756c50ab Mon Sep 17 00:00:00 2001 From: John Wesely Date: Fri, 18 Sep 2026 10:49:18 -0600 Subject: [PATCH 7/8] fix: satisfy PHPStan on award layout helpers Co-authored-by: Cursor --- src/Support/AwardCertificateLayoutApplier.php | 18 +++--------------- src/Support/CertificateBuilder.php | 2 +- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/src/Support/AwardCertificateLayoutApplier.php b/src/Support/AwardCertificateLayoutApplier.php index 4f51fe3c..a40a6e67 100644 --- a/src/Support/AwardCertificateLayoutApplier.php +++ b/src/Support/AwardCertificateLayoutApplier.php @@ -53,11 +53,7 @@ public function apply(array $layout, AwardCertificateBlueprint $blueprint): arra $layout['signature_count'] = 0; $elements = array_values(array_filter( $elements, - function (mixed $element): bool { - if (! is_array($element)) { - return true; - } - + function (array $element): bool { $type = $element['type'] ?? null; $id = $element['id'] ?? null; @@ -95,10 +91,6 @@ private function placeElementsBelowHeader(array $elements, array $header): array $overlappingYs = []; foreach ($elements as $element) { - if (! is_array($element)) { - continue; - } - $id = isset($element['id']) && is_string($element['id']) ? $element['id'] : null; $y = (int) ($element['y'] ?? 0); @@ -119,10 +111,6 @@ private function placeElementsBelowHeader(array $elements, array $header): array : ($headerHeight + 16) - $minOverlappingY; foreach ($elements as $index => $element) { - if (! is_array($element)) { - continue; - } - $id = isset($element['id']) && is_string($element['id']) ? $element['id'] : null; $y = (int) ($element['y'] ?? 0); @@ -132,7 +120,7 @@ private function placeElementsBelowHeader(array $elements, array $header): array continue; } - if ($shift > 0 && $minOverlappingY !== null && $y >= $minOverlappingY) { + if ($shift > 0 && $y >= $minOverlappingY) { $elements[$index]['y'] = $y + $shift; } } @@ -155,7 +143,7 @@ private function separateStackedText(array $elements, array $header): array $indexes = []; foreach ($elements as $index => $element) { - if (! is_array($element) || ! ($element['visible'] ?? true)) { + if (! ($element['visible'] ?? true)) { continue; } diff --git a/src/Support/CertificateBuilder.php b/src/Support/CertificateBuilder.php index 1a7942bb..67baf7bb 100644 --- a/src/Support/CertificateBuilder.php +++ b/src/Support/CertificateBuilder.php @@ -41,7 +41,7 @@ public static function defaultTemplateId(): ?int } $layoutClass = self::LAYOUT_CLASS; - $layout = class_exists($layoutClass) && method_exists($layoutClass, 'default') + $layout = class_exists($layoutClass) ? $layoutClass::default($tokenSet) : []; From 7e4aca16c6645a8c4c1dfc3552c72294fde97aed Mon Sep 17 00:00:00 2001 From: John Wesely Date: Fri, 18 Sep 2026 11:19:29 -0600 Subject: [PATCH 8/8] fix: default templates after award drop CSV import omitted certificate_template_id, and award remigration queried the dropped column and reused same-named templates from other token sets. Co-authored-by: Cursor --- src/Imports/CourseStepsImport.php | 2 + .../MigrateAwardsToCertificateTemplates.php | 42 ++++--- .../AwardCertificateBlueprintFactory.php | 17 +-- tests/Feature/CourseImportTest.php | 19 +++ ...wardsToCertificateTemplatesCommandTest.php | 110 +++++++++++++++++- .../AwardCertificateBlueprintFactoryTest.php | 15 +++ 6 files changed, 178 insertions(+), 27 deletions(-) diff --git a/src/Imports/CourseStepsImport.php b/src/Imports/CourseStepsImport.php index d321752f..c768d83c 100644 --- a/src/Imports/CourseStepsImport.php +++ b/src/Imports/CourseStepsImport.php @@ -16,6 +16,7 @@ use Tapp\FilamentLms\Models\Step; use Tapp\FilamentLms\Models\Video; use Tapp\FilamentLms\Services\VideoUrlService; +use Tapp\FilamentLms\Support\CertificateBuilder; class CourseStepsImport implements ToCollection, WithHeadingRow { @@ -74,6 +75,7 @@ protected function importRows(Collection $rows, bool $hasStepNameColumn, bool $h 'name' => $this->courseName, 'slug' => Str::slug($this->courseName), 'external_id' => Str::slug($this->courseName, '_'), + 'certificate_template_id' => CertificateBuilder::defaultTemplateId(), ]); $lessonOrder = 0; diff --git a/src/Services/MigrateAwardsToCertificateTemplates.php b/src/Services/MigrateAwardsToCertificateTemplates.php index df3968fd..f2a4365c 100644 --- a/src/Services/MigrateAwardsToCertificateTemplates.php +++ b/src/Services/MigrateAwardsToCertificateTemplates.php @@ -52,16 +52,6 @@ public function handle( throw new RuntimeException('Certificate builder is not installed.'); } - if (! Schema::hasColumn('lms_courses', 'award')) { - return [ - 'templates_created' => 0, - 'templates_reused' => 0, - 'courses_updated' => 0, - 'logos_attached' => 0, - 'awards' => [], - ]; - } - if (! Schema::hasColumn('lms_courses', 'certificate_template_id')) { throw new RuntimeException('lms_courses.certificate_template_id is missing. Publish and run filament-lms migrations first.'); } @@ -77,9 +67,7 @@ public function handle( foreach ($this->blueprints->awardKeys($award) as $awardKey) { $blueprint = $this->blueprints->make($awardKey); - $existing = $templateClass::query() - ->where('name', $blueprint->templateName) - ->first(); + $existing = $this->findTemplate($templateClass, $blueprint->templateName, $tokenSet); $created = $existing === null; $logoCount = 0; @@ -146,8 +134,13 @@ public function unverifiedCourseIds(?string $templateClass = null): array */ private function coursesToUpdate(string $awardKey, string $templateClass): Builder { - return $this->coursesMissingTemplate($templateClass) - ->where('award', $awardKey); + $query = $this->coursesMissingTemplate($templateClass); + + if (! Schema::hasColumn('lms_courses', 'award')) { + return $query->whereRaw('0 = 1'); + } + + return $query->where('award', $awardKey); } /** @@ -174,6 +167,10 @@ private function assignCoursesWithoutAward( string $tokenSet, bool $dryRun, ): int { + if (! Schema::hasColumn('lms_courses', 'award')) { + return 0; + } + $query = $this->coursesMissingTemplate($templateClass) ->where(function (Builder $query): void { $query->whereNull('award') @@ -186,9 +183,7 @@ private function assignCoursesWithoutAward( return $count; } - $template = $templateClass::query() - ->where('name', 'Default Certificate') - ->first(); + $template = $this->findTemplate($templateClass, 'Default Certificate', $tokenSet); if ($template === null) { $blueprint = $this->blueprints->make('default'); @@ -204,6 +199,17 @@ private function assignCoursesWithoutAward( ]); } + /** + * @param class-string $templateClass + */ + private function findTemplate(string $templateClass, string $name, string $tokenSet): ?Model + { + return $templateClass::query() + ->where('name', $name) + ->where('token_set', $tokenSet) + ->first(); + } + /** * @param class-string|null $layoutClass * @return array diff --git a/src/Support/AwardCertificateBlueprintFactory.php b/src/Support/AwardCertificateBlueprintFactory.php index 1c83bd8c..1ac0db74 100644 --- a/src/Support/AwardCertificateBlueprintFactory.php +++ b/src/Support/AwardCertificateBlueprintFactory.php @@ -5,6 +5,7 @@ namespace Tapp\FilamentLms\Support; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Tapp\FilamentLms\Models\Course; use Throwable; @@ -21,13 +22,15 @@ public function awardKeys(?string $only = null): array } $configured = array_keys($this->configuredAwards()); - $used = $this->courseQuery() - ->whereNotNull('award') - ->where('award', '!=', '') - ->distinct() - ->orderBy('award') - ->pluck('award') - ->all(); + $used = Schema::hasColumn('lms_courses', 'award') + ? $this->courseQuery() + ->whereNotNull('award') + ->where('award', '!=', '') + ->distinct() + ->orderBy('award') + ->pluck('award') + ->all() + : []; $keys = array_values(array_unique([ 'default', diff --git a/tests/Feature/CourseImportTest.php b/tests/Feature/CourseImportTest.php index fbc9851b..fa0a2b9a 100644 --- a/tests/Feature/CourseImportTest.php +++ b/tests/Feature/CourseImportTest.php @@ -8,6 +8,7 @@ use Tapp\FilamentLms\Imports\CourseStepsImport; use Tapp\FilamentLms\Models\Course; use Tapp\FilamentLms\Models\Video; +use Tapp\FilamentLms\Support\CertificateBuilder; use Tapp\FilamentLms\Tests\TestUser; beforeEach(function () { @@ -22,6 +23,7 @@ $course = Course::where('name', 'Imported Course')->first(); expect($course)->not->toBeNull(); expect($course->slug)->toBe('imported-course'); + expect($course->certificate_template_id)->toBe(CertificateBuilder::defaultTemplateId()); $lessons = $course->lessons()->orderBy('order')->get(); expect($lessons)->toHaveCount(2); @@ -52,6 +54,23 @@ expect($lessons->first()->slug)->toBe('my-course'); }); +test('course steps import assigns the default certificate template', function () { + $templateIdOnCreate = null; + $templateIdWasSet = false; + + Course::creating(function (Course $course) use (&$templateIdOnCreate, &$templateIdWasSet): void { + $templateIdWasSet = array_key_exists('certificate_template_id', $course->getAttributes()); + $templateIdOnCreate = $course->certificate_template_id; + }); + + $path = __DIR__.'/../fixtures/course-import-format-a.csv'; + + Excel::import(new CourseStepsImport('Imported Course'), $path); + + expect($templateIdWasSet)->toBeTrue() + ->and($templateIdOnCreate)->toBe(CertificateBuilder::defaultTemplateId()); +}); + test('course steps import format b creates course with steps and text', function () { $path = __DIR__.'/../fixtures/course-import-format-b.csv'; diff --git a/tests/Feature/MigrateAwardsToCertificateTemplatesCommandTest.php b/tests/Feature/MigrateAwardsToCertificateTemplatesCommandTest.php index e5bd9515..35348615 100644 --- a/tests/Feature/MigrateAwardsToCertificateTemplatesCommandTest.php +++ b/tests/Feature/MigrateAwardsToCertificateTemplatesCommandTest.php @@ -2,7 +2,9 @@ declare(strict_types=1); +use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Schema; use Tapp\FilamentLms\Models\Course; use Tapp\FilamentLms\Services\MigrateAwardsToCertificateTemplates; use Tapp\FilamentLms\Tests\Fakes\FakeCertificateLayout; @@ -182,7 +184,74 @@ function seedAwardLogo(string $relativePath): string ->and($decanCourse->refresh()->certificate_template_id)->not->toBeNull(); }); -it('reassigns existing templates when forced', function () { +it('does not reuse a same-named template from another token set', function () { + config(['filament-lms.awards' => [ + 'decan' => 'Delaware Contraceptive Access Network', + ]]); + + $foreign = FakeCertificateTemplate::query()->create([ + 'name' => 'Delaware Contraceptive Access Network Certificate', + 'token_set' => 'training', + 'layout' => ['elements' => []], + ]); + + $course = Course::factory()->withoutCertificateTemplate()->create([ + 'name' => 'Decan Token Set Course', + 'external_id' => 'decan_token_set_course', + 'award' => 'decan', + ]); + + $summary = app(MigrateAwardsToCertificateTemplates::class)->handle( + templateClass: FakeCertificateTemplate::class, + layoutClass: FakeCertificateLayout::class, + ); + + $migrated = FakeCertificateTemplate::query() + ->where('name', 'Delaware Contraceptive Access Network Certificate') + ->where('token_set', 'course') + ->first(); + + expect($summary['templates_created'])->toBe(2) + ->and($foreign->refresh()->token_set)->toBe('training') + ->and($migrated)->not->toBeNull() + ->and($migrated->id)->not->toBe($foreign->id) + ->and($course->refresh()->certificate_template_id)->toBe($migrated->id); +}); + +it('does not assign orphan courses to a default template from another token set', function () { + config(['filament-lms.awards' => [ + 'default' => 'Default', + ]]); + + $foreign = FakeCertificateTemplate::query()->create([ + 'name' => 'Default Certificate', + 'token_set' => 'training', + 'layout' => ['elements' => []], + ]); + + $course = Course::factory()->withoutCertificateTemplate()->create([ + 'name' => 'Orphan Token Set Course', + 'external_id' => 'orphan_token_set_course', + 'award' => null, + ]); + + app(MigrateAwardsToCertificateTemplates::class)->handle( + templateClass: FakeCertificateTemplate::class, + layoutClass: FakeCertificateLayout::class, + ); + + $migrated = FakeCertificateTemplate::query() + ->where('name', 'Default Certificate') + ->where('token_set', 'course') + ->first(); + + expect($foreign->refresh()->token_set)->toBe('training') + ->and($migrated)->not->toBeNull() + ->and($migrated->id)->not->toBe($foreign->id) + ->and($course->refresh()->certificate_template_id)->toBe($migrated->id); +}); + +it('recreates layouts for matching token set templates when forced', function () { config(['filament-lms.awards' => [ 'decan' => 'Delaware Contraceptive Access Network', ]]); @@ -190,6 +259,12 @@ function seedAwardLogo(string $relativePath): string publishAwardFixture('decan', 'custom-award.blade.php'); $existing = FakeCertificateTemplate::query()->create([ + 'name' => 'Delaware Contraceptive Access Network Certificate', + 'token_set' => 'course', + 'layout' => ['elements' => []], + ]); + + $foreign = FakeCertificateTemplate::query()->create([ 'name' => 'Delaware Contraceptive Access Network Certificate', 'token_set' => 'training', 'layout' => ['elements' => []], @@ -214,12 +289,43 @@ function seedAwardLogo(string $relativePath): string layoutClass: FakeCertificateLayout::class, ); - expect(FakeCertificateTemplate::query()->count())->toBe(3) + expect(FakeCertificateTemplate::query()->count())->toBe(4) ->and($existing->refresh()->token_set)->toBe('course') ->and($existing->layout['elements'])->not->toBeEmpty() + ->and($foreign->refresh()->token_set)->toBe('training') + ->and($foreign->layout['elements'])->toBe([]) ->and($course->refresh()->certificate_template_id)->toBe($customTemplate->id); }); +it('refreshes migrated layouts when forced after the award column is dropped', function () { + config(['filament-lms.awards' => [ + 'decan' => 'Delaware Contraceptive Access Network', + ]]); + + publishAwardFixture('decan', 'custom-award.blade.php'); + + $existing = FakeCertificateTemplate::query()->create([ + 'name' => 'Delaware Contraceptive Access Network Certificate', + 'token_set' => 'course', + 'layout' => ['elements' => []], + ]); + + Schema::table('lms_courses', function (Blueprint $table) { + $table->dropColumn('award'); + }); + + $summary = app(MigrateAwardsToCertificateTemplates::class)->handle( + force: true, + templateClass: FakeCertificateTemplate::class, + layoutClass: FakeCertificateLayout::class, + ); + + expect($summary['templates_reused'])->toBe(1) + ->and($summary['awards'])->not->toBeEmpty() + ->and(collect($summary['awards'])->pluck('award'))->toContain('decan') + ->and($existing->refresh()->layout['elements'])->not->toBeEmpty(); +}); + it('assigns courses with a null award to the default template', function () { config(['filament-lms.awards' => [ 'default' => 'Default', diff --git a/tests/Unit/AwardCertificateBlueprintFactoryTest.php b/tests/Unit/AwardCertificateBlueprintFactoryTest.php index 9824d023..cc1c8970 100644 --- a/tests/Unit/AwardCertificateBlueprintFactoryTest.php +++ b/tests/Unit/AwardCertificateBlueprintFactoryTest.php @@ -2,7 +2,9 @@ declare(strict_types=1); +use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Schema; use Tapp\FilamentLms\Models\Course; use Tapp\FilamentLms\Support\AwardCertificateBlueprintFactory; @@ -40,6 +42,19 @@ function publishAwardView(string $award, string $fixture): void expect(app(AwardCertificateBlueprintFactory::class)->awardKeys('safb'))->toBe(['safb']); }); +it('discovers configured awards after the award column is dropped', function () { + config(['filament-lms.awards' => [ + 'default' => 'Default', + 'safb' => 'Safe Arms For Babies', + ]]); + + Schema::table('lms_courses', function (Blueprint $table) { + $table->dropColumn('award'); + }); + + expect(app(AwardCertificateBlueprintFactory::class)->awardKeys())->toBe(['default', 'safb']); +}); + it('extracts logos and copy from a custom award blade', function () { config(['filament-lms.awards' => [ 'decan' => 'Delaware Contraceptive Access Network',