diff --git a/app/Filament/Resources/ModerationCases/Tables/ModerationCasesTable.php b/app/Filament/Resources/ModerationCases/Tables/ModerationCasesTable.php index 84b3590..8702680 100644 --- a/app/Filament/Resources/ModerationCases/Tables/ModerationCasesTable.php +++ b/app/Filament/Resources/ModerationCases/Tables/ModerationCasesTable.php @@ -6,6 +6,7 @@ use App\Enums\ModerationCaseStatus; use App\Enums\ReportStatus; use App\Models\MealPosts; use App\Models\ModerationCase; +use App\Models\PostReviews; use App\Models\User; use Filament\Actions\Action; use Filament\Actions\EditAction; @@ -36,6 +37,7 @@ class ModerationCasesTable return $query->where(function (Builder $query) use ($search): void { $query ->whereHasMorph('caseable', [MealPosts::class], fn (Builder $query): Builder => $query->where('title', 'like', "%{$search}%")) + ->orWhereHasMorph('caseable', [PostReviews::class], fn (Builder $query): Builder => $query->where('comment', 'like', "%{$search}%")) ->orWhereHasMorph('caseable', [User::class], fn (Builder $query): Builder => $query->where('name', 'like', "%{$search}%")); }); }), @@ -80,6 +82,7 @@ class ModerationCasesTable ->label(__('admin.moderation_cases.filters.target_type')) ->options([ MealPosts::class => __('admin.moderation_cases.targets.meal_post'), + PostReviews::class => __('admin.moderation_cases.targets.post_review'), User::class => __('admin.moderation_cases.targets.user'), ]), ]) diff --git a/app/Http/Controllers/PostReviewsController.php b/app/Http/Controllers/PostReviewsController.php index 60a7edb..1a8d428 100644 --- a/app/Http/Controllers/PostReviewsController.php +++ b/app/Http/Controllers/PostReviewsController.php @@ -58,6 +58,22 @@ class PostReviewsController extends Controller ->setStatusCode(201); } + public function mine(Request $request, MealPosts $mealPost): JsonResponse|PostReviewsResource + { + $this->abortIfUnavailable($mealPost); + + $postReview = $mealPost->reviews() + ->where('user_id', $request->user()->getKey()) + ->with('user:id,name,avatar_url') + ->first(); + + if (! $postReview) { + return response()->json(['data' => null]); + } + + return new PostReviewsResource($postReview); + } + public function show(PostReviews $postReview): PostReviewsResource { $postReview->loadMissing('mealPost.user'); diff --git a/app/Http/Controllers/ReportController.php b/app/Http/Controllers/ReportController.php index 1f0f754..6720d70 100644 --- a/app/Http/Controllers/ReportController.php +++ b/app/Http/Controllers/ReportController.php @@ -6,6 +6,7 @@ use App\Enums\ReportReason; use App\Http\Requests\StoreReportRequest; use App\Http\Resources\ReportResource; use App\Models\MealPosts; +use App\Models\PostReviews; use App\Models\User; use App\Services\ModerationReportService; use Illuminate\Http\JsonResponse; @@ -47,4 +48,22 @@ class ReportController extends Controller ->response() ->setStatusCode(201); } + + public function storePostReview( + StoreReportRequest $request, + PostReviews $postReview, + ModerationReportService $reports, + ): JsonResponse { + $report = $reports->report( + reportable: $postReview, + reporter: $request->user(), + reason: ReportReason::from($request->validated('reason')), + details: $request->validated('details'), + ); + + return (new ReportResource($report)) + ->additional(['message' => __('api.reports.created')]) + ->response() + ->setStatusCode(201); + } } diff --git a/app/Http/Resources/ReportResource.php b/app/Http/Resources/ReportResource.php index d7f54b3..7a8a770 100644 --- a/app/Http/Resources/ReportResource.php +++ b/app/Http/Resources/ReportResource.php @@ -3,6 +3,7 @@ namespace App\Http\Resources; use App\Models\MealPosts; +use App\Models\PostReviews; use App\Models\Report; use App\Models\User; use Illuminate\Http\Request; @@ -31,6 +32,7 @@ class ReportResource extends JsonResource { return match ($this->reportable_type) { MealPosts::class => 'meal_post', + PostReviews::class => 'post_review', User::class => 'user', default => 'unknown', }; diff --git a/app/Models/ModerationCase.php b/app/Models/ModerationCase.php index 725940e..be86a3c 100644 --- a/app/Models/ModerationCase.php +++ b/app/Models/ModerationCase.php @@ -52,6 +52,7 @@ class ModerationCase extends Model { return match ($this->caseable_type) { MealPosts::class => 'meal_post', + PostReviews::class => 'post_review', User::class => 'user', default => 'unknown', }; @@ -61,6 +62,7 @@ class ModerationCase extends Model { return match ($this->caseable_type) { MealPosts::class => __('admin.moderation_cases.targets.meal_post'), + PostReviews::class => __('admin.moderation_cases.targets.post_review'), User::class => __('admin.moderation_cases.targets.user'), default => __('admin.moderation_cases.targets.unknown'), }; @@ -72,6 +74,9 @@ class ModerationCase extends Model return match (true) { $target instanceof MealPosts => $target->title, + $target instanceof PostReviews => filled($target->comment) + ? str($target->comment)->limit(80)->toString() + : __('admin.moderation_cases.targets.post_review'), $target instanceof User => $target->name, default => (string) $this->caseable_id, }; diff --git a/app/Models/PostReviews.php b/app/Models/PostReviews.php index 8d16b06..aad729b 100644 --- a/app/Models/PostReviews.php +++ b/app/Models/PostReviews.php @@ -5,6 +5,8 @@ namespace App\Models; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphMany; +use Illuminate\Database\Eloquent\Relations\MorphOne; class PostReviews extends Model { @@ -27,6 +29,16 @@ class PostReviews extends Model return $this->belongsTo(User::class); } + public function reports(): MorphMany + { + return $this->morphMany(Report::class, 'reportable'); + } + + public function moderationCase(): MorphOne + { + return $this->morphOne(ModerationCase::class, 'caseable'); + } + protected function casts(): array { return [ diff --git a/app/Services/ModerationReportService.php b/app/Services/ModerationReportService.php index 495f10b..865abd9 100644 --- a/app/Services/ModerationReportService.php +++ b/app/Services/ModerationReportService.php @@ -8,6 +8,7 @@ use App\Enums\ReportStatus; use App\Enums\UserRole; use App\Models\MealPosts; use App\Models\ModerationCase; +use App\Models\PostReviews; use App\Models\Report; use App\Models\User; use Filament\Notifications\Notification as FilamentNotification; @@ -135,6 +136,12 @@ class ModerationReportService 422, __('api.reports.cannot_report_own_meal'), ); + + abort_if( + $reportable instanceof PostReviews && $reportable->user_id === $reporter->getKey(), + 422, + __('api.reports.cannot_report_own_review'), + ); } private function isDuplicateReportException(QueryException $exception): bool diff --git a/config/moderation.php b/config/moderation.php index 35c3799..3fa5ab3 100644 --- a/config/moderation.php +++ b/config/moderation.php @@ -1,6 +1,7 @@ [ MealPosts::class => 3, + PostReviews::class => 3, User::class => 5, ], ]; diff --git a/lang/en/admin.php b/lang/en/admin.php index b9d94db..d3344d9 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -91,6 +91,7 @@ return [ ], 'targets' => [ 'meal_post' => 'Meal', + 'post_review' => 'Review', 'user' => 'User', 'unknown' => 'Unknown', ], diff --git a/lang/en/api.php b/lang/en/api.php index b919ddf..b8c28f6 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -41,8 +41,10 @@ return [ 'already_exists' => 'You have already reported this content.', 'cannot_report_self' => 'You cannot report your own profile.', 'cannot_report_own_meal' => 'You cannot report your own meal.', + 'cannot_report_own_review' => 'You cannot report your own review.', 'targets' => [ 'meal_post' => 'a meal', + 'post_review' => 'a review', 'user' => 'a user', 'unknown' => 'content', ], diff --git a/lang/fr/admin.php b/lang/fr/admin.php index d836ab4..3324e3e 100644 --- a/lang/fr/admin.php +++ b/lang/fr/admin.php @@ -91,6 +91,7 @@ return [ ], 'targets' => [ 'meal_post' => 'Plat', + 'post_review' => 'Avis', 'user' => 'Utilisateur', 'unknown' => 'Inconnu', ], diff --git a/lang/fr/api.php b/lang/fr/api.php index 810b24c..77fa318 100644 --- a/lang/fr/api.php +++ b/lang/fr/api.php @@ -41,8 +41,10 @@ return [ 'already_exists' => 'Vous avez déjà signalé ce contenu.', 'cannot_report_self' => 'Vous ne pouvez pas signaler votre propre profil.', 'cannot_report_own_meal' => 'Vous ne pouvez pas signaler votre propre plat.', + 'cannot_report_own_review' => 'Vous ne pouvez pas signaler votre propre avis.', 'targets' => [ 'meal_post' => 'un plat', + 'post_review' => 'un avis', 'user' => 'un utilisateur', 'unknown' => 'un contenu', ], diff --git a/routes/api.php b/routes/api.php index 83a3b7e..024550e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -76,12 +76,16 @@ Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function Route::post('meal-posts/{mealPost}/reports', [ReportController::class, 'storeMealPost']) ->middleware('throttle:reports') ->name('meal-posts.reports.store'); + Route::get('meal-posts/{mealPost}/my-review', [PostReviewsController::class, 'mine'])->name('meal-posts.reviews.mine'); Route::get('meal-posts/{mealPost}/reviews', [PostReviewsController::class, 'index'])->name('meal-posts.reviews.index'); Route::post('meal-posts/{mealPost}/reviews', [PostReviewsController::class, 'store'])->middleware('throttle:content-write')->name('meal-posts.reviews.store'); Route::get('post-reviews/{postReview}', [PostReviewsController::class, 'show'])->name('post-reviews.show'); Route::put('post-reviews/{postReview}', [PostReviewsController::class, 'update'])->middleware('throttle:content-write')->name('post-reviews.update'); Route::patch('post-reviews/{postReview}', [PostReviewsController::class, 'update'])->middleware('throttle:content-write')->name('post-reviews.patch'); Route::delete('post-reviews/{postReview}', [PostReviewsController::class, 'destroy'])->middleware('throttle:content-write')->name('post-reviews.destroy'); + Route::post('post-reviews/{postReview}/reports', [ReportController::class, 'storePostReview']) + ->middleware('throttle:reports') + ->name('post-reviews.reports.store'); Route::apiResource('meal-posts', MealPostController::class) ->except(['update']) ->middlewareFor(['store', 'destroy'], 'throttle:content-write'); diff --git a/tests/Feature/ModerationReportsTest.php b/tests/Feature/ModerationReportsTest.php index 8651122..a464475 100644 --- a/tests/Feature/ModerationReportsTest.php +++ b/tests/Feature/ModerationReportsTest.php @@ -4,6 +4,7 @@ use App\Enums\ReportReason; use App\Enums\UserRole; use App\Models\MealPosts; use App\Models\ModerationCase; +use App\Models\PostReviews; use App\Models\Report; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -96,6 +97,40 @@ it('reports users and prevents reporting own profile', function () { ->assertJsonPath('message', __('api.reports.cannot_report_self')); }); +it('reports post reviews and prevents reporting own review', function () { + $reviewAuthor = User::factory()->create(); + $reporter = User::factory()->create(); + $review = PostReviews::query()->create([ + 'meal_post_id' => MealPosts::factory()->create()->id, + 'user_id' => $reviewAuthor->id, + 'rating' => 2, + 'comment' => 'Commentaire problématique.', + ]); + + Sanctum::actingAs($reporter); + + $this->postJson("/api/post-reviews/{$review->id}/reports", [ + 'reason' => ReportReason::INAPPROPRIATE->value, + ]) + ->assertCreated() + ->assertJsonPath('data.reportableType', 'post_review') + ->assertJsonPath('data.reportableId', $review->id); + + $this->assertDatabaseHas('moderation_cases', [ + 'caseable_type' => PostReviews::class, + 'caseable_id' => $review->id, + 'threshold' => 3, + ]); + + Sanctum::actingAs($reviewAuthor); + + $this->postJson("/api/post-reviews/{$review->id}/reports", [ + 'reason' => ReportReason::OTHER->value, + ]) + ->assertUnprocessable() + ->assertJsonPath('message', __('api.reports.cannot_report_own_review')); +}); + it('notifies moderators once when a report threshold is reached', function () { $admin = User::factory()->create(['role' => UserRole::ADMIN]); $moderator = User::factory()->create(['role' => UserRole::MODERATOR]); diff --git a/tests/Feature/PostReviewsControllerTest.php b/tests/Feature/PostReviewsControllerTest.php index bd02dc4..c87399b 100644 --- a/tests/Feature/PostReviewsControllerTest.php +++ b/tests/Feature/PostReviewsControllerTest.php @@ -156,6 +156,41 @@ it('lists reviews for a single meal post', function () { ->assertJsonMissing(['id' => $otherReview->id]); }); +it('shows the authenticated users review for a meal post', function () { + $user = User::factory()->create(); + $mealPost = MealPosts::factory()->create(); + $review = PostReviews::query()->create([ + 'meal_post_id' => $mealPost->id, + 'user_id' => $user->id, + 'rating' => 4, + 'comment' => 'Mon avis.', + ]); + PostReviews::query()->create([ + 'meal_post_id' => $mealPost->id, + 'user_id' => User::factory()->create()->id, + 'rating' => 5, + 'comment' => 'Un autre avis.', + ]); + + Sanctum::actingAs($user); + + $this->getJson("/api/meal-posts/{$mealPost->id}/my-review") + ->assertOk() + ->assertJsonPath('data.id', $review->id) + ->assertJsonPath('data.user_id', $user->id); +}); + +it('returns null when the authenticated user has not reviewed a meal post', function () { + $user = User::factory()->create(); + $mealPost = MealPosts::factory()->create(); + + Sanctum::actingAs($user); + + $this->getJson("/api/meal-posts/{$mealPost->id}/my-review") + ->assertOk() + ->assertJsonPath('data', null); +}); + it('lets the review author update and delete their review', function () { $user = User::factory()->create(); $review = PostReviews::query()->create([