diff --git a/app/Form/Actions/PreventionRememberAction.php b/app/Form/Actions/PreventionRememberAction.php index 5faae2c8..3ca7bb78 100644 --- a/app/Form/Actions/PreventionRememberAction.php +++ b/app/Form/Actions/PreventionRememberAction.php @@ -33,7 +33,7 @@ class PreventionRememberAction continue; } - if ($participant->getFields()->getMailRecipient() === null || count($participant->preventions()) === 0) { + if ($participant->getFields()->getMailRecipient() === null || $participant->preventions()->count() === 0) { continue; } diff --git a/app/Form/Models/Participant.php b/app/Form/Models/Participant.php index 1db6002a..e2b47af6 100644 --- a/app/Form/Models/Participant.php +++ b/app/Form/Models/Participant.php @@ -14,6 +14,7 @@ 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\Collection; use Illuminate\Support\Facades\Mail; use Laravel\Scout\Searchable; use stdClass; @@ -81,17 +82,12 @@ class Participant extends Model implements Preventable Mail::to($this->getMailRecipient())->queue(new ConfirmRegistrationMail($this)); } - public function preventableLayout(): string - { - return 'mail.prevention.prevention-remember-participant'; - } - /** * @inheritdoc */ - public function preventions(): array + public function preventions(): Collection { - return $this->member?->preventions($this->form->from) ?: []; + return $this->member?->preventions($this->form->from) ?: collect([]); } public function getMailRecipient(): ?stdClass diff --git a/app/Member/Member.php b/app/Member/Member.php index 650e0be9..d04bec79 100644 --- a/app/Member/Member.php +++ b/app/Member/Member.php @@ -13,6 +13,8 @@ use App\Nami\HasNamiField; use App\Nationality; use App\Payment\Subscription; use App\Pdf\Sender; +use App\Prevention\Contracts\Preventable; +use App\Prevention\Data\PreventionData; use App\Region; use App\Setting\NamiSettings; use Carbon\Carbon; @@ -35,12 +37,14 @@ use Zoomyboy\Phone\HasPhoneNumbers; use App\Prevention\Enums\Prevention; use Database\Factories\Member\MemberFactory; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Support\Collection; +use stdClass; /** * @property string $subscription_name * @property int $pending_payment */ -class Member extends Model implements Geolocatable +class Member extends Model implements Geolocatable, Preventable { use Notifiable; use HasNamiField; @@ -194,6 +198,20 @@ class Member extends Model implements Geolocatable return (int) $this->invoicePositions()->whereHas('invoice', fn($query) => $query->whereNeedsPayment())->sum('price'); } + public function getMailRecipient(): ?stdClass + { + if (!$this->fullname) { + return null; + } + + return (object) ['name' => $this->fullname, 'email' => $this->email]; + } + + public function preventableSubject(): string + { + return 'Nachweise erforderlich'; + } + // ---------------------------------- Relations ---------------------------------- /** * @return BelongsTo @@ -364,32 +382,47 @@ class Member extends Model implements Geolocatable } /** - * @return array + * @inheritdoc */ - public function preventions(?Carbon $date = null): array + public function preventions(?Carbon $date = null): Collection { $date = $date ?: now(); - /** @var array */ - $preventions = []; + /** @var Collection */ + $preventions = collect([]); if ($this->efz === null || $this->efz->diffInYears($date) >= 5) { - $preventions[] = Prevention::EFZ; + $preventions->push(PreventionData::from([ + 'type' => Prevention::EFZ, + 'expires' => $this->efz === null ? now() : $this->efz->addYears(5) + ])); } if (!$this->has_vk) { - $preventions[] = Prevention::VK; + $preventions->push(PreventionData::from([ + 'type' => Prevention::VK, + 'expires' => now(), + ])); } if ($this->more_ps_at === null) { if ($this->ps_at === null) { - $preventions[] = Prevention::PS; + $preventions->push(PreventionData::from([ + 'type' => Prevention::PS, + 'expires' => now(), + ])); } else if ($this->ps_at->diffInYears($date) >= 5) { - $preventions[] = Prevention::MOREPS; + $preventions->push(PreventionData::from([ + 'type' => Prevention::MOREPS, + 'expires' => $this->ps_at->addYears(5), + ])); } } else { if ($this->more_ps_at === null || $this->more_ps_at->diffInYears($date) >= 5) { - $preventions[] = Prevention::MOREPS; + $preventions->push(PreventionData::from([ + 'type' => Prevention::MOREPS, + 'expires' => $this->more_ps_at->addYears(5), + ])); } } diff --git a/app/Prevention/Actions/YearlyRememberAction.php b/app/Prevention/Actions/YearlyRememberAction.php new file mode 100644 index 00000000..c1fbd2bc --- /dev/null +++ b/app/Prevention/Actions/YearlyRememberAction.php @@ -0,0 +1,56 @@ +addWeeks($settings->weeks); + + foreach (Member::get() as $member) { + $noticePreventions = $member->preventions($expireDate) + ->filter(fn($prevention) => $prevention->expiresAt($expireDate)); + + if ($noticePreventions->count() === 0) { + continue; + } + + Mail::send($this->createMail($member, $noticePreventions)); + } + + foreach (Member::get() as $member) { + $preventions = $member->preventions() + ->filter(fn($prevention) => $prevention->expiresAt(now())); + + if ($preventions->count() === 0) { + continue; + } + + Mail::send($this->createMail($member, $preventions)); + } + } + + /** + * @param Collection $preventions + */ + protected function createMail(Member $member, Collection $preventions): YearlyMail + { + $body = app(PreventionSettings::class)->refresh()->formmail; + return new YearlyMail($member, $body, $preventions); + } +} diff --git a/app/Prevention/Contracts/Preventable.php b/app/Prevention/Contracts/Preventable.php index 472355c1..59f86c45 100644 --- a/app/Prevention/Contracts/Preventable.php +++ b/app/Prevention/Contracts/Preventable.php @@ -2,19 +2,19 @@ namespace App\Prevention\Contracts; -use App\Prevention\Enums\Prevention; +use App\Prevention\Data\PreventionData; +use Illuminate\Support\Collection; use stdClass; interface Preventable { - public function preventableLayout(): string; public function preventableSubject(): string; /** - * @return array + * @return Collection */ - public function preventions(): array; + public function preventions(): Collection; public function getMailRecipient(): ?stdClass; } diff --git a/app/Prevention/Data/PreventionData.php b/app/Prevention/Data/PreventionData.php new file mode 100644 index 00000000..c9474550 --- /dev/null +++ b/app/Prevention/Data/PreventionData.php @@ -0,0 +1,17 @@ +expires->isSameDay($date); + } +} diff --git a/app/Prevention/Enums/Prevention.php b/app/Prevention/Enums/Prevention.php index 7754114c..0561a746 100644 --- a/app/Prevention/Enums/Prevention.php +++ b/app/Prevention/Enums/Prevention.php @@ -2,8 +2,6 @@ namespace App\Prevention\Enums; -use App\Member\Member; -use Carbon\Carbon; use Illuminate\Support\Collection; enum Prevention @@ -44,7 +42,7 @@ enum Prevention */ public static function items(array $preventions): Collection { - return collect(static::cases())->map(fn ($case) => [ + return collect(static::cases())->map(fn($case) => [ 'letter' => $case->letter(), 'value' => !in_array($case, $preventions), 'tooltip' => $case->tooltip(!in_array($case, $preventions)), diff --git a/app/Prevention/Mails/PreventionRememberMail.php b/app/Prevention/Mails/PreventionRememberMail.php index 2ab91b39..4d572fd4 100644 --- a/app/Prevention/Mails/PreventionRememberMail.php +++ b/app/Prevention/Mails/PreventionRememberMail.php @@ -25,7 +25,7 @@ class PreventionRememberMail extends Mailable { $this->settings = app(InvoiceSettings::class); $this->bodyText = $this->bodyText - ->replaceWithList('wanted', collect($preventable->preventions())->map(fn($prevention) => $prevention->text())->toArray()); + ->replaceWithList('wanted', collect($preventable->preventions())->map(fn($prevention) => $prevention->type->text())->toArray()); } /** @@ -48,7 +48,7 @@ class PreventionRememberMail extends Mailable public function content() { return new Content( - markdown: $this->preventable->preventableLayout(), + markdown: 'mail.prevention.prevention-remember-participant', ); } diff --git a/app/Prevention/Mails/YearlyMail.php b/app/Prevention/Mails/YearlyMail.php new file mode 100644 index 00000000..04bc954c --- /dev/null +++ b/app/Prevention/Mails/YearlyMail.php @@ -0,0 +1,68 @@ + $preventions + */ + public function __construct(public Preventable $preventable, public EditorData $bodyText, public Collection $preventions) + { + $this->settings = app(InvoiceSettings::class); + $this->bodyText = $this->bodyText + ->replaceWithList('wanted', collect($preventions)->pluck('type')->map(fn($prevention) => $prevention->text())->toArray()); + } + + /** + * 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: 'mail.prevention.prevention-remember-participant', + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments(): array + { + return []; + } +} diff --git a/app/Prevention/PreventionSettings.php b/app/Prevention/PreventionSettings.php index d1832a0a..00ee3ac9 100644 --- a/app/Prevention/PreventionSettings.php +++ b/app/Prevention/PreventionSettings.php @@ -10,6 +10,7 @@ class PreventionSettings extends LocalSettings public EditorData $formmail; public EditorData $yearlymail; + public int $weeks; public static function group(): string { diff --git a/database/settings/2025_05_24_202013_create_prevention_yearly_mail_settings.php b/database/settings/2025_05_24_202013_create_prevention_yearly_mail_settings.php index 97b3b2f5..6f324517 100644 --- a/database/settings/2025_05_24_202013_create_prevention_yearly_mail_settings.php +++ b/database/settings/2025_05_24_202013_create_prevention_yearly_mail_settings.php @@ -7,5 +7,6 @@ return new class extends SettingsMigration public function up(): void { $this->migrator->add('prevention.yearlymail', ['time' => 1, 'blocks' => [], 'version' => '1.0']); + $this->migrator->add('prevention.weeks', 8); } }; diff --git a/tests/Feature/Member/PreventionTest.php b/tests/Feature/Member/PreventionTest.php index f30fb452..29b3216a 100644 --- a/tests/Feature/Member/PreventionTest.php +++ b/tests/Feature/Member/PreventionTest.php @@ -13,14 +13,13 @@ use App\Lib\Editor\Condition; use App\Prevention\Mails\PreventionRememberMail; use App\Member\Member; use App\Member\Membership; +use App\Prevention\Actions\YearlyRememberAction; +use App\Prevention\Mails\YearlyMail; use App\Prevention\PreventionSettings; -use Generator; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\Mail; -use PHPUnit\Framework\Attributes\DataProvider; use Tests\Lib\CreatesFormFields; use Tests\RequestFactories\EditorRequestFactory; -use Tests\TestCase; uses(DatabaseTransactions::class); uses(CreatesFormFields::class); @@ -43,7 +42,17 @@ function createParticipant(Form $form): Participant ])->for(Member::factory()->defaults()->has(Membership::factory()->inLocal('€ LeiterIn', 'Wölfling')))->create(); } +function createMember(array $attributes): Member +{ + return Member::factory()->defaults()->has(Membership::factory()->inLocal('€ LeiterIn', 'Wölfling'))->create($attributes); +} + dataset('attributes', fn() => [ + [ + ['has_vk' => false, 'efz' => null, 'ps_at' => null], + [Prevention::EFZ, Prevention::VK, Prevention::PS] + ], + [ ['has_vk' => true, 'efz' => null, 'ps_at' => now()], [Prevention::EFZ] @@ -174,7 +183,7 @@ it('testItDoesntRememberWhenParticipantDoesntHaveMember', function () { $this->assertNull($participant->fresh()->last_remembered_at); }); -it('testItRemembersNonLeaders', function () { +it('doesnt remember non leaders', function () { Mail::fake(); $form = createForm(); $participant = createParticipant($form); @@ -186,7 +195,7 @@ it('testItRemembersNonLeaders', function () { }); -it('testItRemembersMember', function ($attrs, $preventions) { +it('remembers event participant', function ($attrs, $preventions) { Mail::fake(); $form = createForm(); $participant = createParticipant($form); @@ -195,7 +204,7 @@ it('testItRemembersMember', function ($attrs, $preventions) { PreventionRememberAction::run(); if (count($preventions)) { - Mail::assertSent(PreventionRememberMail::class, fn($mail) => $mail->preventable->preventions() === $preventions); + Mail::assertSent(PreventionRememberMail::class, fn($mail) => $mail->preventable->preventions()->pluck('type')->toArray() === $preventions); $this->assertNotNull($participant->fresh()->last_remembered_at); } else { Mail::assertNotSent(PreventionRememberMail::class); @@ -203,6 +212,49 @@ it('testItRemembersMember', function ($attrs, $preventions) { } })->with('attributes'); +it('sets due date in mail when not now', function () { + Mail::fake(); + $form = createForm(); + $form->update(['from' => now()->addMonths(8)]); + $participant = createParticipant($form); + $participant->member->update(['efz' => now()->subYears(5)->addMonth(), 'ps_at' => now(), 'has_vk' => true]); + + PreventionRememberAction::run(); + + Mail::assertSent(PreventionRememberMail::class, fn($mail) => $mail->preventable->preventions()->first()->expires->isSameDay(now()->addMonth())); +}); + +it('notices a few weeks before', function ($date, bool $shouldSend) { + Mail::fake(); + app(PreventionSettings::class)->fill(['weeks' => 2])->save(); + createMember(['efz' => $date, 'ps_at' => now(), 'has_vk' => true]); + + YearlyRememberAction::run(); + + $shouldSend + ? Mail::assertSent(YearlyMail::class, fn($mail) => $mail->preventions->first()->expires->isSameDay(now()->addWeeks(2))) + : Mail::assertNotSent(YearlyMail::class); +})->with([ + [fn() => now()->subYears(5)->addWeeks(2), true], + [fn() => now()->subYears(5)->addWeeks(2)->addDay(), false], + [fn() => now()->subYears(5)->addWeeks(2)->subDay(), false], +]); + +it('remembers members yearly', function ($date, $shouldSend) { + Mail::fake(); + createMember(['efz' => $date, 'ps_at' => now(), 'has_vk' => true]); + + YearlyRememberAction::run(); + + $shouldSend + ? Mail::assertSent(YearlyMail::class, fn($mail) => $mail->preventions->first()->expires->isSameDay(now())) + : Mail::assertNotSent(YearlyMail::class); +})->with([ + [fn() => now()->subYears(5), true], + [fn() => now()->subYears(5)->addDay(), false], + [fn() => now()->subYears(5)->subDay(), false], +]); + it('testItDoesntRememberParticipantThatHasNoMail', function () { Mail::fake(); $form = createForm(); @@ -214,16 +266,6 @@ it('testItDoesntRememberParticipantThatHasNoMail', function () { Mail::assertNotSent(PreventionRememberMail::class); }); -it('testItRendersMail', function () { - InvoiceSettings::fake(['from_long' => 'Stamm Beispiel']); - $form = createForm(); - $participant = createParticipant($form); - (new PreventionRememberMail($participant, app(PreventionSettings::class)->formmail)) - ->assertSeeInText($participant->member->firstname) - ->assertSeeInText($participant->member->lastname) - ->assertSeeInText('Stamm Beispiel'); -}); - it('testItRendersSetttingMail', function () { Mail::fake(); app(PreventionSettings::class)->fill([ @@ -273,10 +315,30 @@ it('testItDoesntAppendTextTwice', function () { Mail::assertSent(PreventionRememberMail::class, fn($mail) => $mail->bodyText->hasAll(['oberhausen']) && !$mail->bodyText->hasAll(['siegburg'])); }); -it('testItDisplaysBodyTextInMail', function () { +/* ----------------------------------------- Mail contents ----------------------------------------- */ +it('displays body text in prevention remember mail', function () { $form = createForm(); $participant = createParticipant($form); $mail = new PreventionRememberMail($participant, EditorRequestFactory::new()->paragraphs(['ggtt'])->toData()); $mail->assertSeeInText('ggtt'); }); + +it('renders prevention mail for events with group name', function () { + InvoiceSettings::fake(['from_long' => 'Stamm Beispiel']); + $form = createForm(); + $participant = createParticipant($form); + (new PreventionRememberMail($participant, app(PreventionSettings::class)->formmail, collect([]))) + ->assertSeeInText('Max') + ->assertSeeInText('Muster') + ->assertSeeInText('Stamm Beispiel'); +}); + +it('renders yearly mail', function () { + InvoiceSettings::fake(['from_long' => 'Stamm Beispiel']); + $member = createMember([]); + $mail = new YearlyMail($member, EditorRequestFactory::new()->paragraphs(['ggtt'])->toData(), collect([])); + $mail + ->assertSeeInText('ggtt') + ->assertSeeInText('Stamm Beispiel'); +});