diff --git a/app/Enums/ModerationCaseStatus.php b/app/Enums/ModerationCaseStatus.php new file mode 100644 index 0000000..059b6aa --- /dev/null +++ b/app/Enums/ModerationCaseStatus.php @@ -0,0 +1,34 @@ + __('enums.moderation_case_status.open'), + self::REVIEWING => __('enums.moderation_case_status.reviewing'), + self::RESOLVED => __('enums.moderation_case_status.resolved'), + self::REJECTED => __('enums.moderation_case_status.rejected'), + }; + } + + public function getColor(): string + { + return match ($this) { + self::OPEN => 'danger', + self::REVIEWING => 'warning', + self::RESOLVED => 'success', + self::REJECTED => 'gray', + }; + } +} diff --git a/app/Enums/ReportReason.php b/app/Enums/ReportReason.php new file mode 100644 index 0000000..3ee6bd6 --- /dev/null +++ b/app/Enums/ReportReason.php @@ -0,0 +1,40 @@ + __('enums.report_reason.spam'), + self::HARASSMENT => __('enums.report_reason.harassment'), + self::HATE_SPEECH => __('enums.report_reason.hate_speech'), + self::INAPPROPRIATE => __('enums.report_reason.inappropriate'), + self::DANGEROUS_CONTENT => __('enums.report_reason.dangerous_content'), + self::MISLEADING => __('enums.report_reason.misleading'), + self::OTHER => __('enums.report_reason.other'), + }; + } + + public function getColor(): string + { + return match ($this) { + self::HATE_SPEECH, self::DANGEROUS_CONTENT => 'danger', + self::HARASSMENT, self::INAPPROPRIATE => 'warning', + self::SPAM, self::MISLEADING => 'info', + self::OTHER => 'gray', + }; + } +} diff --git a/app/Enums/ReportStatus.php b/app/Enums/ReportStatus.php new file mode 100644 index 0000000..d82bef0 --- /dev/null +++ b/app/Enums/ReportStatus.php @@ -0,0 +1,34 @@ + __('enums.report_status.open'), + self::REVIEWING => __('enums.report_status.reviewing'), + self::RESOLVED => __('enums.report_status.resolved'), + self::REJECTED => __('enums.report_status.rejected'), + }; + } + + public function getColor(): string + { + return match ($this) { + self::OPEN => 'danger', + self::REVIEWING => 'warning', + self::RESOLVED => 'success', + self::REJECTED => 'gray', + }; + } +} diff --git a/app/Filament/Resources/ModerationCases/ModerationCaseResource.php b/app/Filament/Resources/ModerationCases/ModerationCaseResource.php new file mode 100644 index 0000000..66e37dc --- /dev/null +++ b/app/Filament/Resources/ModerationCases/ModerationCaseResource.php @@ -0,0 +1,69 @@ + ListModerationCases::route('/'), + 'view' => ViewModerationCase::route('/{record}'), + 'edit' => EditModerationCase::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/ModerationCases/Pages/CreateModerationCase.php b/app/Filament/Resources/ModerationCases/Pages/CreateModerationCase.php new file mode 100644 index 0000000..74ee066 --- /dev/null +++ b/app/Filament/Resources/ModerationCases/Pages/CreateModerationCase.php @@ -0,0 +1,11 @@ +components([ + Section::make(__('admin.moderation_cases.sections.case')) + ->columns(2) + ->schema([ + Select::make('status') + ->label(__('admin.moderation_cases.fields.status')) + ->options(ModerationCaseStatus::class) + ->required(), + Select::make('assigned_to') + ->label(__('admin.moderation_cases.fields.assigned_to')) + ->relationship('assignedTo', 'name') + ->searchable() + ->preload(), + TextInput::make('reports_count') + ->label(__('admin.moderation_cases.fields.reports_count')) + ->numeric() + ->disabled() + ->dehydrated(false), + TextInput::make('threshold') + ->label(__('admin.moderation_cases.fields.threshold')) + ->numeric() + ->disabled() + ->dehydrated(false), + ]), + Section::make(__('admin.moderation_cases.sections.target')) + ->columns(2) + ->schema([ + TextInput::make('caseable_type') + ->label(__('admin.moderation_cases.fields.target_type')) + ->disabled() + ->dehydrated(false), + TextInput::make('caseable_id') + ->label(__('admin.moderation_cases.fields.caseable_id')) + ->disabled() + ->dehydrated(false), + ]), + Section::make(__('admin.moderation_cases.sections.resolution')) + ->columns(2) + ->schema([ + DateTimePicker::make('resolved_at') + ->label(__('admin.moderation_cases.fields.resolved_at')), + Select::make('resolved_by') + ->label(__('admin.moderation_cases.fields.resolved_by')) + ->relationship('resolvedBy', 'name') + ->searchable() + ->preload(), + Textarea::make('resolution_note') + ->label(__('admin.moderation_cases.fields.resolution_note')) + ->rows(4) + ->columnSpanFull(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/ModerationCases/Schemas/ModerationCaseInfolist.php b/app/Filament/Resources/ModerationCases/Schemas/ModerationCaseInfolist.php new file mode 100644 index 0000000..e0cf938 --- /dev/null +++ b/app/Filament/Resources/ModerationCases/Schemas/ModerationCaseInfolist.php @@ -0,0 +1,78 @@ +components([ + Section::make(__('admin.moderation_cases.sections.case')) + ->columns(3) + ->schema([ + TextEntry::make('id') + ->label(__('admin.moderation_cases.fields.id')) + ->copyable(), + TextEntry::make('status') + ->label(__('admin.moderation_cases.fields.status')) + ->badge(), + TextEntry::make('reports_count') + ->label(__('admin.moderation_cases.fields.reports_count')), + TextEntry::make('threshold') + ->label(__('admin.moderation_cases.fields.threshold')), + TextEntry::make('threshold_notified_at') + ->label(__('admin.moderation_cases.fields.threshold_notified_at')) + ->dateTime() + ->placeholder(__('admin.moderation_cases.placeholders.empty')), + TextEntry::make('assignedTo.name') + ->label(__('admin.moderation_cases.fields.assigned_to')) + ->placeholder(__('admin.moderation_cases.placeholders.empty')), + ]), + Section::make(__('admin.moderation_cases.sections.target')) + ->columns(3) + ->schema([ + TextEntry::make('target_type') + ->label(__('admin.moderation_cases.fields.target_type')) + ->state(fn (ModerationCase $record): string => $record->targetLabel()) + ->badge(), + TextEntry::make('target_title') + ->label(__('admin.moderation_cases.fields.target_title')) + ->state(fn (ModerationCase $record): string => $record->targetTitle()), + TextEntry::make('caseable_id') + ->label(__('admin.moderation_cases.fields.caseable_id')) + ->copyable(), + ]), + Section::make(__('admin.moderation_cases.sections.resolution')) + ->columns(2) + ->schema([ + TextEntry::make('resolvedBy.name') + ->label(__('admin.moderation_cases.fields.resolved_by')) + ->placeholder(__('admin.moderation_cases.placeholders.empty')), + TextEntry::make('resolved_at') + ->label(__('admin.moderation_cases.fields.resolved_at')) + ->dateTime() + ->placeholder(__('admin.moderation_cases.placeholders.empty')), + TextEntry::make('resolution_note') + ->label(__('admin.moderation_cases.fields.resolution_note')) + ->placeholder(__('admin.moderation_cases.placeholders.empty')) + ->columnSpanFull(), + ]), + Section::make(__('admin.moderation_cases.sections.metadata')) + ->columns(2) + ->schema([ + TextEntry::make('created_at') + ->label(__('admin.moderation_cases.fields.created_at')) + ->dateTime(), + TextEntry::make('updated_at') + ->label(__('admin.moderation_cases.fields.updated_at')) + ->dateTime(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/ModerationCases/Tables/ModerationCasesTable.php b/app/Filament/Resources/ModerationCases/Tables/ModerationCasesTable.php new file mode 100644 index 0000000..84b3590 --- /dev/null +++ b/app/Filament/Resources/ModerationCases/Tables/ModerationCasesTable.php @@ -0,0 +1,199 @@ +columns([ + TextColumn::make('id') + ->label(__('admin.moderation_cases.fields.id')) + ->copyable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('target_type') + ->label(__('admin.moderation_cases.fields.target_type')) + ->state(fn (ModerationCase $record): string => $record->targetLabel()) + ->badge(), + TextColumn::make('target_title') + ->label(__('admin.moderation_cases.fields.target_title')) + ->state(fn (ModerationCase $record): string => $record->targetTitle()) + ->searchable(query: function (Builder $query, string $search): Builder { + 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', [User::class], fn (Builder $query): Builder => $query->where('name', 'like', "%{$search}%")); + }); + }), + TextColumn::make('status') + ->label(__('admin.moderation_cases.fields.status')) + ->badge() + ->sortable(), + TextColumn::make('reports_count') + ->label(__('admin.moderation_cases.fields.reports_count')) + ->numeric() + ->sortable(), + TextColumn::make('threshold') + ->label(__('admin.moderation_cases.fields.threshold')) + ->numeric() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('threshold_notified_at') + ->label(__('admin.moderation_cases.fields.threshold_notified_at')) + ->dateTime() + ->sortable() + ->placeholder(__('admin.moderation_cases.placeholders.empty')), + TextColumn::make('assignedTo.name') + ->label(__('admin.moderation_cases.fields.assigned_to')) + ->placeholder(__('admin.moderation_cases.placeholders.empty')) + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('resolved_at') + ->label(__('admin.moderation_cases.fields.resolved_at')) + ->dateTime() + ->sortable() + ->placeholder(__('admin.moderation_cases.placeholders.empty')), + TextColumn::make('created_at') + ->label(__('admin.moderation_cases.fields.created_at')) + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + SelectFilter::make('status') + ->label(__('admin.moderation_cases.fields.status')) + ->options(ModerationCaseStatus::class), + SelectFilter::make('caseable_type') + ->label(__('admin.moderation_cases.filters.target_type')) + ->options([ + MealPosts::class => __('admin.moderation_cases.targets.meal_post'), + User::class => __('admin.moderation_cases.targets.user'), + ]), + ]) + ->recordActions([ + ViewAction::make(), + EditAction::make(), + Action::make('markReviewing') + ->label(__('admin.moderation_cases.actions.mark_reviewing')) + ->visible(fn (ModerationCase $record): bool => $record->status !== ModerationCaseStatus::REVIEWING) + ->action(fn (ModerationCase $record): bool => self::markReviewing($record)), + Action::make('hideMeal') + ->label(__('admin.moderation_cases.actions.hide_meal')) + ->color('warning') + ->requiresConfirmation() + ->visible(fn (ModerationCase $record): bool => $record->caseable_type === MealPosts::class + && $record->caseable instanceof MealPosts + && ! $record->caseable->isHidden()) + ->action(fn (ModerationCase $record): bool => self::hideMeal($record)), + Action::make('suspendUser') + ->label(__('admin.moderation_cases.actions.suspend_user')) + ->color('danger') + ->requiresConfirmation() + ->visible(fn (ModerationCase $record): bool => $record->caseable_type === User::class + && $record->caseable instanceof User + && ! $record->caseable->isSuspended()) + ->action(fn (ModerationCase $record): bool => self::suspendUser($record)), + Action::make('resolve') + ->label(__('admin.moderation_cases.actions.resolve')) + ->color('success') + ->requiresConfirmation() + ->visible(fn (ModerationCase $record): bool => $record->status !== ModerationCaseStatus::RESOLVED) + ->action(fn (ModerationCase $record): bool => self::closeCase($record, ModerationCaseStatus::RESOLVED, ReportStatus::RESOLVED)), + Action::make('reject') + ->label(__('admin.moderation_cases.actions.reject')) + ->color('gray') + ->requiresConfirmation() + ->visible(fn (ModerationCase $record): bool => $record->status !== ModerationCaseStatus::REJECTED) + ->action(fn (ModerationCase $record): bool => self::closeCase($record, ModerationCaseStatus::REJECTED, ReportStatus::REJECTED)), + ]) + ->toolbarActions([ + // + ]); + } + + private static function markReviewing(ModerationCase $record): bool + { + $record->forceFill([ + 'status' => ModerationCaseStatus::REVIEWING, + 'assigned_to' => $record->assigned_to ?: auth()->id(), + ])->save(); + + $record->reports() + ->where('status', ReportStatus::OPEN->value) + ->update([ + 'status' => ReportStatus::REVIEWING->value, + 'reviewed_by' => auth()->id(), + 'reviewed_at' => now(), + ]); + + return true; + } + + private static function hideMeal(ModerationCase $record): bool + { + $mealPost = $record->caseable; + + if (! $mealPost instanceof MealPosts) { + return false; + } + + $mealPost->forceFill([ + 'hidden_at' => now(), + 'hidden_by' => auth()->id(), + 'hidden_reason' => __('admin.moderation_cases.actions.hide_meal'), + ])->save(); + + return self::markReviewing($record); + } + + private static function suspendUser(ModerationCase $record): bool + { + $user = $record->caseable; + + if (! $user instanceof User) { + return false; + } + + $user->forceFill([ + 'suspended_at' => now(), + 'suspended_by' => auth()->id(), + 'suspended_reason' => __('admin.moderation_cases.actions.suspend_user'), + ])->save(); + + return self::markReviewing($record); + } + + private static function closeCase( + ModerationCase $record, + ModerationCaseStatus $caseStatus, + ReportStatus $reportStatus, + ): bool { + $record->forceFill([ + 'status' => $caseStatus, + 'assigned_to' => $record->assigned_to ?: auth()->id(), + 'resolved_by' => auth()->id(), + 'resolved_at' => now(), + ])->save(); + + $record->reports()->update([ + 'status' => $reportStatus->value, + 'reviewed_by' => auth()->id(), + 'reviewed_at' => now(), + ]); + + return true; + } +} diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 7acc752..2c40fc4 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -65,6 +65,12 @@ class AuthController extends Controller ], 401); } + if ($user->isSuspended()) { + return response()->json([ + 'message' => __('api.auth.suspended'), + ], 403); + } + if (isset($data['locale']) && $user->locale !== $data['locale']) { $user->forceFill(['locale' => $data['locale']])->save(); } @@ -224,6 +230,9 @@ class AuthController extends Controller public function update(UpdateUserRequest $request): JsonResource { $user = $request->user(); + + abort_if($user->isSuspended(), 403, __('api.auth.suspended')); + $data = $request->validated(); $nutritionGoals = $data['nutritionGoals'] ?? null; diff --git a/app/Http/Controllers/MealPostController.php b/app/Http/Controllers/MealPostController.php index 77499fe..8d1d3e3 100644 --- a/app/Http/Controllers/MealPostController.php +++ b/app/Http/Controllers/MealPostController.php @@ -137,6 +137,8 @@ class MealPostController extends Controller public function show(Request $request, MealPosts $mealPost): MealPostsResource { + $this->abortIfUnavailable($mealPost); + return new MealPostsResource($this->loadMealPostForResponse($mealPost, $request)); } @@ -171,6 +173,8 @@ class MealPostController extends Controller public function like(Request $request, MealPosts $mealPost): MealPostsResource { + $this->abortIfUnavailable($mealPost); + $mealPost->likedByUsers()->syncWithoutDetaching([$request->user()->getKey()]); return new MealPostsResource($this->loadMealPostForResponse($mealPost, $request)); @@ -178,6 +182,8 @@ class MealPostController extends Controller public function unlike(Request $request, MealPosts $mealPost): MealPostsResource { + $this->abortIfUnavailable($mealPost); + $mealPost->likedByUsers()->detach($request->user()->getKey()); return new MealPostsResource($this->loadMealPostForResponse($mealPost, $request)); @@ -198,6 +204,14 @@ class MealPostController extends Controller abort_unless($mealPost->user_id === $request->user()->getKey(), 404); } + private function abortIfUnavailable(MealPosts $mealPost): void + { + abort_if( + $mealPost->isHidden() || $mealPost->user()->whereNotNull('suspended_at')->exists(), + 404, + ); + } + private function loadMealPostForResponse(MealPosts $mealPost, Request $request): MealPosts { return $mealPost @@ -207,6 +221,8 @@ class MealPostController extends Controller private function mealPostListQuery(Request $request): Builder { return MealPosts::query() + ->whereNull('hidden_at') + ->whereHas('user', fn (Builder $query): Builder => $query->whereNull('suspended_at')) ->with('user:id,name,avatar_url,account_verified_at'); } diff --git a/app/Http/Controllers/PostReviewsController.php b/app/Http/Controllers/PostReviewsController.php index 390bb80..608b711 100644 --- a/app/Http/Controllers/PostReviewsController.php +++ b/app/Http/Controllers/PostReviewsController.php @@ -15,6 +15,8 @@ class PostReviewsController extends Controller { public function index(Request $request, MealPosts $mealPost): AnonymousResourceCollection { + $this->abortIfUnavailable($mealPost); + $perPage = max(1, min($request->integer('per_page', 15), 100)); $reviews = $mealPost->reviews() @@ -28,6 +30,8 @@ class PostReviewsController extends Controller public function store(PostReviewsRequest $request, MealPosts $mealPost): JsonResponse { + $this->abortIfUnavailable($mealPost); + $userId = $request->user()->getKey(); abort_if( @@ -52,6 +56,9 @@ class PostReviewsController extends Controller public function show(PostReviews $postReview): PostReviewsResource { + $postReview->loadMissing('mealPost.user'); + $this->abortIfUnavailable($postReview->mealPost); + return new PostReviewsResource($postReview->loadMissing( 'mealPost.ingredients', 'mealPost.user:id,name,avatar_url', @@ -82,4 +89,12 @@ class PostReviewsController extends Controller abort_unless($request->user(), 401); abort_unless($postReview->user_id === $request->user()->getKey(), 404); } + + private function abortIfUnavailable(MealPosts $mealPost): void + { + abort_if( + $mealPost->isHidden() || $mealPost->user()->whereNotNull('suspended_at')->exists(), + 404, + ); + } } diff --git a/app/Http/Controllers/PublicUserProfileController.php b/app/Http/Controllers/PublicUserProfileController.php index 924ea9e..2f6c372 100644 --- a/app/Http/Controllers/PublicUserProfileController.php +++ b/app/Http/Controllers/PublicUserProfileController.php @@ -13,8 +13,11 @@ class PublicUserProfileController extends Controller { public function show(Request $request, User $user): JsonResponse { + abort_if($user->isSuspended(), 404); + $meals = $user->mealPosts() ->where('visibility', MealPostVisibility::Public->value) + ->whereNull('hidden_at') ->with('user:id,name,avatar_url,account_verified_at') ->latest('eaten_at') ->limit(18) @@ -34,6 +37,7 @@ class PublicUserProfileController extends Controller 'stats' => [ 'mealsCount' => $user->mealPosts() ->where('visibility', MealPostVisibility::Public->value) + ->whereNull('hidden_at') ->count(), ], 'meals' => MealPostsResource::collection($meals)->resolve($request), diff --git a/app/Http/Controllers/ReportController.php b/app/Http/Controllers/ReportController.php new file mode 100644 index 0000000..1f0f754 --- /dev/null +++ b/app/Http/Controllers/ReportController.php @@ -0,0 +1,50 @@ +report( + reportable: $mealPost, + 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); + } + + public function storeUser( + StoreReportRequest $request, + User $user, + ModerationReportService $reports, + ): JsonResponse { + $report = $reports->report( + reportable: $user, + 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/Middleware/EnsureAccountIsNotSuspended.php b/app/Http/Middleware/EnsureAccountIsNotSuspended.php new file mode 100644 index 0000000..4626272 --- /dev/null +++ b/app/Http/Middleware/EnsureAccountIsNotSuspended.php @@ -0,0 +1,26 @@ +user()?->isSuspended()) { + return response()->json([ + 'message' => __('api.auth.suspended'), + ], 403); + } + + return $next($request); + } +} diff --git a/app/Http/Requests/StoreReportRequest.php b/app/Http/Requests/StoreReportRequest.php new file mode 100644 index 0000000..1959202 --- /dev/null +++ b/app/Http/Requests/StoreReportRequest.php @@ -0,0 +1,32 @@ + ['required', Rule::enum(ReportReason::class)], + 'details' => ['nullable', 'string', 'max:2000'], + ]; + } + + public function messages(): array + { + return [ + 'reason.required' => __('api.validation.report_reason_required'), + 'reason' => __('api.validation.report_reason_valid'), + 'details.max' => __('api.validation.report_details_max'), + ]; + } +} diff --git a/app/Http/Resources/ReportResource.php b/app/Http/Resources/ReportResource.php new file mode 100644 index 0000000..d7f54b3 --- /dev/null +++ b/app/Http/Resources/ReportResource.php @@ -0,0 +1,38 @@ + $this->id, + 'reason' => $this->reason, + 'reasonLabel' => $this->reason?->getLabel(), + 'details' => $this->details, + 'status' => $this->status, + 'statusLabel' => $this->status?->getLabel(), + 'reportableType' => $this->reportableType(), + 'reportableId' => $this->reportable_id, + 'moderationCaseId' => $this->moderation_case_id, + 'createdAt' => $this->created_at, + ]; + } + + private function reportableType(): string + { + return match ($this->reportable_type) { + MealPosts::class => 'meal_post', + User::class => 'user', + default => 'unknown', + }; + } +} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index ac103ba..e8461b5 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -27,6 +27,7 @@ class UserResource extends JsonResource 'bio' => $this->bio, 'role' => $this->role, 'accountVerified' => $this->account_verified_at !== null, + 'suspendedAt' => $this->suspended_at?->toISOString(), 'physicalActivityLevel' => $this->physical_activity_level, 'physicalActivityLevelLabel' => $this->physical_activity_level?->getLabel(), 'weightGoal' => $this->weight_goal, diff --git a/app/Models/MealPosts.php b/app/Models/MealPosts.php index 7d16a5f..fdd07e0 100644 --- a/app/Models/MealPosts.php +++ b/app/Models/MealPosts.php @@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; +use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\SoftDeletes; class MealPosts extends Model @@ -31,6 +33,9 @@ class MealPosts extends Model 'type', 'ai_generated', 'diet_type', + 'hidden_at', + 'hidden_by', + 'hidden_reason', ]; protected $attributes = [ @@ -60,6 +65,26 @@ class MealPosts extends Model ->withTimestamps(); } + public function reports(): MorphMany + { + return $this->morphMany(Report::class, 'reportable'); + } + + public function moderationCase(): MorphOne + { + return $this->morphOne(ModerationCase::class, 'caseable'); + } + + public function hiddenBy(): BelongsTo + { + return $this->belongsTo(User::class, 'hidden_by'); + } + + public function isHidden(): bool + { + return $this->hidden_at !== null; + } + protected function casts(): array { return [ @@ -68,6 +93,7 @@ class MealPosts extends Model 'type' => MealPostType::class, 'diet_type' => DietType::class, 'ai_generated' => 'boolean', + 'hidden_at' => 'datetime', ]; } } diff --git a/app/Models/ModerationCase.php b/app/Models/ModerationCase.php new file mode 100644 index 0000000..725940e --- /dev/null +++ b/app/Models/ModerationCase.php @@ -0,0 +1,90 @@ + */ + use HasFactory, HasUlids; + + protected $fillable = [ + 'caseable_type', + 'caseable_id', + 'status', + 'reports_count', + 'threshold', + 'threshold_notified_at', + 'assigned_to', + 'resolved_by', + 'resolved_at', + 'resolution_note', + ]; + + public function caseable(): MorphTo + { + return $this->morphTo(); + } + + public function reports(): HasMany + { + return $this->hasMany(Report::class); + } + + public function assignedTo(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function resolvedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'resolved_by'); + } + + public function targetType(): string + { + return match ($this->caseable_type) { + MealPosts::class => 'meal_post', + User::class => 'user', + default => 'unknown', + }; + } + + public function targetLabel(): string + { + return match ($this->caseable_type) { + MealPosts::class => __('admin.moderation_cases.targets.meal_post'), + User::class => __('admin.moderation_cases.targets.user'), + default => __('admin.moderation_cases.targets.unknown'), + }; + } + + public function targetTitle(): string + { + $target = $this->caseable; + + return match (true) { + $target instanceof MealPosts => $target->title, + $target instanceof User => $target->name, + default => (string) $this->caseable_id, + }; + } + + protected function casts(): array + { + return [ + 'status' => ModerationCaseStatus::class, + 'reports_count' => 'integer', + 'threshold' => 'integer', + 'threshold_notified_at' => 'datetime', + 'resolved_at' => 'datetime', + ]; + } +} diff --git a/app/Models/Report.php b/app/Models/Report.php new file mode 100644 index 0000000..cf95744 --- /dev/null +++ b/app/Models/Report.php @@ -0,0 +1,58 @@ + */ + use HasFactory, HasUlids; + + protected $fillable = [ + 'moderation_case_id', + 'reporter_id', + 'reportable_type', + 'reportable_id', + 'reason', + 'details', + 'status', + 'reviewed_by', + 'reviewed_at', + ]; + + public function moderationCase(): BelongsTo + { + return $this->belongsTo(ModerationCase::class); + } + + public function reporter(): BelongsTo + { + return $this->belongsTo(User::class, 'reporter_id'); + } + + public function reviewedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'reviewed_by'); + } + + public function reportable(): MorphTo + { + return $this->morphTo(); + } + + protected function casts(): array + { + return [ + 'reason' => ReportReason::class, + 'status' => ReportStatus::class, + 'reviewed_at' => 'datetime', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 7c49fe6..68f7601 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -13,8 +13,11 @@ use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Translation\HasLocalePreference; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Database\Eloquent\Relations\MorphMany; +use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -50,6 +53,9 @@ class User extends Authenticatable implements FilamentUser, HasLocalePreference, 'pace_preference', 'sex', 'date_of_birth', + 'suspended_at', + 'suspended_by', + 'suspended_reason', ]; /** @@ -83,6 +89,7 @@ class User extends Authenticatable implements FilamentUser, HasLocalePreference, 'sex' => UserSex::class, 'role' => UserRole::class, 'date_of_birth' => 'immutable_date', + 'suspended_at' => 'datetime', ]; } @@ -106,6 +113,41 @@ class User extends Authenticatable implements FilamentUser, HasLocalePreference, return $this->hasOne(StravaConnection::class); } + public function reportsMade(): HasMany + { + return $this->hasMany(Report::class, 'reporter_id'); + } + + public function receivedReports(): MorphMany + { + return $this->morphMany(Report::class, 'reportable'); + } + + public function moderationCase(): MorphOne + { + return $this->morphOne(ModerationCase::class, 'caseable'); + } + + public function assignedModerationCases(): HasMany + { + return $this->hasMany(ModerationCase::class, 'assigned_to'); + } + + public function resolvedModerationCases(): HasMany + { + return $this->hasMany(ModerationCase::class, 'resolved_by'); + } + + public function suspendedBy(): BelongsTo + { + return $this->belongsTo(self::class, 'suspended_by'); + } + + public function isSuspended(): bool + { + return $this->suspended_at !== null; + } + public function routeNotificationForExpoPush(): array { return $this->deviceTokens() diff --git a/app/Notifications/ModerationThresholdReached.php b/app/Notifications/ModerationThresholdReached.php new file mode 100644 index 0000000..5395dd3 --- /dev/null +++ b/app/Notifications/ModerationThresholdReached.php @@ -0,0 +1,73 @@ +payload() + [ + 'title' => __('api.notifications.moderation_threshold_title'), + 'body' => __('api.notifications.moderation_threshold_body', [ + 'type' => $this->targetLabel(), + 'count' => $this->moderationCase->reports_count, + ]), + 'targetTitle' => $this->targetTitle(), + ]; + } + + private function payload(): array + { + return [ + 'type' => 'moderation_case_threshold', + 'moderationCaseId' => $this->moderationCase->getKey(), + 'reportableType' => $this->targetType(), + 'reportableId' => $this->moderationCase->caseable_id, + 'reportsCount' => $this->moderationCase->reports_count, + ]; + } + + private function targetType(): string + { + return match ($this->moderationCase->caseable_type) { + MealPosts::class => 'meal_post', + User::class => 'user', + default => 'unknown', + }; + } + + private function targetLabel(): string + { + return match ($this->moderationCase->caseable_type) { + MealPosts::class => __('api.reports.targets.meal_post'), + User::class => __('api.reports.targets.user'), + default => __('api.reports.targets.unknown'), + }; + } + + private function targetTitle(): string + { + $target = $this->moderationCase->caseable; + + return match (true) { + $target instanceof MealPosts => $target->title, + $target instanceof User => $target->name, + default => (string) $this->moderationCase->caseable_id, + }; + } +} diff --git a/app/Services/ModerationReportService.php b/app/Services/ModerationReportService.php new file mode 100644 index 0000000..256ec2d --- /dev/null +++ b/app/Services/ModerationReportService.php @@ -0,0 +1,138 @@ +abortIfInvalidTarget($reportable, $reporter); + + $shouldNotifyModerators = false; + + try { + $report = DB::transaction(function () use ($reportable, $reporter, $reason, $details, &$shouldNotifyModerators): Report { + $moderationCase = $this->findOrCreateModerationCase($reportable); + + abort_if( + Report::query() + ->where('reporter_id', $reporter->getKey()) + ->where('reportable_type', $reportable::class) + ->where('reportable_id', $reportable->getKey()) + ->exists(), + 409, + __('api.reports.already_exists'), + ); + + $report = Report::create([ + 'moderation_case_id' => $moderationCase->getKey(), + 'reporter_id' => $reporter->getKey(), + 'reportable_type' => $reportable::class, + 'reportable_id' => $reportable->getKey(), + 'reason' => $reason, + 'details' => $details, + 'status' => ReportStatus::OPEN, + ]); + + $reportsCount = $moderationCase->reports()->count(); + $updates = ['reports_count' => $reportsCount]; + + if ($reportsCount >= $moderationCase->threshold && $moderationCase->threshold_notified_at === null) { + $updates['threshold_notified_at'] = now(); + $shouldNotifyModerators = true; + } + + $moderationCase->forceFill($updates)->save(); + + return $report->load('moderationCase.caseable'); + }); + } catch (QueryException $exception) { + if ($this->isDuplicateReportException($exception)) { + abort(409, __('api.reports.already_exists')); + } + + throw $exception; + } + + if ($shouldNotifyModerators) { + $this->notifyModerators($report->moderationCase); + } + + return $report; + } + + private function findOrCreateModerationCase(Model $reportable): ModerationCase + { + $moderationCase = ModerationCase::query() + ->where('caseable_type', $reportable::class) + ->where('caseable_id', $reportable->getKey()) + ->lockForUpdate() + ->first(); + + if ($moderationCase) { + return $moderationCase; + } + + return ModerationCase::create([ + 'caseable_type' => $reportable::class, + 'caseable_id' => $reportable->getKey(), + 'status' => ModerationCaseStatus::OPEN, + 'threshold' => $this->thresholdFor($reportable), + ]); + } + + private function thresholdFor(Model $reportable): int + { + $thresholds = config('moderation.report_thresholds', []); + + return (int) ($thresholds[$reportable::class] ?? config('moderation.default_report_threshold', 3)); + } + + private function notifyModerators(ModerationCase $moderationCase): void + { + $freshCase = $moderationCase->fresh('caseable'); + + if (! $freshCase) { + return; + } + + User::query() + ->whereIn('role', [UserRole::ADMIN->value, UserRole::MODERATOR->value]) + ->get() + ->each(fn (User $moderator): mixed => $moderator->notify(new ModerationThresholdReached($freshCase))); + } + + private function abortIfInvalidTarget(Model $reportable, User $reporter): void + { + abort_if( + $reportable instanceof User && $reportable->is($reporter), + 422, + __('api.reports.cannot_report_self'), + ); + + abort_if( + $reportable instanceof MealPosts && $reportable->user_id === $reporter->getKey(), + 422, + __('api.reports.cannot_report_own_meal'), + ); + } + + private function isDuplicateReportException(QueryException $exception): bool + { + return $exception->getCode() === '23000' + && str_contains($exception->getMessage(), 'reports_reporter_reportable_unique'); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 6383258..6f759c5 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -19,6 +19,7 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->prepend(\App\Http\Middleware\SetLocale::class); $middleware->alias([ 'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class, + 'not_suspended' => \App\Http\Middleware\EnsureAccountIsNotSuspended::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/config/moderation.php b/config/moderation.php new file mode 100644 index 0000000..35c3799 --- /dev/null +++ b/config/moderation.php @@ -0,0 +1,13 @@ + 3, + + 'report_thresholds' => [ + MealPosts::class => 3, + User::class => 5, + ], +]; diff --git a/database/factories/ModerationCaseFactory.php b/database/factories/ModerationCaseFactory.php new file mode 100644 index 0000000..51e62d6 --- /dev/null +++ b/database/factories/ModerationCaseFactory.php @@ -0,0 +1,34 @@ + + */ +class ModerationCaseFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'caseable_type' => MealPosts::class, + 'caseable_id' => MealPosts::factory(), + 'status' => ModerationCaseStatus::OPEN, + 'reports_count' => 0, + 'threshold' => 3, + 'threshold_notified_at' => null, + 'assigned_to' => null, + 'resolved_by' => null, + 'resolved_at' => null, + 'resolution_note' => null, + ]; + } +} diff --git a/database/factories/ReportFactory.php b/database/factories/ReportFactory.php new file mode 100644 index 0000000..f139b0e --- /dev/null +++ b/database/factories/ReportFactory.php @@ -0,0 +1,36 @@ + + */ +class ReportFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'moderation_case_id' => ModerationCase::factory(), + 'reporter_id' => User::factory(), + 'reportable_type' => MealPosts::class, + 'reportable_id' => MealPosts::factory(), + 'reason' => ReportReason::OTHER, + 'details' => $this->faker->optional()->sentence(), + 'status' => ReportStatus::OPEN, + 'reviewed_by' => null, + 'reviewed_at' => null, + ]; + } +} diff --git a/database/migrations/2026_05_22_093513_create_moderation_cases_table.php b/database/migrations/2026_05_22_093513_create_moderation_cases_table.php new file mode 100644 index 0000000..8e068b3 --- /dev/null +++ b/database/migrations/2026_05_22_093513_create_moderation_cases_table.php @@ -0,0 +1,40 @@ +ulid('id')->primary(); + $table->ulidMorphs('caseable'); + $table->string('status', 32)->default(ModerationCaseStatus::OPEN->value); + $table->unsignedInteger('reports_count')->default(0); + $table->unsignedSmallInteger('threshold')->default(3); + $table->timestamp('threshold_notified_at')->nullable(); + $table->foreignUlid('assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignUlid('resolved_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('resolved_at')->nullable(); + $table->text('resolution_note')->nullable(); + $table->timestamps(); + + $table->unique(['caseable_type', 'caseable_id'], 'moderation_cases_caseable_unique'); + $table->index(['status', 'created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('moderation_cases'); + } +}; diff --git a/database/migrations/2026_05_22_093516_create_reports_table.php b/database/migrations/2026_05_22_093516_create_reports_table.php new file mode 100644 index 0000000..a63959c --- /dev/null +++ b/database/migrations/2026_05_22_093516_create_reports_table.php @@ -0,0 +1,39 @@ +ulid('id')->primary(); + $table->foreignUlid('moderation_case_id')->constrained('moderation_cases')->cascadeOnDelete(); + $table->foreignUlid('reporter_id')->nullable()->constrained('users')->nullOnDelete(); + $table->ulidMorphs('reportable'); + $table->string('reason', 64); + $table->text('details')->nullable(); + $table->string('status', 32)->default(ReportStatus::OPEN->value); + $table->foreignUlid('reviewed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('reviewed_at')->nullable(); + $table->timestamps(); + + $table->unique(['reporter_id', 'reportable_type', 'reportable_id'], 'reports_reporter_reportable_unique'); + $table->index(['status', 'created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('reports'); + } +}; diff --git a/database/migrations/2026_05_22_093542_add_moderation_fields_to_meal_posts_table.php b/database/migrations/2026_05_22_093542_add_moderation_fields_to_meal_posts_table.php new file mode 100644 index 0000000..0dbebb5 --- /dev/null +++ b/database/migrations/2026_05_22_093542_add_moderation_fields_to_meal_posts_table.php @@ -0,0 +1,31 @@ +timestamp('hidden_at')->nullable()->after('deleted_at')->index(); + $table->foreignUlid('hidden_by')->nullable()->after('hidden_at')->constrained('users')->nullOnDelete(); + $table->text('hidden_reason')->nullable()->after('hidden_by'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('meal_posts', function (Blueprint $table) { + $table->dropForeign(['hidden_by']); + $table->dropColumn(['hidden_at', 'hidden_by', 'hidden_reason']); + }); + } +}; diff --git a/database/migrations/2026_05_22_093550_add_moderation_fields_to_users_table.php b/database/migrations/2026_05_22_093550_add_moderation_fields_to_users_table.php new file mode 100644 index 0000000..b85cf01 --- /dev/null +++ b/database/migrations/2026_05_22_093550_add_moderation_fields_to_users_table.php @@ -0,0 +1,31 @@ +timestamp('suspended_at')->nullable()->after('deleted_at')->index(); + $table->foreignUlid('suspended_by')->nullable()->after('suspended_at')->constrained('users')->nullOnDelete(); + $table->text('suspended_reason')->nullable()->after('suspended_by'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropForeign(['suspended_by']); + $table->dropColumn(['suspended_at', 'suspended_by', 'suspended_reason']); + }); + } +}; diff --git a/lang/en/admin.php b/lang/en/admin.php index d5b2b05..341d819 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -53,4 +53,51 @@ return [ 'empty' => '-', ], ], + 'moderation_cases' => [ + 'navigation' => [ + 'label' => 'Moderation', + 'singular' => 'Moderation case', + 'plural' => 'Moderation cases', + ], + 'sections' => [ + 'case' => 'Case', + 'target' => 'Reported content', + 'resolution' => 'Resolution', + 'metadata' => 'Metadata', + ], + 'fields' => [ + 'id' => 'ID', + 'target_type' => 'Type', + 'target_title' => 'Target', + 'caseable_id' => 'Target ID', + 'status' => 'Status', + 'reports_count' => 'Reports', + 'threshold' => 'Threshold', + 'threshold_notified_at' => 'Notification sent at', + 'assigned_to' => 'Assigned to', + 'resolved_by' => 'Resolved by', + 'resolved_at' => 'Resolved at', + 'resolution_note' => 'Resolution note', + 'created_at' => 'Created at', + 'updated_at' => 'Updated at', + ], + 'targets' => [ + 'meal_post' => 'Meal', + 'user' => 'User', + 'unknown' => 'Unknown', + ], + 'actions' => [ + 'mark_reviewing' => 'Start review', + 'hide_meal' => 'Hide meal', + 'suspend_user' => 'Suspend', + 'resolve' => 'Resolve', + 'reject' => 'Reject', + ], + 'filters' => [ + 'target_type' => 'Type', + ], + 'placeholders' => [ + 'empty' => '-', + ], + ], ]; diff --git a/lang/en/api.php b/lang/en/api.php index cb1d6bd..3a89996 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -5,6 +5,7 @@ return [ 'registered_unverified' => 'Account created. Check your email to verify it.', 'invalid_credentials' => 'Invalid credentials.', 'email_not_verified' => 'Verify your email address before signing in.', + 'suspended' => 'This account is suspended.', 'already_verified' => 'Account already verified.', 'verified' => 'Account verified successfully.', 'verification_sent' => 'Verification email sent.', @@ -27,6 +28,19 @@ return [ 'notifications' => [ 'test_title' => 'Test', 'test_body' => 'Test notification from the Laravel API.', + 'moderation_threshold_title' => 'Reports need review', + 'moderation_threshold_body' => ':count reports received for :type.', + ], + 'reports' => [ + 'created' => 'Report sent. Thanks, our team will review this content.', + '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.', + 'targets' => [ + 'meal_post' => 'a meal', + 'user' => 'a user', + 'unknown' => 'content', + ], ], 'post_reviews' => [ 'already_exists' => 'You have already reviewed this meal.', @@ -50,5 +64,8 @@ return [ 'nutrition_goals_required' => 'Fill in all nutrition goals.', 'nutrition_goals_positive' => 'Nutrition goals must be positive.', 'nutrition_goals_max' => 'This nutrition goal value is too high.', + 'report_reason_required' => 'Choose a report reason.', + 'report_reason_valid' => 'Choose a valid report reason.', + 'report_details_max' => 'The report details may not be greater than 2000 characters.', ], ]; diff --git a/lang/en/enums.php b/lang/en/enums.php index 1fa44a7..420dfd1 100644 --- a/lang/en/enums.php +++ b/lang/en/enums.php @@ -24,6 +24,27 @@ return [ 'admin' => 'Administrator', 'moderator' => 'Moderator', ], + 'report_reason' => [ + 'spam' => 'Spam', + 'harassment' => 'Harassment', + 'hate_speech' => 'Hate speech', + 'inappropriate' => 'Inappropriate content', + 'dangerous_content' => 'Dangerous content', + 'misleading' => 'Misleading information', + 'other' => 'Other', + ], + 'report_status' => [ + 'open' => 'Open', + 'reviewing' => 'Reviewing', + 'resolved' => 'Resolved', + 'rejected' => 'Rejected', + ], + 'moderation_case_status' => [ + 'open' => 'Open', + 'reviewing' => 'Reviewing', + 'resolved' => 'Resolved', + 'rejected' => 'Rejected', + ], 'weight_goal' => [ 'lose_weight' => 'Lose weight', 'maintain_weight' => 'Maintain weight', diff --git a/lang/fr/admin.php b/lang/fr/admin.php index 9480f96..f5d55fb 100644 --- a/lang/fr/admin.php +++ b/lang/fr/admin.php @@ -53,4 +53,51 @@ return [ 'empty' => '-', ], ], + 'moderation_cases' => [ + 'navigation' => [ + 'label' => 'Modération', + 'singular' => 'Dossier de modération', + 'plural' => 'Dossiers de modération', + ], + 'sections' => [ + 'case' => 'Dossier', + 'target' => 'Contenu signalé', + 'resolution' => 'Traitement', + 'metadata' => 'Métadonnées', + ], + 'fields' => [ + 'id' => 'ID', + 'target_type' => 'Type', + 'target_title' => 'Cible', + 'caseable_id' => 'ID cible', + 'status' => 'Statut', + 'reports_count' => 'Signalements', + 'threshold' => 'Seuil', + 'threshold_notified_at' => 'Notification envoyée le', + 'assigned_to' => 'Assigné à', + 'resolved_by' => 'Traité par', + 'resolved_at' => 'Traité le', + 'resolution_note' => 'Note de traitement', + 'created_at' => 'Créé le', + 'updated_at' => 'Mis à jour le', + ], + 'targets' => [ + 'meal_post' => 'Plat', + 'user' => 'Utilisateur', + 'unknown' => 'Inconnu', + ], + 'actions' => [ + 'mark_reviewing' => 'Prendre en charge', + 'hide_meal' => 'Masquer le plat', + 'suspend_user' => 'Suspendre', + 'resolve' => 'Résoudre', + 'reject' => 'Rejeter', + ], + 'filters' => [ + 'target_type' => 'Type', + ], + 'placeholders' => [ + 'empty' => '-', + ], + ], ]; diff --git a/lang/fr/api.php b/lang/fr/api.php index c467ac4..109dbd2 100644 --- a/lang/fr/api.php +++ b/lang/fr/api.php @@ -5,6 +5,7 @@ return [ 'registered_unverified' => 'Compte créé. Consulte tes emails pour le vérifier.', 'invalid_credentials' => 'Identifiants invalides.', 'email_not_verified' => 'Vérifie ton adresse email avant de te connecter.', + 'suspended' => 'Ce compte est suspendu.', 'already_verified' => 'Compte déjà vérifié.', 'verified' => 'Compte vérifié avec succès.', 'verification_sent' => 'Email de vérification envoyé.', @@ -27,6 +28,19 @@ return [ 'notifications' => [ 'test_title' => 'Test', 'test_body' => 'Notification test depuis Laravel API.', + 'moderation_threshold_title' => 'Signalements à vérifier', + 'moderation_threshold_body' => ':count signalements reçus pour :type.', + ], + 'reports' => [ + 'created' => 'Signalement envoyé. Merci, notre équipe va examiner ce contenu.', + '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.', + 'targets' => [ + 'meal_post' => 'un plat', + 'user' => 'un utilisateur', + 'unknown' => 'un contenu', + ], ], 'post_reviews' => [ 'already_exists' => 'Vous avez déjà donné un avis pour ce repas.', @@ -50,5 +64,8 @@ return [ 'nutrition_goals_required' => 'Renseigne tous les objectifs nutritionnels.', 'nutrition_goals_positive' => 'Les objectifs nutritionnels doivent être positifs.', 'nutrition_goals_max' => 'La valeur de cet objectif nutritionnel est trop élevée.', + 'report_reason_required' => 'Choisis une raison de signalement.', + 'report_reason_valid' => 'Choisis une raison de signalement valide.', + 'report_details_max' => 'Le détail du signalement ne doit pas dépasser 2000 caractères.', ], ]; diff --git a/lang/fr/enums.php b/lang/fr/enums.php index f0e4b18..00788af 100644 --- a/lang/fr/enums.php +++ b/lang/fr/enums.php @@ -24,6 +24,27 @@ return [ 'admin' => 'Administrateur', 'moderator' => 'Modérateur', ], + 'report_reason' => [ + 'spam' => 'Spam', + 'harassment' => 'Harcèlement', + 'hate_speech' => 'Discours haineux', + 'inappropriate' => 'Contenu inapproprié', + 'dangerous_content' => 'Contenu dangereux', + 'misleading' => 'Information trompeuse', + 'other' => 'Autre', + ], + 'report_status' => [ + 'open' => 'Ouvert', + 'reviewing' => 'En cours', + 'resolved' => 'Résolu', + 'rejected' => 'Rejeté', + ], + 'moderation_case_status' => [ + 'open' => 'Ouvert', + 'reviewing' => 'En cours', + 'resolved' => 'Résolu', + 'rejected' => 'Rejeté', + ], 'weight_goal' => [ 'lose_weight' => 'Perdre du poids', 'maintain_weight' => 'Maintenir le poids', diff --git a/routes/api.php b/routes/api.php index e8b03e9..7cdd7a0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -6,6 +6,7 @@ use App\Http\Controllers\MealImageAnalysisController; use App\Http\Controllers\MealPostController; use App\Http\Controllers\PostReviewsController; use App\Http\Controllers\PublicUserProfileController; +use App\Http\Controllers\ReportController; use App\Http\Controllers\StravaController; use App\Http\Controllers\WorkoutSessionController; use Illuminate\Support\Facades\Route; @@ -40,14 +41,14 @@ Route::prefix('auth')->group(function (): void { Route::get('/health', function () { return response()->json(['status' => 'ok']); }); -Route::middleware(['auth:sanctum', 'verified'])->group(function (): void { +Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void { Route::post('device-tokens', [DeviceTokenController::class, 'store']); Route::delete('device-tokens', [DeviceTokenController::class, 'destroy']); Route::get('test-notifs', [DeviceTokenController::class, 'notifs']); }); // Workouts -Route::middleware(['auth:sanctum', 'verified'])->group(function (): void { +Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void { Route::get('workouts/calendar', [WorkoutSessionController::class, 'calendar'])->name('workouts.calendar'); Route::get('workouts', [WorkoutSessionController::class, 'index'])->name('workouts.index'); Route::post('workouts', [WorkoutSessionController::class, 'store'])->name('workouts.store'); @@ -59,14 +60,20 @@ Route::middleware(['auth:sanctum', 'verified'])->group(function (): void { }); // Meals -Route::middleware(['auth:sanctum', 'verified'])->group(function (): void { +Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void { Route::get('users/{user}', [PublicUserProfileController::class, 'show'])->name('users.show'); + Route::post('users/{user}/reports', [ReportController::class, 'storeUser']) + ->middleware('throttle:10,1') + ->name('users.reports.store'); Route::get('me/meal-posts', [MealPostController::class, 'mine'])->name('meal-posts.mine'); Route::get('meal-posts/stats', [MealPostController::class, 'stats']); Route::get('meal-posts/calendar', [MealPostController::class, 'calendar']); Route::post('meal-posts/analyze-image', [MealImageAnalysisController::class, 'store']); Route::post('meal-posts/{mealPost}/like', [MealPostController::class, 'like'])->name('meal-posts.like'); Route::delete('meal-posts/{mealPost}/like', [MealPostController::class, 'unlike'])->name('meal-posts.unlike'); + Route::post('meal-posts/{mealPost}/reports', [ReportController::class, 'storeMealPost']) + ->middleware('throttle:10,1') + ->name('meal-posts.reports.store'); Route::get('meal-posts/{mealPost}/reviews', [PostReviewsController::class, 'index'])->name('meal-posts.reviews.index'); Route::post('meal-posts/{mealPost}/reviews', [PostReviewsController::class, 'store'])->name('meal-posts.reviews.store'); Route::get('post-reviews/{postReview}', [PostReviewsController::class, 'show'])->name('post-reviews.show'); diff --git a/tests/Feature/ModerationCaseFilamentTest.php b/tests/Feature/ModerationCaseFilamentTest.php new file mode 100644 index 0000000..c871a79 --- /dev/null +++ b/tests/Feature/ModerationCaseFilamentTest.php @@ -0,0 +1,87 @@ +setLocale('fr'); + + Filament::setCurrentPanel(Filament::getPanel('dashboard')); +}); + +it('exposes moderation cases in filament and hides a reported meal post', function () { + $moderator = User::factory()->create(['role' => UserRole::MODERATOR]); + $mealPost = MealPosts::factory()->create(); + $case = ModerationCase::factory()->create([ + 'caseable_type' => MealPosts::class, + 'caseable_id' => $mealPost->id, + 'reports_count' => 3, + 'threshold' => 3, + ]); + + Report::factory()->create([ + 'moderation_case_id' => $case->id, + 'reportable_type' => MealPosts::class, + 'reportable_id' => $mealPost->id, + 'reason' => ReportReason::INAPPROPRIATE, + ]); + + $this->actingAs($moderator); + + Livewire::test(ListModerationCases::class) + ->assertTableColumnExists( + 'status', + fn (TextColumn $column): bool => $column->getLabel() === 'Statut', + ) + ->callTableAction('hideMeal', $case); + + expect($mealPost->fresh()->hidden_at)->not->toBeNull() + ->and($mealPost->fresh()->hidden_by)->toBe($moderator->id) + ->and($case->fresh()->status)->toBe(ModerationCaseStatus::REVIEWING); + + Sanctum::actingAs(User::factory()->create()); + + $this->getJson("/api/meal-posts/{$mealPost->id}") + ->assertNotFound(); +}); + +it('suspends a reported user from the moderation table', function () { + $moderator = User::factory()->create(['role' => UserRole::MODERATOR]); + $target = User::factory()->create(); + $case = ModerationCase::factory()->create([ + 'caseable_type' => User::class, + 'caseable_id' => $target->id, + 'reports_count' => 5, + 'threshold' => 5, + ]); + + $this->actingAs($moderator); + + Livewire::test(ListModerationCases::class) + ->callTableAction('suspendUser', $case); + + expect($target->fresh()->suspended_at)->not->toBeNull() + ->and($target->fresh()->suspended_by)->toBe($moderator->id); + + Sanctum::actingAs($target->fresh()); + + $this->postJson('/api/meal-posts', [ + 'image_url' => 'https://example.com/meal.jpg', + 'title' => 'Repas suspendu', + 'eaten_at' => '2026-05-22T12:00:00.000Z', + 'type' => \App\Enums\MealPostType::BREAKFAST->value, + ])->assertForbidden(); +}); diff --git a/tests/Feature/ModerationReportsTest.php b/tests/Feature/ModerationReportsTest.php new file mode 100644 index 0000000..40f5b61 --- /dev/null +++ b/tests/Feature/ModerationReportsTest.php @@ -0,0 +1,135 @@ +create(); + $mealPost = MealPosts::factory()->create(); + + Sanctum::actingAs($reporter); + + $this->postJson("/api/meal-posts/{$mealPost->id}/reports", [ + 'reason' => ReportReason::SPAM->value, + 'details' => 'Même image publiée en boucle.', + ]) + ->assertCreated() + ->assertJsonPath('message', __('api.reports.created')) + ->assertJsonPath('data.reason', ReportReason::SPAM->value) + ->assertJsonPath('data.reportableType', 'meal_post') + ->assertJsonPath('data.reportableId', $mealPost->id); + + $this->assertDatabaseHas('reports', [ + 'reporter_id' => $reporter->id, + 'reportable_type' => MealPosts::class, + 'reportable_id' => $mealPost->id, + 'reason' => ReportReason::SPAM->value, + 'details' => 'Même image publiée en boucle.', + ]); + + $this->assertDatabaseHas('moderation_cases', [ + 'caseable_type' => MealPosts::class, + 'caseable_id' => $mealPost->id, + 'reports_count' => 1, + 'threshold' => 3, + ]); +}); + +it('prevents duplicate reports and reports on own content', function () { + $owner = User::factory()->create(); + $mealPost = MealPosts::factory()->for($owner, 'user')->create(); + $reporter = User::factory()->create(); + + Sanctum::actingAs($reporter); + + $payload = ['reason' => ReportReason::OTHER->value]; + + $this->postJson("/api/meal-posts/{$mealPost->id}/reports", $payload) + ->assertCreated(); + + $this->postJson("/api/meal-posts/{$mealPost->id}/reports", $payload) + ->assertConflict() + ->assertJsonPath('message', __('api.reports.already_exists')); + + expect(Report::query()->count())->toBe(1); + + Sanctum::actingAs($owner); + + $this->postJson("/api/meal-posts/{$mealPost->id}/reports", $payload) + ->assertUnprocessable() + ->assertJsonPath('message', __('api.reports.cannot_report_own_meal')); +}); + +it('reports users and prevents reporting own profile', function () { + $target = User::factory()->create(); + $reporter = User::factory()->create(); + + Sanctum::actingAs($reporter); + + $this->postJson("/api/users/{$target->id}/reports", [ + 'reason' => ReportReason::HARASSMENT->value, + ]) + ->assertCreated() + ->assertJsonPath('data.reportableType', 'user') + ->assertJsonPath('data.reportableId', $target->id); + + $this->assertDatabaseHas('moderation_cases', [ + 'caseable_type' => User::class, + 'caseable_id' => $target->id, + 'threshold' => 5, + ]); + + Sanctum::actingAs($target); + + $this->postJson("/api/users/{$target->id}/reports", [ + 'reason' => ReportReason::OTHER->value, + ]) + ->assertUnprocessable() + ->assertJsonPath('message', __('api.reports.cannot_report_self')); +}); + +it('notifies moderators once when a report threshold is reached', function () { + Notification::fake(); + + $admin = User::factory()->create(['role' => UserRole::ADMIN]); + $moderator = User::factory()->create(['role' => UserRole::MODERATOR]); + $regularUser = User::factory()->create(); + $mealPost = MealPosts::factory()->create(); + + foreach (User::factory()->count(3)->create() as $reporter) { + Sanctum::actingAs($reporter); + + $this->postJson("/api/meal-posts/{$mealPost->id}/reports", [ + 'reason' => ReportReason::INAPPROPRIATE->value, + ])->assertCreated(); + } + + $moderationCase = ModerationCase::query()->where('caseable_id', $mealPost->id)->firstOrFail(); + + expect($moderationCase->reports_count)->toBe(3) + ->and($moderationCase->threshold_notified_at)->not->toBeNull(); + + Notification::assertSentTo($admin, ModerationThresholdReached::class); + Notification::assertSentTo($moderator, ModerationThresholdReached::class); + Notification::assertNotSentTo($regularUser, ModerationThresholdReached::class); + Notification::assertSentTimes(ModerationThresholdReached::class, 2); + expect((new ModerationThresholdReached($moderationCase))->via($admin))->toBe(['database']); + + Sanctum::actingAs(User::factory()->create()); + + $this->postJson("/api/meal-posts/{$mealPost->id}/reports", [ + 'reason' => ReportReason::MISLEADING->value, + ])->assertCreated(); + + Notification::assertSentTimes(ModerationThresholdReached::class, 2); +});