diff --git a/app/Http/Controllers/ReportController.php b/app/Http/Controllers/ReportController.php new file mode 100644 index 0000000..878701d --- /dev/null +++ b/app/Http/Controllers/ReportController.php @@ -0,0 +1,78 @@ +user(); + + $type = match ($request->input("type")) { + "user" => User::class, + "organization" => Organization::class, + "event" => Event::class, + }; + + $alreadyReported = Report::alreadyReportedToday( + $user->id, + $type, + (int)$request->input("id"), + ); + + if ($alreadyReported) { + return response()->json([ + "message" => __("report.already_reported"), + ], Status::HTTP_TOO_MANY_REQUESTS); + } + + Report::create([ + "reporter_id" => $user->id, + "reportable_type" => $type, + "reportable_id" => $request->input("id"), + "reason" => $request->input("reason"), + ]); + + return response()->json([ + "message" => __("report.success"), + ], Status::HTTP_OK); + } + + public function userReports(): JsonResponse + { + return response()->json([ + "data" => ReportResource::collection( + Report::query()->where("reportable_type", User::class)->latest()->get(), + ), + ], Status::HTTP_OK); + } + + public function organizationReports(): JsonResponse + { + return response()->json([ + "data" => ReportResource::collection( + Report::query()->where("reportable_type", Organization::class)->latest()->get(), + ), + ], Status::HTTP_OK); + } + + public function eventReports(): JsonResponse + { + return response()->json([ + "data" => ReportResource::collection( + Report::query()->where("reportable_type", Event::class)->latest()->get(), + ), + ], Status::HTTP_OK); + } +} diff --git a/app/Http/Requests/StoreReportRequest.php b/app/Http/Requests/StoreReportRequest.php new file mode 100644 index 0000000..c0c1101 --- /dev/null +++ b/app/Http/Requests/StoreReportRequest.php @@ -0,0 +1,19 @@ + "required|in:user,organization,event", + "id" => "required|integer", + "reason" => "nullable|string|max:1000", + ]; + } +} diff --git a/app/Http/Resources/ReportResource.php b/app/Http/Resources/ReportResource.php new file mode 100644 index 0000000..139d0f8 --- /dev/null +++ b/app/Http/Resources/ReportResource.php @@ -0,0 +1,22 @@ + $this->id, + "reporter_id" => $this->reporter_id, + "reportable_type" => class_basename($this->reportable_type), + "reportable_id" => $this->reportable_id, + "reason" => $this->reason, + ]; + } +} diff --git a/app/Models/Report.php b/app/Models/Report.php new file mode 100644 index 0000000..d06b6f1 --- /dev/null +++ b/app/Models/Report.php @@ -0,0 +1,44 @@ +morphTo(); + } + + public static function alreadyReportedToday(int $reporterId, string $reportableType, int $reportableId): bool + { + return static::query() + ->where("reporter_id", $reporterId) + ->where("reportable_type", $reportableType) + ->where("reportable_id", $reportableId) + ->whereDate("created_at", Carbon::today()) + ->exists(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index d7fed32..6b717f4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -120,6 +120,11 @@ public function eventLimit(): int return 1 + $bonus; } + public function reports(): HasMany + { + return $this->hasMany(Report::class, "reporter_id"); + } + protected function casts(): array { return [ diff --git a/database/factories/ReportFactory.php b/database/factories/ReportFactory.php new file mode 100644 index 0000000..e48374b --- /dev/null +++ b/database/factories/ReportFactory.php @@ -0,0 +1,37 @@ +faker->randomElement($reportables); + $reportableId = $reportableType::factory()->create()->id; + + return [ + "reporter_id" => User::factory()->create()->id, + "reportable_type" => $reportableType, + "reportable_id" => $reportableId, + "reason" => $this->faker->sentence(), + "created_at" => now(), + "updated_at" => now(), + ]; + } +} diff --git a/database/migrations/2025_07_25_082312_create_reports_table.php b/database/migrations/2025_07_25_082312_create_reports_table.php new file mode 100644 index 0000000..7a15f7f --- /dev/null +++ b/database/migrations/2025_07_25_082312_create_reports_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId("reporter_id")->constrained("users")->cascadeOnDelete(); + $table->morphs("reportable"); + $table->text("reason")->nullable(); + $table->timestamps(); + + $table->unique([ + "reporter_id", + "reportable_type", + "reportable_id", + "created_at", + ], "daily_unique_report"); + }); + } + + public function down(): void + { + Schema::dropIfExists("reports"); + } +}; diff --git a/lang/en/report.php b/lang/en/report.php new file mode 100644 index 0000000..72f510d --- /dev/null +++ b/lang/en/report.php @@ -0,0 +1,8 @@ + "You have already reported this today.", + "success" => "Reported successfully.", +]; diff --git a/lang/pl/report.php b/lang/pl/report.php new file mode 100644 index 0000000..a9a505e --- /dev/null +++ b/lang/pl/report.php @@ -0,0 +1,8 @@ + "Już zgłosiłeś to dzisiaj.", + "success" => "Zgłoszono pomyślnie.", +]; diff --git a/routes/api.php b/routes/api.php index 05d8ffa..64726a2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -17,6 +17,7 @@ use Interns2025b\Http\Controllers\OrganizationEventController; use Interns2025b\Http\Controllers\OrganizationInvitationController; use Interns2025b\Http\Controllers\RegisterController; +use Interns2025b\Http\Controllers\ReportController; use Interns2025b\Http\Controllers\ResetPasswordController; use Interns2025b\Http\Controllers\UpdatePasswordController; use Interns2025b\Http\Controllers\UserDeletionController; @@ -40,6 +41,7 @@ Route::post("/events", [EventController::class, "store"]); Route::put("/events/{event}", [EventController::class, "update"]); Route::delete("/events/{event}", [EventController::class, "destroy"]); + Route::post("/reports", [ReportController::class, "store"]); Route::scopeBindings()->group(function (): void { Route::get("/organizations/{organization}/events", [OrganizationEventController::class, "index"]); @@ -48,7 +50,6 @@ Route::delete("/organizations/{organization}/events/{event}", [OrganizationEventController::class, "destroy"]); }); }); - Route::get("/confirm-delete/{user}", [UserDeletionController::class, "confirmDelete"]) ->middleware("signed") ->name("api.confirmDelete"); @@ -81,6 +82,9 @@ Route::put("/users/{user}", [UserManagementController::class, "update"])->name("users.update"); Route::delete("/users/{user}", [UserManagementController::class, "destroy"])->name("users.destroy"); Route::resource("organizations", OrganizationController::class); + Route::get("/reports/users", [ReportController::class, "userReports"]); + Route::get("/reports/organizations", [ReportController::class, "organizationReports"]); + Route::get("/reports/events", [ReportController::class, "eventReports"]); }); Route::group(["middleware" => ["auth:sanctum", "role:superAdministrator"]], function (): void { diff --git a/tests/Feature/ReportTest.php b/tests/Feature/ReportTest.php new file mode 100644 index 0000000..9c3d3ac --- /dev/null +++ b/tests/Feature/ReportTest.php @@ -0,0 +1,225 @@ +reporter = User::factory()->admin()->create(); + $this->targetUser = User::factory()->create(); + $this->anotherUser = User::factory()->create(); + $this->organization = Organization::factory()->create(); + $this->event = Event::factory()->create(); + } + + public function testUserCanReportAnotherUser(): void + { + $response = $this->actingAs($this->reporter)->postJson("/api/reports", [ + "type" => "user", + "id" => $this->targetUser->id, + ]); + + $response->assertStatus(Status::HTTP_OK); + + $this->assertDatabaseHas("reports", [ + "reportable_type" => User::class, + "reportable_id" => $this->targetUser->id, + "reporter_id" => $this->reporter->id, + ]); + } + + public function testUserCanReportAnOrganization(): void + { + $response = $this->actingAs($this->reporter)->postJson("/api/reports", [ + "type" => "organization", + "id" => $this->organization->id, + ]); + + $response->assertStatus(Status::HTTP_OK); + } + + public function testUserCanReportAnEvent(): void + { + $response = $this->actingAs($this->reporter)->postJson("/api/reports", [ + "type" => "event", + "id" => $this->event->id, + ]); + + $response->assertStatus(Status::HTTP_OK); + } + + public function testUserCannotReportSameTargetTwiceInOneDay(): void + { + Report::factory()->create([ + "reporter_id" => $this->reporter->id, + "reportable_type" => User::class, + "reportable_id" => $this->targetUser->id, + ]); + + $response = $this->actingAs($this->reporter)->postJson("/api/reports", [ + "type" => "user", + "id" => $this->targetUser->id, + ]); + + $response->assertStatus(Status::HTTP_TOO_MANY_REQUESTS); + } + + public function testUserCanReportSameTargetNextDay(): void + { + Report::factory()->create([ + "reporter_id" => $this->reporter->id, + "reportable_type" => User::class, + "reportable_id" => $this->targetUser->id, + "created_at" => Carbon::yesterday(), + ]); + + $response = $this->actingAs($this->reporter)->postJson("/api/reports", [ + "type" => "user", + "id" => $this->targetUser->id, + ]); + + $response->assertStatus(Status::HTTP_OK); + } + + public function testGuestCannotReport(): void + { + $response = $this->postJson("/api/reports", [ + "type" => "user", + "id" => $this->targetUser->id, + ]); + + $response->assertStatus(Status::HTTP_UNAUTHORIZED); + } + + public function testInvalidTypeIsRejected(): void + { + $response = $this->actingAs($this->reporter)->postJson("/api/reports", [ + "type" => "invalid_type", + "id" => 123, + ]); + + $response->assertStatus(Status::HTTP_UNPROCESSABLE_ENTITY); + } + + public function testMissingIdIsRejected(): void + { + $response = $this->actingAs($this->reporter)->postJson("/api/reports", [ + "type" => "user", + ]); + + $response->assertStatus(Status::HTTP_UNPROCESSABLE_ENTITY); + } + + public function testReasonFieldIsOptional(): void + { + $response = $this->actingAs($this->reporter)->postJson("/api/reports", [ + "type" => "user", + "id" => $this->targetUser->id, + ]); + + $response->assertStatus(Status::HTTP_OK); + } + + public function testReasonFieldIsStored(): void + { + $reason = "Violation"; + + $response = $this->actingAs($this->reporter)->postJson("/api/reports", [ + "type" => "organization", + "id" => $this->organization->id, + "reason" => $reason, + ]); + + $response->assertStatus(Status::HTTP_OK); + + $this->assertDatabaseHas("reports", [ + "reportable_type" => Organization::class, + "reportable_id" => $this->organization->id, + "reporter_id" => $this->reporter->id, + "reason" => $reason, + ]); + } + + public function testDifferentUsersCanReportSameTarget(): void + { + $this->actingAs($this->reporter)->postJson("/api/reports", [ + "type" => "user", + "id" => $this->targetUser->id, + ])->assertStatus(Status::HTTP_OK); + + $this->actingAs($this->anotherUser)->postJson("/api/reports", [ + "type" => "user", + "id" => $this->targetUser->id, + ])->assertStatus(Status::HTTP_OK); + } + + public function testUserCanViewUserReports(): void + { + Report::factory()->create([ + "reportable_type" => User::class, + "reportable_id" => $this->targetUser->id, + "reporter_id" => $this->reporter->id, + ]); + + $response = $this->actingAs($this->reporter)->getJson("/api/admin/reports/users"); + + $response->assertStatus(Status::HTTP_OK) + ->assertJsonStructure([ + "data" => [["id", "reason", "reportable_type", "reportable_id"]], + ]) + ->assertJsonFragment(["reportable_type" => "User"]); + } + + public function testUserCanViewOrganizationReports(): void + { + Report::factory()->create([ + "reportable_type" => Organization::class, + "reportable_id" => $this->organization->id, + "reporter_id" => $this->reporter->id, + ]); + + $response = $this->actingAs($this->reporter)->getJson("/api/admin/reports/organizations"); + + $response->assertStatus(Status::HTTP_OK) + ->assertJsonStructure([ + "data" => [["id", "reason", "reportable_type", "reportable_id"]], + ]) + ->assertJsonFragment(["reportable_type" => "Organization"]); + } + + public function testUserCanViewEventReports(): void + { + Report::factory()->create([ + "reportable_type" => Event::class, + "reportable_id" => $this->event->id, + "reporter_id" => $this->reporter->id, + ]); + + $response = $this->actingAs($this->reporter)->getJson("/api/admin/reports/events"); + + $response->assertStatus(Status::HTTP_OK) + ->assertJsonStructure([ + "data" => [["id", "reason", "reportable_type", "reportable_id"]], + ]) + ->assertJsonFragment(["reportable_type" => "Event"]); + } +}