feat: moderation
Laravel CI-CD / Tests Unitaires (push) Successful in 1m28s Details
Laravel CI-CD / Deploy with Kamal (push) Successful in 1m59s Details

This commit is contained in:
Leon Morival 2026-05-22 11:56:17 +02:00
parent f5b460c650
commit 1c472e46db
43 changed files with 1836 additions and 3 deletions

View File

@ -0,0 +1,34 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum ModerationCaseStatus: string implements HasColor, HasLabel
{
case OPEN = 'open';
case REVIEWING = 'reviewing';
case RESOLVED = 'resolved';
case REJECTED = 'rejected';
public function getLabel(): string
{
return match ($this) {
self::OPEN => __('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',
};
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum ReportReason: string implements HasColor, HasLabel
{
case SPAM = 'spam';
case HARASSMENT = 'harassment';
case HATE_SPEECH = 'hate_speech';
case INAPPROPRIATE = 'inappropriate';
case DANGEROUS_CONTENT = 'dangerous_content';
case MISLEADING = 'misleading';
case OTHER = 'other';
public function getLabel(): string
{
return match ($this) {
self::SPAM => __('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',
};
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum ReportStatus: string implements HasColor, HasLabel
{
case OPEN = 'open';
case REVIEWING = 'reviewing';
case RESOLVED = 'resolved';
case REJECTED = 'rejected';
public function getLabel(): string
{
return match ($this) {
self::OPEN => __('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',
};
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Filament\Resources\ModerationCases;
use App\Filament\Resources\ModerationCases\Pages\EditModerationCase;
use App\Filament\Resources\ModerationCases\Pages\ListModerationCases;
use App\Filament\Resources\ModerationCases\Pages\ViewModerationCase;
use App\Filament\Resources\ModerationCases\Schemas\ModerationCaseForm;
use App\Filament\Resources\ModerationCases\Schemas\ModerationCaseInfolist;
use App\Filament\Resources\ModerationCases\Tables\ModerationCasesTable;
use App\Models\ModerationCase;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class ModerationCaseResource extends Resource
{
protected static ?string $model = ModerationCase::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function getNavigationLabel(): string
{
return __('admin.moderation_cases.navigation.label');
}
public static function getModelLabel(): string
{
return __('admin.moderation_cases.navigation.singular');
}
public static function getPluralModelLabel(): string
{
return __('admin.moderation_cases.navigation.plural');
}
public static function form(Schema $schema): Schema
{
return ModerationCaseForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return ModerationCaseInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return ModerationCasesTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListModerationCases::route('/'),
'view' => ViewModerationCase::route('/{record}'),
'edit' => EditModerationCase::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\ModerationCases\Pages;
use App\Filament\Resources\ModerationCases\ModerationCaseResource;
use Filament\Resources\Pages\CreateRecord;
class CreateModerationCase extends CreateRecord
{
protected static string $resource = ModerationCaseResource::class;
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\ModerationCases\Pages;
use App\Filament\Resources\ModerationCases\ModerationCaseResource;
use Filament\Actions\ViewAction;
use Filament\Resources\Pages\EditRecord;
class EditModerationCase extends EditRecord
{
protected static string $resource = ModerationCaseResource::class;
protected function getHeaderActions(): array
{
return [
ViewAction::make(),
];
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Filament\Resources\ModerationCases\Pages;
use App\Filament\Resources\ModerationCases\ModerationCaseResource;
use Filament\Resources\Pages\ListRecords;
class ListModerationCases extends ListRecords
{
protected static string $resource = ModerationCaseResource::class;
protected function getHeaderActions(): array
{
return [
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\ModerationCases\Pages;
use App\Filament\Resources\ModerationCases\ModerationCaseResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewModerationCase extends ViewRecord
{
protected static string $resource = ModerationCaseResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make(),
];
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Filament\Resources\ModerationCases\Schemas;
use App\Enums\ModerationCaseStatus;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class ModerationCaseForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->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(),
]),
]);
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Filament\Resources\ModerationCases\Schemas;
use App\Models\ModerationCase;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class ModerationCaseInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->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(),
]),
]);
}
}

View File

@ -0,0 +1,199 @@
<?php
namespace App\Filament\Resources\ModerationCases\Tables;
use App\Enums\ModerationCaseStatus;
use App\Enums\ReportStatus;
use App\Models\MealPosts;
use App\Models\ModerationCase;
use App\Models\User;
use Filament\Actions\Action;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class ModerationCasesTable
{
public static function configure(Table $table): Table
{
return $table
->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;
}
}

View File

@ -65,6 +65,12 @@ class AuthController extends Controller
], 401); ], 401);
} }
if ($user->isSuspended()) {
return response()->json([
'message' => __('api.auth.suspended'),
], 403);
}
if (isset($data['locale']) && $user->locale !== $data['locale']) { if (isset($data['locale']) && $user->locale !== $data['locale']) {
$user->forceFill(['locale' => $data['locale']])->save(); $user->forceFill(['locale' => $data['locale']])->save();
} }
@ -224,6 +230,9 @@ class AuthController extends Controller
public function update(UpdateUserRequest $request): JsonResource public function update(UpdateUserRequest $request): JsonResource
{ {
$user = $request->user(); $user = $request->user();
abort_if($user->isSuspended(), 403, __('api.auth.suspended'));
$data = $request->validated(); $data = $request->validated();
$nutritionGoals = $data['nutritionGoals'] ?? null; $nutritionGoals = $data['nutritionGoals'] ?? null;

View File

@ -137,6 +137,8 @@ class MealPostController extends Controller
public function show(Request $request, MealPosts $mealPost): MealPostsResource public function show(Request $request, MealPosts $mealPost): MealPostsResource
{ {
$this->abortIfUnavailable($mealPost);
return new MealPostsResource($this->loadMealPostForResponse($mealPost, $request)); return new MealPostsResource($this->loadMealPostForResponse($mealPost, $request));
} }
@ -171,6 +173,8 @@ class MealPostController extends Controller
public function like(Request $request, MealPosts $mealPost): MealPostsResource public function like(Request $request, MealPosts $mealPost): MealPostsResource
{ {
$this->abortIfUnavailable($mealPost);
$mealPost->likedByUsers()->syncWithoutDetaching([$request->user()->getKey()]); $mealPost->likedByUsers()->syncWithoutDetaching([$request->user()->getKey()]);
return new MealPostsResource($this->loadMealPostForResponse($mealPost, $request)); return new MealPostsResource($this->loadMealPostForResponse($mealPost, $request));
@ -178,6 +182,8 @@ class MealPostController extends Controller
public function unlike(Request $request, MealPosts $mealPost): MealPostsResource public function unlike(Request $request, MealPosts $mealPost): MealPostsResource
{ {
$this->abortIfUnavailable($mealPost);
$mealPost->likedByUsers()->detach($request->user()->getKey()); $mealPost->likedByUsers()->detach($request->user()->getKey());
return new MealPostsResource($this->loadMealPostForResponse($mealPost, $request)); return new MealPostsResource($this->loadMealPostForResponse($mealPost, $request));
@ -198,6 +204,14 @@ class MealPostController extends Controller
abort_unless($mealPost->user_id === $request->user()->getKey(), 404); 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 private function loadMealPostForResponse(MealPosts $mealPost, Request $request): MealPosts
{ {
return $mealPost return $mealPost
@ -207,6 +221,8 @@ class MealPostController extends Controller
private function mealPostListQuery(Request $request): Builder private function mealPostListQuery(Request $request): Builder
{ {
return MealPosts::query() 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'); ->with('user:id,name,avatar_url,account_verified_at');
} }

View File

@ -15,6 +15,8 @@ class PostReviewsController extends Controller
{ {
public function index(Request $request, MealPosts $mealPost): AnonymousResourceCollection public function index(Request $request, MealPosts $mealPost): AnonymousResourceCollection
{ {
$this->abortIfUnavailable($mealPost);
$perPage = max(1, min($request->integer('per_page', 15), 100)); $perPage = max(1, min($request->integer('per_page', 15), 100));
$reviews = $mealPost->reviews() $reviews = $mealPost->reviews()
@ -28,6 +30,8 @@ class PostReviewsController extends Controller
public function store(PostReviewsRequest $request, MealPosts $mealPost): JsonResponse public function store(PostReviewsRequest $request, MealPosts $mealPost): JsonResponse
{ {
$this->abortIfUnavailable($mealPost);
$userId = $request->user()->getKey(); $userId = $request->user()->getKey();
abort_if( abort_if(
@ -52,6 +56,9 @@ class PostReviewsController extends Controller
public function show(PostReviews $postReview): PostReviewsResource public function show(PostReviews $postReview): PostReviewsResource
{ {
$postReview->loadMissing('mealPost.user');
$this->abortIfUnavailable($postReview->mealPost);
return new PostReviewsResource($postReview->loadMissing( return new PostReviewsResource($postReview->loadMissing(
'mealPost.ingredients', 'mealPost.ingredients',
'mealPost.user:id,name,avatar_url', 'mealPost.user:id,name,avatar_url',
@ -82,4 +89,12 @@ class PostReviewsController extends Controller
abort_unless($request->user(), 401); abort_unless($request->user(), 401);
abort_unless($postReview->user_id === $request->user()->getKey(), 404); 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,
);
}
} }

View File

@ -13,8 +13,11 @@ class PublicUserProfileController extends Controller
{ {
public function show(Request $request, User $user): JsonResponse public function show(Request $request, User $user): JsonResponse
{ {
abort_if($user->isSuspended(), 404);
$meals = $user->mealPosts() $meals = $user->mealPosts()
->where('visibility', MealPostVisibility::Public->value) ->where('visibility', MealPostVisibility::Public->value)
->whereNull('hidden_at')
->with('user:id,name,avatar_url,account_verified_at') ->with('user:id,name,avatar_url,account_verified_at')
->latest('eaten_at') ->latest('eaten_at')
->limit(18) ->limit(18)
@ -34,6 +37,7 @@ class PublicUserProfileController extends Controller
'stats' => [ 'stats' => [
'mealsCount' => $user->mealPosts() 'mealsCount' => $user->mealPosts()
->where('visibility', MealPostVisibility::Public->value) ->where('visibility', MealPostVisibility::Public->value)
->whereNull('hidden_at')
->count(), ->count(),
], ],
'meals' => MealPostsResource::collection($meals)->resolve($request), 'meals' => MealPostsResource::collection($meals)->resolve($request),

View File

@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers;
use App\Enums\ReportReason;
use App\Http\Requests\StoreReportRequest;
use App\Http\Resources\ReportResource;
use App\Models\MealPosts;
use App\Models\User;
use App\Services\ModerationReportService;
use Illuminate\Http\JsonResponse;
class ReportController extends Controller
{
public function storeMealPost(
StoreReportRequest $request,
MealPosts $mealPost,
ModerationReportService $reports,
): JsonResponse {
$report = $reports->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);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureAccountIsNotSuspended
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if ($request->user()?->isSuspended()) {
return response()->json([
'message' => __('api.auth.suspended'),
], 403);
}
return $next($request);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use App\Enums\ReportReason;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreReportRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'reason' => ['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'),
];
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Http\Resources;
use App\Models\MealPosts;
use App\Models\Report;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Report */
class ReportResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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',
};
}
}

View File

@ -27,6 +27,7 @@ class UserResource extends JsonResource
'bio' => $this->bio, 'bio' => $this->bio,
'role' => $this->role, 'role' => $this->role,
'accountVerified' => $this->account_verified_at !== null, 'accountVerified' => $this->account_verified_at !== null,
'suspendedAt' => $this->suspended_at?->toISOString(),
'physicalActivityLevel' => $this->physical_activity_level, 'physicalActivityLevel' => $this->physical_activity_level,
'physicalActivityLevelLabel' => $this->physical_activity_level?->getLabel(), 'physicalActivityLevelLabel' => $this->physical_activity_level?->getLabel(),
'weightGoal' => $this->weight_goal, 'weightGoal' => $this->weight_goal,

View File

@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
class MealPosts extends Model class MealPosts extends Model
@ -31,6 +33,9 @@ class MealPosts extends Model
'type', 'type',
'ai_generated', 'ai_generated',
'diet_type', 'diet_type',
'hidden_at',
'hidden_by',
'hidden_reason',
]; ];
protected $attributes = [ protected $attributes = [
@ -60,6 +65,26 @@ class MealPosts extends Model
->withTimestamps(); ->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 protected function casts(): array
{ {
return [ return [
@ -68,6 +93,7 @@ class MealPosts extends Model
'type' => MealPostType::class, 'type' => MealPostType::class,
'diet_type' => DietType::class, 'diet_type' => DietType::class,
'ai_generated' => 'boolean', 'ai_generated' => 'boolean',
'hidden_at' => 'datetime',
]; ];
} }
} }

View File

@ -0,0 +1,90 @@
<?php
namespace App\Models;
use App\Enums\ModerationCaseStatus;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class ModerationCase extends Model
{
/** @use HasFactory<\Database\Factories\ModerationCaseFactory> */
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',
];
}
}

58
app/Models/Report.php Normal file
View File

@ -0,0 +1,58 @@
<?php
namespace App\Models;
use App\Enums\ReportReason;
use App\Enums\ReportStatus;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Report extends Model
{
/** @use HasFactory<\Database\Factories\ReportFactory> */
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',
];
}
}

View File

@ -13,8 +13,11 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Contracts\Translation\HasLocalePreference; use Illuminate\Contracts\Translation\HasLocalePreference;
use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne; 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\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
@ -50,6 +53,9 @@ class User extends Authenticatable implements FilamentUser, HasLocalePreference,
'pace_preference', 'pace_preference',
'sex', 'sex',
'date_of_birth', 'date_of_birth',
'suspended_at',
'suspended_by',
'suspended_reason',
]; ];
/** /**
@ -83,6 +89,7 @@ class User extends Authenticatable implements FilamentUser, HasLocalePreference,
'sex' => UserSex::class, 'sex' => UserSex::class,
'role' => UserRole::class, 'role' => UserRole::class,
'date_of_birth' => 'immutable_date', 'date_of_birth' => 'immutable_date',
'suspended_at' => 'datetime',
]; ];
} }
@ -106,6 +113,41 @@ class User extends Authenticatable implements FilamentUser, HasLocalePreference,
return $this->hasOne(StravaConnection::class); 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 public function routeNotificationForExpoPush(): array
{ {
return $this->deviceTokens() return $this->deviceTokens()

View File

@ -0,0 +1,73 @@
<?php
namespace App\Notifications;
use App\Models\MealPosts;
use App\Models\ModerationCase;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class ModerationThresholdReached extends Notification
{
use Queueable;
public function __construct(public ModerationCase $moderationCase) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
return $this->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,
};
}
}

View File

@ -0,0 +1,138 @@
<?php
namespace App\Services;
use App\Enums\ModerationCaseStatus;
use App\Enums\ReportReason;
use App\Enums\ReportStatus;
use App\Enums\UserRole;
use App\Models\MealPosts;
use App\Models\ModerationCase;
use App\Models\Report;
use App\Models\User;
use App\Notifications\ModerationThresholdReached;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
class ModerationReportService
{
public function report(Model $reportable, User $reporter, ReportReason $reason, ?string $details): Report
{
$this->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');
}
}

View File

@ -19,6 +19,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->prepend(\App\Http\Middleware\SetLocale::class); $middleware->prepend(\App\Http\Middleware\SetLocale::class);
$middleware->alias([ $middleware->alias([
'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class, 'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class,
'not_suspended' => \App\Http\Middleware\EnsureAccountIsNotSuspended::class,
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {

13
config/moderation.php Normal file
View File

@ -0,0 +1,13 @@
<?php
use App\Models\MealPosts;
use App\Models\User;
return [
'default_report_threshold' => 3,
'report_thresholds' => [
MealPosts::class => 3,
User::class => 5,
],
];

View File

@ -0,0 +1,34 @@
<?php
namespace Database\Factories;
use App\Enums\ModerationCaseStatus;
use App\Models\MealPosts;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ModerationCase>
*/
class ModerationCaseFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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,
];
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace Database\Factories;
use App\Enums\ReportReason;
use App\Enums\ReportStatus;
use App\Models\MealPosts;
use App\Models\ModerationCase;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Report>
*/
class ReportFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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,
];
}
}

View File

@ -0,0 +1,40 @@
<?php
use App\Enums\ModerationCaseStatus;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('moderation_cases', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,39 @@
<?php
use App\Enums\ReportStatus;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('reports', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('meal_posts', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -53,4 +53,51 @@ return [
'empty' => '-', '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' => '-',
],
],
]; ];

View File

@ -5,6 +5,7 @@ return [
'registered_unverified' => 'Account created. Check your email to verify it.', 'registered_unverified' => 'Account created. Check your email to verify it.',
'invalid_credentials' => 'Invalid credentials.', 'invalid_credentials' => 'Invalid credentials.',
'email_not_verified' => 'Verify your email address before signing in.', 'email_not_verified' => 'Verify your email address before signing in.',
'suspended' => 'This account is suspended.',
'already_verified' => 'Account already verified.', 'already_verified' => 'Account already verified.',
'verified' => 'Account verified successfully.', 'verified' => 'Account verified successfully.',
'verification_sent' => 'Verification email sent.', 'verification_sent' => 'Verification email sent.',
@ -27,6 +28,19 @@ return [
'notifications' => [ 'notifications' => [
'test_title' => 'Test', 'test_title' => 'Test',
'test_body' => 'Test notification from the Laravel API.', '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' => [ 'post_reviews' => [
'already_exists' => 'You have already reviewed this meal.', 'already_exists' => 'You have already reviewed this meal.',
@ -50,5 +64,8 @@ return [
'nutrition_goals_required' => 'Fill in all nutrition goals.', 'nutrition_goals_required' => 'Fill in all nutrition goals.',
'nutrition_goals_positive' => 'Nutrition goals must be positive.', 'nutrition_goals_positive' => 'Nutrition goals must be positive.',
'nutrition_goals_max' => 'This nutrition goal value is too high.', '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.',
], ],
]; ];

View File

@ -24,6 +24,27 @@ return [
'admin' => 'Administrator', 'admin' => 'Administrator',
'moderator' => 'Moderator', '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' => [ 'weight_goal' => [
'lose_weight' => 'Lose weight', 'lose_weight' => 'Lose weight',
'maintain_weight' => 'Maintain weight', 'maintain_weight' => 'Maintain weight',

View File

@ -53,4 +53,51 @@ return [
'empty' => '-', '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' => '-',
],
],
]; ];

View File

@ -5,6 +5,7 @@ return [
'registered_unverified' => 'Compte créé. Consulte tes emails pour le vérifier.', 'registered_unverified' => 'Compte créé. Consulte tes emails pour le vérifier.',
'invalid_credentials' => 'Identifiants invalides.', 'invalid_credentials' => 'Identifiants invalides.',
'email_not_verified' => 'Vérifie ton adresse email avant de te connecter.', 'email_not_verified' => 'Vérifie ton adresse email avant de te connecter.',
'suspended' => 'Ce compte est suspendu.',
'already_verified' => 'Compte déjà vérifié.', 'already_verified' => 'Compte déjà vérifié.',
'verified' => 'Compte vérifié avec succès.', 'verified' => 'Compte vérifié avec succès.',
'verification_sent' => 'Email de vérification envoyé.', 'verification_sent' => 'Email de vérification envoyé.',
@ -27,6 +28,19 @@ return [
'notifications' => [ 'notifications' => [
'test_title' => 'Test', 'test_title' => 'Test',
'test_body' => 'Notification test depuis Laravel API.', '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' => [ 'post_reviews' => [
'already_exists' => 'Vous avez déjà donné un avis pour ce repas.', '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_required' => 'Renseigne tous les objectifs nutritionnels.',
'nutrition_goals_positive' => 'Les objectifs nutritionnels doivent être positifs.', 'nutrition_goals_positive' => 'Les objectifs nutritionnels doivent être positifs.',
'nutrition_goals_max' => 'La valeur de cet objectif nutritionnel est trop élevée.', '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.',
], ],
]; ];

View File

@ -24,6 +24,27 @@ return [
'admin' => 'Administrateur', 'admin' => 'Administrateur',
'moderator' => 'Modérateur', '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' => [ 'weight_goal' => [
'lose_weight' => 'Perdre du poids', 'lose_weight' => 'Perdre du poids',
'maintain_weight' => 'Maintenir le poids', 'maintain_weight' => 'Maintenir le poids',

View File

@ -6,6 +6,7 @@ use App\Http\Controllers\MealImageAnalysisController;
use App\Http\Controllers\MealPostController; use App\Http\Controllers\MealPostController;
use App\Http\Controllers\PostReviewsController; use App\Http\Controllers\PostReviewsController;
use App\Http\Controllers\PublicUserProfileController; use App\Http\Controllers\PublicUserProfileController;
use App\Http\Controllers\ReportController;
use App\Http\Controllers\StravaController; use App\Http\Controllers\StravaController;
use App\Http\Controllers\WorkoutSessionController; use App\Http\Controllers\WorkoutSessionController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -40,14 +41,14 @@ Route::prefix('auth')->group(function (): void {
Route::get('/health', function () { Route::get('/health', function () {
return response()->json(['status' => 'ok']); 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::post('device-tokens', [DeviceTokenController::class, 'store']);
Route::delete('device-tokens', [DeviceTokenController::class, 'destroy']); Route::delete('device-tokens', [DeviceTokenController::class, 'destroy']);
Route::get('test-notifs', [DeviceTokenController::class, 'notifs']); Route::get('test-notifs', [DeviceTokenController::class, 'notifs']);
}); });
// Workouts // 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/calendar', [WorkoutSessionController::class, 'calendar'])->name('workouts.calendar');
Route::get('workouts', [WorkoutSessionController::class, 'index'])->name('workouts.index'); Route::get('workouts', [WorkoutSessionController::class, 'index'])->name('workouts.index');
Route::post('workouts', [WorkoutSessionController::class, 'store'])->name('workouts.store'); Route::post('workouts', [WorkoutSessionController::class, 'store'])->name('workouts.store');
@ -59,14 +60,20 @@ Route::middleware(['auth:sanctum', 'verified'])->group(function (): void {
}); });
// Meals // 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::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('me/meal-posts', [MealPostController::class, 'mine'])->name('meal-posts.mine');
Route::get('meal-posts/stats', [MealPostController::class, 'stats']); Route::get('meal-posts/stats', [MealPostController::class, 'stats']);
Route::get('meal-posts/calendar', [MealPostController::class, 'calendar']); Route::get('meal-posts/calendar', [MealPostController::class, 'calendar']);
Route::post('meal-posts/analyze-image', [MealImageAnalysisController::class, 'store']); Route::post('meal-posts/analyze-image', [MealImageAnalysisController::class, 'store']);
Route::post('meal-posts/{mealPost}/like', [MealPostController::class, 'like'])->name('meal-posts.like'); 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::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::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::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'); Route::get('post-reviews/{postReview}', [PostReviewsController::class, 'show'])->name('post-reviews.show');

View File

@ -0,0 +1,87 @@
<?php
use App\Enums\ModerationCaseStatus;
use App\Enums\ReportReason;
use App\Enums\UserRole;
use App\Filament\Resources\ModerationCases\Pages\ListModerationCases;
use App\Models\MealPosts;
use App\Models\ModerationCase;
use App\Models\Report;
use App\Models\User;
use Filament\Facades\Filament;
use Filament\Tables\Columns\TextColumn;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
app()->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();
});

View File

@ -0,0 +1,135 @@
<?php
use App\Enums\ReportReason;
use App\Enums\UserRole;
use App\Models\MealPosts;
use App\Models\ModerationCase;
use App\Models\Report;
use App\Models\User;
use App\Notifications\ModerationThresholdReached;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class);
it('creates a report and a moderation case for a meal post', function () {
$reporter = User::factory()->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);
});