Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .cursor/rules/reports.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ Public creates go through `StoreScammerReportRequest` or `StoreOrganizationRepor

A linked scammer or organization does not count as the required contact or payment method. That check reads only the top-level `contacts` and `payment_methods`.

## Report against an existing record

`POST /api/public/scammers/{id}/reports` and `POST /api/public/organizations/{id}/reports` file a report against an active scammer or organization. They do not create or rename that record. A missing, inactive, or soft-deleted id is not found.

These creates go through `StoreAttachedReportRequest`, then `StoreReportForScammerCommand` or `StoreReportForOrganizationCommand`, then `ReportUsecase`. The request trims `title` and `email`, and title-cases each product name.

- `title`: required string, max 50.
- `description`: required string.
- `email`: required, `email:rfc`, max 255.
- `products`: required array, at least one item. Each item is a string, max 75.
- `proofs`: optional array of temporary public paths. The same temporary-file check and copy used by a new report applies. Scammer proofs are stored under `reports/scammers/proofs`. Organization proofs are stored under `reports/organizations/proofs`.

These bodies do not accept contacts, payment methods, or a profile picture. An invalid id is `400`.

## Images

Upload the file before the report body.
Expand Down
1 change: 1 addition & 0 deletions .cursor/rules/scammers-and-organizations.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ They link through the `scammers_organizations` pivot. A scammer can belong to ma
- A scammer report uses `StoreScammerReportRequest`. `scammer.name` is required. `organizations` is optional.
- An organization report uses `StoreOrganizationReportRequest`. `organization.name` is required. `scammers` is optional.
- The report still needs at least one contact or payment method on that primary subject. A linked organization or scammer does not satisfy that check. See `reports.mdc`.
- A report filed against an existing scammer or organization by id does not send contacts or payment methods. See `reports.mdc`.

## Attaching the other side later

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

namespace App\Application\Report\Commands;

final readonly class StoreReportForOrganizationCommand
{
/**
* @param list<string> $proofs
* @param list<string> $productNames
*/
public function __construct(
public int $organizationId,
public string $title,
public string $description,
public string $email,
public array $proofs,
public array $productNames,
) {}

/**
* @param array<string, mixed> $input
*/
public static function fromValidated(int $organizationId, array $input): self
{
return new self(
organizationId: $organizationId,
title: (string) ($input['title'] ?? ''),
description: is_string($input['description'] ?? null) ? $input['description'] : '',
email: (string) ($input['email'] ?? ''),
proofs: self::proofsFrom($input['proofs'] ?? []),
productNames: self::productNamesFrom($input['products'] ?? []),
);
}

/**
* @return list<string>
*/
private static function proofsFrom(mixed $proofs): array
{
if (! is_array($proofs)) {
return [];
}

$paths = [];

foreach ($proofs as $url) {
$paths[] = is_string($url) ? $url : '';
}

return $paths;
}

/**
* @return list<string>
*/
private static function productNamesFrom(mixed $products): array
{
if (! is_array($products)) {
return [];
}

$names = [];

foreach ($products as $name) {
if (is_string($name) && $name !== '') {
$names[] = $name;
}
}

return $names;
}
}
72 changes: 72 additions & 0 deletions app/Application/Report/Commands/StoreReportForScammerCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

namespace App\Application\Report\Commands;

final readonly class StoreReportForScammerCommand
{
/**
* @param list<string> $proofs
* @param list<string> $productNames
*/
public function __construct(
public int $scammerId,
public string $title,
public string $description,
public string $email,
public array $proofs,
public array $productNames,
) {}

/**
* @param array<string, mixed> $input
*/
public static function fromValidated(int $scammerId, array $input): self
{
return new self(
scammerId: $scammerId,
title: (string) ($input['title'] ?? ''),
description: is_string($input['description'] ?? null) ? $input['description'] : '',
email: (string) ($input['email'] ?? ''),
proofs: self::proofsFrom($input['proofs'] ?? []),
productNames: self::productNamesFrom($input['products'] ?? []),
);
}

/**
* @return list<string>
*/
private static function proofsFrom(mixed $proofs): array
{
if (! is_array($proofs)) {
return [];
}

$paths = [];

foreach ($proofs as $url) {
$paths[] = is_string($url) ? $url : '';
}

return $paths;
}

/**
* @return list<string>
*/
private static function productNamesFrom(mixed $products): array
{
if (! is_array($products)) {
return [];
}

$names = [];

foreach ($products as $name) {
if (is_string($name) && $name !== '') {
$names[] = $name;
}
}

return $names;
}
}
125 changes: 125 additions & 0 deletions app/Application/Report/Usecases/ReportUsecase.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
use App\Application\Report\Commands\ScammerReportOrganizationInput;
use App\Application\Report\Commands\SearchReportsCommand;
use App\Application\Report\Commands\StoreOrganizationReportCommand;
use App\Application\Report\Commands\StoreReportForOrganizationCommand;
use App\Application\Report\Commands\StoreReportForScammerCommand;
use App\Application\Report\Commands\StoreScammerReportCommand;
use App\Application\Report\Commands\StoreTemporaryProofsCommand;
use App\Domain\Contact\Entities\ContactEntity;
Expand All @@ -30,6 +32,7 @@
use App\Repositories\Scammer\ScammerRepositoryInterface;
use App\Repositories\Search\SearchRepositoryInterface;
use App\Repositories\User\UserRepositoryInterface;
use Closure;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
Expand Down Expand Up @@ -282,6 +285,128 @@ public function storeScammer(StoreScammerReportCommand $command): array
}
}

/**
* @return array{id: int, scammer_id: int, product_ids: list<int>, report_proof_ids: list<int>}|null
*/
public function storeForScammer(StoreReportForScammerCommand $command): ?array
{
$scammer = $this->scammers->findScammerById($command->scammerId);

if ($scammer === null) {
return null;
}

$created = $this->createAttachedReport(
$command->title,
$command->description,
$command->email,
$command->proofs,
$command->productNames,
self::SCAMMER_PROOF_DIRECTORY,
fn (Report $report) => $this->reports->attachToScammer($scammer, $report),
);

return [
'id' => $created['id'],
'scammer_id' => $scammer->id,
'product_ids' => $created['product_ids'],
'report_proof_ids' => $created['report_proof_ids'],
];
}

/**
* @return array{id: int, organization_id: int, product_ids: list<int>, report_proof_ids: list<int>}|null
*/
public function storeForOrganization(StoreReportForOrganizationCommand $command): ?array
{
$organization = $this->organizations->findOrganizationById($command->organizationId);

if ($organization === null) {
return null;
}

$created = $this->createAttachedReport(
$command->title,
$command->description,
$command->email,
$command->proofs,
$command->productNames,
self::ORGANIZATION_PROOF_DIRECTORY,
fn (Report $report) => $this->reports->attachToOrganization($organization, $report),
);

return [
'id' => $created['id'],
'organization_id' => $organization->id,
'product_ids' => $created['product_ids'],
'report_proof_ids' => $created['report_proof_ids'],
];
}

/**
* @param list<string> $proofs
* @param list<string> $productNames
* @param Closure(Report): void $attach
* @return array{id: int, product_ids: list<int>, report_proof_ids: list<int>}
*/
private function createAttachedReport(
string $title,
string $description,
string $email,
array $proofs,
array $productNames,
string $proofDirectory,
Closure $attach,
): array {
$copied = [];

try {
$proofPaths = [];

foreach ($proofs as $index => $url) {
$path = $this->copyTemporary(
$url,
TemporaryImageStorageInterface::PROOF_DIRECTORY,
$proofDirectory,
'proofs.'.$index,
);
$copied[] = $path;
$proofPaths[] = $path;
}

return DB::transaction(function () use (
$title,
$description,
$email,
$productNames,
$proofPaths,
$attach,
): array {
$user = $this->users->findByEmail($email) ?? $this->users->createReporter($email);
$report = $this->reports->create($title, $description, $user->id);
$attach($report);
$productIds = $this->attachProducts($report, $productNames);
$proofIds = [];

foreach ($proofPaths as $path) {
$proofIds[] = $this->reports->addProof($report, $path);
}

return [
'id' => $report->id,
'product_ids' => $productIds,
'report_proof_ids' => $proofIds,
];
});
} catch (Throwable $exception) {
foreach ($copied as $path) {
$this->images->deletePublished($path);
}

throw $exception;
}
}

/**
* @param list<string> $names
* @return list<int>
Expand Down
12 changes: 12 additions & 0 deletions app/Application/Report/Usecases/ReportUsecaseInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

use App\Application\Report\Commands\SearchReportsCommand;
use App\Application\Report\Commands\StoreOrganizationReportCommand;
use App\Application\Report\Commands\StoreReportForOrganizationCommand;
use App\Application\Report\Commands\StoreReportForScammerCommand;
use App\Application\Report\Commands\StoreScammerReportCommand;
use App\Application\Report\Commands\StoreTemporaryProofsCommand;
use App\Domain\Search\ValueObjects\CardSearchResult;
Expand All @@ -26,4 +28,14 @@ public function storeOrganization(StoreOrganizationReportCommand $command): arra
* @return array{id: int, scammer_id: int, contact_ids: list<int>, payment_method_ids: list<int>, product_ids: list<int>, organization_ids: list<int>, report_proof_ids: list<int>}
*/
public function storeScammer(StoreScammerReportCommand $command): array;

/**
* @return array{id: int, scammer_id: int, product_ids: list<int>, report_proof_ids: list<int>}|null
*/
public function storeForScammer(StoreReportForScammerCommand $command): ?array;

/**
* @return array{id: int, organization_id: int, product_ids: list<int>, report_proof_ids: list<int>}|null
*/
public function storeForOrganization(StoreReportForOrganizationCommand $command): ?array;
}
37 changes: 37 additions & 0 deletions app/Http/Controllers/Public/ReportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@

use App\Application\Report\Commands\SearchReportsCommand;
use App\Application\Report\Commands\StoreOrganizationReportCommand;
use App\Application\Report\Commands\StoreReportForOrganizationCommand;
use App\Application\Report\Commands\StoreReportForScammerCommand;
use App\Application\Report\Commands\StoreScammerReportCommand;
use App\Application\Report\Commands\StoreTemporaryProofsCommand;
use App\Application\Report\Usecases\ReportUsecaseInterface;
use App\Domain\Scammer\ValueObjects\Clue;
use App\Http\Controllers\Controller;
use App\Http\Requests\Public\StoreAttachedReportRequest;
use App\Http\Requests\Public\StoreOrganizationReportRequest;
use App\Http\Requests\Public\StoreProofRequest;
use App\Http\Requests\Public\StoreScammerReportRequest;
Expand Down Expand Up @@ -60,4 +63,38 @@ public function storeScammer(StoreScammerReportRequest $request): JsonResponse
{
return response()->json($this->reports->storeScammer(StoreScammerReportCommand::fromValidated($request->validated())), 201);
}

public function storeForScammer(StoreAttachedReportRequest $request, string $id): JsonResponse
{
if (filter_var($id, FILTER_VALIDATE_INT) === false) {
return response()->json(['message' => 'Invalid scammer ID'], 400);
}

$created = $this->reports->storeForScammer(
StoreReportForScammerCommand::fromValidated((int) $id, $request->validated()),
);

if ($created === null) {
return response()->json(['message' => 'Scammer not found'], 404);
}

return response()->json($created, 201);
}

public function storeForOrganization(StoreAttachedReportRequest $request, string $id): JsonResponse
{
if (filter_var($id, FILTER_VALIDATE_INT) === false) {
return response()->json(['message' => 'Invalid organization ID'], 400);
}

$created = $this->reports->storeForOrganization(
StoreReportForOrganizationCommand::fromValidated((int) $id, $request->validated()),
);

if ($created === null) {
return response()->json(['message' => 'Organization not found'], 404);
}

return response()->json($created, 201);
}
}
Loading
Loading