Skip to content

Commit 0706c17

Browse files
Copilotildyria
andauthored
feat(031): configurable webhooks — frontend UI + full test coverage (#4234)
Co-authored-by: ildyria <627094+ildyria@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ildyria <beviguier@gmail.com>
1 parent f54e99e commit 0706c17

100 files changed

Lines changed: 6755 additions & 3 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/Actions/Photo/Delete.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,17 @@
99
namespace App\Actions\Photo;
1010

1111
use App\Actions\Shop\PurchasableService;
12+
use App\Assets\Features;
1213
use App\Constants\PhotoAlbum as PA;
1314
use App\DTO\Delete\PhotosToBeDeletedDTO;
15+
use App\Enum\SizeVariantType;
1416
use App\Events\PhotoDeleted;
17+
use App\Events\PhotoWillBeDeleted;
1518
use App\Exceptions\Internal\LycheeLogicException;
1619
use App\Exceptions\ModelDBException;
20+
use App\Models\SizeVariant;
1721
use Illuminate\Support\Facades\DB;
22+
use Illuminate\Support\Facades\Storage;
1823

1924
/**
2025
* Deletes the photos with the designated IDs **efficiently**.
@@ -108,6 +113,13 @@ public function do(array $photo_ids, string|null $from_id): void
108113

109114
$this->purchasable_service->deleteMulitplePhotoPurchasables($photo_ids, [$from_id]);
110115

116+
// Fire PhotoWillBeDeleted for each photo that will be hard-deleted,
117+
// BEFORE executeDelete() removes the records from the database.
118+
// Load a lean snapshot of photo data (id, title) and their size variants.
119+
if (count($delete_photo_ids) > 0) {
120+
$this->dispatchWillBeDeletedEvents($delete_photo_ids, $from_id);
121+
}
122+
111123
$photos_to_be_deleted = new PhotosToBeDeletedDTO(
112124
force_delete_photo_ids: $delete_photo_ids,
113125
soft_delete_photo_ids: $photo_ids,
@@ -122,4 +134,56 @@ public function do(array $photo_ids, string|null $from_id): void
122134
dispatch($job);
123135
}
124136
}
137+
138+
/**
139+
* Fire PhotoWillBeDeleted for each photo scheduled for hard deletion.
140+
*
141+
* Uses a lean DB query (no full Eloquent hydration) to load photo title
142+
* and size variant URLs before the records are removed.
143+
*
144+
* @param string[] $photo_ids IDs of photos to be hard-deleted
145+
* @param string $album_id the album they are being deleted from
146+
*/
147+
private function dispatchWillBeDeletedEvents(array $photo_ids, string $album_id): void
148+
{
149+
// Skip this
150+
if (Features::inactive('webhook')) {
151+
return;
152+
}
153+
154+
// Load minimal photo data.
155+
$photos_data = DB::table('photos')
156+
->whereIn('id', $photo_ids)
157+
->select(['id', 'title'])
158+
->get();
159+
160+
$photos_data->chunk(500)->each(function ($chunk) use ($album_id): void {
161+
$photo_ids_chunked = $chunk->pluck('id')->all();
162+
// Load size variant data for all photos in one query.
163+
$size_variants = SizeVariant::whereIn('photo_id', $photo_ids_chunked)
164+
->select(['photo_id', 'type', 'short_path', 'storage_disk'])
165+
->get()
166+
->groupBy('photo_id');
167+
168+
foreach ($chunk as $photo) {
169+
$variants = [];
170+
$raw_variants = $size_variants->get($photo->id, collect());
171+
/** @var SizeVariant $sv */
172+
foreach ($raw_variants as $sv) {
173+
$url = $sv->type === SizeVariantType::PLACEHOLDER ? $sv->short_path : $sv->getDownloadUrlAttribute();
174+
$variants[] = [
175+
'type' => $sv->type->name(),
176+
'url' => $url,
177+
];
178+
}
179+
180+
PhotoWillBeDeleted::dispatch(
181+
$photo->id,
182+
$album_id,
183+
$photo->title,
184+
$variants,
185+
);
186+
}
187+
});
188+
}
125189
}

app/Actions/Photo/MoveOrDuplicate.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use App\Contracts\Models\AbstractAlbum;
1515
use App\Events\AlbumSaved;
1616
use App\Events\PhotoDeleted;
17+
use App\Events\PhotoMoved;
1718
use App\Models\Album;
1819
use App\Models\Photo;
1920
use App\Models\Purchasable;
@@ -83,6 +84,13 @@ public function do(Collection $photos, ?AbstractAlbum $from_album, ?Album $to_al
8384
foreach ($photos as $photo) {
8485
$this->applyToPurchasable($photo->id, $from_album->get_id(), $to_album?->get_id());
8586
}
87+
88+
// Dispatch PhotoMoved for each moved photo (cross-album move only; not duplication).
89+
if ($to_album !== null) {
90+
foreach ($photos as $photo) {
91+
PhotoMoved::dispatch($photo->id, $from_album->get_id(), $to_album->id);
92+
}
93+
}
8694
}
8795

8896
$notify = new Notify();

app/Actions/Photo/Pipes/Shared/SetParent.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use App\Contracts\PhotoCreate\SharedPipe;
1313
use App\DTO\PhotoCreate\DuplicateDTO;
1414
use App\DTO\PhotoCreate\StandaloneDTO;
15+
use App\Events\PhotoAdded;
1516
use App\Events\PhotoSaved;
1617
use App\Exceptions\Internal\LycheeLogicException;
1718
use App\Models\Album;
@@ -49,6 +50,12 @@ public function handle(DuplicateDTO|StandaloneDTO $state, \Closure $next): Dupli
4950
// Dispatch event for album stats recomputation
5051
// This must be done after SetParent so the photo_album relationship exists
5152
PhotoSaved::dispatch($state->photo->id);
53+
54+
// Dispatch PhotoAdded for new photo records only (upload, import, duplication).
55+
// Existing records that were re-saved into a different album are handled by PhotoMoved.
56+
if ($state->photo->wasRecentlyCreated) {
57+
PhotoAdded::dispatch($state->photo->id);
58+
}
5259
}
5360

5461
return $next($state);
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\Console\Commands;
10+
11+
use App\Assets\Features;
12+
use App\DTO\WebhookPayload;
13+
use App\Jobs\WebhookDispatchJob;
14+
use App\Models\Webhook;
15+
use Illuminate\Console\Command;
16+
17+
/**
18+
* Artisan command to test a webhook configuration by sending a synthetic HTTP request.
19+
*
20+
* Usage: php artisan lychee:webhook-test <id>
21+
*
22+
* Fires the job synchronously so the operator can immediately see the result.
23+
*/
24+
class WebhookTest extends Command
25+
{
26+
protected $signature = 'lychee:webhook-test {id : The ULID of the webhook to test}';
27+
28+
protected $description = 'Send a test HTTP request to a configured webhook endpoint.';
29+
30+
public function handle(): int
31+
{
32+
if (Features::inactive('webhook')) {
33+
$this->warn('The webhook feature is disabled (WEBHOOK_ENABLED is not set to true).');
34+
$this->warn('Set WEBHOOK_ENABLED=true in your .env file and clear the config cache to enable it.');
35+
36+
return self::FAILURE;
37+
}
38+
39+
$id = $this->argument('id');
40+
41+
/** @var Webhook|null $webhook */
42+
$webhook = Webhook::find($id);
43+
44+
if ($webhook === null) {
45+
$this->error("Webhook not found: {$id}");
46+
47+
return self::FAILURE;
48+
}
49+
50+
$this->info("Testing webhook: {$webhook->name} ({$webhook->url})");
51+
52+
// Build a synthetic payload with clearly labelled test data.
53+
$payload = new WebhookPayload(
54+
photo_id: 'TEST_PHOTO_ID',
55+
album_id: 'TEST_ALBUM_ID',
56+
title: 'Test Photo (lychee:webhook-test)',
57+
size_variants: [
58+
['type' => 'original', 'url' => 'https://example.com/test-original.jpg'],
59+
['type' => 'thumb', 'url' => 'https://example.com/test-thumb.jpg'],
60+
],
61+
);
62+
63+
// Dispatch synchronously so the operator sees the outcome immediately.
64+
try {
65+
(new WebhookDispatchJob($webhook, $payload))->handle();
66+
$this->info('Webhook test dispatched. Check storage/logs/laravel.log for the result.');
67+
} catch (\Throwable $e) {
68+
$this->error('Webhook dispatch failed: ' . $e->getMessage());
69+
70+
return self::FAILURE;
71+
}
72+
73+
return self::SUCCESS;
74+
}
75+
}

app/DTO/WebhookPayload.php

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\DTO;
10+
11+
/**
12+
* Data transfer object representing the payload to be sent to a webhook endpoint.
13+
*
14+
* Fields are nullable; a null value means the field was not selected for inclusion.
15+
*/
16+
final class WebhookPayload
17+
{
18+
/**
19+
* @param string|null $photo_id the photo ID (null if not selected)
20+
* @param string|null $album_id the album ID (null if not selected)
21+
* @param string|null $title the photo title (null if not selected)
22+
* @param array<int,array{type:string,url:string}>|null $size_variants array of {type, url} objects (null if not selected)
23+
*/
24+
public function __construct(
25+
public readonly ?string $photo_id,
26+
public readonly ?string $album_id,
27+
public readonly ?string $title,
28+
public readonly ?array $size_variants,
29+
) {
30+
}
31+
32+
/**
33+
* Return the payload as a flat associative array for JSON encoding.
34+
* Only non-null fields are included.
35+
*
36+
* @return array<string, mixed>
37+
*/
38+
public function toJsonArray(): array
39+
{
40+
$payload = [];
41+
42+
if ($this->photo_id !== null) {
43+
$payload['photo_id'] = $this->photo_id;
44+
}
45+
if ($this->album_id !== null) {
46+
$payload['album_id'] = $this->album_id;
47+
}
48+
if ($this->title !== null) {
49+
$payload['title'] = $this->title;
50+
}
51+
if ($this->size_variants !== null) {
52+
$payload['size_variants'] = $this->size_variants;
53+
}
54+
55+
return $payload;
56+
}
57+
58+
/**
59+
* Return the payload as a flat associative array for query-string encoding.
60+
*
61+
* Scalar fields (photo_id, album_id, title) are passed as-is.
62+
* Size variant URLs are base64-encoded (standard base64) and keyed as
63+
* `size_variant_{type}` — e.g. `size_variant_original`, `size_variant_medium`.
64+
* This avoids URL-encoding ambiguity for S3/CDN URLs.
65+
*
66+
* @return array<string, string>
67+
*/
68+
public function toQueryArray(): array
69+
{
70+
$payload = [];
71+
72+
if ($this->photo_id !== null) {
73+
$payload['photo_id'] = $this->photo_id;
74+
}
75+
if ($this->album_id !== null) {
76+
$payload['album_id'] = $this->album_id;
77+
}
78+
if ($this->title !== null) {
79+
$payload['title'] = $this->title;
80+
}
81+
if ($this->size_variants !== null) {
82+
foreach ($this->size_variants as $variant) {
83+
$key = 'size_variant_' . $variant['type'];
84+
$payload[$key] = base64_encode($variant['url']);
85+
}
86+
}
87+
88+
return $payload;
89+
}
90+
}

app/Enum/PhotoWebhookEvent.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\Enum;
10+
11+
use App\Enum\Traits\DecorateBackedEnum;
12+
13+
/**
14+
* Enum PhotoWebhookEvent.
15+
*
16+
* The lifecycle events for which a webhook can be triggered.
17+
*/
18+
enum PhotoWebhookEvent: string
19+
{
20+
use DecorateBackedEnum;
21+
22+
case ADD = 'photo.add';
23+
case MOVE = 'photo.move';
24+
case DELETE = 'photo.delete';
25+
}

app/Enum/WebhookMethod.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\Enum;
10+
11+
use App\Enum\Traits\DecorateBackedEnum;
12+
13+
/**
14+
* Enum WebhookMethod.
15+
*
16+
* The HTTP methods allowed for outgoing webhook requests.
17+
*/
18+
enum WebhookMethod: string
19+
{
20+
use DecorateBackedEnum;
21+
22+
case GET = 'GET';
23+
case POST = 'POST';
24+
case PUT = 'PUT';
25+
case PATCH = 'PATCH';
26+
case DELETE = 'DELETE';
27+
}

app/Enum/WebhookPayloadFormat.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\Enum;
10+
11+
use App\Enum\Traits\DecorateBackedEnum;
12+
13+
/**
14+
* Enum WebhookPayloadFormat.
15+
*
16+
* Determines how the webhook payload is delivered in outgoing HTTP requests.
17+
*/
18+
enum WebhookPayloadFormat: string
19+
{
20+
use DecorateBackedEnum;
21+
22+
/** Payload is sent as a JSON request body with Content-Type: application/json. */
23+
case JSON = 'json';
24+
25+
/** Payload is sent as URL query parameters appended to the webhook URL. */
26+
case QUERY_STRING = 'query_string';
27+
}

0 commit comments

Comments
 (0)