feat: moderation

This commit is contained in:
2026-05-22 11:56:17 +02:00
parent f5b460c650
commit 1c472e46db
43 changed files with 1836 additions and 3 deletions
+34
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',
};
}
}
+40
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',
};
}
}
+34
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',
};
}
}
@@ -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'),
];
}
}
@@ -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;
}
@@ -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(),
];
}
}
@@ -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 [
];
}
}
@@ -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(),
];
}
}
@@ -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(),
]),
]);
}
}
@@ -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(),
]),
]);
}
}
@@ -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;
}
}
+9
View File
@@ -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;
@@ -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');
}
@@ -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,
);
}
}
@@ -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),
+50
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);
}
}
@@ -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);
}
}
+32
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'),
];
}
}
+38
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',
};
}
}
+1
View File
@@ -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,
+26
View File
@@ -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',
];
}
}
+90
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
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',
];
}
}
+42
View File
@@ -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()
@@ -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,
};
}
}
+138
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');
}
}