feat: moderation
This commit is contained in:
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user