'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); } }