diff --git a/app/Domains/Contact/ManageAvatar/Services/SuggestAvatar.php b/app/Domains/Contact/ManageAvatar/Services/SuggestAvatar.php new file mode 100644 index 00000000000..af28740856e --- /dev/null +++ b/app/Domains/Contact/ManageAvatar/Services/SuggestAvatar.php @@ -0,0 +1,88 @@ + + */ + public function rules(): array + { + return [ + 'account_id' => 'required|uuid|exists:accounts,id', + 'vault_id' => 'required|uuid|exists:vaults,id', + 'author_id' => 'required|uuid|exists:users,id', + 'contact_id' => 'required|uuid|exists:contacts,id', + 'search_term' => 'nullable|string', + ]; + } + + /** + * Get the permissions that apply to the user calling the service. + * + * @return array + */ + public function permissions(): array + { + return [ + 'author_must_belong_to_account', + 'vault_must_belong_to_account', + 'author_must_be_vault_editor', + 'contact_must_belong_to_vault', + ]; + } + + /** + * Remove the current file used as avatar and put the default avatar back. + * + * @throws Exception + */ + public function execute(array $data): array + { + $this->validate($data); + $this->setSearchTerm($data); + + if (empty($this->getSearchTerm())) { + return []; + } + + try { + return (new GooglePhotoService())->search($this->getSearchTerm()); + } catch (Exception $e) { + return []; + } + } + + public function getSearchTerm(): string + { + return $this->search_term; + } + + public function setSearchTerm(array $data): self + { + $this->search_term = $data['search_term'] ?? $this->contact->name; + + return $this; + } + + /** + * @throws Exception + */ + private function validate(array $data): void + { + $this->validateRules($data); + } +} diff --git a/app/Domains/Contact/ManageAvatar/Web/Controllers/ModuleAvatarController.php b/app/Domains/Contact/ManageAvatar/Web/Controllers/ModuleAvatarController.php index 12b06847bbd..c2023c20ece 100644 --- a/app/Domains/Contact/ManageAvatar/Web/Controllers/ModuleAvatarController.php +++ b/app/Domains/Contact/ManageAvatar/Web/Controllers/ModuleAvatarController.php @@ -3,10 +3,12 @@ namespace App\Domains\Contact\ManageAvatar\Web\Controllers; use App\Domains\Contact\ManageAvatar\Services\DestroyAvatar; +use App\Domains\Contact\ManageAvatar\Services\SuggestAvatar; use App\Domains\Contact\ManageAvatar\Services\UpdatePhotoAsAvatar; use App\Domains\Contact\ManageDocuments\Services\UploadFile; use App\Http\Controllers\Controller; use App\Models\File; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -66,4 +68,25 @@ public function destroy(Request $request, string $vaultId, string $contactId) ]), ], 200); } + + public function suggest(Request $request, string $vaultId, string $contactId): JsonResponse + { + $accountId = Auth::user()->account_id; + $authorId = Auth::id(); + $searchTerm = $request->search_term; + + $data = [ + 'account_id' => $accountId, + 'author_id' => $authorId, + 'vault_id' => $vaultId, + 'contact_id' => $contactId, + 'search_term' => $searchTerm, + ]; + + $imageUrls = (new SuggestAvatar())->execute($data); + + return response()->json([ + 'data' => $imageUrls, + ]); + } } diff --git a/app/Domains/Contact/ManageAvatar/Web/ViewHelpers/ModuleAvatarViewHelper.php b/app/Domains/Contact/ManageAvatar/Web/ViewHelpers/ModuleAvatarViewHelper.php index aba2e0cf332..8eba6feef40 100644 --- a/app/Domains/Contact/ManageAvatar/Web/ViewHelpers/ModuleAvatarViewHelper.php +++ b/app/Domains/Contact/ManageAvatar/Web/ViewHelpers/ModuleAvatarViewHelper.php @@ -2,6 +2,7 @@ namespace App\Domains\Contact\ManageAvatar\Web\ViewHelpers; +use App\Helpers\StorageHelper; use App\Models\Contact; class ModuleAvatarViewHelper @@ -10,6 +11,21 @@ public static function data(Contact $contact): array { return [ 'avatar' => $contact->avatar, + 'uploadcare' => StorageHelper::uploadcare(), + 'url' => [ + 'update' => route('contact.avatar.update', [ + 'vault' => $contact->vault_id, + 'contact' => $contact->id, + ]), + 'destroy' => route('contact.avatar.destroy', [ + 'vault' => $contact->vault_id, + 'contact' => $contact->id, + ]), + 'suggest' => route('contact.avatar.suggest', [ + 'vault' => $contact->vault_id, + 'contact' => $contact->id, + ]), + ], ]; } } diff --git a/app/Services/GooglePhotoService.php b/app/Services/GooglePhotoService.php new file mode 100644 index 00000000000..134c4434501 --- /dev/null +++ b/app/Services/GooglePhotoService.php @@ -0,0 +1,87 @@ + 'isch', + ]; + + /** + * Set the params for the service. + */ + public function params(array $params): self + { + $this->params = $params; + + return $this; + } + + /** + * Extract image URLs from the HTML. + */ + public function imageUrls(string $html): array + { + $imageUrls = []; + + if (empty($html)) { + return $imageUrls; + } + + $doc = new DOMDocument(); + @$doc->loadHTML($html); + + $imgTags = $doc->getElementsByTagName('img'); + + foreach ($imgTags as $imgTag) { + $src = $imgTag->getAttribute('src'); + + // Add only full-size images (exclude thumbnails, etc.) + if (filter_var($src, FILTER_VALIDATE_URL) + && Str::startsWith($src, self::GOOGLE_IMAGE_URL)) { + $imageUrls[] = $src; + } + } + + return $imageUrls; + } + + /** + * Fetch the HTML from Google. + * + * @throws Exception + */ + public function search(string $searchTerm): array + { + $params = array_merge($this->params, [ + 'q' => $searchTerm, + ]); + + try { + $html = Http::get(self::GOOGLE_SEARCH_URL, $params)->body(); + } catch (Exception $e) { + throw new Exception('Failed to fetch data from Google.'); + } + + return $this->imageUrls($html); + } +} diff --git a/config/monica.php b/config/monica.php index fed12ab04e2..1f4357f04d0 100644 --- a/config/monica.php +++ b/config/monica.php @@ -144,4 +144,6 @@ 'settings_preferences_maps' => 'user-and-account-settings/manage-preferences#timezone', 'settings_account_deletion' => 'user-and-account-settings/account-deletion', ], + + 'uploadcare_public_key' => env('UPLOADCARE_PUBLIC_KEY', null), ]; diff --git a/resources/js/Shared/Form/AvatarSuggestion.vue b/resources/js/Shared/Form/AvatarSuggestion.vue new file mode 100644 index 00000000000..68618f6c90a --- /dev/null +++ b/resources/js/Shared/Form/AvatarSuggestion.vue @@ -0,0 +1,27 @@ + + + diff --git a/resources/js/Shared/Modules/ContactAvatar.vue b/resources/js/Shared/Modules/ContactAvatar.vue index 7976642355a..2a316ba9c15 100644 --- a/resources/js/Shared/Modules/ContactAvatar.vue +++ b/resources/js/Shared/Modules/ContactAvatar.vue @@ -1,15 +1,47 @@ diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 578725d0e99..8b3db2025bd 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -30,8 +30,9 @@ } else { document.documentElement.classList.remove('dark') } - + UPLOADCARE_PUBLIC_KEY = "{{ config('monica.uploadcare_public_key') }}"; + @routes @vite('resources/js/app.js') @inertiaHead diff --git a/routes/web.php b/routes/web.php index a3337c2ec73..3175fbf7ba9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -278,6 +278,7 @@ // avatar Route::put('avatar', [ModuleAvatarController::class, 'update'])->name('contact.avatar.update'); Route::delete('avatar', [ModuleAvatarController::class, 'destroy'])->name('contact.avatar.destroy'); + Route::get('avatar/suggest', [ModuleAvatarController::class, 'suggest'])->name('contact.avatar.suggest'); // contact feed entries Route::get('feed', [ContactFeedController::class, 'show'])->name('contact.feed.show'); diff --git a/tests/Unit/Domains/Contact/ManageAvatar/Services/SuggestAvatarTest.php b/tests/Unit/Domains/Contact/ManageAvatar/Services/SuggestAvatarTest.php new file mode 100644 index 00000000000..7d98349b147 --- /dev/null +++ b/tests/Unit/Domains/Contact/ManageAvatar/Services/SuggestAvatarTest.php @@ -0,0 +1,107 @@ +fakeHttpResponse(); + $request = $this->requestParams(); + + $suggestAvatar = new SuggestAvatar(); + $suggestions = $suggestAvatar->execute($request); + + $this->assertCount(2, $suggestions); + $this->assertEquals( + $suggestions, + array_slice($this->suggestions, 0, 2) + ); + + $contact = Contact::find($request['contact_id']); + + $this->assertSame($suggestAvatar->getSearchTerm(), $contact->name); + } + + /** + * @test + * + * @group suggest-avatar + * @group it_suggests_list_based_on_search_term + * + * @throws Exception + */ + public function it_suggests_list_based_on_search_term(): void + { + $this->fakeHttpResponse(); + $request = $this->requestParams(); + $request['search_term'] = 'John Doe'; + + $suggestAvatar = new SuggestAvatar(); + $suggestions = $suggestAvatar->execute($request); + + $this->assertCount(2, $suggestions); + $this->assertEquals( + $suggestions, + array_slice($this->suggestions, 0, 2) + ); + + $this->assertSame($suggestAvatar->getSearchTerm(), $request['search_term']); + } + + private function requestParams(): array + { + $author = $this->createUser(); + $vault = $this->createVault($author->account); + $vault = $this->setPermissionInVault($author, Vault::PERMISSION_EDIT, $vault); + $contact = Contact::factory()->create(['vault_id' => $vault->id]); + + return [ + 'account_id' => $author->account->id, + 'vault_id' => $vault->id, + 'author_id' => $author->id, + 'contact_id' => $contact->id, + ]; + } + + /** + * @throws Exception + */ + private function fakeHttpResponse(): void + { + Http::fake(function () { + return Http::response(' + + + + + + + '); + }); + } +}