Files
SkinbaseNova/app/Services/Moderation/ContentModerationReviewService.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;
}
}