Compare commits

...

2 Commits

Author SHA1 Message Date
Leon Morival 2bdffccea2 feat: return avatar url
Laravel CI-CD / Tests Unitaires (push) Successful in 1m29s Details
Laravel CI-CD / Deploy with Kamal (push) Successful in 1m44s Details
2026-05-24 10:26:58 +02:00
Leon Morival 4af28f3828 feat: report reviews etc ... 2026-05-23 12:15:11 +02:00
17 changed files with 153 additions and 2 deletions

View File

@ -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'),
]),
])

View File

@ -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');

View File

@ -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);
}
}

View File

@ -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',
};

View File

@ -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,
};

View File

@ -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 [

View File

@ -8,6 +8,7 @@ use App\Enums\UserRole;
use App\Enums\UserSex;
use App\Enums\WeightGoal;
use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasAvatar;
use Filament\Panel;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Contracts\Translation\HasLocalePreference;
@ -21,9 +22,10 @@ use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable implements FilamentUser, HasLocalePreference, MustVerifyEmail
class User extends Authenticatable implements FilamentUser, HasLocalePreference, MustVerifyEmail, HasAvatar
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, HasUlids, Notifiable, SoftDeletes;
@ -93,6 +95,10 @@ class User extends Authenticatable implements FilamentUser, HasLocalePreference,
];
}
public function getFilamentAvatarUrl(): ?string
{
return Storage::url($this->avatar_url);
}
public function deviceTokens(): HasMany
{
return $this->hasMany(DeviceToken::class);

View File

@ -58,7 +58,6 @@ class DashboardPanelProvider extends PanelProvider
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
->widgets([
AccountWidget::class,
FilamentInfoWidget::class,
])
->middleware([
EncryptCookies::class,

View File

@ -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

View File

@ -1,6 +1,7 @@
<?php
use App\Models\MealPosts;
use App\Models\PostReviews;
use App\Models\User;
return [
@ -8,6 +9,7 @@ return [
'report_thresholds' => [
MealPosts::class => 3,
PostReviews::class => 3,
User::class => 5,
],
];

View File

@ -91,6 +91,7 @@ return [
],
'targets' => [
'meal_post' => 'Meal',
'post_review' => 'Review',
'user' => 'User',
'unknown' => 'Unknown',
],

View File

@ -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',
],

View File

@ -91,6 +91,7 @@ return [
],
'targets' => [
'meal_post' => 'Plat',
'post_review' => 'Avis',
'user' => 'Utilisateur',
'unknown' => 'Inconnu',
],

View File

@ -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',
],

View File

@ -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');

View File

@ -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]);

View File

@ -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([