feat: moderation
This commit is contained in:
parent
f5b460c650
commit
1c472e46db
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
$middleware->prepend(\App\Http\Middleware\SetLocale::class);
|
||||
$middleware->alias([
|
||||
'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class,
|
||||
'not_suspended' => \App\Http\Middleware\EnsureAccountIsNotSuspended::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
];
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -53,4 +53,51 @@ return [
|
|||
'empty' => '-',
|
||||
],
|
||||
],
|
||||
'moderation_cases' => [
|
||||
'navigation' => [
|
||||
'label' => 'Moderation',
|
||||
'singular' => 'Moderation case',
|
||||
'plural' => 'Moderation cases',
|
||||
],
|
||||
'sections' => [
|
||||
'case' => 'Case',
|
||||
'target' => 'Reported content',
|
||||
'resolution' => 'Resolution',
|
||||
'metadata' => 'Metadata',
|
||||
],
|
||||
'fields' => [
|
||||
'id' => 'ID',
|
||||
'target_type' => 'Type',
|
||||
'target_title' => 'Target',
|
||||
'caseable_id' => 'Target ID',
|
||||
'status' => 'Status',
|
||||
'reports_count' => 'Reports',
|
||||
'threshold' => 'Threshold',
|
||||
'threshold_notified_at' => 'Notification sent at',
|
||||
'assigned_to' => 'Assigned to',
|
||||
'resolved_by' => 'Resolved by',
|
||||
'resolved_at' => 'Resolved at',
|
||||
'resolution_note' => 'Resolution note',
|
||||
'created_at' => 'Created at',
|
||||
'updated_at' => 'Updated at',
|
||||
],
|
||||
'targets' => [
|
||||
'meal_post' => 'Meal',
|
||||
'user' => 'User',
|
||||
'unknown' => 'Unknown',
|
||||
],
|
||||
'actions' => [
|
||||
'mark_reviewing' => 'Start review',
|
||||
'hide_meal' => 'Hide meal',
|
||||
'suspend_user' => 'Suspend',
|
||||
'resolve' => 'Resolve',
|
||||
'reject' => 'Reject',
|
||||
],
|
||||
'filters' => [
|
||||
'target_type' => 'Type',
|
||||
],
|
||||
'placeholders' => [
|
||||
'empty' => '-',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ return [
|
|||
'registered_unverified' => 'Account created. Check your email to verify it.',
|
||||
'invalid_credentials' => 'Invalid credentials.',
|
||||
'email_not_verified' => 'Verify your email address before signing in.',
|
||||
'suspended' => 'This account is suspended.',
|
||||
'already_verified' => 'Account already verified.',
|
||||
'verified' => 'Account verified successfully.',
|
||||
'verification_sent' => 'Verification email sent.',
|
||||
|
|
@ -27,6 +28,19 @@ return [
|
|||
'notifications' => [
|
||||
'test_title' => 'Test',
|
||||
'test_body' => 'Test notification from the Laravel API.',
|
||||
'moderation_threshold_title' => 'Reports need review',
|
||||
'moderation_threshold_body' => ':count reports received for :type.',
|
||||
],
|
||||
'reports' => [
|
||||
'created' => 'Report sent. Thanks, our team will review this content.',
|
||||
'already_exists' => 'You have already reported this content.',
|
||||
'cannot_report_self' => 'You cannot report your own profile.',
|
||||
'cannot_report_own_meal' => 'You cannot report your own meal.',
|
||||
'targets' => [
|
||||
'meal_post' => 'a meal',
|
||||
'user' => 'a user',
|
||||
'unknown' => 'content',
|
||||
],
|
||||
],
|
||||
'post_reviews' => [
|
||||
'already_exists' => 'You have already reviewed this meal.',
|
||||
|
|
@ -50,5 +64,8 @@ return [
|
|||
'nutrition_goals_required' => 'Fill in all nutrition goals.',
|
||||
'nutrition_goals_positive' => 'Nutrition goals must be positive.',
|
||||
'nutrition_goals_max' => 'This nutrition goal value is too high.',
|
||||
'report_reason_required' => 'Choose a report reason.',
|
||||
'report_reason_valid' => 'Choose a valid report reason.',
|
||||
'report_details_max' => 'The report details may not be greater than 2000 characters.',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -24,6 +24,27 @@ return [
|
|||
'admin' => 'Administrator',
|
||||
'moderator' => 'Moderator',
|
||||
],
|
||||
'report_reason' => [
|
||||
'spam' => 'Spam',
|
||||
'harassment' => 'Harassment',
|
||||
'hate_speech' => 'Hate speech',
|
||||
'inappropriate' => 'Inappropriate content',
|
||||
'dangerous_content' => 'Dangerous content',
|
||||
'misleading' => 'Misleading information',
|
||||
'other' => 'Other',
|
||||
],
|
||||
'report_status' => [
|
||||
'open' => 'Open',
|
||||
'reviewing' => 'Reviewing',
|
||||
'resolved' => 'Resolved',
|
||||
'rejected' => 'Rejected',
|
||||
],
|
||||
'moderation_case_status' => [
|
||||
'open' => 'Open',
|
||||
'reviewing' => 'Reviewing',
|
||||
'resolved' => 'Resolved',
|
||||
'rejected' => 'Rejected',
|
||||
],
|
||||
'weight_goal' => [
|
||||
'lose_weight' => 'Lose weight',
|
||||
'maintain_weight' => 'Maintain weight',
|
||||
|
|
|
|||
|
|
@ -53,4 +53,51 @@ return [
|
|||
'empty' => '-',
|
||||
],
|
||||
],
|
||||
'moderation_cases' => [
|
||||
'navigation' => [
|
||||
'label' => 'Modération',
|
||||
'singular' => 'Dossier de modération',
|
||||
'plural' => 'Dossiers de modération',
|
||||
],
|
||||
'sections' => [
|
||||
'case' => 'Dossier',
|
||||
'target' => 'Contenu signalé',
|
||||
'resolution' => 'Traitement',
|
||||
'metadata' => 'Métadonnées',
|
||||
],
|
||||
'fields' => [
|
||||
'id' => 'ID',
|
||||
'target_type' => 'Type',
|
||||
'target_title' => 'Cible',
|
||||
'caseable_id' => 'ID cible',
|
||||
'status' => 'Statut',
|
||||
'reports_count' => 'Signalements',
|
||||
'threshold' => 'Seuil',
|
||||
'threshold_notified_at' => 'Notification envoyée le',
|
||||
'assigned_to' => 'Assigné à',
|
||||
'resolved_by' => 'Traité par',
|
||||
'resolved_at' => 'Traité le',
|
||||
'resolution_note' => 'Note de traitement',
|
||||
'created_at' => 'Créé le',
|
||||
'updated_at' => 'Mis à jour le',
|
||||
],
|
||||
'targets' => [
|
||||
'meal_post' => 'Plat',
|
||||
'user' => 'Utilisateur',
|
||||
'unknown' => 'Inconnu',
|
||||
],
|
||||
'actions' => [
|
||||
'mark_reviewing' => 'Prendre en charge',
|
||||
'hide_meal' => 'Masquer le plat',
|
||||
'suspend_user' => 'Suspendre',
|
||||
'resolve' => 'Résoudre',
|
||||
'reject' => 'Rejeter',
|
||||
],
|
||||
'filters' => [
|
||||
'target_type' => 'Type',
|
||||
],
|
||||
'placeholders' => [
|
||||
'empty' => '-',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ return [
|
|||
'registered_unverified' => 'Compte créé. Consulte tes emails pour le vérifier.',
|
||||
'invalid_credentials' => 'Identifiants invalides.',
|
||||
'email_not_verified' => 'Vérifie ton adresse email avant de te connecter.',
|
||||
'suspended' => 'Ce compte est suspendu.',
|
||||
'already_verified' => 'Compte déjà vérifié.',
|
||||
'verified' => 'Compte vérifié avec succès.',
|
||||
'verification_sent' => 'Email de vérification envoyé.',
|
||||
|
|
@ -27,6 +28,19 @@ return [
|
|||
'notifications' => [
|
||||
'test_title' => 'Test',
|
||||
'test_body' => 'Notification test depuis Laravel API.',
|
||||
'moderation_threshold_title' => 'Signalements à vérifier',
|
||||
'moderation_threshold_body' => ':count signalements reçus pour :type.',
|
||||
],
|
||||
'reports' => [
|
||||
'created' => 'Signalement envoyé. Merci, notre équipe va examiner ce contenu.',
|
||||
'already_exists' => 'Vous avez déjà signalé ce contenu.',
|
||||
'cannot_report_self' => 'Vous ne pouvez pas signaler votre propre profil.',
|
||||
'cannot_report_own_meal' => 'Vous ne pouvez pas signaler votre propre plat.',
|
||||
'targets' => [
|
||||
'meal_post' => 'un plat',
|
||||
'user' => 'un utilisateur',
|
||||
'unknown' => 'un contenu',
|
||||
],
|
||||
],
|
||||
'post_reviews' => [
|
||||
'already_exists' => 'Vous avez déjà donné un avis pour ce repas.',
|
||||
|
|
@ -50,5 +64,8 @@ return [
|
|||
'nutrition_goals_required' => 'Renseigne tous les objectifs nutritionnels.',
|
||||
'nutrition_goals_positive' => 'Les objectifs nutritionnels doivent être positifs.',
|
||||
'nutrition_goals_max' => 'La valeur de cet objectif nutritionnel est trop élevée.',
|
||||
'report_reason_required' => 'Choisis une raison de signalement.',
|
||||
'report_reason_valid' => 'Choisis une raison de signalement valide.',
|
||||
'report_details_max' => 'Le détail du signalement ne doit pas dépasser 2000 caractères.',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -24,6 +24,27 @@ return [
|
|||
'admin' => 'Administrateur',
|
||||
'moderator' => 'Modérateur',
|
||||
],
|
||||
'report_reason' => [
|
||||
'spam' => 'Spam',
|
||||
'harassment' => 'Harcèlement',
|
||||
'hate_speech' => 'Discours haineux',
|
||||
'inappropriate' => 'Contenu inapproprié',
|
||||
'dangerous_content' => 'Contenu dangereux',
|
||||
'misleading' => 'Information trompeuse',
|
||||
'other' => 'Autre',
|
||||
],
|
||||
'report_status' => [
|
||||
'open' => 'Ouvert',
|
||||
'reviewing' => 'En cours',
|
||||
'resolved' => 'Résolu',
|
||||
'rejected' => 'Rejeté',
|
||||
],
|
||||
'moderation_case_status' => [
|
||||
'open' => 'Ouvert',
|
||||
'reviewing' => 'En cours',
|
||||
'resolved' => 'Résolu',
|
||||
'rejected' => 'Rejeté',
|
||||
],
|
||||
'weight_goal' => [
|
||||
'lose_weight' => 'Perdre du poids',
|
||||
'maintain_weight' => 'Maintenir le poids',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use App\Http\Controllers\MealImageAnalysisController;
|
|||
use App\Http\Controllers\MealPostController;
|
||||
use App\Http\Controllers\PostReviewsController;
|
||||
use App\Http\Controllers\PublicUserProfileController;
|
||||
use App\Http\Controllers\ReportController;
|
||||
use App\Http\Controllers\StravaController;
|
||||
use App\Http\Controllers\WorkoutSessionController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
|
@ -40,14 +41,14 @@ Route::prefix('auth')->group(function (): void {
|
|||
Route::get('/health', function () {
|
||||
return response()->json(['status' => 'ok']);
|
||||
});
|
||||
Route::middleware(['auth:sanctum', 'verified'])->group(function (): void {
|
||||
Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void {
|
||||
Route::post('device-tokens', [DeviceTokenController::class, 'store']);
|
||||
Route::delete('device-tokens', [DeviceTokenController::class, 'destroy']);
|
||||
Route::get('test-notifs', [DeviceTokenController::class, 'notifs']);
|
||||
});
|
||||
|
||||
// Workouts
|
||||
Route::middleware(['auth:sanctum', 'verified'])->group(function (): void {
|
||||
Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void {
|
||||
Route::get('workouts/calendar', [WorkoutSessionController::class, 'calendar'])->name('workouts.calendar');
|
||||
Route::get('workouts', [WorkoutSessionController::class, 'index'])->name('workouts.index');
|
||||
Route::post('workouts', [WorkoutSessionController::class, 'store'])->name('workouts.store');
|
||||
|
|
@ -59,14 +60,20 @@ Route::middleware(['auth:sanctum', 'verified'])->group(function (): void {
|
|||
});
|
||||
|
||||
// Meals
|
||||
Route::middleware(['auth:sanctum', 'verified'])->group(function (): void {
|
||||
Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void {
|
||||
Route::get('users/{user}', [PublicUserProfileController::class, 'show'])->name('users.show');
|
||||
Route::post('users/{user}/reports', [ReportController::class, 'storeUser'])
|
||||
->middleware('throttle:10,1')
|
||||
->name('users.reports.store');
|
||||
Route::get('me/meal-posts', [MealPostController::class, 'mine'])->name('meal-posts.mine');
|
||||
Route::get('meal-posts/stats', [MealPostController::class, 'stats']);
|
||||
Route::get('meal-posts/calendar', [MealPostController::class, 'calendar']);
|
||||
Route::post('meal-posts/analyze-image', [MealImageAnalysisController::class, 'store']);
|
||||
Route::post('meal-posts/{mealPost}/like', [MealPostController::class, 'like'])->name('meal-posts.like');
|
||||
Route::delete('meal-posts/{mealPost}/like', [MealPostController::class, 'unlike'])->name('meal-posts.unlike');
|
||||
Route::post('meal-posts/{mealPost}/reports', [ReportController::class, 'storeMealPost'])
|
||||
->middleware('throttle:10,1')
|
||||
->name('meal-posts.reports.store');
|
||||
Route::get('meal-posts/{mealPost}/reviews', [PostReviewsController::class, 'index'])->name('meal-posts.reviews.index');
|
||||
Route::post('meal-posts/{mealPost}/reviews', [PostReviewsController::class, 'store'])->name('meal-posts.reviews.store');
|
||||
Route::get('post-reviews/{postReview}', [PostReviewsController::class, 'show'])->name('post-reviews.show');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue