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;
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;
use Ramsey\Uuid\Lazy\LazyUuidFromString;
use Ramsey\Uuid\UuidInterface;
class JobEvent implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $reload = false;
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
@ -46,15 +45,7 @@ class JobEvent implements ShouldBroadcastNow
public function broadcastOn()
{
return [
new Channel($this->channel),
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\JobFinished;
use App\Lib\Events\JobStarted;
use App\Lib\Events\ReloadTriggered;
use Closure;
use Lorisleiva\Actions\Decorators\JobDecorator;
use Ramsey\Uuid\Lazy\LazyUuidFromString;
use Ramsey\Uuid\UuidInterface;
use Throwable;
@ -17,51 +17,47 @@ class WithJobState
public ?JobStarted $beforeMessage = null;
public ?JobFinished $afterMessage = 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
{
$this->beforeMessage = JobStarted::on($this->channel, $this->jobId)->withMessage($message);
$this->beforeMessage = JobStarted::on($this->jobId)->withMessage($message);
return $this;
}
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;
}
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;
}
public function shouldReload(): self
public function shouldReload(JobChannels $channels): self
{
$this->afterMessage?->shouldReload();
$this->failedMessage?->shouldReload();
$this->reloadAfter = ReloadTriggered::on($channels);
return $this;
}
public function handle(JobDecorator $job, Closure $next): void
{
if ($this->beforeMessage) {
event($this->beforeMessage);
}
try {
$next($job);
} catch (Throwable $e) {
@ -74,5 +70,9 @@ class WithJobState
if ($this->afterMessage) {
event($this->afterMessage);
}
if ($this->reloadAfter) {
event($this->reloadAfter);
}
}
}

View File

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

View File

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

View File

@ -2,45 +2,58 @@
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\Member\Member;
use App\Member\Membership;
use App\Setting\NamiSettings;
use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class MembershipDestroyAction
{
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) {
$settings->login()->deleteMembership(
$member->nami_id,
$api->membership($member->nami_id, $membership->nami_id)
$api->deleteMembership(
$membership->member->nami_id,
$api->membership($membership->member->nami_id, $membership->nami_id)
);
}
$membership->delete();
if ($membership->hasNami) {
$member->syncVersion();
$membership->member->syncVersion();
}
}
public function asController(Membership $membership, NamiSettings $settings): JsonResponse
{
$this->handle(
$membership->member,
$membership,
$settings,
);
ResyncAction::dispatch();
}
public function asController(Membership $membership): JsonResponse
{
$this->startJob($membership);
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\Group;
use App\Lib\JobMiddleware\JobChannels;
use App\Lib\JobMiddleware\WithJobState;
use App\Lib\Queue\TracksJob;
use App\Maildispatcher\Actions\ResyncAction;
@ -12,6 +13,7 @@ use App\Member\Membership;
use App\Setting\NamiSettings;
use App\Subactivity;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Validation\Rule;
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(
$member,
@ -100,7 +102,7 @@ class MembershipStoreAction
$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')
->after('Mitgliedschaft für ' . $member->fullname . ' gespeichert')
->failed('Fehler beim Erstellen der Mitgliedschaft für ' . $member->fullname)
->shouldReload();
}
public function jobChannel(): string
{
return 'member';
->shouldReload(JobChannels::make()->add('member')->add('membership'));
}
}

View File

@ -3,6 +3,9 @@
namespace App\Membership\Actions;
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\Member\Membership;
use App\Subactivity;
@ -16,6 +19,7 @@ use Lorisleiva\Actions\Concerns\AsAction;
class MembershipUpdateAction
{
use AsAction;
use TracksJob;
public function handle(Membership $membership, Activity $activity, ?Subactivity $subactivity, ?Carbon $promisedAt): Membership
{
@ -25,6 +29,8 @@ class MembershipUpdateAction
'promised_at' => $promisedAt,
]);
ResyncAction::dispatch();
return $membership;
}
@ -55,15 +61,27 @@ class MembershipUpdateAction
public function asController(Membership $membership, ActionRequest $request): JsonResponse
{
$this->handle(
$this->startJob(
$membership,
Activity::find($request->activity_id),
$request->subactivity_id ? Subactivity::find($request->subactivity_id) : null,
$request->promised_at ? Carbon::parse($request->promised_at) : null,
);
ResyncAction::dispatch();
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')
->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) {
return {
startListener: function () {
window.Echo.channel('jobs').listen('\\App\\Lib\\Events\\ClientMessage', (e) => handleJobEvent(e, 'success', 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));
window.Echo.channel(siteName).listen('\\App\\Lib\\Events\\ReloadTriggered', () => reloadCallback());
},
stopListener() {
window.Echo.leave(siteName);
window.Echo.leave('jobs');
},
};
}

View File

@ -1,8 +1,18 @@
import Pusher from 'pusher-js';
import Echo from 'laravel-echo';
import {useToast} from 'vue-toastification';
const toast = useToast();
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',
key: 'adremakey',
wsHost: window.location.hostname,
@ -13,3 +23,10 @@ export default new Echo({
cluster: 'adrema',
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\Group;
use App\Lib\Events\JobFinished;
use App\Lib\Events\JobStarted;
use App\Lib\Events\ReloadTriggered;
use App\Member\Member;
use App\Subactivity;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Event;
use Tests\RequestFactories\MembershipRequestFactory;
use Tests\TestCase;
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()
);
$response->assertRedirect('/member');
$response->assertOk();
$this->assertEquals(1506, $member->fresh()->version);
$this->assertDatabaseHas('memberships', [
'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
{
$this->withoutExceptionHandling();