From ee02b8df3a6633de5c65e8f104abea6a0874e91f Mon Sep 17 00:00:00 2001 From: philipp lang Date: Tue, 2 Jul 2024 22:55:37 +0200 Subject: [PATCH] Add prevention --- app/Console/Kernel.php | 3 + app/Form/Actions/FormStoreAction.php | 1 + app/Form/Actions/FormUpdateAction.php | 1 + app/Form/Actions/PreventionRememberAction.php | 34 ++++ app/Form/Models/Form.php | 1 + app/Form/Models/Participant.php | 36 +++- app/Form/Resources/FormResource.php | 1 + app/Member/Member.php | 33 +++- app/Prevention/Contracts/Preventable.php | 20 ++ app/Prevention/Enums/Prevention.php | 19 ++ .../Mails/PreventionRememberMail.php | 62 ++++++ ...4_07_02_165058_create_needs_prevention.php | 35 ++++ resources/js/views/form/Index.vue | 1 + .../prevention-remember-participant.blade.php | 17 ++ tests/EndToEnd/Form/FormIndexActionTest.php | 1 + tests/Feature/Form/FormRequest.php | 1 + tests/Feature/Form/FormUpdateActionTest.php | 11 +- tests/Feature/Member/PreventionTest.php | 183 ++++++++++++++++++ 18 files changed, 456 insertions(+), 4 deletions(-) create mode 100644 app/Form/Actions/PreventionRememberAction.php create mode 100644 app/Prevention/Contracts/Preventable.php create mode 100644 app/Prevention/Enums/Prevention.php create mode 100644 app/Prevention/Mails/PreventionRememberMail.php create mode 100644 database/migrations/2024_07_02_165058_create_needs_prevention.php create mode 100644 resources/views/mail/prevention/prevention-remember-participant.blade.php create mode 100644 tests/Feature/Member/PreventionTest.php diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 2fe7629d..31727f59 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -3,6 +3,7 @@ namespace App\Console; use App\Actions\DbMaintainAction; +use App\Form\Actions\PreventionRememberAction; use App\Initialize\InitializeMembers; use App\Invoice\Actions\InvoiceSendAction; use Illuminate\Console\Scheduling\Schedule; @@ -19,6 +20,7 @@ class Kernel extends ConsoleKernel InvoiceSendAction::class, InitializeMembers::class, DbMaintainAction::class, + PreventionRememberAction::class, ]; /** @@ -30,6 +32,7 @@ class Kernel extends ConsoleKernel { $schedule->command(DbMaintainAction::class)->daily(); $schedule->command(InitializeMembers::class)->dailyAt('03:00'); + $schedule->command(PreventionRememberAction::class)->dailyAt('11:00'); } /** diff --git a/app/Form/Actions/FormStoreAction.php b/app/Form/Actions/FormStoreAction.php index f929d8b0..a5fd4f39 100644 --- a/app/Form/Actions/FormStoreAction.php +++ b/app/Form/Actions/FormStoreAction.php @@ -35,6 +35,7 @@ class FormStoreAction 'is_active' => 'boolean', 'is_private' => 'boolean', 'export' => 'nullable|array', + 'needs_prevention' => 'present|boolean', ]; } diff --git a/app/Form/Actions/FormUpdateAction.php b/app/Form/Actions/FormUpdateAction.php index 3dd3892f..eebd7379 100644 --- a/app/Form/Actions/FormUpdateAction.php +++ b/app/Form/Actions/FormUpdateAction.php @@ -34,6 +34,7 @@ class FormUpdateAction 'is_active' => 'boolean', 'is_private' => 'boolean', 'export' => 'nullable|array', + 'needs_prevention' => 'present|boolean', ]; } diff --git a/app/Form/Actions/PreventionRememberAction.php b/app/Form/Actions/PreventionRememberAction.php new file mode 100644 index 00000000..e5f960c7 --- /dev/null +++ b/app/Form/Actions/PreventionRememberAction.php @@ -0,0 +1,34 @@ + $form->where('needs_prevention', true)) + ->where( + fn ($q) => $q + ->where('last_remembered_at', '<=', now()->subWeeks(2)) + ->orWhereNull('last_remembered_at') + ); + foreach ($query->get() as $participant) { + if (count($participant->preventions()) === 0) { + return; + } + + Mail::send(new PreventionRememberMail($participant)); + + $participant->update(['last_remembered_at' => now()]); + } + } +} diff --git a/app/Form/Models/Form.php b/app/Form/Models/Form.php index 902a6508..99245f00 100644 --- a/app/Form/Models/Form.php +++ b/app/Form/Models/Form.php @@ -35,6 +35,7 @@ class Form extends Model implements HasMedia 'is_active' => 'boolean', 'is_private' => 'boolean', 'export' => ExportData::class, + 'needs_prevention' => 'boolean', ]; /** @var array */ diff --git a/app/Form/Models/Participant.php b/app/Form/Models/Participant.php index 05a31570..96c055c6 100644 --- a/app/Form/Models/Participant.php +++ b/app/Form/Models/Participant.php @@ -6,14 +6,17 @@ use App\Form\Data\FieldCollection; use App\Form\Data\FormConfigData; use App\Form\Mails\ConfirmRegistrationMail; use App\Form\Scopes\ParticipantFilterScope; +use App\Member\Member; +use App\Prevention\Contracts\Preventable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\Mail; +use stdClass; -class Participant extends Model +class Participant extends Model implements Preventable { use HasFactory; @@ -21,6 +24,7 @@ class Participant extends Model public $casts = [ 'data' => 'json', + 'last_remembered_at' => 'datetime', ]; /** @@ -48,6 +52,11 @@ class Participant extends Model return $filter->apply($query); } + public function member(): BelongsTo + { + return $this->belongsTo(Member::class); + } + public function getFields(): FieldCollection { return FieldCollection::fromRequest($this->form, $this->data); @@ -70,6 +79,29 @@ class Participant extends Model return; } - Mail::to($this->getFields()->getMailRecipient())->queue(new ConfirmRegistrationMail($this)); + Mail::to($this->getMailRecipient())->queue(new ConfirmRegistrationMail($this)); + } + + public function preventableLayout(): string + { + return 'mail.prevention.prevention-remember-participant'; + } + + /** + * @inheritdoc + */ + public function preventions(): array + { + return $this->member?->preventions($this->form->from) ?: []; + } + + public function getMailRecipient(): stdClass + { + return $this->getFields()->getMailRecipient(); + } + + public function preventableSubject(): string + { + return 'Nachweise erforderlich für deine Anmeldung zu ' . $this->form->name; } } diff --git a/app/Form/Resources/FormResource.php b/app/Form/Resources/FormResource.php index b937dcd7..493462d1 100644 --- a/app/Form/Resources/FormResource.php +++ b/app/Form/Resources/FormResource.php @@ -49,6 +49,7 @@ class FormResource extends JsonResource 'is_private' => $this->is_private, 'has_nami_field' => $this->getFields()->hasNamiField(), 'export' => $this->export, + 'needs_prevention' => $this->needs_prevention, 'links' => [ 'participant_index' => route('form.participant.index', ['form' => $this->getModel(), 'parent' => null]), 'participant_root_index' => route('form.participant.index', ['form' => $this->getModel(), 'parent' => -1]), diff --git a/app/Member/Member.php b/app/Member/Member.php index ad6b3848..fe5de866 100644 --- a/app/Member/Member.php +++ b/app/Member/Member.php @@ -23,7 +23,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Notifications\Notifiable; -use Laravel\Scout\Attributes\SearchUsingFullText; use Laravel\Scout\Searchable; use Sabre\VObject\Component\VCard; use Sabre\VObject\Reader; @@ -33,6 +32,7 @@ use Zoomyboy\Osm\Coordinate; use Zoomyboy\Osm\Geolocatable; use Zoomyboy\Osm\HasGeolocation; use Zoomyboy\Phone\HasPhoneNumbers; +use App\Prevention\Enums\Prevention; /** * @property string $subscription_name @@ -341,6 +341,37 @@ class Member extends Model implements Geolocatable return $query->where('bill_kind', '!=', null)->where('subscription_id', '!=', null); } + /** + * @return array + */ + public function preventions(?Carbon $date = null): array + { + $date = $date ?: now(); + if (!$this->isLeader()) { + return []; + } + + /** @var arrayefz === null || $this->efz->diffInYears($date) >= 5) { + $preventions[] = Prevention::EFZ; + } + + if ($this->more_ps_at === null) { + if ($this->ps_at === null || $this->ps_at->diffInYears($date) >= 5) { + $preventions[] = Prevention::PS; + } + } else { + if ($this->more_ps_at === null || $this->more_ps_at->diffInYears($date) >= 5) { + $preventions[] = Prevention::MOREPS; + } + } + + return $preventions; + } + + /** * @param Builder $query * diff --git a/app/Prevention/Contracts/Preventable.php b/app/Prevention/Contracts/Preventable.php new file mode 100644 index 00000000..f6b487d7 --- /dev/null +++ b/app/Prevention/Contracts/Preventable.php @@ -0,0 +1,20 @@ + + */ + public function preventions(): array; + + public function getMailRecipient(): stdClass; +} diff --git a/app/Prevention/Enums/Prevention.php b/app/Prevention/Enums/Prevention.php new file mode 100644 index 00000000..4472bc39 --- /dev/null +++ b/app/Prevention/Enums/Prevention.php @@ -0,0 +1,19 @@ + 'erweitertes Führungszeugnis', + static::PS => 'Präventionsschulung Basis Plus', + static::MOREPS => 'Präventionsschulung (Auffrischung)', + }; + } +} diff --git a/app/Prevention/Mails/PreventionRememberMail.php b/app/Prevention/Mails/PreventionRememberMail.php new file mode 100644 index 00000000..f423c575 --- /dev/null +++ b/app/Prevention/Mails/PreventionRememberMail.php @@ -0,0 +1,62 @@ +settings = app(InvoiceSettings::class); + $this->documents = collect($preventable->preventions())->map(fn ($prevention) => "* {$prevention->text()}")->implode("\n"); + } + + /** + * Get the message envelope. + * + * @return \Illuminate\Mail\Mailables\Envelope + */ + public function envelope() + { + return (new Envelope( + subject: $this->preventable->preventableSubject(), + ))->to($this->preventable->getMailRecipient()->email, $this->preventable->getMailRecipient()->name); + } + + /** + * Get the message content definition. + * + * @return \Illuminate\Mail\Mailables\Content + */ + public function content() + { + return new Content( + markdown: $this->preventable->preventableLayout(), + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments() + { + return []; + } +} diff --git a/database/migrations/2024_07_02_165058_create_needs_prevention.php b/database/migrations/2024_07_02_165058_create_needs_prevention.php new file mode 100644 index 00000000..1f946368 --- /dev/null +++ b/database/migrations/2024_07_02_165058_create_needs_prevention.php @@ -0,0 +1,35 @@ +datetime('last_remembered_at')->nullable(); + }); + + Schema::table('forms', function (Blueprint $table) { + $table->boolean('needs_prevention')->default(false); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + } +}; diff --git a/resources/js/views/form/Index.vue b/resources/js/views/form/Index.vue index 75974e34..676edd19 100644 --- a/resources/js/views/form/Index.vue +++ b/resources/js/views/form/Index.vue @@ -43,6 +43,7 @@ + member->fullname }}, + +Du hast dich für die Veranstaltung __{{$preventable->form->name}}__ angemeldet. + +Damit du an der Veranstaltung als leitende oder helfende Person teilnehmen kannst, ist noch folgendes einzureichen oder zu beachten. + +{!! $documents !!} + +@component('mail::subcopy') + +Herzliche Grüße und gut Pfad + +{{$settings->from_long}} +@endcomponent + +@endcomponent diff --git a/tests/EndToEnd/Form/FormIndexActionTest.php b/tests/EndToEnd/Form/FormIndexActionTest.php index 8a384acb..ee8e4f48 100644 --- a/tests/EndToEnd/Form/FormIndexActionTest.php +++ b/tests/EndToEnd/Form/FormIndexActionTest.php @@ -52,6 +52,7 @@ class FormIndexActionTest extends FormTestCase ->assertInertiaPath('data.data.0.is_active', true) ->assertInertiaPath('data.data.0.is_private', false) ->assertInertiaPath('data.data.0.registration_from', '2023-05-06 04:00:00') + ->assertInertiaPath('data.data.0.needs_prevention', false) ->assertInertiaPath('data.data.0.registration_until', '2023-04-01 05:00:00') ->assertInertiaPath('data.data.0.links.participant_index', route('form.participant.index', ['form' => $form])) ->assertInertiaPath('data.data.0.links.export', route('form.export', ['form' => $form])) diff --git a/tests/Feature/Form/FormRequest.php b/tests/Feature/Form/FormRequest.php index 31203f47..f5cb9db6 100644 --- a/tests/Feature/Form/FormRequest.php +++ b/tests/Feature/Form/FormRequest.php @@ -49,6 +49,7 @@ class FormRequest extends RequestFactory 'header_image' => $this->getHeaderImagePayload(str()->uuid() . '.jpg'), 'mailattachments' => [], 'export' => ExportData::from([])->toArray(), + 'needs_prevention' => $this->faker->boolean(), ]; } diff --git a/tests/Feature/Form/FormUpdateActionTest.php b/tests/Feature/Form/FormUpdateActionTest.php index ee2ba1d6..6efe1cef 100644 --- a/tests/Feature/Form/FormUpdateActionTest.php +++ b/tests/Feature/Form/FormUpdateActionTest.php @@ -6,7 +6,6 @@ use App\Fileshare\Data\FileshareResourceData; use App\Form\Data\ExportData; use App\Form\Models\Form; use Illuminate\Foundation\Testing\DatabaseTransactions; -use Illuminate\Support\Facades\Http; class FormUpdateActionTest extends FormTestCase { @@ -124,4 +123,14 @@ class FormUpdateActionTest extends FormTestCase $this->patchJson(route('form.update', ['form' => $form]), $payload)->assertSessionDoesntHaveErrors()->assertOk(); $this->assertEquals(['firstname', 'geb', 'lastname'], $form->fresh()->meta['active_columns']); } + + public function testItUpdatesPrevention(): void + { + $this->login()->loginNami()->withoutExceptionHandling(); + $form = Form::factory()->create(); + $payload = FormRequest::new()->state(['needs_prevention' => true])->create(); + + $this->patchJson(route('form.update', ['form' => $form]), $payload); + $this->assertTrue($form->fresh()->needs_prevention); + } } diff --git a/tests/Feature/Member/PreventionTest.php b/tests/Feature/Member/PreventionTest.php new file mode 100644 index 00000000..9a662ace --- /dev/null +++ b/tests/Feature/Member/PreventionTest.php @@ -0,0 +1,183 @@ +createForm(); + $participant = $this->createParticipant($form); + + PreventionRememberAction::run(); + + $this->assertEquals(now()->format('Y-m-d'), $participant->fresh()->last_remembered_at->format('Y-m-d')); + } + + public function testItRemembersWhenRememberIsDue(): void + { + Mail::fake(); + $form = $this->createForm(); + $participant = tap($this->createParticipant($form), fn ($p) => $p->update(['last_remembered_at' => now()->subWeeks(3)])); + + PreventionRememberAction::run(); + + $this->assertEquals(now()->format('Y-m-d'), $participant->fresh()->last_remembered_at->format('Y-m-d')); + } + + public function testItDoesntRememberWhenRememberingIsNotDue(): void + { + Mail::fake(); + $form = $this->createForm(); + $participant = tap($this->createParticipant($form), fn ($p) => $p->update(['last_remembered_at' => now()->subWeeks(1)])); + + PreventionRememberAction::run(); + + $this->assertEquals(now()->subWeeks(1)->format('Y-m-d'), $participant->fresh()->last_remembered_at->format('Y-m-d')); + } + + public function testItDoesntRememberWhenFormDoesntNeedPrevention(): void + { + Mail::fake(); + $form = tap($this->createForm(), fn ($form) => $form->update(['needs_prevention' => false])); + $participant = $this->createParticipant($form); + + PreventionRememberAction::run(); + + $this->assertNull($participant->fresh()->last_remembered_at); + } + + public function testItDoesntRememberWhenParticipantDoesntHaveMember(): void + { + Mail::fake(); + $form = $this->createForm(); + $participant = $this->createParticipant($form); + $participant->member->delete(); + + PreventionRememberAction::run(); + + $this->assertNull($participant->fresh()->last_remembered_at); + } + + public function testItDoesntRememberWhenMemberIsNotALeader(): void + { + Mail::fake(); + $form = $this->createForm(); + $participant = $this->createParticipant($form); + $participant->member->memberships->each->delete(); + + PreventionRememberAction::run(); + + $this->assertNull($participant->fresh()->last_remembered_at); + } + + private function attributes(): Generator + { + yield [ + 'attrs' => ['efz' => null, 'ps_at' => now()], + 'preventions' => [Prevention::EFZ] + ]; + + yield [ + 'attrs' => ['efz' => now(), 'ps_at' => null], + 'preventions' => [Prevention::PS] + ]; + + yield [ + 'attrs' => ['efz' => now()->subDay(), 'ps_at' => now()], + 'preventions' => [] + ]; + + yield [ + 'attrs' => ['efz' => now(), 'ps_at' => now()->subDay()], + 'preventions' => [] + ]; + + yield [ + 'attrs' => ['efz' => now()->subYears(5)->subDay(), 'ps_at' => now()], + 'preventions' => [Prevention::EFZ] + ]; + + yield [ + 'attrs' => ['efz' => now(), 'ps_at' => now()->subYears(5)->subDay()], + 'preventions' => [Prevention::PS] + ]; + + yield [ + 'attrs' => ['efz' => now(), 'ps_at' => now()->subYears(5)->subDay(), 'more_ps_at' => now()], + 'preventions' => [] + ]; + + yield [ + 'attrs' => ['efz' => now(), 'ps_at' => now()->subYears(15), 'more_ps_at' => now()->subYears(5)->subDay()], + 'preventions' => [Prevention::MOREPS], + ]; + } + + /** + * @param array $preventions + * @dataProvider attributes + */ + public function testItRemembersMember(array $memberAttributes, array $preventions): void + { + Mail::fake(); + $form = $this->createForm(); + $participant = $this->createParticipant($form); + $participant->member->update($memberAttributes); + + PreventionRememberAction::run(); + + if (count($preventions)) { + Mail::assertSent(PreventionRememberMail::class, fn ($mail) => $mail->preventable->preventions() === $preventions); + $this->assertNotNull($participant->fresh()->last_remembered_at); + } else { + Mail::assertNotSent(PreventionRememberMail::class); + $this->assertNull($participant->fresh()->last_remembered_at); + } + } + + public function testItRendersMail(): void + { + InvoiceSettings::fake(['from_long' => 'Stamm Beispiel']); + $form = $this->createForm(); + $participant = $this->createParticipant($form); + (new PreventionRememberMail($participant)) + ->assertSeeInText($participant->member->firstname) + ->assertSeeInText($participant->form->name) + ->assertSeeInText('erweitertes Führungszeugnis') + ->assertSeeInText('Stamm Beispiel') + ->assertSeeInText($participant->member->lastname); + } + + protected function createForm(): Form + { + return Form::factory()->fields([ + $this->textField('vorname')->namiType(NamiType::FIRSTNAME), + ])->create(['needs_prevention' => true]); + } + + protected function createParticipant(Form $form): Participant + { + return Participant::factory()->for($form)->data(['vorname' => 'Max'])->for(Member::factory()->defaults()->has(Membership::factory()->inLocal('€ LeiterIn', 'Wölfling')))->create(); + } +}