Add queue events for memberships

This commit is contained in:
Philipp Lang 2023-10-16 15:41:37 +02:00
parent e60bc94b80
commit 20833426ca
13 changed files with 205 additions and 89 deletions

View File

@ -2,28 +2,27 @@
namespace App\Lib\Events; namespace App\Lib\Events;
use App\Lib\JobMiddleware\JobChannels;
use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Ramsey\Uuid\Lazy\LazyUuidFromString;
use Ramsey\Uuid\UuidInterface; use Ramsey\Uuid\UuidInterface;
class JobEvent implements ShouldBroadcastNow class JobEvent implements ShouldBroadcastNow
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $reload = false;
public string $message = ''; public string $message = '';
final private function __construct(public string $channel, public UuidInterface $jobId) final private function __construct(public UuidInterface $jobId)
{ {
} }
public static function on(string $channel, UuidInterface $jobId): static public static function on(UuidInterface $jobId): static
{ {
return new static($channel, $jobId); return new static($jobId);
} }
public function withMessage(string $message): static public function withMessage(string $message): static
@ -46,15 +45,7 @@ class JobEvent implements ShouldBroadcastNow
public function broadcastOn() public function broadcastOn()
{ {
return [ return [
new Channel($this->channel),
new Channel('jobs'), new Channel('jobs'),
]; ];
} }
public function shouldReload(): static
{
$this->reload = true;
return $this;
}
} }

View File

@ -0,0 +1,39 @@
<?php
namespace App\Lib\Events;
use App\Lib\JobMiddleware\JobChannels;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ReloadTriggered implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
final private function __construct(public JobChannels $channels)
{
}
public static function on(JobChannels $channels): self
{
return new static($channels);
}
public function dispatch(): void
{
event($this);
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, Channel>
*/
public function broadcastOn()
{
return $this->channels->toBroadcast();
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Lib\JobMiddleware;
use Illuminate\Broadcasting\Channel;
use Illuminate\Contracts\Support\Arrayable;
/**
* @implements Arrayable<int, string>
*/
class JobChannels implements Arrayable
{
public static function make(): self
{
return new self();
}
/**
* @param array<int, string> $channels
*/
public function __construct(
public array $channels = []
) {
}
public function add(string $channelName): self
{
$this->channels[] = $channelName;
return $this;
}
public function toArray(): array
{
return $this->channels;
}
/**
* @return array<int, Channel>
*/
public function toBroadcast(): array
{
return array_map(fn ($channel) => new Channel($channel), $this->channels);
}
}

View File

@ -5,9 +5,9 @@ namespace App\Lib\JobMiddleware;
use App\Lib\Events\JobFailed; use App\Lib\Events\JobFailed;
use App\Lib\Events\JobFinished; use App\Lib\Events\JobFinished;
use App\Lib\Events\JobStarted; use App\Lib\Events\JobStarted;
use App\Lib\Events\ReloadTriggered;
use Closure; use Closure;
use Lorisleiva\Actions\Decorators\JobDecorator; use Lorisleiva\Actions\Decorators\JobDecorator;
use Ramsey\Uuid\Lazy\LazyUuidFromString;
use Ramsey\Uuid\UuidInterface; use Ramsey\Uuid\UuidInterface;
use Throwable; use Throwable;
@ -17,51 +17,47 @@ class WithJobState
public ?JobStarted $beforeMessage = null; public ?JobStarted $beforeMessage = null;
public ?JobFinished $afterMessage = null; public ?JobFinished $afterMessage = null;
public ?JobFailed $failedMessage = null; public ?JobFailed $failedMessage = null;
public ?ReloadTriggered $reloadAfter = null;
private function __construct(public string $channel, public UuidInterface $jobId) private function __construct(public UuidInterface $jobId)
{ {
} }
public static function make(string $channel, UuidInterface $jobId): self public static function make(UuidInterface $jobId): self
{ {
return new self($channel, $jobId); return new self($jobId);
} }
public function before(string $message): self public function before(string $message): self
{ {
$this->beforeMessage = JobStarted::on($this->channel, $this->jobId)->withMessage($message); $this->beforeMessage = JobStarted::on($this->jobId)->withMessage($message);
return $this; return $this;
} }
public function after(string $message): self public function after(string $message): self
{ {
$this->afterMessage = JobFinished::on($this->channel, $this->jobId)->withMessage($message); $this->afterMessage = JobFinished::on($this->jobId)->withMessage($message);
return $this; return $this;
} }
public function failed(string $message): self public function failed(string $message): self
{ {
$this->failedMessage = JobFailed::on($this->channel, $this->jobId)->withMessage($message); $this->failedMessage = JobFailed::on($this->jobId)->withMessage($message);
return $this; return $this;
} }
public function shouldReload(): self public function shouldReload(JobChannels $channels): self
{ {
$this->afterMessage?->shouldReload(); $this->reloadAfter = ReloadTriggered::on($channels);
$this->failedMessage?->shouldReload();
return $this; return $this;
} }
public function handle(JobDecorator $job, Closure $next): void public function handle(JobDecorator $job, Closure $next): void
{ {
if ($this->beforeMessage) {
event($this->beforeMessage);
}
try { try {
$next($job); $next($job);
} catch (Throwable $e) { } catch (Throwable $e) {
@ -74,5 +70,9 @@ class WithJobState
if ($this->afterMessage) { if ($this->afterMessage) {
event($this->afterMessage); event($this->afterMessage);
} }
if ($this->reloadAfter) {
event($this->reloadAfter);
}
} }
} }

View File

@ -2,13 +2,13 @@
namespace App\Lib\Queue; namespace App\Lib\Queue;
use App\Lib\JobMiddleware\JobChannels;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Lib\JobMiddleware\WithJobState; use App\Lib\JobMiddleware\WithJobState;
trait TracksJob trait TracksJob
{ {
abstract public function jobState(WithJobState $jobState, ...$parameters): WithJobState; abstract public function jobState(WithJobState $jobState, ...$parameters): WithJobState;
abstract public function jobChannel(): string;
/** /**
* @param mixed $parameters * @param mixed $parameters
@ -16,7 +16,7 @@ trait TracksJob
public function startJob(...$parameters): void public function startJob(...$parameters): void
{ {
$jobId = Str::uuid(); $jobId = Str::uuid();
$jobState = WithJobState::make($this->jobChannel(), $jobId); $jobState = WithJobState::make($jobId);
$this->jobState(...[$jobState, ...$parameters])->beforeMessage->dispatch(); $this->jobState(...[$jobState, ...$parameters])->beforeMessage->dispatch();
$parameters[] = $jobId; $parameters[] = $jobId;
static::dispatch(...$parameters); static::dispatch(...$parameters);
@ -30,7 +30,7 @@ trait TracksJob
public function getJobMiddleware(...$parameters): array public function getJobMiddleware(...$parameters): array
{ {
$jobId = array_pop($parameters); $jobId = array_pop($parameters);
$jobState = WithJobState::make($this->jobChannel(), $jobId); $jobState = WithJobState::make($jobId);
$jobState = $this->jobState(...[$jobState, ...$parameters]); $jobState = $this->jobState(...[$jobState, ...$parameters]);
$jobState->beforeMessage = null; $jobState->beforeMessage = null;

View File

@ -2,6 +2,7 @@
namespace App\Member\Actions; namespace App\Member\Actions;
use App\Lib\JobMiddleware\JobChannels;
use App\Lib\JobMiddleware\WithJobState; use App\Lib\JobMiddleware\WithJobState;
use App\Lib\Queue\TracksJob; use App\Lib\Queue\TracksJob;
use App\Maildispatcher\Actions\ResyncAction; use App\Maildispatcher\Actions\ResyncAction;
@ -45,11 +46,6 @@ class MemberDeleteAction
->before('Mitglied ' . $member->fullname . ' wird gelöscht') ->before('Mitglied ' . $member->fullname . ' wird gelöscht')
->after('Mitglied ' . $member->fullname . ' gelöscht') ->after('Mitglied ' . $member->fullname . ' gelöscht')
->failed('Löschen von ' . $member->fullname . ' fehlgeschlagen.') ->failed('Löschen von ' . $member->fullname . ' fehlgeschlagen.')
->shouldReload(); ->shouldReload(JobChannels::make()->add('member'));
}
public function jobChannel(): string
{
return 'member';
} }
} }

View File

@ -2,45 +2,58 @@
namespace App\Membership\Actions; namespace App\Membership\Actions;
use App\Lib\JobMiddleware\JobChannels;
use App\Lib\JobMiddleware\WithJobState;
use App\Lib\Queue\TracksJob;
use App\Maildispatcher\Actions\ResyncAction; use App\Maildispatcher\Actions\ResyncAction;
use App\Member\Member;
use App\Member\Membership; use App\Member\Membership;
use App\Setting\NamiSettings; use App\Setting\NamiSettings;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
class MembershipDestroyAction class MembershipDestroyAction
{ {
use AsAction; use AsAction;
use TracksJob;
public function handle(Member $member, Membership $membership, NamiSettings $settings): void public function handle(Membership $membership): void
{ {
$api = $settings->login(); $api = app(NamiSettings::class)->login();
if ($membership->hasNami) { if ($membership->hasNami) {
$settings->login()->deleteMembership( $api->deleteMembership(
$member->nami_id, $membership->member->nami_id,
$api->membership($member->nami_id, $membership->nami_id) $api->membership($membership->member->nami_id, $membership->nami_id)
); );
} }
$membership->delete(); $membership->delete();
if ($membership->hasNami) { if ($membership->hasNami) {
$member->syncVersion(); $membership->member->syncVersion();
} }
}
public function asController(Membership $membership, NamiSettings $settings): JsonResponse
{
$this->handle(
$membership->member,
$membership,
$settings,
);
ResyncAction::dispatch(); ResyncAction::dispatch();
}
public function asController(Membership $membership): JsonResponse
{
$this->startJob($membership);
return response()->json([]); return response()->json([]);
} }
/**
* @param mixed $parameters
*/
public function jobState(WithJobState $jobState, ...$parameters): WithJobState
{
$member = $parameters[0]->member;
return $jobState
->before('Mitgliedschaft für ' . $member->fullname . ' wird gelöscht')
->after('Mitgliedschaft für ' . $member->fullname . ' gelöscht')
->failed('Fehler beim Löschen der Mitgliedschaft für ' . $member->fullname)
->shouldReload(JobChannels::make()->add('member')->add('membership'));
}
} }

View File

@ -4,6 +4,7 @@ namespace App\Membership\Actions;
use App\Activity; use App\Activity;
use App\Group; use App\Group;
use App\Lib\JobMiddleware\JobChannels;
use App\Lib\JobMiddleware\WithJobState; use App\Lib\JobMiddleware\WithJobState;
use App\Lib\Queue\TracksJob; use App\Lib\Queue\TracksJob;
use App\Maildispatcher\Actions\ResyncAction; use App\Maildispatcher\Actions\ResyncAction;
@ -12,6 +13,7 @@ use App\Member\Membership;
use App\Setting\NamiSettings; use App\Setting\NamiSettings;
use App\Subactivity; use App\Subactivity;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In; use Illuminate\Validation\Rules\In;
@ -90,7 +92,7 @@ class MembershipStoreAction
]; ];
} }
public function asController(Member $member, ActionRequest $request): Response public function asController(Member $member, ActionRequest $request): JsonResponse
{ {
$this->startJob( $this->startJob(
$member, $member,
@ -100,7 +102,7 @@ class MembershipStoreAction
$request->promised_at ? Carbon::parse($request->promised_at) : null, $request->promised_at ? Carbon::parse($request->promised_at) : null,
); );
return response(''); return response()->json([]);
} }
/** /**
@ -114,11 +116,6 @@ class MembershipStoreAction
->before('Mitgliedschaft für ' . $member->fullname . ' wird gespeichert') ->before('Mitgliedschaft für ' . $member->fullname . ' wird gespeichert')
->after('Mitgliedschaft für ' . $member->fullname . ' gespeichert') ->after('Mitgliedschaft für ' . $member->fullname . ' gespeichert')
->failed('Fehler beim Erstellen der Mitgliedschaft für ' . $member->fullname) ->failed('Fehler beim Erstellen der Mitgliedschaft für ' . $member->fullname)
->shouldReload(); ->shouldReload(JobChannels::make()->add('member')->add('membership'));
}
public function jobChannel(): string
{
return 'member';
} }
} }

View File

@ -3,6 +3,9 @@
namespace App\Membership\Actions; namespace App\Membership\Actions;
use App\Activity; use App\Activity;
use App\Lib\JobMiddleware\JobChannels;
use App\Lib\JobMiddleware\WithJobState;
use App\Lib\Queue\TracksJob;
use App\Maildispatcher\Actions\ResyncAction; use App\Maildispatcher\Actions\ResyncAction;
use App\Member\Membership; use App\Member\Membership;
use App\Subactivity; use App\Subactivity;
@ -16,6 +19,7 @@ use Lorisleiva\Actions\Concerns\AsAction;
class MembershipUpdateAction class MembershipUpdateAction
{ {
use AsAction; use AsAction;
use TracksJob;
public function handle(Membership $membership, Activity $activity, ?Subactivity $subactivity, ?Carbon $promisedAt): Membership public function handle(Membership $membership, Activity $activity, ?Subactivity $subactivity, ?Carbon $promisedAt): Membership
{ {
@ -25,6 +29,8 @@ class MembershipUpdateAction
'promised_at' => $promisedAt, 'promised_at' => $promisedAt,
]); ]);
ResyncAction::dispatch();
return $membership; return $membership;
} }
@ -55,15 +61,27 @@ class MembershipUpdateAction
public function asController(Membership $membership, ActionRequest $request): JsonResponse public function asController(Membership $membership, ActionRequest $request): JsonResponse
{ {
$this->handle( $this->startJob(
$membership, $membership,
Activity::find($request->activity_id), Activity::find($request->activity_id),
$request->subactivity_id ? Subactivity::find($request->subactivity_id) : null, $request->subactivity_id ? Subactivity::find($request->subactivity_id) : null,
$request->promised_at ? Carbon::parse($request->promised_at) : null, $request->promised_at ? Carbon::parse($request->promised_at) : null,
); );
ResyncAction::dispatch();
return response()->json([]); return response()->json([]);
} }
/**
* @param mixed $parameters
*/
public function jobState(WithJobState $jobState, ...$parameters): WithJobState
{
$member = $parameters[0]->member;
return $jobState
->before('Mitgliedschaft für ' . $member->fullname . ' wird aktualisiert')
->after('Mitgliedschaft für ' . $member->fullname . ' aktualisiert')
->failed('Fehler beim Aktualisieren der Mitgliedschaft für ' . $member->fullname)
->shouldReload(JobChannels::make()->add('member')->add('membership'));
}
} }

View File

@ -91,9 +91,4 @@ class StoreForGroupAction
->after('Gruppen aktualisiert') ->after('Gruppen aktualisiert')
->failed('Aktualisieren von Gruppen fehlgeschlagen'); ->failed('Aktualisieren von Gruppen fehlgeschlagen');
} }
public function jobChannel(): string
{
return 'group';
}
} }

View File

@ -1,27 +1,10 @@
import {useToast} from 'vue-toastification';
const toast = useToast();
function handleJobEvent(event, type = 'success', reloadCallback) {
if (event.message) {
toast[type](event.message);
}
if (event.reload) {
reloadCallback();
}
}
export default function (siteName, reloadCallback) { export default function (siteName, reloadCallback) {
return { return {
startListener: function () { startListener: function () {
window.Echo.channel('jobs').listen('\\App\\Lib\\Events\\ClientMessage', (e) => handleJobEvent(e, 'success', reloadCallback)); window.Echo.channel(siteName).listen('\\App\\Lib\\Events\\ReloadTriggered', () => reloadCallback());
window.Echo.channel(siteName)
.listen('\\App\\Lib\\Events\\JobStarted', (e) => handleJobEvent(e, 'success', reloadCallback))
.listen('\\App\\Lib\\Events\\JobFinished', (e) => handleJobEvent(e, 'success', reloadCallback))
.listen('\\App\\Lib\\Events\\JobFailed', (e) => handleJobEvent(e, 'error', reloadCallback));
}, },
stopListener() { stopListener() {
window.Echo.leave(siteName); window.Echo.leave(siteName);
window.Echo.leave('jobs');
}, },
}; };
} }

View File

@ -1,8 +1,18 @@
import Pusher from 'pusher-js'; import Pusher from 'pusher-js';
import Echo from 'laravel-echo'; import Echo from 'laravel-echo';
import {useToast} from 'vue-toastification';
const toast = useToast();
window.Pusher = Pusher; window.Pusher = Pusher;
export default new Echo({
function handleJobEvent(event, type = 'success') {
if (event.message) {
toast[type](event.message);
}
}
var echo = new Echo({
broadcaster: 'pusher', broadcaster: 'pusher',
key: 'adremakey', key: 'adremakey',
wsHost: window.location.hostname, wsHost: window.location.hostname,
@ -13,3 +23,10 @@ export default new Echo({
cluster: 'adrema', cluster: 'adrema',
enabledTransports: ['ws', 'wss'], enabledTransports: ['ws', 'wss'],
}); });
echo.channel('jobs')
.listen('\\App\\Lib\\Events\\JobStarted', (e) => handleJobEvent(e, 'success'))
.listen('\\App\\Lib\\Events\\JobFinished', (e) => handleJobEvent(e, 'success'))
.listen('\\App\\Lib\\Events\\JobFailed', (e) => handleJobEvent(e, 'error'));
export default echo;

View File

@ -4,10 +4,14 @@ namespace Tests\Feature\Membership;
use App\Activity; use App\Activity;
use App\Group; use App\Group;
use App\Lib\Events\JobFinished;
use App\Lib\Events\JobStarted;
use App\Lib\Events\ReloadTriggered;
use App\Member\Member; use App\Member\Member;
use App\Subactivity; use App\Subactivity;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Event;
use Tests\RequestFactories\MembershipRequestFactory; use Tests\RequestFactories\MembershipRequestFactory;
use Tests\TestCase; use Tests\TestCase;
use Zoomyboy\LaravelNami\Fakes\MemberFake; use Zoomyboy\LaravelNami\Fakes\MemberFake;
@ -45,7 +49,7 @@ class StoreTest extends TestCase
MembershipRequestFactory::new()->promise(now())->in($activity, $activity->subactivities->first())->group($member->group)->create() MembershipRequestFactory::new()->promise(now())->in($activity, $activity->subactivities->first())->group($member->group)->create()
); );
$response->assertRedirect('/member'); $response->assertOk();
$this->assertEquals(1506, $member->fresh()->version); $this->assertEquals(1506, $member->fresh()->version);
$this->assertDatabaseHas('memberships', [ $this->assertDatabaseHas('memberships', [
'member_id' => $member->id, 'member_id' => $member->id,
@ -64,6 +68,23 @@ class StoreTest extends TestCase
]); ]);
} }
public function testItFiresJobEvents(): void
{
Event::fake([JobStarted::class, JobFinished::class, ReloadTriggered::class]);
$this->withoutExceptionHandling();
$member = Member::factory()->defaults()->for(Group::factory())->createOne();
$activity = Activity::factory()->hasAttached(Subactivity::factory())->createOne();
$this->from('/member')->post(
"/member/{$member->id}/membership",
MembershipRequestFactory::new()->in($activity, $activity->subactivities->first())->group($member->group)->create()
);
Event::assertDispatched(JobStarted::class, fn ($event) => $event->broadcastOn()[0]->name === 'jobs' && $event->message !== null);
Event::assertDispatched(JobFinished::class, fn ($event) => $event->broadcastOn()[0]->name === 'jobs' && $event->message !== null);
Event::assertDispatched(ReloadTriggered::class, fn ($event) => ['member', 'membership'] === $event->channels->toArray());
}
public function testItDoesntFireNamiWhenMembershipIsLocal(): void public function testItDoesntFireNamiWhenMembershipIsLocal(): void
{ {
$this->withoutExceptionHandling(); $this->withoutExceptionHandling();