diff --git a/app/Application/Report/Commands/OrganizationReportScammerInput.php b/app/Application/Report/Commands/OrganizationReportScammerInput.php index b835359..dec3cbf 100644 --- a/app/Application/Report/Commands/OrganizationReportScammerInput.php +++ b/app/Application/Report/Commands/OrganizationReportScammerInput.php @@ -10,6 +10,7 @@ */ public function __construct( public string $name, + public ?string $profilePicture, public array $contacts, public array $paymentMethods, ) {} diff --git a/app/Application/Report/Commands/StoreOrganizationReportCommand.php b/app/Application/Report/Commands/StoreOrganizationReportCommand.php index cb3837e..ba9631c 100644 --- a/app/Application/Report/Commands/StoreOrganizationReportCommand.php +++ b/app/Application/Report/Commands/StoreOrganizationReportCommand.php @@ -40,8 +40,11 @@ public static function fromValidated(array $input): self continue; } + $profilePicture = $scammer['profile_picture_path'] ?? null; + $scammers[] = new OrganizationReportScammerInput( name: (string) ($scammer['name'] ?? ''), + profilePicture: is_string($profilePicture) && $profilePicture !== '' ? $profilePicture : null, contacts: self::contactsFrom($scammer['contacts'] ?? []), paymentMethods: self::paymentMethodsFrom($scammer['payment_methods'] ?? []), ); diff --git a/app/Application/Report/Usecases/ReportUsecase.php b/app/Application/Report/Usecases/ReportUsecase.php index 770fb61..d604486 100644 --- a/app/Application/Report/Usecases/ReportUsecase.php +++ b/app/Application/Report/Usecases/ReportUsecase.php @@ -110,6 +110,8 @@ public function storeOrganization(StoreOrganizationReportCommand $command): arra $proofPaths[] = $path; } + $scammerPayloads = $this->copyScammerPictures($scammerPayloads, $copied); + return DB::transaction(function () use ( $command, $avatarPath, @@ -156,7 +158,7 @@ public function storeOrganization(StoreOrganizationReportCommand $command): arra $paymentMethodIds[] = $model->id; } - $scammerIds = $this->attachScammers($organization, $scammerPayloads); + $scammerIds = $this->attachScammers($organization, $scammerPayloads, $copied); return [ 'id' => $report->id, @@ -315,7 +317,7 @@ private function copyTemporary(string $url, string $sourceDirectory, string $des /** * @param list $scammers - * @return list, payment_methods: list}> + * @return list, payment_methods: list}> */ private function normalizeScammers(array $scammers): array { @@ -324,6 +326,7 @@ private function normalizeScammers(array $scammers): array foreach ($scammers as $index => $scammer) { $normalized[] = [ 'name' => $scammer->name, + 'profile_picture' => $scammer->profilePicture, 'contacts' => $this->normalizeContacts($scammer->contacts, "scammers.{$index}.contacts"), 'payment_methods' => $this->normalizePaymentMethods($scammer->paymentMethods, "scammers.{$index}.payment_methods"), ]; @@ -333,15 +336,46 @@ private function normalizeScammers(array $scammers): array } /** - * @param list, payment_methods: list}> $scammers + * @param list, payment_methods: list}> $scammers + * @param list $copied + * @return list, payment_methods: list}> + */ + private function copyScammerPictures(array $scammers, array &$copied): array + { + foreach ($scammers as $index => $scammer) { + $picture = $scammer['profile_picture']; + + if (! is_string($picture) || $picture === '') { + $scammers[$index]['profile_picture'] = null; + + continue; + } + + $path = $this->copyTemporary( + $picture, + TemporaryImageStorageInterface::PROFILE_PICTURE_DIRECTORY, + self::SCAMMER_PROFILE_DIRECTORY, + "scammers.{$index}.profile_picture_path", + ); + $copied[] = $path; + $scammers[$index]['profile_picture'] = $path; + } + + return $scammers; + } + + /** + * @param list, payment_methods: list}> $scammers + * @param list $copied * @return list */ - private function attachScammers(Organization $organization, array $scammers): array + private function attachScammers(Organization $organization, array $scammers, array &$copied): array { $scammerIds = []; foreach ($scammers as $scammer) { - $model = $this->scammers->firstOrCreate($scammer['name']); + $model = $this->scammers->firstOrCreate($scammer['name'], $scammer['profile_picture']); + $this->discardUnusedAvatar($scammer['profile_picture'], $model->profile_picture_path, $copied); $this->organizations->attachScammer($organization, $model); foreach ($scammer['contacts'] as $contact) { diff --git a/app/Http/Requests/Public/StoreOrganizationReportRequest.php b/app/Http/Requests/Public/StoreOrganizationReportRequest.php index b1fb68a..49e81fb 100644 --- a/app/Http/Requests/Public/StoreOrganizationReportRequest.php +++ b/app/Http/Requests/Public/StoreOrganizationReportRequest.php @@ -6,6 +6,7 @@ use App\Domain\PaymentMethod\Enums\PaymentMethodType; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class StoreOrganizationReportRequest extends FormRequest { @@ -84,7 +85,7 @@ public function rules(): array return [ 'title' => ['required', 'string', 'max:50'], 'description' => ['required', 'string'], - 'profile_picture' => ['required', 'string'], + 'profile_picture' => ['string'], 'proofs' => ['sometimes', 'array'], 'proofs.*' => ['required', 'string'], 'organization' => ['required', 'array'], @@ -99,6 +100,7 @@ public function rules(): array 'products.*' => ['required', 'string', 'max:75'], 'scammers' => ['sometimes', 'array'], 'scammers.*.name' => ['required', 'string', 'max:100'], + 'scammers.*.profile_picture_path' => ['sometimes', 'nullable', 'string'], 'scammers.*.contacts' => ['sometimes', 'array'], 'scammers.*.contacts.*.platform' => ['required', Rule::enum(PlatformType::class)], 'scammers.*.contacts.*.reference' => ['required', 'string', 'max:255'], @@ -108,6 +110,19 @@ public function rules(): array ]; } + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + if ($this->hasContactOrPaymentMethod()) { + return; + } + + $message = 'A contact or a payment method is required.'; + $validator->errors()->add('contacts', $message); + $validator->errors()->add('payment_methods', $message); + }); + } + /** * @param callable(mixed): (?\BackedEnum) $resolve */ @@ -142,4 +157,13 @@ private function resolveEnumItems(array $items, string $enumKey, callable $resol return $item; }, $items); } + + private function hasContactOrPaymentMethod(): bool + { + $contacts = $this->input('contacts', []); + $paymentMethods = $this->input('payment_methods', []); + + return (is_array($contacts) && $contacts !== []) + || (is_array($paymentMethods) && $paymentMethods !== []); + } } diff --git a/app/Http/Requests/Public/StoreScammerReportRequest.php b/app/Http/Requests/Public/StoreScammerReportRequest.php index d225372..d7d71e1 100644 --- a/app/Http/Requests/Public/StoreScammerReportRequest.php +++ b/app/Http/Requests/Public/StoreScammerReportRequest.php @@ -6,6 +6,7 @@ use App\Domain\PaymentMethod\Enums\PaymentMethodType; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class StoreScammerReportRequest extends FormRequest { @@ -84,7 +85,7 @@ public function rules(): array return [ 'title' => ['required', 'string', 'max:50'], 'description' => ['required', 'string'], - 'profile_picture' => ['required', 'string'], + 'profile_picture' => ['string'], 'proofs' => ['sometimes', 'array'], 'proofs.*' => ['required', 'string'], 'scammer' => ['required', 'array'], @@ -109,6 +110,19 @@ public function rules(): array ]; } + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + if ($this->hasContactOrPaymentMethod()) { + return; + } + + $message = 'A contact or a payment method is required.'; + $validator->errors()->add('contacts', $message); + $validator->errors()->add('payment_methods', $message); + }); + } + /** * @param callable(mixed): (?\BackedEnum) $resolve */ @@ -143,4 +157,13 @@ private function resolveEnumItems(array $items, string $enumKey, callable $resol return $item; }, $items); } + + private function hasContactOrPaymentMethod(): bool + { + $contacts = $this->input('contacts', []); + $paymentMethods = $this->input('payment_methods', []); + + return (is_array($contacts) && $contacts !== []) + || (is_array($paymentMethods) && $paymentMethods !== []); + } } diff --git a/app/Repositories/Scammer/ScammerRepository.php b/app/Repositories/Scammer/ScammerRepository.php index 116eea5..971e455 100644 --- a/app/Repositories/Scammer/ScammerRepository.php +++ b/app/Repositories/Scammer/ScammerRepository.php @@ -220,7 +220,7 @@ public function loadDetails(Scammer $scammer): Scammer return $scammer->load(['contacts', 'paymentMethods', 'organizations']); } - public function firstOrCreate(string $name): Scammer + public function firstOrCreate(string $name, ?string $profilePicturePath): Scammer { $existing = $this->findByName($name); @@ -231,6 +231,7 @@ public function firstOrCreate(string $name): Scammer try { return DB::transaction(fn (): Scammer => Scammer::query()->create([ 'name' => $name, + 'profile_picture_path' => $profilePicturePath, 'is_active' => true, ])); } catch (UniqueConstraintViolationException $exception) { diff --git a/app/Repositories/Scammer/ScammerRepositoryInterface.php b/app/Repositories/Scammer/ScammerRepositoryInterface.php index 319d0cb..8ed32eb 100644 --- a/app/Repositories/Scammer/ScammerRepositoryInterface.php +++ b/app/Repositories/Scammer/ScammerRepositoryInterface.php @@ -31,7 +31,7 @@ public function list(): Collection; public function loadDetails(Scammer $scammer): Scammer; - public function firstOrCreate(string $name): Scammer; + public function firstOrCreate(string $name, ?string $profilePicturePath): Scammer; /** * @param array $attributes diff --git a/openapi.yaml b/openapi.yaml index 7cbec8a..f647625 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -768,7 +768,7 @@ components: - http://localhost:9000/storage/tmp/reports/proofs/9a4d2b.png CreateOrganizationReportRequest: type: object - required: [title, description, profile_picture, organization, contacts, payment_methods, products] + required: [title, description, profile_picture, organization, products] properties: title: type: string @@ -799,7 +799,7 @@ components: example: Tienda Falsa contacts: type: array - minItems: 1 + description: At least one contact or one payment method is required. items: type: object required: [platform, reference] @@ -826,7 +826,7 @@ components: maxLength: 255 payment_methods: type: array - minItems: 1 + description: At least one contact or one payment method is required. items: type: object required: [type, reference] @@ -860,6 +860,10 @@ components: type: string maxLength: 100 example: Juan Perez + profile_picture_path: + type: string + description: Public URL or `/storage` path of a file under `tmp/reports/profiles`. + example: /storage/tmp/reports/profiles/8f3c1a.jpg contacts: type: array items: @@ -942,7 +946,7 @@ components: minimum: 1 CreateScammerReportRequest: type: object - required: [title, description, profile_picture, scammer, contacts, payment_methods, products] + required: [title, description, profile_picture, scammer, products] properties: title: type: string @@ -973,7 +977,7 @@ components: example: Juan Perez contacts: type: array - minItems: 1 + description: At least one contact or one payment method is required. items: type: object required: [platform, reference] @@ -1000,7 +1004,7 @@ components: maxLength: 255 payment_methods: type: array - minItems: 1 + description: At least one contact or one payment method is required. items: type: object required: [type, reference] diff --git a/tests/Feature/StoreOrganizationReportTest.php b/tests/Feature/StoreOrganizationReportTest.php index 2d82c0c..edb402e 100644 --- a/tests/Feature/StoreOrganizationReportTest.php +++ b/tests/Feature/StoreOrganizationReportTest.php @@ -541,6 +541,153 @@ public function test_reuses_an_existing_organization_and_still_files_the_report( $this->assertCount(2, $reused->paymentMethods); } + public function test_stores_a_new_scammer_picture_and_keeps_an_existing_one(): void + { + Storage::disk('public')->put('reports/scammers/profiles/kept.jpg', 'kept'); + $existing = Scammer::factory()->create([ + 'name' => 'Juan Perez', + 'profile_picture_path' => 'reports/scammers/profiles/kept.jpg', + ]); + + $profile = $this->publishTemporary(TemporaryImageStorageInterface::PROFILE_PICTURE_DIRECTORY, 'avatar.jpg', 'avatar'); + $newPicture = $this->publishTemporary(TemporaryImageStorageInterface::PROFILE_PICTURE_DIRECTORY, 'new-scammer.jpg', 'new-scammer'); + $unusedPicture = $this->publishTemporary(TemporaryImageStorageInterface::PROFILE_PICTURE_DIRECTORY, 'unused.jpg', 'unused'); + + $response = $this->postJson('/api/public/reports/organizations', [ + 'title' => 'Fake store took my money', + 'description' => 'They never shipped the order.', + 'profile_picture' => $profile, + 'organization' => ['name' => 'Tienda Falsa'], + 'contacts' => [ + ['platform' => 'cellphone', 'reference' => '+52 55 1111 2222'], + ], + 'payment_methods' => [ + ['type' => 'clabe', 'reference' => '012 345 678 901 234 567'], + ], + 'products' => ['Crypto'], + 'scammers' => [ + ['name' => 'Ana Lopez', 'profile_picture_path' => $newPicture], + ['name' => 'juan perez', 'profile_picture_path' => $unusedPicture], + ], + ]); + + $response->assertCreated(); + + $ana = Scammer::query()->where('name', 'Ana Lopez')->first(); + $reused = $existing->fresh(); + + $this->assertNotNull($ana); + $this->assertNotNull($reused); + $this->assertStringStartsWith('reports/scammers/profiles/', $ana->profile_picture_path); + $this->assertSame('new-scammer', Storage::disk('public')->get($ana->profile_picture_path)); + $this->assertSame('reports/scammers/profiles/kept.jpg', $reused->profile_picture_path); + $this->assertSame('kept', Storage::disk('public')->get($reused->profile_picture_path)); + + $profileFiles = collect(Storage::disk('public')->allFiles()) + ->filter(fn (string $path): bool => str_starts_with($path, 'reports/scammers/profiles/')) + ->sort() + ->values() + ->all(); + $expected = collect(['reports/scammers/profiles/kept.jpg', $ana->profile_picture_path])->sort()->values()->all(); + $this->assertSame($expected, $profileFiles); + } + + public function test_rejects_a_scammer_picture_outside_the_temporary_directory(): void + { + Storage::disk('public')->put('other/avatar.jpg', 'avatar'); + $profile = $this->publishTemporary(TemporaryImageStorageInterface::PROFILE_PICTURE_DIRECTORY, 'avatar.jpg', 'avatar'); + + $response = $this->postJson('/api/public/reports/organizations', [ + 'title' => 'Fake store took my money', + 'description' => 'They never shipped the order.', + 'profile_picture' => $profile, + 'organization' => ['name' => 'Tienda Falsa'], + 'contacts' => [ + ['platform' => 'cellphone', 'reference' => '+52 55 1111 2222'], + ], + 'payment_methods' => [ + ['type' => 'clabe', 'reference' => '012 345 678 901 234 567'], + ], + 'products' => ['Crypto'], + 'scammers' => [ + [ + 'name' => 'Ana Lopez', + 'profile_picture_path' => $this->storagePath('other/avatar.jpg'), + ], + ], + ]); + + $response->assertStatus(422)->assertJsonPath('error.code', 'validation_failed'); + $this->assertArrayHasKey('scammers.0.profile_picture_path', $response->json('error.details')); + $this->assertSame(0, Organization::query()->count()); + $this->assertSame(0, Scammer::query()->count()); + + $files = Storage::disk('public')->allFiles(); + $expected = [ + TemporaryImageStorageInterface::PROFILE_PICTURE_DIRECTORY.'/avatar.jpg', + 'other/avatar.jpg', + ]; + sort($files); + sort($expected); + $this->assertSame($expected, $files); + } + + public function test_accepts_contacts_or_payment_methods_but_not_neither(): void + { + $profile = $this->publishTemporary(TemporaryImageStorageInterface::PROFILE_PICTURE_DIRECTORY, 'avatar.jpg', 'avatar'); + $payload = [ + 'title' => 'Fake store took my money', + 'description' => 'They never shipped the order.', + 'profile_picture' => $profile, + 'organization' => ['name' => 'Tienda Falsa'], + 'products' => ['Crypto'], + ]; + + $neither = $this->postJson('/api/public/reports/organizations', $payload); + $neither->assertStatus(422)->assertJsonPath('error.code', 'validation_failed'); + $details = $neither->json('error.details'); + $this->assertArrayHasKey('contacts', $details); + $this->assertArrayHasKey('payment_methods', $details); + + $empty = $this->postJson('/api/public/reports/organizations', [ + ...$payload, + 'contacts' => [], + 'payment_methods' => [], + ]); + $empty->assertStatus(422); + $this->assertArrayHasKey('contacts', $empty->json('error.details')); + $this->assertArrayHasKey('payment_methods', $empty->json('error.details')); + $this->assertSame(0, Organization::query()->count()); + + $contactsOnly = $this->postJson('/api/public/reports/organizations', [ + ...$payload, + 'contacts' => [ + ['platform' => 'cellphone', 'reference' => '+52 55 1111 2222'], + ], + ]); + $contactsOnly->assertCreated(); + $contactsOnly->assertJsonPath('payment_method_ids', []); + $this->assertSame(1, Contact::query()->count()); + $this->assertSame(0, PaymentMethod::query()->count()); + $this->assertSame(1, Organization::query()->first()->contacts()->count()); + + $paymentsOnly = $this->postJson('/api/public/reports/organizations', [ + ...$payload, + 'organization' => ['name' => 'Otra Tienda'], + 'payment_methods' => [ + ['type' => 'clabe', 'reference' => '012 345 678 901 234 567'], + ], + ]); + $paymentsOnly->assertCreated(); + $paymentsOnly->assertJsonPath('contact_ids', []); + $this->assertSame(1, Contact::query()->count()); + $this->assertSame(1, PaymentMethod::query()->count()); + $paymentOrganization = Organization::query()->where('name', 'Otra Tienda')->first(); + $this->assertNotNull($paymentOrganization); + $this->assertSame(0, $paymentOrganization->contacts()->count()); + $this->assertSame(1, $paymentOrganization->paymentMethods()->count()); + } + public function test_requires_every_field_except_proofs(): void { $response = $this->postJson('/api/public/reports/organizations', [ diff --git a/tests/Feature/StoreScammerReportTest.php b/tests/Feature/StoreScammerReportTest.php index 3f66a72..27e7361 100644 --- a/tests/Feature/StoreScammerReportTest.php +++ b/tests/Feature/StoreScammerReportTest.php @@ -570,6 +570,62 @@ public function test_rejects_an_organization_picture_outside_the_temporary_direc $this->assertSame($expected, $files); } + public function test_accepts_contacts_or_payment_methods_but_not_neither(): void + { + $profile = $this->publishTemporary(TemporaryImageStorageInterface::PROFILE_PICTURE_DIRECTORY, 'avatar.jpg', 'avatar'); + $payload = [ + 'title' => 'Called me about a prize', + 'description' => 'They asked for a transfer to release the prize.', + 'profile_picture' => $profile, + 'scammer' => ['name' => 'Juan Perez'], + 'products' => ['Crypto'], + ]; + + $neither = $this->postJson('/api/public/reports/scammers', $payload); + $neither->assertStatus(422)->assertJsonPath('error.code', 'validation_failed'); + $details = $neither->json('error.details'); + $this->assertArrayHasKey('contacts', $details); + $this->assertArrayHasKey('payment_methods', $details); + + $empty = $this->postJson('/api/public/reports/scammers', [ + ...$payload, + 'contacts' => [], + 'payment_methods' => [], + ]); + $empty->assertStatus(422); + $this->assertArrayHasKey('contacts', $empty->json('error.details')); + $this->assertArrayHasKey('payment_methods', $empty->json('error.details')); + $this->assertSame(0, Scammer::query()->count()); + + $contactsOnly = $this->postJson('/api/public/reports/scammers', [ + ...$payload, + 'contacts' => [ + ['platform' => 'cellphone', 'reference' => '+52 55 1111 2222'], + ], + ]); + $contactsOnly->assertCreated(); + $contactsOnly->assertJsonPath('payment_method_ids', []); + $this->assertSame(1, Contact::query()->count()); + $this->assertSame(0, PaymentMethod::query()->count()); + $this->assertSame(1, Scammer::query()->first()->contacts()->count()); + + $paymentsOnly = $this->postJson('/api/public/reports/scammers', [ + ...$payload, + 'scammer' => ['name' => 'Ana Lopez'], + 'payment_methods' => [ + ['type' => 'clabe', 'reference' => '012 345 678 901 234 567'], + ], + ]); + $paymentsOnly->assertCreated(); + $paymentsOnly->assertJsonPath('contact_ids', []); + $this->assertSame(1, Contact::query()->count()); + $this->assertSame(1, PaymentMethod::query()->count()); + $paymentScammer = Scammer::query()->where('name', 'Ana Lopez')->first(); + $this->assertNotNull($paymentScammer); + $this->assertSame(0, $paymentScammer->contacts()->count()); + $this->assertSame(1, $paymentScammer->paymentMethods()->count()); + } + public function test_requires_every_field_except_proofs(): void { $response = $this->postJson('/api/public/reports/scammers', [