231 lines
8.0 KiB
PHP
231 lines
8.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\NovaCards;
|
|
|
|
use App\Models\NovaCard;
|
|
use App\Models\User;
|
|
|
|
class NovaCardPublishModerationService
|
|
{
|
|
private const REASON_LABELS = [
|
|
'duplicate_content' => 'Duplicate content',
|
|
'self_remix_loop' => 'Self-remix loop',
|
|
];
|
|
|
|
public const DISPOSITION_LABELS = [
|
|
'cleared_after_review' => 'Cleared after review',
|
|
'approved_with_watch' => 'Approved with watch',
|
|
'escalated_for_review' => 'Escalated for review',
|
|
'rights_review_required' => 'Rights review required',
|
|
'rejected_after_review' => 'Rejected after review',
|
|
'returned_to_pending' => 'Returned to pending',
|
|
];
|
|
|
|
public function evaluate(NovaCard $card): array
|
|
{
|
|
$card->loadMissing(['originalCard.user', 'rootCard.user']);
|
|
|
|
$reasons = [];
|
|
|
|
if ($this->hasDuplicateContent($card)) {
|
|
$reasons[] = 'duplicate_content';
|
|
}
|
|
|
|
if ($this->hasSelfRemixLoop($card)) {
|
|
$reasons[] = 'self_remix_loop';
|
|
}
|
|
|
|
return [
|
|
'flagged' => $reasons !== [],
|
|
'reasons' => $reasons,
|
|
];
|
|
}
|
|
|
|
public function moderationStatus(NovaCard $card): string
|
|
{
|
|
return $this->evaluate($card)['flagged'] ? NovaCard::MOD_FLAGGED : NovaCard::MOD_APPROVED;
|
|
}
|
|
|
|
public function applyPublishOutcome(NovaCard $card, array $evaluation): NovaCard
|
|
{
|
|
$project = (array) ($card->project_json ?? []);
|
|
$project['moderation'] = [
|
|
'source' => 'publish_heuristics',
|
|
'flagged' => (bool) ($evaluation['flagged'] ?? false),
|
|
'reasons' => $this->normalizeReasons($evaluation['reasons'] ?? []),
|
|
'updated_at' => now()->toISOString(),
|
|
];
|
|
|
|
$card->forceFill([
|
|
'project_json' => $project,
|
|
'status' => NovaCard::STATUS_PUBLISHED,
|
|
'moderation_status' => (bool) ($evaluation['flagged'] ?? false) ? NovaCard::MOD_FLAGGED : NovaCard::MOD_APPROVED,
|
|
])->save();
|
|
|
|
return $card->refresh();
|
|
}
|
|
|
|
public function storedReasons(NovaCard $card): array
|
|
{
|
|
return $this->normalizeReasons(((array) (($card->project_json ?? [])['moderation'] ?? []))['reasons'] ?? []);
|
|
}
|
|
|
|
public function storedReasonLabels(NovaCard $card): array
|
|
{
|
|
return $this->labelsFor($this->storedReasons($card));
|
|
}
|
|
|
|
public function storedSource(NovaCard $card): ?string
|
|
{
|
|
$source = ((array) (($card->project_json ?? [])['moderation'] ?? []))['source'] ?? null;
|
|
|
|
return is_string($source) && $source !== '' ? $source : null;
|
|
}
|
|
|
|
public function latestOverride(NovaCard $card): ?array
|
|
{
|
|
$override = ((array) (($card->project_json ?? [])['moderation'] ?? []))['override'] ?? null;
|
|
|
|
return is_array($override) && $override !== [] ? $override : null;
|
|
}
|
|
|
|
public function dispositionOptions(?string $moderationStatus = null): array
|
|
{
|
|
$keys = match ($moderationStatus) {
|
|
NovaCard::MOD_APPROVED => ['cleared_after_review', 'approved_with_watch'],
|
|
NovaCard::MOD_FLAGGED => ['escalated_for_review', 'rights_review_required'],
|
|
NovaCard::MOD_REJECTED => ['rejected_after_review'],
|
|
NovaCard::MOD_PENDING => ['returned_to_pending'],
|
|
default => array_keys(self::DISPOSITION_LABELS),
|
|
};
|
|
|
|
return array_values(array_map(fn (string $key): array => [
|
|
'value' => $key,
|
|
'label' => self::DISPOSITION_LABELS[$key] ?? ucwords(str_replace('_', ' ', $key)),
|
|
], $keys));
|
|
}
|
|
|
|
public function overrideHistory(NovaCard $card): array
|
|
{
|
|
$history = ((array) (($card->project_json ?? [])['moderation'] ?? []))['override_history'] ?? [];
|
|
|
|
return array_values(array_filter($history, fn ($entry): bool => is_array($entry) && $entry !== []));
|
|
}
|
|
|
|
public function recordStaffOverride(
|
|
NovaCard $card,
|
|
string $moderationStatus,
|
|
?User $actor,
|
|
string $source,
|
|
array $context = [],
|
|
): NovaCard {
|
|
$project = (array) ($card->project_json ?? []);
|
|
$moderation = (array) ($project['moderation'] ?? []);
|
|
$disposition = $this->normalizeDisposition(
|
|
$context['disposition'] ?? $this->defaultDispositionForStatus($moderationStatus),
|
|
$moderationStatus,
|
|
);
|
|
$override = array_filter([
|
|
'moderation_status' => $moderationStatus,
|
|
'previous_status' => (string) $card->moderation_status,
|
|
'disposition' => $disposition,
|
|
'disposition_label' => self::DISPOSITION_LABELS[$disposition] ?? ucwords(str_replace('_', ' ', $disposition)),
|
|
'source' => $source,
|
|
'actor_user_id' => $actor?->id,
|
|
'actor_username' => $actor?->username,
|
|
'note' => isset($context['note']) && is_string($context['note']) && trim($context['note']) !== '' ? trim($context['note']) : null,
|
|
'report_id' => isset($context['report_id']) ? (int) $context['report_id'] : null,
|
|
'updated_at' => now()->toISOString(),
|
|
], fn ($value): bool => $value !== null);
|
|
|
|
$history = $this->overrideHistory($card);
|
|
array_unshift($history, $override);
|
|
|
|
$moderation['override'] = $override;
|
|
$moderation['override_history'] = array_slice($history, 0, 10);
|
|
$project['moderation'] = $moderation;
|
|
|
|
$card->forceFill([
|
|
'moderation_status' => $moderationStatus,
|
|
'project_json' => $project,
|
|
])->save();
|
|
|
|
return $card->refresh();
|
|
}
|
|
|
|
public function labelsFor(array $reasons): array
|
|
{
|
|
return array_values(array_map(
|
|
fn (string $reason): string => self::REASON_LABELS[$reason] ?? ucwords(str_replace('_', ' ', $reason)),
|
|
$this->normalizeReasons($reasons),
|
|
));
|
|
}
|
|
|
|
private function normalizeReasons(array $reasons): array
|
|
{
|
|
return array_values(array_unique(array_filter(array_map(
|
|
fn ($reason): string => is_string($reason) ? trim($reason) : '',
|
|
$reasons,
|
|
))));
|
|
}
|
|
|
|
private function normalizeDisposition(mixed $disposition, string $moderationStatus): string
|
|
{
|
|
$value = is_string($disposition) ? trim($disposition) : '';
|
|
|
|
return $value !== '' && array_key_exists($value, self::DISPOSITION_LABELS)
|
|
? $value
|
|
: $this->defaultDispositionForStatus($moderationStatus);
|
|
}
|
|
|
|
private function defaultDispositionForStatus(string $moderationStatus): string
|
|
{
|
|
return match ($moderationStatus) {
|
|
NovaCard::MOD_APPROVED => 'cleared_after_review',
|
|
NovaCard::MOD_FLAGGED => 'escalated_for_review',
|
|
NovaCard::MOD_REJECTED => 'rejected_after_review',
|
|
default => 'returned_to_pending',
|
|
};
|
|
}
|
|
|
|
private function hasDuplicateContent(NovaCard $card): bool
|
|
{
|
|
$title = mb_strtolower(trim((string) $card->title));
|
|
$quote = mb_strtolower(trim((string) $card->quote_text));
|
|
|
|
if ($title === '' || $quote === '') {
|
|
return false;
|
|
}
|
|
|
|
return NovaCard::query()
|
|
->where('id', '!=', $card->id)
|
|
->where('status', NovaCard::STATUS_PUBLISHED)
|
|
->whereNotIn('moderation_status', [NovaCard::MOD_FLAGGED, NovaCard::MOD_REJECTED])
|
|
->whereRaw('LOWER(title) = ?', [$title])
|
|
->whereRaw('LOWER(quote_text) = ?', [$quote])
|
|
->exists();
|
|
}
|
|
|
|
private function hasSelfRemixLoop(NovaCard $card): bool
|
|
{
|
|
if (! $card->originalCard || ! $card->rootCard) {
|
|
return false;
|
|
}
|
|
|
|
$depth = 0;
|
|
$cursor = $card;
|
|
$visited = [];
|
|
|
|
while ($cursor->originalCard && ! in_array($cursor->id, $visited, true)) {
|
|
$visited[] = $cursor->id;
|
|
$cursor = $cursor->originalCard;
|
|
$depth++;
|
|
}
|
|
|
|
return $depth >= 3
|
|
&& (int) $card->user_id === (int) ($card->originalCard->user_id ?? 0)
|
|
&& (int) $card->user_id === (int) ($card->rootCard->user_id ?? 0);
|
|
}
|
|
} |