From ede5568e47439bd50416522ccf8754d804ef6a9f Mon Sep 17 00:00:00 2001 From: Oscar Cedano Date: Sat, 7 Nov 2026 04:45:22 -0600 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20feat:=20new=20API=20service=20Publi?= =?UTF-8?q?c/ReportController@show?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Cursor Grok 4.7 --- .../Report/Commands/ShowReportCommand.php | 10 ++ .../Report/Usecases/ReportUsecase.php | 6 + .../Usecases/ReportUsecaseInterface.php | 4 + .../Controllers/Public/ReportController.php | 17 +++ .../Resources/Public/ReportDetailResource.php | 31 ++++++ app/Repositories/Report/ReportRepository.php | 15 +++ .../Report/ReportRepositoryInterface.php | 2 + openapi.yaml | 59 ++++++++++ routes/api.public.php | 2 + schemas/ReportDetail.yaml | 45 ++++++++ tests/Feature/PublicReportShowTest.php | 104 ++++++++++++++++++ 11 files changed, 295 insertions(+) create mode 100644 app/Application/Report/Commands/ShowReportCommand.php create mode 100644 app/Http/Resources/Public/ReportDetailResource.php create mode 100644 schemas/ReportDetail.yaml create mode 100644 tests/Feature/PublicReportShowTest.php diff --git a/app/Application/Report/Commands/ShowReportCommand.php b/app/Application/Report/Commands/ShowReportCommand.php new file mode 100644 index 0000000..517cb4f --- /dev/null +++ b/app/Application/Report/Commands/ShowReportCommand.php @@ -0,0 +1,10 @@ +reports->findActiveById($command->id); + } + public function storeTemporaryProofs(StoreTemporaryProofsCommand $command): array { $paths = []; diff --git a/app/Application/Report/Usecases/ReportUsecaseInterface.php b/app/Application/Report/Usecases/ReportUsecaseInterface.php index 4070cd2..20098fc 100644 --- a/app/Application/Report/Usecases/ReportUsecaseInterface.php +++ b/app/Application/Report/Usecases/ReportUsecaseInterface.php @@ -4,6 +4,7 @@ use App\Application\Report\Commands\SearchReportsByTitleCommand; use App\Application\Report\Commands\SearchReportsCommand; +use App\Application\Report\Commands\ShowReportCommand; use App\Application\Report\Commands\StoreOrganizationReportCommand; use App\Application\Report\Commands\StoreReportForOrganizationCommand; use App\Application\Report\Commands\StoreReportForScammerCommand; @@ -11,6 +12,7 @@ use App\Application\Report\Commands\StoreTemporaryProofsCommand; use App\Domain\Search\ValueObjects\CardSearchResult; use App\Domain\Search\ValueObjects\PaginatedResult; +use App\Models\Report; interface ReportUsecaseInterface { @@ -18,6 +20,8 @@ public function search(SearchReportsCommand $command): CardSearchResult; public function searchByTitle(SearchReportsByTitleCommand $command): PaginatedResult; + public function show(ShowReportCommand $command): ?Report; + /** * @return list */ diff --git a/app/Http/Controllers/Public/ReportController.php b/app/Http/Controllers/Public/ReportController.php index fa5fcd8..3bfa316 100644 --- a/app/Http/Controllers/Public/ReportController.php +++ b/app/Http/Controllers/Public/ReportController.php @@ -4,6 +4,7 @@ use App\Application\Report\Commands\SearchReportsByTitleCommand; use App\Application\Report\Commands\SearchReportsCommand; +use App\Application\Report\Commands\ShowReportCommand; use App\Application\Report\Commands\StoreOrganizationReportCommand; use App\Application\Report\Commands\StoreReportForOrganizationCommand; use App\Application\Report\Commands\StoreReportForScammerCommand; @@ -17,6 +18,7 @@ use App\Http\Requests\Public\StoreProofRequest; use App\Http\Requests\Public\StoreScammerReportRequest; use App\Http\Resources\Public\ReportCardResource; +use App\Http\Resources\Public\ReportDetailResource; use App\Http\Resources\Public\ReportResource; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -76,6 +78,21 @@ public function searchByTitle(Request $request): JsonResponse ]); } + public function show(string $id): JsonResponse + { + if (filter_var($id, FILTER_VALIDATE_INT) === false) { + return response()->json(['message' => 'Invalid report ID'], 400); + } + + $report = $this->reports->show(new ShowReportCommand((int) $id)); + + if ($report === null) { + return response()->json(['message' => 'Report not found'], 404); + } + + return response()->json((new ReportDetailResource($report))->resolve()); + } + public function storeTemporaryProof(StoreProofRequest $request): JsonResponse { $images = $request->file('images'); diff --git a/app/Http/Resources/Public/ReportDetailResource.php b/app/Http/Resources/Public/ReportDetailResource.php new file mode 100644 index 0000000..273c62f --- /dev/null +++ b/app/Http/Resources/Public/ReportDetailResource.php @@ -0,0 +1,31 @@ + $this->title, + 'description' => $this->description, + 'is_active' => $this->is_active, + 'proofs' => $this->proofs->pluck('path')->all(), + 'products' => $this->products->pluck('name')->all(), + 'created_at' => $this->created_at->format('Y-m-d'), + ]; + } +} diff --git a/app/Repositories/Report/ReportRepository.php b/app/Repositories/Report/ReportRepository.php index b91c8c1..1bd831c 100644 --- a/app/Repositories/Report/ReportRepository.php +++ b/app/Repositories/Report/ReportRepository.php @@ -48,6 +48,21 @@ public function attachProduct(Report $report, int $productId): void SearchCache::invalidate(); } + public function findActiveById(int $id): ?Report + { + return Cache::remember( + SearchCache::key("public:report:{$id}"), + self::CACHE_TTL_SECONDS, + fn () => Report::query() + ->where('is_active', true) + ->with([ + 'proofs' => fn ($query) => $query->orderBy('id'), + 'products' => fn ($query) => $query->orderBy('name'), + ]) + ->find($id), + ); + } + public function findPaginatedByTitleSearch(string $query, int $page, int $count): PaginatedResult { if ($query === '') { diff --git a/app/Repositories/Report/ReportRepositoryInterface.php b/app/Repositories/Report/ReportRepositoryInterface.php index 416eaea..739e980 100644 --- a/app/Repositories/Report/ReportRepositoryInterface.php +++ b/app/Repositories/Report/ReportRepositoryInterface.php @@ -19,5 +19,7 @@ public function addProof(Report $report, string $path): int; public function attachProduct(Report $report, int $productId): void; + public function findActiveById(int $id): ?Report; + public function findPaginatedByTitleSearch(string $query, int $page, int $count): PaginatedResult; } diff --git a/openapi.yaml b/openapi.yaml index 2978548..e80b404 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -37,6 +37,7 @@ info: The service stores a copy under `reports/scammers/profiles` or `reports/organizations/profiles` and returns the public URL of the stored file. `POST /public/media/profiles` uploads a temporary profile picture for reports and profile updates. + `GET /public/reports/{id}` returns one active report, including its proof paths and product names. `POST /public/scammers/{id}/reports` files a report against an active scammer. `POST /public/organizations/{id}/reports` files a report against an active organization. Each body sends a title, description, email, and at least one product. Proofs are optional @@ -1185,6 +1186,53 @@ paths: message: Invalid page or count "429": $ref: "#/components/responses/RateLimited" + /public/reports/{id}: + get: + tags: [Reports] + operationId: getReport + summary: Get a report by ID + description: | + Returns the full public view of an active report: title, description, status, + proof paths, product names, and the creation date. An inactive or soft-deleted + report is not found. + parameters: + - $ref: "#/components/parameters/ReportId" + responses: + "200": + description: Public report. + content: + application/json: + schema: + $ref: "#/components/schemas/ReportDetail" + example: + title: Scammed me while I was pregnant! + description: I'm devastated, this guy scammed me while I was pregnant. + is_active: true + proofs: + - reports/scammers/proofs/8f3c1a.jpg + - reports/scammers/proofs/9a4d2b.png + products: + - Phone + - Watch + created_at: "2026-09-01" + "400": + description: The report ID is not a valid integer. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorMessage" + example: + message: Invalid report ID + "404": + description: No active report exists for this ID. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorMessage" + example: + message: Report not found + "429": + $ref: "#/components/responses/RateLimited" /public/media/profiles: post: tags: [Media] @@ -1535,6 +1583,15 @@ components: maximum: 100000 default: 1 example: 1 + ReportId: + name: id + in: path + required: true + description: Numeric ID of the report. + schema: + type: integer + minimum: 1 + example: 1 ReportTitleQuery: name: q in: query @@ -2126,6 +2183,8 @@ components: $ref: ./schemas/PaginatedPaymentMethods.yaml Report: $ref: ./schemas/Report.yaml + ReportDetail: + $ref: ./schemas/ReportDetail.yaml PaginatedReports: $ref: ./schemas/PaginatedReports.yaml ScammerOrganization: diff --git a/routes/api.public.php b/routes/api.public.php index 07af15b..1f28503 100644 --- a/routes/api.public.php +++ b/routes/api.public.php @@ -61,6 +61,8 @@ ->middleware(['throttle:public-search', AuditApiRequest::class]) ->name('public.reports.search'); + Route::get('reports/{id}', [ReportController::class, 'show']); + Route::post('media/profiles', [MediaController::class, 'storeProfilePicture']); Route::post('reports/media/proofs', [ReportController::class, 'storeTemporaryProof']); diff --git a/schemas/ReportDetail.yaml b/schemas/ReportDetail.yaml new file mode 100644 index 0000000..67b92ed --- /dev/null +++ b/schemas/ReportDetail.yaml @@ -0,0 +1,45 @@ +type: object +description: Full public view of one active report. +required: + - title + - description + - is_active + - proofs + - products + - created_at +properties: + title: + type: string + description: Report title. + example: Scammed me while I was pregnant! + description: + type: + - string + - "null" + description: Full report description. + example: I'm devastated, this guy scammed me while I was pregnant. + is_active: + type: boolean + description: Whether the report is active. Public reads only return active reports. + example: true + proofs: + type: array + description: Public-disk paths of the report's proof images, ordered by proof id. + items: + type: string + example: + - reports/scammers/proofs/8f3c1a.jpg + - reports/scammers/proofs/9a4d2b.png + products: + type: array + description: Names of the products attached to the report, ordered by name. Soft-deleted links are omitted. + items: + type: string + example: + - Phone + - Watch + created_at: + type: string + format: date + description: Date the report was created (`YYYY-MM-DD`). + example: "2026-09-01" diff --git a/tests/Feature/PublicReportShowTest.php b/tests/Feature/PublicReportShowTest.php new file mode 100644 index 0000000..4ec23cb --- /dev/null +++ b/tests/Feature/PublicReportShowTest.php @@ -0,0 +1,104 @@ +create([ + 'title' => 'Invoice scam', + 'description' => 'They asked me to pay a fake invoice.', + 'is_active' => true, + 'created_at' => '2026-09-01 15:30:00', + ]); + + $watch = Product::factory()->create(['name' => 'Watch']); + $phone = Product::factory()->create(['name' => 'Phone']); + $report->products()->attach([$watch->id, $phone->id]); + + $removed = Product::factory()->create(['name' => 'Removed']); + $report->products()->attach($removed->id); + $report->products()->updateExistingPivot($removed->id, ['deleted_at' => now()]); + + ReportProof::query()->create([ + 'report_id' => $report->id, + 'path' => 'reports/scammers/proofs/first.jpg', + ]); + ReportProof::query()->create([ + 'report_id' => $report->id, + 'path' => 'reports/organizations/proofs/second.png', + ]); + + $this->getJson("/api/public/reports/{$report->id}") + ->assertOk() + ->assertExactJson([ + 'title' => 'Invoice scam', + 'description' => 'They asked me to pay a fake invoice.', + 'is_active' => true, + 'proofs' => [ + 'reports/scammers/proofs/first.jpg', + 'reports/organizations/proofs/second.png', + ], + 'products' => ['Phone', 'Watch'], + 'created_at' => '2026-09-01', + ]); + } + + public function test_returns_empty_proofs_and_products_when_the_report_has_none(): void + { + $report = Report::factory()->create([ + 'title' => 'No extras', + 'description' => null, + 'is_active' => true, + 'created_at' => '2026-03-14 08:00:00', + ]); + + $this->getJson("/api/public/reports/{$report->id}") + ->assertOk() + ->assertExactJson([ + 'title' => 'No extras', + 'description' => null, + 'is_active' => true, + 'proofs' => [], + 'products' => [], + 'created_at' => '2026-03-14', + ]); + } + + public function test_rejects_an_invalid_report_id(): void + { + $this->getJson('/api/public/reports/9999999999999999999999999999999999999999') + ->assertStatus(400) + ->assertExactJson(['message' => 'Invalid report ID']); + + $this->getJson('/api/public/reports/not-a-number') + ->assertStatus(400) + ->assertExactJson(['message' => 'Invalid report ID']); + } + + public function test_returns_not_found_for_a_missing_inactive_or_deleted_report(): void + { + $this->getJson('/api/public/reports/0') + ->assertNotFound() + ->assertExactJson(['message' => 'Report not found']); + + $inactive = Report::factory()->create(['is_active' => false]); + + $this->getJson("/api/public/reports/{$inactive->id}") + ->assertNotFound() + ->assertExactJson(['message' => 'Report not found']); + + $deleted = Report::factory()->create(['is_active' => true]); + $deleted->delete(); + + $this->getJson("/api/public/reports/{$deleted->id}") + ->assertNotFound() + ->assertExactJson(['message' => 'Report not found']); + } +}