292 lines
12 KiB
PHP
292 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Moderation;
|
|
|
|
use App\Enums\ModerationActionType;
|
|
use App\Enums\ModerationContentType;
|
|
use App\Enums\ModerationStatus;
|
|
use App\Models\Artwork;
|
|
use App\Models\ArtworkComment;
|
|
use App\Models\ContentModerationFinding;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class ContentModerationReviewService
|
|
{
|
|
public function __construct(
|
|
private readonly ContentModerationActionLogService $actionLogs,
|
|
private readonly DomainReputationService $domains,
|
|
private readonly ModerationFeedbackService $feedback,
|
|
) {
|
|
}
|
|
|
|
public function markSafe(ContentModerationFinding $finding, User $reviewer, ?string $notes = null): void
|
|
{
|
|
$this->updateFinding($finding, ModerationStatus::ReviewedSafe, $reviewer, $notes, ModerationActionType::MarkSafe);
|
|
$this->feedback->record($finding->fresh(), 'marked_safe', $reviewer, $notes);
|
|
}
|
|
|
|
public function confirmSpam(ContentModerationFinding $finding, User $reviewer, ?string $notes = null): void
|
|
{
|
|
$this->updateFinding($finding, ModerationStatus::ConfirmedSpam, $reviewer, $notes, ModerationActionType::ConfirmSpam);
|
|
$this->domains->trackDomains((array) $finding->matched_domains_json, true, true);
|
|
$this->feedback->record($finding->fresh(), 'confirmed_spam', $reviewer, $notes);
|
|
}
|
|
|
|
public function ignore(ContentModerationFinding $finding, User $reviewer, ?string $notes = null): void
|
|
{
|
|
$this->updateFinding($finding, ModerationStatus::Ignored, $reviewer, $notes, ModerationActionType::Ignore);
|
|
}
|
|
|
|
public function resolve(ContentModerationFinding $finding, User $reviewer, ?string $notes = null): void
|
|
{
|
|
$this->updateFinding($finding, ModerationStatus::Resolved, $reviewer, $notes, ModerationActionType::Resolve);
|
|
$this->feedback->record($finding->fresh(), 'resolved', $reviewer, $notes);
|
|
}
|
|
|
|
public function markFalsePositive(ContentModerationFinding $finding, User $reviewer, ?string $notes = null): void
|
|
{
|
|
DB::transaction(function () use ($finding, $reviewer, $notes): void {
|
|
$oldVisibility = null;
|
|
$newVisibility = null;
|
|
|
|
if (in_array($finding->content_type, [ModerationContentType::ArtworkComment, ModerationContentType::ArtworkDescription, ModerationContentType::ArtworkTitle], true)) {
|
|
[$action, $oldVisibility, $newVisibility] = match ($finding->content_type) {
|
|
ModerationContentType::ArtworkComment => $this->restoreComment($finding),
|
|
default => $this->restoreArtwork($finding),
|
|
};
|
|
|
|
$actionType = $action;
|
|
} else {
|
|
$actionType = ModerationActionType::MarkFalsePositive;
|
|
}
|
|
|
|
$oldStatus = $finding->status->value;
|
|
$finding->forceFill([
|
|
'status' => ModerationStatus::ReviewedSafe,
|
|
'reviewed_by' => $reviewer->id,
|
|
'reviewed_at' => now(),
|
|
'resolved_by' => $reviewer->id,
|
|
'resolved_at' => now(),
|
|
'restored_by' => $oldVisibility !== null ? $reviewer->id : $finding->restored_by,
|
|
'restored_at' => $oldVisibility !== null ? now() : $finding->restored_at,
|
|
'is_auto_hidden' => false,
|
|
'is_false_positive' => true,
|
|
'false_positive_count' => ((int) $finding->false_positive_count) + 1,
|
|
'action_taken' => ModerationActionType::MarkFalsePositive->value,
|
|
'admin_notes' => $this->normalizeNotes($notes, $finding->admin_notes),
|
|
])->save();
|
|
|
|
$this->actionLogs->log(
|
|
$finding,
|
|
$finding->content_type->value,
|
|
$finding->content_id,
|
|
ModerationActionType::MarkFalsePositive->value,
|
|
$reviewer,
|
|
$oldStatus,
|
|
ModerationStatus::ReviewedSafe->value,
|
|
$oldVisibility,
|
|
$newVisibility,
|
|
$notes,
|
|
['restored_action' => $actionType->value],
|
|
);
|
|
|
|
$this->feedback->record($finding->fresh(), 'false_positive', $reviewer, $notes, ['restored' => $oldVisibility !== null]);
|
|
});
|
|
}
|
|
|
|
public function hideContent(ContentModerationFinding $finding, User $reviewer, ?string $notes = null): ModerationActionType
|
|
{
|
|
return DB::transaction(function () use ($finding, $reviewer, $notes): ModerationActionType {
|
|
[$action, $oldVisibility, $newVisibility] = match ($finding->content_type) {
|
|
ModerationContentType::ArtworkComment => $this->hideComment($finding, false),
|
|
ModerationContentType::ArtworkDescription, ModerationContentType::ArtworkTitle => $this->hideArtwork($finding, false),
|
|
};
|
|
|
|
$this->updateFinding($finding, ModerationStatus::ConfirmedSpam, $reviewer, $notes, $action, $oldVisibility, $newVisibility);
|
|
$this->domains->trackDomains((array) $finding->matched_domains_json, true, true);
|
|
|
|
return $action;
|
|
});
|
|
}
|
|
|
|
public function autoHideContent(ContentModerationFinding $finding, ?string $notes = null): ModerationActionType
|
|
{
|
|
return DB::transaction(function () use ($finding, $notes): ModerationActionType {
|
|
[$action, $oldVisibility, $newVisibility] = match ($finding->content_type) {
|
|
ModerationContentType::ArtworkComment => $this->hideComment($finding, true),
|
|
ModerationContentType::ArtworkDescription, ModerationContentType::ArtworkTitle => $this->hideArtwork($finding, true),
|
|
};
|
|
|
|
$finding->forceFill([
|
|
'is_auto_hidden' => true,
|
|
'auto_action_taken' => $action->value,
|
|
'auto_hidden_at' => \now(),
|
|
'action_taken' => $action->value,
|
|
'admin_notes' => $this->normalizeNotes($notes, $finding->admin_notes),
|
|
])->save();
|
|
|
|
$this->actionLogs->log(
|
|
$finding,
|
|
$finding->content_type->value,
|
|
$finding->content_id,
|
|
$action->value,
|
|
null,
|
|
$finding->status->value,
|
|
$finding->status->value,
|
|
$oldVisibility,
|
|
$newVisibility,
|
|
$notes,
|
|
['automated' => true],
|
|
);
|
|
|
|
$this->domains->trackDomains((array) $finding->matched_domains_json, true, false);
|
|
|
|
return $action;
|
|
});
|
|
}
|
|
|
|
public function restoreContent(ContentModerationFinding $finding, User $reviewer, ?string $notes = null): ModerationActionType
|
|
{
|
|
return DB::transaction(function () use ($finding, $reviewer, $notes): ModerationActionType {
|
|
[$action, $oldVisibility, $newVisibility] = match ($finding->content_type) {
|
|
ModerationContentType::ArtworkComment => $this->restoreComment($finding),
|
|
ModerationContentType::ArtworkDescription, ModerationContentType::ArtworkTitle => $this->restoreArtwork($finding),
|
|
};
|
|
|
|
$oldStatus = $finding->status->value;
|
|
|
|
$finding->forceFill([
|
|
'status' => ModerationStatus::ReviewedSafe,
|
|
'reviewed_by' => $reviewer->id,
|
|
'reviewed_at' => \now(),
|
|
'resolved_by' => $reviewer->id,
|
|
'resolved_at' => \now(),
|
|
'restored_by' => $reviewer->id,
|
|
'restored_at' => \now(),
|
|
'is_auto_hidden' => false,
|
|
'action_taken' => $action->value,
|
|
'admin_notes' => $this->normalizeNotes($notes, $finding->admin_notes),
|
|
])->save();
|
|
|
|
$this->actionLogs->log(
|
|
$finding,
|
|
$finding->content_type->value,
|
|
$finding->content_id,
|
|
$action->value,
|
|
$reviewer,
|
|
$oldStatus,
|
|
ModerationStatus::ReviewedSafe->value,
|
|
$oldVisibility,
|
|
$newVisibility,
|
|
$notes,
|
|
['restored' => true],
|
|
);
|
|
|
|
return $action;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return array{0:ModerationActionType,1:string,2:string}
|
|
*/
|
|
private function hideComment(ContentModerationFinding $finding, bool $automated): array
|
|
{
|
|
$comment = ArtworkComment::query()->find($finding->content_id);
|
|
$oldVisibility = $comment && $comment->is_approved ? 'visible' : 'hidden';
|
|
|
|
if ($comment) {
|
|
$comment->forceFill(['is_approved' => false])->save();
|
|
}
|
|
|
|
return [$automated ? ModerationActionType::AutoHideComment : ModerationActionType::HideComment, $oldVisibility, 'hidden'];
|
|
}
|
|
|
|
/**
|
|
* @return array{0:ModerationActionType,1:string,2:string}
|
|
*/
|
|
private function hideArtwork(ContentModerationFinding $finding, bool $automated): array
|
|
{
|
|
$artworkId = (int) ($finding->artwork_id ?? $finding->content_id);
|
|
$artwork = Artwork::query()->find($artworkId);
|
|
$oldVisibility = $artwork && $artwork->is_public ? 'visible' : 'hidden';
|
|
|
|
if ($artwork) {
|
|
$artwork->forceFill(['is_public' => false])->save();
|
|
}
|
|
|
|
return [$automated ? ModerationActionType::AutoHideArtwork : ModerationActionType::HideArtwork, $oldVisibility, 'hidden'];
|
|
}
|
|
|
|
/**
|
|
* @return array{0:ModerationActionType,1:string,2:string}
|
|
*/
|
|
private function restoreComment(ContentModerationFinding $finding): array
|
|
{
|
|
$comment = ArtworkComment::query()->find($finding->content_id);
|
|
$oldVisibility = $comment && $comment->is_approved ? 'visible' : 'hidden';
|
|
|
|
if ($comment) {
|
|
$comment->forceFill(['is_approved' => true])->save();
|
|
}
|
|
|
|
return [ModerationActionType::RestoreComment, $oldVisibility, 'visible'];
|
|
}
|
|
|
|
/**
|
|
* @return array{0:ModerationActionType,1:string,2:string}
|
|
*/
|
|
private function restoreArtwork(ContentModerationFinding $finding): array
|
|
{
|
|
$artworkId = (int) ($finding->artwork_id ?? $finding->content_id);
|
|
$artwork = Artwork::query()->find($artworkId);
|
|
$oldVisibility = $artwork && $artwork->is_public ? 'visible' : 'hidden';
|
|
|
|
if ($artwork) {
|
|
$artwork->forceFill(['is_public' => true])->save();
|
|
}
|
|
|
|
return [ModerationActionType::RestoreArtwork, $oldVisibility, 'visible'];
|
|
}
|
|
|
|
private function updateFinding(
|
|
ContentModerationFinding $finding,
|
|
ModerationStatus $status,
|
|
User $reviewer,
|
|
?string $notes,
|
|
ModerationActionType $action,
|
|
?string $oldVisibility = null,
|
|
?string $newVisibility = null,
|
|
): void {
|
|
$oldStatus = $finding->status->value;
|
|
$finding->forceFill([
|
|
'status' => $status,
|
|
'reviewed_by' => $reviewer->id,
|
|
'reviewed_at' => \now(),
|
|
'resolved_by' => $reviewer->id,
|
|
'resolved_at' => \now(),
|
|
'action_taken' => $action->value,
|
|
'admin_notes' => $this->normalizeNotes($notes, $finding->admin_notes),
|
|
])->save();
|
|
|
|
$this->actionLogs->log(
|
|
$finding,
|
|
$finding->content_type->value,
|
|
$finding->content_id,
|
|
$action->value,
|
|
$reviewer,
|
|
$oldStatus,
|
|
$status->value,
|
|
$oldVisibility,
|
|
$newVisibility,
|
|
$notes,
|
|
);
|
|
}
|
|
|
|
private function normalizeNotes(?string $incoming, ?string $existing): ?string
|
|
{
|
|
$normalized = is_string($incoming) ? trim($incoming) : '';
|
|
|
|
return $normalized !== '' ? $normalized : $existing;
|
|
}
|
|
} |