Implement creator studio and upload updates
This commit is contained in:
@@ -14,7 +14,7 @@ final class ArtworkDraftService
|
||||
public function createDraft(int $userId, string $title, ?string $description, ?int $categoryId = null, bool $isMature = false): ArtworkDraftResult
|
||||
{
|
||||
return DB::transaction(function () use ($userId, $title, $description, $categoryId, $isMature) {
|
||||
$slug = $this->uniqueSlug($title);
|
||||
$slug = $this->makeSlug($title);
|
||||
|
||||
$artwork = Artwork::create([
|
||||
'user_id' => $userId,
|
||||
@@ -44,20 +44,10 @@ final class ArtworkDraftService
|
||||
});
|
||||
}
|
||||
|
||||
private function uniqueSlug(string $title): string
|
||||
private function makeSlug(string $title): string
|
||||
{
|
||||
$base = Str::slug($title);
|
||||
$base = $base !== '' ? $base : 'artwork';
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$suffix = Str::lower(Str::random(6));
|
||||
$slug = Str::limit($base . '-' . $suffix, 160, '');
|
||||
|
||||
if (! Artwork::where('slug', $slug)->exists()) {
|
||||
return $slug;
|
||||
}
|
||||
}
|
||||
|
||||
return Str::limit($base . '-' . Str::uuid()->toString(), 160, '');
|
||||
return Str::limit($base !== '' ? $base : 'artwork', 160, '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
|
||||
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
|
||||
use Intervention\Image\Encoders\PngEncoder;
|
||||
use Intervention\Image\Encoders\WebpEncoder;
|
||||
use Intervention\Image\ImageManager;
|
||||
use RuntimeException;
|
||||
@@ -91,7 +92,16 @@ class AvatarService
|
||||
public function removeAvatar(int $userId): void
|
||||
{
|
||||
$diskName = (string) config('avatars.disk', 's3');
|
||||
Storage::disk($diskName)->deleteDirectory("avatars/{$userId}");
|
||||
$disk = Storage::disk($diskName);
|
||||
$existingHash = UserProfile::query()
|
||||
->where('user_id', $userId)
|
||||
->value('avatar_hash');
|
||||
|
||||
if (is_string($existingHash) && trim($existingHash) !== '') {
|
||||
$disk->deleteDirectory($this->avatarDirectory(trim($existingHash)));
|
||||
}
|
||||
|
||||
$disk->deleteDirectory("avatars/{$userId}");
|
||||
|
||||
UserProfile::query()->updateOrCreate(
|
||||
['user_id' => $userId],
|
||||
@@ -108,20 +118,25 @@ class AvatarService
|
||||
$image = $this->readImageFromBinary($binary);
|
||||
$image = $this->normalizeImage($image);
|
||||
$cropPosition = $this->normalizePosition($position);
|
||||
$normalizedSource = (string) $image->encode(new PngEncoder());
|
||||
|
||||
if ($normalizedSource === '') {
|
||||
throw new RuntimeException('Avatar processing failed to prepare the source image.');
|
||||
}
|
||||
|
||||
$diskName = (string) config('avatars.disk', 's3');
|
||||
$disk = Storage::disk($diskName);
|
||||
$basePath = "avatars/{$userId}";
|
||||
|
||||
$existingHash = UserProfile::query()
|
||||
->where('user_id', $userId)
|
||||
->value('avatar_hash');
|
||||
|
||||
$hashSeed = '';
|
||||
$encodedVariants = [];
|
||||
foreach ($this->sizes as $size) {
|
||||
$variant = $image->cover($size, $size, $cropPosition);
|
||||
$variant = $this->manager->read($normalizedSource)->cover($size, $size, $cropPosition);
|
||||
$encoded = (string) $variant->encode(new WebpEncoder($this->quality));
|
||||
$disk->put("{$basePath}/{$size}.webp", $encoded, [
|
||||
'visibility' => 'public',
|
||||
'CacheControl' => 'public, max-age=31536000, immutable',
|
||||
'ContentType' => 'image/webp',
|
||||
]);
|
||||
$encodedVariants[(int) $size] = $encoded;
|
||||
|
||||
if ($size === 128) {
|
||||
$hashSeed = $encoded;
|
||||
@@ -133,11 +148,34 @@ class AvatarService
|
||||
}
|
||||
|
||||
$hash = hash('sha256', $hashSeed);
|
||||
$basePath = $this->avatarDirectory($hash);
|
||||
|
||||
foreach ($encodedVariants as $size => $encoded) {
|
||||
$disk->put("{$basePath}/{$size}.webp", $encoded, [
|
||||
'visibility' => 'public',
|
||||
'CacheControl' => 'public, max-age=31536000, immutable',
|
||||
'ContentType' => 'image/webp',
|
||||
]);
|
||||
}
|
||||
|
||||
if (is_string($existingHash) && trim($existingHash) !== '' && trim($existingHash) !== $hash) {
|
||||
$disk->deleteDirectory($this->avatarDirectory(trim($existingHash)));
|
||||
}
|
||||
|
||||
$disk->deleteDirectory("avatars/{$userId}");
|
||||
$this->updateProfileMetadata($userId, $hash);
|
||||
|
||||
return $hash;
|
||||
}
|
||||
|
||||
private function avatarDirectory(string $hash): string
|
||||
{
|
||||
$p1 = substr($hash, 0, 2);
|
||||
$p2 = substr($hash, 2, 2);
|
||||
|
||||
return sprintf('avatars/%s/%s/%s', $p1, $p2, $hash);
|
||||
}
|
||||
|
||||
private function normalizePosition(string $position): string
|
||||
{
|
||||
$normalized = strtolower(trim($position));
|
||||
|
||||
145
app/Services/Cdn/ArtworkCdnPurgeService.php
Normal file
145
app/Services/Cdn/ArtworkCdnPurgeService.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Cdn;
|
||||
|
||||
use App\Services\ThumbnailService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
final class ArtworkCdnPurgeService
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $objectPaths
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function purgeArtworkObjectPaths(array $objectPaths, array $context = []): bool
|
||||
{
|
||||
$urls = array_values(array_unique(array_filter(array_map(
|
||||
fn (mixed $path): ?string => is_string($path) && trim($path) !== ''
|
||||
? $this->cdnUrlForObjectPath($path)
|
||||
: null,
|
||||
$objectPaths,
|
||||
))));
|
||||
|
||||
return $this->purgeUrls($urls, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $variants
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function purgeArtworkHashVariants(string $hash, string $extension = 'webp', array $variants = ['xs', 'sm', 'md', 'lg', 'xl', 'sq'], array $context = []): bool
|
||||
{
|
||||
$urls = array_values(array_unique(array_filter(array_map(
|
||||
fn (string $variant): ?string => ThumbnailService::fromHash($hash, $extension, $variant),
|
||||
$variants,
|
||||
))));
|
||||
|
||||
return $this->purgeUrls($urls, $context + ['hash' => $hash]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function purgeUrls(array $urls, array $context = []): bool
|
||||
{
|
||||
if ($urls === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasCloudflareCredentials()) {
|
||||
return $this->purgeViaCloudflare($urls, $context);
|
||||
}
|
||||
|
||||
$legacyPurgeUrl = trim((string) config('cdn.purge_url', ''));
|
||||
if ($legacyPurgeUrl !== '') {
|
||||
return $this->purgeViaLegacyWebhook($legacyPurgeUrl, $urls, $context);
|
||||
}
|
||||
|
||||
Log::debug('CDN purge skipped - no Cloudflare or legacy purge configuration is available', $context + [
|
||||
'url_count' => count($urls),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function purgeViaCloudflare(array $urls, array $context): bool
|
||||
{
|
||||
$purgeUrl = sprintf(
|
||||
'https://api.cloudflare.com/client/v4/zones/%s/purge_cache',
|
||||
trim((string) config('cdn.cloudflare.zone_id')),
|
||||
);
|
||||
|
||||
try {
|
||||
$response = Http::timeout(10)
|
||||
->acceptJson()
|
||||
->withToken(trim((string) config('cdn.cloudflare.api_token')))
|
||||
->post($purgeUrl, ['files' => $urls]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Log::warning('Cloudflare artwork CDN purge failed', $context + [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
'url_count' => count($urls),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Cloudflare artwork CDN purge threw an exception', $context + [
|
||||
'error' => $e->getMessage(),
|
||||
'url_count' => count($urls),
|
||||
]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function purgeViaLegacyWebhook(string $purgeUrl, array $urls, array $context): bool
|
||||
{
|
||||
$paths = array_values(array_unique(array_filter(array_map(function (string $url): ?string {
|
||||
$path = parse_url($url, PHP_URL_PATH);
|
||||
|
||||
return is_string($path) && $path !== '' ? $path : null;
|
||||
}, $urls))));
|
||||
|
||||
if ($paths === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::timeout(10)->acceptJson()->post($purgeUrl, ['paths' => $paths]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Log::warning('Legacy artwork CDN purge failed', $context + [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
'path_count' => count($paths),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Legacy artwork CDN purge threw an exception', $context + [
|
||||
'error' => $e->getMessage(),
|
||||
'path_count' => count($paths),
|
||||
]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function hasCloudflareCredentials(): bool
|
||||
{
|
||||
return trim((string) config('cdn.cloudflare.zone_id', '')) !== ''
|
||||
&& trim((string) config('cdn.cloudflare.api_token', '')) !== '';
|
||||
}
|
||||
|
||||
private function cdnUrlForObjectPath(string $objectPath): string
|
||||
{
|
||||
return rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/') . '/' . ltrim($objectPath, '/');
|
||||
}
|
||||
}
|
||||
347
app/Services/Images/ArtworkSquareThumbnailBackfillService.php
Normal file
347
app/Services/Images/ArtworkSquareThumbnailBackfillService.php
Normal file
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Images;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Repositories\Uploads\ArtworkFileRepository;
|
||||
use App\Services\Cdn\ArtworkCdnPurgeService;
|
||||
use App\Services\ThumbnailService;
|
||||
use App\Services\Uploads\UploadDerivativesService;
|
||||
use App\Services\Uploads\UploadStorageService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use RuntimeException;
|
||||
|
||||
final class ArtworkSquareThumbnailBackfillService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UploadDerivativesService $derivatives,
|
||||
private readonly UploadStorageService $storage,
|
||||
private readonly ArtworkFileRepository $artworkFiles,
|
||||
private readonly ArtworkCdnPurgeService $cdnPurge,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function ensureSquareThumbnail(Artwork $artwork, bool $force = false, bool $dryRun = false): array
|
||||
{
|
||||
$hash = strtolower((string) ($artwork->hash ?? ''));
|
||||
if ($hash === '') {
|
||||
throw new RuntimeException('Artwork hash is required to generate a square thumbnail.');
|
||||
}
|
||||
|
||||
$existing = DB::table('artwork_files')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('variant', 'sq')
|
||||
->first(['path']);
|
||||
|
||||
if ($existing !== null && ! $force) {
|
||||
return [
|
||||
'status' => 'skipped',
|
||||
'reason' => 'already_exists',
|
||||
'artwork_id' => $artwork->id,
|
||||
'path' => (string) ($existing->path ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$resolved = $this->resolveBestSource($artwork);
|
||||
if ($dryRun) {
|
||||
return [
|
||||
'status' => 'dry_run',
|
||||
'artwork_id' => $artwork->id,
|
||||
'source_variant' => $resolved['variant'],
|
||||
'source_path' => $resolved['source_path'],
|
||||
'object_path' => $this->storage->objectPathForVariant('sq', $hash, $hash . '.webp'),
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$asset = $this->derivatives->generateSquareDerivative($resolved['source_path'], $hash, [
|
||||
'context' => ['artwork' => $artwork],
|
||||
]);
|
||||
|
||||
$this->artworkFiles->upsert($artwork->id, 'sq', $asset['path'], $asset['mime'], $asset['size']);
|
||||
|
||||
$this->cdnPurge->purgeArtworkObjectPaths([$asset['path']], [
|
||||
'artwork_id' => $artwork->id,
|
||||
'reason' => 'square_thumbnail_regenerated',
|
||||
]);
|
||||
|
||||
if (! is_string($artwork->thumb_ext) || trim($artwork->thumb_ext) === '') {
|
||||
$artwork->forceFill(['thumb_ext' => 'webp'])->saveQuietly();
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'generated',
|
||||
'artwork_id' => $artwork->id,
|
||||
'path' => $asset['path'],
|
||||
'source_variant' => $resolved['variant'],
|
||||
'crop_mode' => $asset['result']?->cropMode,
|
||||
];
|
||||
} finally {
|
||||
if (($resolved['cleanup'] ?? false) === true) {
|
||||
File::delete($resolved['source_path']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{variant: string, source_path: string, cleanup: bool}
|
||||
*/
|
||||
private function resolveBestSource(Artwork $artwork): array
|
||||
{
|
||||
$hash = strtolower((string) ($artwork->hash ?? ''));
|
||||
$files = DB::table('artwork_files')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->pluck('path', 'variant')
|
||||
->all();
|
||||
|
||||
$variants = ['orig_image', 'orig', 'xl', 'lg', 'md', 'sm', 'xs'];
|
||||
|
||||
foreach ($variants as $variant) {
|
||||
$path = $files[$variant] ?? null;
|
||||
if (! is_string($path) || trim($path) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($variant === 'orig_image' || $variant === 'orig') {
|
||||
$filename = basename($path);
|
||||
$localPath = $this->storage->localOriginalPath($hash, $filename);
|
||||
if (is_file($localPath)) {
|
||||
return [
|
||||
'variant' => $variant,
|
||||
'source_path' => $localPath,
|
||||
'cleanup' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$temporary = $this->downloadToTempFile($path, pathinfo($path, PATHINFO_EXTENSION) ?: 'webp');
|
||||
if ($temporary !== null) {
|
||||
return [
|
||||
'variant' => $variant,
|
||||
'source_path' => $temporary,
|
||||
'cleanup' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$directSource = $this->resolveArtworkFilePathSource($artwork);
|
||||
if ($directSource !== null) {
|
||||
return $directSource;
|
||||
}
|
||||
|
||||
$canonicalDerivativeSource = $this->resolveCanonicalDerivativeSource($artwork);
|
||||
if ($canonicalDerivativeSource !== null) {
|
||||
return $canonicalDerivativeSource;
|
||||
}
|
||||
|
||||
throw new RuntimeException(sprintf('No usable source image was found for artwork %d.', (int) $artwork->id));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{variant: string, source_path: string, cleanup: bool}|null
|
||||
*/
|
||||
private function resolveArtworkFilePathSource(Artwork $artwork): ?array
|
||||
{
|
||||
$relativePath = trim((string) ($artwork->file_path ?? ''), '/');
|
||||
if ($relativePath === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($this->localFilePathCandidates($relativePath) as $candidate) {
|
||||
if (is_file($candidate)) {
|
||||
return [
|
||||
'variant' => 'file_path',
|
||||
'source_path' => $candidate,
|
||||
'cleanup' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$downloaded = $this->downloadUrlToTempFile($this->cdnUrlForPath($relativePath), pathinfo($relativePath, PATHINFO_EXTENSION));
|
||||
|
||||
if ($downloaded === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'variant' => 'file_path',
|
||||
'source_path' => $downloaded,
|
||||
'cleanup' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{variant: string, source_path: string, cleanup: bool}|null
|
||||
*/
|
||||
private function resolveCanonicalDerivativeSource(Artwork $artwork): ?array
|
||||
{
|
||||
$hash = strtolower((string) ($artwork->hash ?? ''));
|
||||
$thumbExt = strtolower(ltrim((string) ($artwork->thumb_ext ?? ''), '.'));
|
||||
|
||||
if ($hash === '' || $thumbExt === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (['xl', 'lg', 'md', 'sm', 'xs'] as $variant) {
|
||||
$url = ThumbnailService::fromHash($hash, $thumbExt, $variant);
|
||||
if (! is_string($url) || $url === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$downloaded = $this->downloadUrlToTempFile($url, $thumbExt);
|
||||
if ($downloaded === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return [
|
||||
'variant' => $variant,
|
||||
'source_path' => $downloaded,
|
||||
'cleanup' => true,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function localFilePathCandidates(string $relativePath): array
|
||||
{
|
||||
$normalizedPath = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relativePath);
|
||||
|
||||
return array_values(array_unique([
|
||||
$normalizedPath,
|
||||
base_path($normalizedPath),
|
||||
public_path($normalizedPath),
|
||||
storage_path('app/public' . DIRECTORY_SEPARATOR . $normalizedPath),
|
||||
storage_path('app/private' . DIRECTORY_SEPARATOR . $normalizedPath),
|
||||
]));
|
||||
}
|
||||
|
||||
private function cdnUrlForPath(string $relativePath): string
|
||||
{
|
||||
return rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/') . '/' . ltrim($relativePath, '/');
|
||||
}
|
||||
|
||||
private function downloadUrlToTempFile(string $url, string $extension = ''): ?string
|
||||
{
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'timeout' => 30,
|
||||
'ignore_errors' => true,
|
||||
'header' => implode("\r\n", [
|
||||
'User-Agent: Skinbase Nova square-thumb backfill',
|
||||
'Accept: image/*,*/*;q=0.8',
|
||||
'Accept-Encoding: identity',
|
||||
'Connection: close',
|
||||
]) . "\r\n",
|
||||
],
|
||||
'ssl' => [
|
||||
'verify_peer' => true,
|
||||
'verify_peer_name' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$contents = @file_get_contents($url, false, $context);
|
||||
$headers = $http_response_header ?? [];
|
||||
|
||||
if (! is_string($contents) || $contents === '' || ! $this->isSuccessfulHttpResponse($url, $headers)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_string($contents) || $contents === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$resolvedExtension = trim($extension) !== ''
|
||||
? trim($extension)
|
||||
: $this->extensionFromContentType($this->contentTypeFromHeaders($headers));
|
||||
|
||||
return $this->writeTemporaryFile($contents, $resolvedExtension);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $headers
|
||||
*/
|
||||
private function isSuccessfulHttpResponse(string $url, array $headers): bool
|
||||
{
|
||||
if ($headers === [] && parse_url($url, PHP_URL_SCHEME) === 'file') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$statusLine = $headers[0] ?? '';
|
||||
if (! is_string($statusLine) || ! preg_match('/\s(\d{3})\s/', $statusLine, $matches)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$statusCode = (int) ($matches[1] ?? 0);
|
||||
|
||||
return $statusCode >= 200 && $statusCode < 300;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $headers
|
||||
*/
|
||||
private function contentTypeFromHeaders(array $headers): string
|
||||
{
|
||||
foreach ($headers as $header) {
|
||||
if (! is_string($header) || stripos($header, 'Content-Type:') !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return trim(substr($header, strlen('Content-Type:')));
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function writeTemporaryFile(string $contents, string $extension = ''): string
|
||||
{
|
||||
$temp = tempnam(sys_get_temp_dir(), 'sq-thumb-');
|
||||
if ($temp === false) {
|
||||
throw new RuntimeException('Unable to allocate a temporary file for square thumbnail generation.');
|
||||
}
|
||||
|
||||
$normalizedExtension = trim((string) $extension);
|
||||
$path = $normalizedExtension !== '' ? $temp . '.' . $normalizedExtension : $temp;
|
||||
|
||||
if ($normalizedExtension !== '') {
|
||||
rename($temp, $path);
|
||||
}
|
||||
|
||||
File::put($path, $contents);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function extensionFromContentType(string $contentType): string
|
||||
{
|
||||
$normalized = strtolower(trim(strtok($contentType, ';') ?: ''));
|
||||
|
||||
return match ($normalized) {
|
||||
'image/jpeg', 'image/jpg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
'image/gif' => 'gif',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function downloadToTempFile(string $objectPath, string $extension): ?string
|
||||
{
|
||||
$contents = $this->storage->readObject($objectPath);
|
||||
if (! is_string($contents) || $contents === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->writeTemporaryFile($contents, $extension);
|
||||
}
|
||||
}
|
||||
30
app/Services/Images/Detectors/ChainedSubjectDetector.php
Normal file
30
app/Services/Images/Detectors/ChainedSubjectDetector.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Images\Detectors;
|
||||
|
||||
use App\Contracts\Images\SubjectDetectorInterface;
|
||||
use App\Data\Images\SubjectDetectionResultData;
|
||||
|
||||
final class ChainedSubjectDetector implements SubjectDetectorInterface
|
||||
{
|
||||
/**
|
||||
* @param iterable<int, SubjectDetectorInterface> $detectors
|
||||
*/
|
||||
public function __construct(private readonly iterable $detectors)
|
||||
{
|
||||
}
|
||||
|
||||
public function detect(string $sourcePath, int $sourceWidth, int $sourceHeight, array $context = []): ?SubjectDetectionResultData
|
||||
{
|
||||
foreach ($this->detectors as $detector) {
|
||||
$result = $detector->detect($sourcePath, $sourceWidth, $sourceHeight, $context);
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
409
app/Services/Images/Detectors/HeuristicSubjectDetector.php
Normal file
409
app/Services/Images/Detectors/HeuristicSubjectDetector.php
Normal file
@@ -0,0 +1,409 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Images\Detectors;
|
||||
|
||||
use App\Contracts\Images\SubjectDetectorInterface;
|
||||
use App\Data\Images\CropBoxData;
|
||||
use App\Data\Images\SubjectDetectionResultData;
|
||||
|
||||
final class HeuristicSubjectDetector implements SubjectDetectorInterface
|
||||
{
|
||||
public function detect(string $sourcePath, int $sourceWidth, int $sourceHeight, array $context = []): ?SubjectDetectionResultData
|
||||
{
|
||||
if (! function_exists('imagecreatefromstring')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$binary = @file_get_contents($sourcePath);
|
||||
if (! is_string($binary) || $binary === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$source = @imagecreatefromstring($binary);
|
||||
if ($source === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$sampleMax = max(24, (int) config('uploads.square_thumbnails.saliency.sample_max_dimension', 96));
|
||||
$longest = max(1, max($sourceWidth, $sourceHeight));
|
||||
$scale = min(1.0, $sampleMax / $longest);
|
||||
$sampleWidth = max(8, (int) round($sourceWidth * $scale));
|
||||
$sampleHeight = max(8, (int) round($sourceHeight * $scale));
|
||||
|
||||
$sample = imagecreatetruecolor($sampleWidth, $sampleHeight);
|
||||
if ($sample === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
imagecopyresampled($sample, $source, 0, 0, 0, 0, $sampleWidth, $sampleHeight, $sourceWidth, $sourceHeight);
|
||||
$gray = $this->grayscaleMatrix($sample, $sampleWidth, $sampleHeight);
|
||||
$rarity = $this->colorRarityMatrix($sample, $sampleWidth, $sampleHeight);
|
||||
$vegetation = $this->vegetationMaskMatrix($sample, $sampleWidth, $sampleHeight);
|
||||
} finally {
|
||||
imagedestroy($sample);
|
||||
}
|
||||
|
||||
$energy = $this->energyMatrix($gray, $sampleWidth, $sampleHeight);
|
||||
$saliency = $this->combineSaliency($energy, $rarity, $sampleWidth, $sampleHeight);
|
||||
$prefix = $this->prefixMatrix($saliency, $sampleWidth, $sampleHeight);
|
||||
$vegetationPrefix = $this->prefixMatrix($vegetation, $sampleWidth, $sampleHeight);
|
||||
$totalEnergy = $prefix[$sampleHeight][$sampleWidth] ?? 0.0;
|
||||
|
||||
if ($totalEnergy < (float) config('uploads.square_thumbnails.saliency.min_total_energy', 2400.0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidate = $this->bestCandidate($prefix, $vegetationPrefix, $sampleWidth, $sampleHeight, $totalEnergy);
|
||||
$rareSubjectCandidate = $this->rareSubjectCandidate($rarity, $vegetation, $sampleWidth, $sampleHeight);
|
||||
|
||||
if ($rareSubjectCandidate !== null && ($candidate === null || $rareSubjectCandidate['score'] > ($candidate['score'] * 0.72))) {
|
||||
$candidate = $rareSubjectCandidate;
|
||||
}
|
||||
|
||||
if ($candidate === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$scaleX = $sourceWidth / max(1, $sampleWidth);
|
||||
$scaleY = $sourceHeight / max(1, $sampleHeight);
|
||||
$sideScale = max($scaleX, $scaleY);
|
||||
|
||||
$cropBox = new CropBoxData(
|
||||
x: (int) floor($candidate['x'] * $scaleX),
|
||||
y: (int) floor($candidate['y'] * $scaleY),
|
||||
width: max(1, (int) round($candidate['side'] * $sideScale)),
|
||||
height: max(1, (int) round($candidate['side'] * $sideScale)),
|
||||
);
|
||||
|
||||
$averageDensity = $totalEnergy / max(1, $sampleWidth * $sampleHeight);
|
||||
$confidence = min(1.0, max(0.15, ($candidate['density'] / max(1.0, $averageDensity)) / 4.0));
|
||||
|
||||
return new SubjectDetectionResultData(
|
||||
cropBox: $cropBox->clampToImage($sourceWidth, $sourceHeight),
|
||||
strategy: 'saliency',
|
||||
reason: 'heuristic_saliency',
|
||||
confidence: $confidence,
|
||||
meta: [
|
||||
'sample_width' => $sampleWidth,
|
||||
'sample_height' => $sampleHeight,
|
||||
'score' => $candidate['score'],
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
imagedestroy($source);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<int, int>>
|
||||
*/
|
||||
private function grayscaleMatrix($sample, int $width, int $height): array
|
||||
{
|
||||
$gray = [];
|
||||
|
||||
for ($y = 0; $y < $height; $y++) {
|
||||
$gray[$y] = [];
|
||||
for ($x = 0; $x < $width; $x++) {
|
||||
$rgb = imagecolorat($sample, $x, $y);
|
||||
$r = ($rgb >> 16) & 0xFF;
|
||||
$g = ($rgb >> 8) & 0xFF;
|
||||
$b = $rgb & 0xFF;
|
||||
$gray[$y][$x] = (int) round($r * 0.299 + $g * 0.587 + $b * 0.114);
|
||||
}
|
||||
}
|
||||
|
||||
return $gray;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<int, int>> $gray
|
||||
* @return array<int, array<int, float>>
|
||||
*/
|
||||
private function energyMatrix(array $gray, int $width, int $height): array
|
||||
{
|
||||
$energy = [];
|
||||
|
||||
for ($y = 0; $y < $height; $y++) {
|
||||
$energy[$y] = [];
|
||||
for ($x = 0; $x < $width; $x++) {
|
||||
$center = $gray[$y][$x] ?? 0;
|
||||
$right = $gray[$y][$x + 1] ?? $center;
|
||||
$down = $gray[$y + 1][$x] ?? $center;
|
||||
$diag = $gray[$y + 1][$x + 1] ?? $center;
|
||||
|
||||
$energy[$y][$x] = abs($center - $right)
|
||||
+ abs($center - $down)
|
||||
+ (abs($center - $diag) * 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
return $energy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map that highlights globally uncommon colors, which helps distinguish
|
||||
* a main subject from repetitive foliage or sky textures.
|
||||
*
|
||||
* @return array<int, array<int, float>>
|
||||
*/
|
||||
private function colorRarityMatrix($sample, int $width, int $height): array
|
||||
{
|
||||
$counts = [];
|
||||
$pixels = [];
|
||||
$totalPixels = max(1, $width * $height);
|
||||
|
||||
for ($y = 0; $y < $height; $y++) {
|
||||
$pixels[$y] = [];
|
||||
|
||||
for ($x = 0; $x < $width; $x++) {
|
||||
$rgb = imagecolorat($sample, $x, $y);
|
||||
$r = ($rgb >> 16) & 0xFF;
|
||||
$g = ($rgb >> 8) & 0xFF;
|
||||
$b = $rgb & 0xFF;
|
||||
|
||||
$bucket = (($r >> 5) << 6) | (($g >> 5) << 3) | ($b >> 5);
|
||||
$counts[$bucket] = ($counts[$bucket] ?? 0) + 1;
|
||||
$pixels[$y][$x] = [$r, $g, $b, $bucket];
|
||||
}
|
||||
}
|
||||
|
||||
$rarity = [];
|
||||
|
||||
for ($y = 0; $y < $height; $y++) {
|
||||
$rarity[$y] = [];
|
||||
|
||||
for ($x = 0; $x < $width; $x++) {
|
||||
[$r, $g, $b, $bucket] = $pixels[$y][$x];
|
||||
$bucketCount = max(1, (int) ($counts[$bucket] ?? 1));
|
||||
$baseRarity = log(($totalPixels + 1) / $bucketCount);
|
||||
$maxChannel = max($r, $g, $b);
|
||||
$minChannel = min($r, $g, $b);
|
||||
$saturation = $maxChannel - $minChannel;
|
||||
$luma = ($r * 0.299) + ($g * 0.587) + ($b * 0.114);
|
||||
|
||||
$neutralLightBoost = ($luma >= 135 && $saturation <= 95) ? 1.0 : 0.0;
|
||||
$warmBoost = ($r >= 96 && $r >= $b + 10) ? 1.0 : 0.0;
|
||||
$vegetationPenalty = ($g >= 72 && $g >= $r * 1.12 && $g >= $b * 1.08) ? 1.0 : 0.0;
|
||||
|
||||
$rarity[$y][$x] = max(0.0,
|
||||
($baseRarity * 32.0)
|
||||
+ ($saturation * 0.10)
|
||||
+ ($neutralLightBoost * 28.0)
|
||||
+ ($warmBoost * 18.0)
|
||||
- ($vegetationPenalty * 18.0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $rarity;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<int, float>>
|
||||
*/
|
||||
private function vegetationMaskMatrix($sample, int $width, int $height): array
|
||||
{
|
||||
$mask = [];
|
||||
|
||||
for ($y = 0; $y < $height; $y++) {
|
||||
$mask[$y] = [];
|
||||
|
||||
for ($x = 0; $x < $width; $x++) {
|
||||
$rgb = imagecolorat($sample, $x, $y);
|
||||
$r = ($rgb >> 16) & 0xFF;
|
||||
$g = ($rgb >> 8) & 0xFF;
|
||||
$b = $rgb & 0xFF;
|
||||
|
||||
$mask[$y][$x] = ($g >= 72 && $g >= $r * 1.12 && $g >= $b * 1.08) ? 1.0 : 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
return $mask;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<int, float>> $energy
|
||||
* @param array<int, array<int, float>> $rarity
|
||||
* @return array<int, array<int, float>>
|
||||
*/
|
||||
private function combineSaliency(array $energy, array $rarity, int $width, int $height): array
|
||||
{
|
||||
$combined = [];
|
||||
|
||||
for ($y = 0; $y < $height; $y++) {
|
||||
$combined[$y] = [];
|
||||
|
||||
for ($x = 0; $x < $width; $x++) {
|
||||
$combined[$y][$x] = ($energy[$y][$x] ?? 0.0) + (($rarity[$y][$x] ?? 0.0) * 1.45);
|
||||
}
|
||||
}
|
||||
|
||||
return $combined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<int, float>> $matrix
|
||||
* @return array<int, array<int, float>>
|
||||
*/
|
||||
private function prefixMatrix(array $matrix, int $width, int $height): array
|
||||
{
|
||||
$prefix = array_fill(0, $height + 1, array_fill(0, $width + 1, 0.0));
|
||||
|
||||
for ($y = 1; $y <= $height; $y++) {
|
||||
for ($x = 1; $x <= $width; $x++) {
|
||||
$prefix[$y][$x] = $matrix[$y - 1][$x - 1]
|
||||
+ $prefix[$y - 1][$x]
|
||||
+ $prefix[$y][$x - 1]
|
||||
- $prefix[$y - 1][$x - 1];
|
||||
}
|
||||
}
|
||||
|
||||
return $prefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<int, float>> $prefix
|
||||
* @return array{x: int, y: int, side: int, density: float, score: float}|null
|
||||
*/
|
||||
private function bestCandidate(array $prefix, array $vegetationPrefix, int $sampleWidth, int $sampleHeight, float $totalEnergy): ?array
|
||||
{
|
||||
$minDimension = min($sampleWidth, $sampleHeight);
|
||||
$ratios = (array) config('uploads.square_thumbnails.saliency.window_ratios', [0.55, 0.7, 0.82, 1.0]);
|
||||
$best = null;
|
||||
|
||||
foreach ($ratios as $ratio) {
|
||||
$side = max(8, min($minDimension, (int) round($minDimension * (float) $ratio)));
|
||||
$step = max(1, (int) floor($side / 5));
|
||||
|
||||
for ($y = 0; $y <= max(0, $sampleHeight - $side); $y += $step) {
|
||||
for ($x = 0; $x <= max(0, $sampleWidth - $side); $x += $step) {
|
||||
$sum = $this->sumRegion($prefix, $x, $y, $side, $side);
|
||||
$density = $sum / max(1, $side * $side);
|
||||
$centerX = ($x + ($side / 2)) / max(1, $sampleWidth);
|
||||
$centerY = ($y + ($side / 2)) / max(1, $sampleHeight);
|
||||
$centerBias = 1.0 - min(1.0, abs($centerX - 0.5) * 1.2 + abs($centerY - 0.42) * 0.9);
|
||||
$coverage = $side / max(1, $minDimension);
|
||||
$coverageFit = 1.0 - min(1.0, abs($coverage - 0.72) / 0.45);
|
||||
$vegetationRatio = $this->sumRegion($vegetationPrefix, $x, $y, $side, $side) / max(1, $side * $side);
|
||||
$score = $density * (1.0 + max(0.0, $centerBias) * 0.18)
|
||||
+ (($sum / max(1.0, $totalEnergy)) * 4.0)
|
||||
+ (max(0.0, $coverageFit) * 2.5)
|
||||
- ($vegetationRatio * 68.0);
|
||||
|
||||
if ($best === null || $score > $best['score']) {
|
||||
$best = [
|
||||
'x' => $x,
|
||||
'y' => $y,
|
||||
'side' => $side,
|
||||
'density' => $density,
|
||||
'score' => $score,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a second candidate from rare, non-foliage pixels so a smooth subject can
|
||||
* still win even when repetitive textured leaves dominate edge energy.
|
||||
*
|
||||
* @param array<int, array<int, float>> $rarity
|
||||
* @param array<int, array<int, float>> $vegetation
|
||||
* @return array{x: int, y: int, side: int, density: float, score: float}|null
|
||||
*/
|
||||
private function rareSubjectCandidate(array $rarity, array $vegetation, int $sampleWidth, int $sampleHeight): ?array
|
||||
{
|
||||
$values = [];
|
||||
|
||||
for ($y = 0; $y < $sampleHeight; $y++) {
|
||||
for ($x = 0; $x < $sampleWidth; $x++) {
|
||||
if (($vegetation[$y][$x] ?? 0.0) >= 0.5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$values[] = (float) ($rarity[$y][$x] ?? 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
if (count($values) < 24) {
|
||||
return null;
|
||||
}
|
||||
|
||||
sort($values);
|
||||
$thresholdIndex = max(0, (int) floor((count($values) - 1) * 0.88));
|
||||
$threshold = max(48.0, (float) ($values[$thresholdIndex] ?? 0.0));
|
||||
|
||||
$weightSum = 0.0;
|
||||
$weightedX = 0.0;
|
||||
$weightedY = 0.0;
|
||||
$minX = $sampleWidth;
|
||||
$minY = $sampleHeight;
|
||||
$maxX = 0;
|
||||
$maxY = 0;
|
||||
$count = 0;
|
||||
|
||||
for ($y = 0; $y < $sampleHeight; $y++) {
|
||||
for ($x = 0; $x < $sampleWidth; $x++) {
|
||||
if (($vegetation[$y][$x] ?? 0.0) >= 0.5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$weight = (float) ($rarity[$y][$x] ?? 0.0);
|
||||
if ($weight < $threshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$weightSum += $weight;
|
||||
$weightedX += ($x + 0.5) * $weight;
|
||||
$weightedY += ($y + 0.5) * $weight;
|
||||
$minX = min($minX, $x);
|
||||
$minY = min($minY, $y);
|
||||
$maxX = max($maxX, $x);
|
||||
$maxY = max($maxY, $y);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($count < 12 || $weightSum <= 0.0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$meanX = $weightedX / $weightSum;
|
||||
$meanY = $weightedY / $weightSum;
|
||||
$boxWidth = max(8, ($maxX - $minX) + 1);
|
||||
$boxHeight = max(8, ($maxY - $minY) + 1);
|
||||
$minDimension = min($sampleWidth, $sampleHeight);
|
||||
$side = max($boxWidth, $boxHeight);
|
||||
$side = max($side, (int) round($minDimension * 0.42));
|
||||
$side = min($minDimension, (int) round($side * 1.18));
|
||||
|
||||
return [
|
||||
'x' => (int) round($meanX - ($side / 2)),
|
||||
'y' => (int) round($meanY - ($side / 2)),
|
||||
'side' => max(8, $side),
|
||||
'density' => $weightSum / max(1, $count),
|
||||
'score' => ($weightSum / max(1, $count)) + ($count * 0.35),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<int, float>> $prefix
|
||||
*/
|
||||
private function sumRegion(array $prefix, int $x, int $y, int $width, int $height): float
|
||||
{
|
||||
$x2 = $x + $width;
|
||||
$y2 = $y + $height;
|
||||
|
||||
return ($prefix[$y2][$x2] ?? 0.0)
|
||||
- ($prefix[$y][$x2] ?? 0.0)
|
||||
- ($prefix[$y2][$x] ?? 0.0)
|
||||
+ ($prefix[$y][$x] ?? 0.0);
|
||||
}
|
||||
}
|
||||
16
app/Services/Images/Detectors/NullSubjectDetector.php
Normal file
16
app/Services/Images/Detectors/NullSubjectDetector.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Images\Detectors;
|
||||
|
||||
use App\Contracts\Images\SubjectDetectorInterface;
|
||||
use App\Data\Images\SubjectDetectionResultData;
|
||||
|
||||
final class NullSubjectDetector implements SubjectDetectorInterface
|
||||
{
|
||||
public function detect(string $sourcePath, int $sourceWidth, int $sourceHeight, array $context = []): ?SubjectDetectionResultData
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
142
app/Services/Images/Detectors/VisionSubjectDetector.php
Normal file
142
app/Services/Images/Detectors/VisionSubjectDetector.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Images\Detectors;
|
||||
|
||||
use App\Contracts\Images\SubjectDetectorInterface;
|
||||
use App\Data\Images\CropBoxData;
|
||||
use App\Data\Images\SubjectDetectionResultData;
|
||||
use App\Models\Artwork;
|
||||
|
||||
final class VisionSubjectDetector implements SubjectDetectorInterface
|
||||
{
|
||||
public function detect(string $sourcePath, int $sourceWidth, int $sourceHeight, array $context = []): ?SubjectDetectionResultData
|
||||
{
|
||||
$boxes = $this->extractCandidateBoxes($context, $sourceWidth, $sourceHeight);
|
||||
if ($boxes === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($boxes, static function (array $left, array $right): int {
|
||||
return $right['score'] <=> $left['score'];
|
||||
});
|
||||
|
||||
$best = $boxes[0];
|
||||
|
||||
return new SubjectDetectionResultData(
|
||||
cropBox: $best['box'],
|
||||
strategy: 'subject',
|
||||
reason: 'vision_subject_box',
|
||||
confidence: (float) $best['confidence'],
|
||||
meta: [
|
||||
'label' => $best['label'],
|
||||
'score' => $best['score'],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{box: CropBoxData, label: string, confidence: float, score: float}>
|
||||
*/
|
||||
private function extractCandidateBoxes(array $context, int $sourceWidth, int $sourceHeight): array
|
||||
{
|
||||
$boxes = [];
|
||||
$preferredLabels = collect((array) config('uploads.square_thumbnails.subject_detector.preferred_labels', []))
|
||||
->map(static fn ($label): string => mb_strtolower((string) $label))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$candidates = $context['subject_boxes'] ?? $context['vision_boxes'] ?? null;
|
||||
|
||||
if ($candidates === null && ($context['artwork'] ?? null) instanceof Artwork) {
|
||||
$candidates = $this->boxesFromArtwork($context['artwork']);
|
||||
}
|
||||
|
||||
foreach ((array) $candidates as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$box = $this->normalizeBox($row, $sourceWidth, $sourceHeight);
|
||||
if ($box === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = mb_strtolower((string) ($row['label'] ?? $row['tag'] ?? $row['name'] ?? 'subject'));
|
||||
$confidence = max(0.0, min(1.0, (float) ($row['confidence'] ?? $row['score'] ?? 0.75)));
|
||||
$areaWeight = ($box->width * $box->height) / max(1, $sourceWidth * $sourceHeight);
|
||||
$preferredBoost = in_array($label, $preferredLabels, true) ? 1.25 : 1.0;
|
||||
|
||||
$boxes[] = [
|
||||
'box' => $box,
|
||||
'label' => $label,
|
||||
'confidence' => $confidence,
|
||||
'score' => ($confidence * 0.8 + $areaWeight * 0.2) * $preferredBoost,
|
||||
];
|
||||
}
|
||||
|
||||
return $boxes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function boxesFromArtwork(Artwork $artwork): array
|
||||
{
|
||||
return collect((array) ($artwork->yolo_objects_json ?? []))
|
||||
->filter(static fn ($row): bool => is_array($row))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function normalizeBox(array $row, int $sourceWidth, int $sourceHeight): ?CropBoxData
|
||||
{
|
||||
$payload = is_array($row['box'] ?? null) ? $row['box'] : $row;
|
||||
|
||||
$left = $payload['x'] ?? $payload['left'] ?? $payload['x1'] ?? null;
|
||||
$top = $payload['y'] ?? $payload['top'] ?? $payload['y1'] ?? null;
|
||||
$width = $payload['width'] ?? null;
|
||||
$height = $payload['height'] ?? null;
|
||||
|
||||
if ($width === null && isset($payload['x2'], $payload['x1'])) {
|
||||
$width = (float) $payload['x2'] - (float) $payload['x1'];
|
||||
}
|
||||
|
||||
if ($height === null && isset($payload['y2'], $payload['y1'])) {
|
||||
$height = (float) $payload['y2'] - (float) $payload['y1'];
|
||||
}
|
||||
|
||||
if (! is_numeric($left) || ! is_numeric($top) || ! is_numeric($width) || ! is_numeric($height)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$left = (float) $left;
|
||||
$top = (float) $top;
|
||||
$width = (float) $width;
|
||||
$height = (float) $height;
|
||||
|
||||
$normalized = max(abs($left), abs($top), abs($width), abs($height)) <= 1.0;
|
||||
if ($normalized) {
|
||||
$left *= $sourceWidth;
|
||||
$top *= $sourceHeight;
|
||||
$width *= $sourceWidth;
|
||||
$height *= $sourceHeight;
|
||||
}
|
||||
|
||||
if ($width <= 1 || $height <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (new CropBoxData(
|
||||
x: (int) floor($left),
|
||||
y: (int) floor($top),
|
||||
width: (int) round($width),
|
||||
height: (int) round($height),
|
||||
))->clampToImage($sourceWidth, $sourceHeight);
|
||||
}
|
||||
}
|
||||
160
app/Services/Images/SquareThumbnailService.php
Normal file
160
app/Services/Images/SquareThumbnailService.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Images;
|
||||
|
||||
use App\Contracts\Images\SubjectDetectorInterface;
|
||||
use App\Data\Images\CropBoxData;
|
||||
use App\Data\Images\SquareThumbnailResultData;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Intervention\Image\Encoders\WebpEncoder;
|
||||
use Intervention\Image\ImageManager;
|
||||
use RuntimeException;
|
||||
|
||||
final class SquareThumbnailService
|
||||
{
|
||||
private ?ImageManager $manager = null;
|
||||
|
||||
public function __construct(private readonly SubjectDetectorInterface $subjectDetector)
|
||||
{
|
||||
try {
|
||||
$this->manager = extension_loaded('gd') ? ImageManager::gd() : ImageManager::imagick();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Square thumbnail image manager configuration failed', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$this->manager = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function generateFromPath(string $sourcePath, string $destinationPath, array $options = []): SquareThumbnailResultData
|
||||
{
|
||||
$this->assertImageManagerAvailable();
|
||||
|
||||
if (! is_file($sourcePath) || ! is_readable($sourcePath)) {
|
||||
throw new RuntimeException('Square thumbnail source image is not readable.');
|
||||
}
|
||||
|
||||
$size = @getimagesize($sourcePath);
|
||||
if (! is_array($size) || ($size[0] ?? 0) < 1 || ($size[1] ?? 0) < 1) {
|
||||
throw new RuntimeException('Square thumbnail source image dimensions are invalid.');
|
||||
}
|
||||
|
||||
$sourceWidth = (int) $size[0];
|
||||
$sourceHeight = (int) $size[1];
|
||||
$config = $this->resolveOptions($options);
|
||||
$context = is_array($options['context'] ?? null) ? $options['context'] : $options;
|
||||
|
||||
$detection = null;
|
||||
if ($config['smart_crop']) {
|
||||
$detection = $this->subjectDetector->detect($sourcePath, $sourceWidth, $sourceHeight, $context);
|
||||
}
|
||||
|
||||
$cropBox = $this->calculateCropBox($sourceWidth, $sourceHeight, $detection?->cropBox, $config);
|
||||
$cropMode = $detection?->strategy ?? $config['fallback_strategy'];
|
||||
|
||||
$image = $this->manager->read($sourcePath)->crop($cropBox->width, $cropBox->height, $cropBox->x, $cropBox->y);
|
||||
$outputWidth = $config['target_width'];
|
||||
$outputHeight = $config['target_height'];
|
||||
|
||||
if ($config['allow_upscale']) {
|
||||
$image = $image->resize($config['target_width'], $config['target_height']);
|
||||
} else {
|
||||
$image = $image->resizeDown($config['target_width'], $config['target_height']);
|
||||
$outputWidth = min($config['target_width'], $cropBox->width);
|
||||
$outputHeight = min($config['target_height'], $cropBox->height);
|
||||
}
|
||||
|
||||
$encoded = (string) $image->encode(new WebpEncoder($config['quality']));
|
||||
File::ensureDirectoryExists(dirname($destinationPath));
|
||||
File::put($destinationPath, $encoded);
|
||||
|
||||
$result = new SquareThumbnailResultData(
|
||||
destinationPath: $destinationPath,
|
||||
cropBox: $cropBox,
|
||||
cropMode: $cropMode,
|
||||
sourceWidth: $sourceWidth,
|
||||
sourceHeight: $sourceHeight,
|
||||
targetWidth: $config['target_width'],
|
||||
targetHeight: $config['target_height'],
|
||||
outputWidth: $outputWidth,
|
||||
outputHeight: $outputHeight,
|
||||
detectionReason: $detection?->reason,
|
||||
meta: [
|
||||
'smart_crop' => $config['smart_crop'],
|
||||
'padding_ratio' => $config['padding_ratio'],
|
||||
'confidence' => $detection?->confidence,
|
||||
],
|
||||
);
|
||||
|
||||
if ($config['log']) {
|
||||
Log::debug('square-thumbnail-generated', $result->toArray());
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function calculateCropBox(int $sourceWidth, int $sourceHeight, ?CropBoxData $focusBox = null, array $options = []): CropBoxData
|
||||
{
|
||||
$config = $this->resolveOptions($options);
|
||||
|
||||
if ($focusBox === null) {
|
||||
return $this->safeCenterCrop($sourceWidth, $sourceHeight);
|
||||
}
|
||||
|
||||
$baseSide = max($focusBox->width, $focusBox->height);
|
||||
$side = max(1, (int) ceil($baseSide * (1 + ($config['padding_ratio'] * 2))));
|
||||
$side = min($side, min($sourceWidth, $sourceHeight));
|
||||
|
||||
$x = (int) round($focusBox->centerX() - ($side / 2));
|
||||
$y = (int) round($focusBox->centerY() - ($side / 2));
|
||||
|
||||
return (new CropBoxData($x, $y, $side, $side))->clampToImage($sourceWidth, $sourceHeight);
|
||||
}
|
||||
|
||||
private function safeCenterCrop(int $sourceWidth, int $sourceHeight): CropBoxData
|
||||
{
|
||||
$side = min($sourceWidth, $sourceHeight);
|
||||
$x = (int) floor(($sourceWidth - $side) / 2);
|
||||
$y = (int) floor(($sourceHeight - $side) / 2);
|
||||
|
||||
return new CropBoxData($x, $y, $side, $side);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
* @return array{target_width: int, target_height: int, quality: int, smart_crop: bool, padding_ratio: float, allow_upscale: bool, fallback_strategy: string, log: bool}
|
||||
*/
|
||||
private function resolveOptions(array $options): array
|
||||
{
|
||||
$config = (array) config('uploads.square_thumbnails', []);
|
||||
$targetWidth = (int) ($options['target_width'] ?? $options['target_size'] ?? $config['width'] ?? config('uploads.derivatives.sq.size', 512));
|
||||
$targetHeight = (int) ($options['target_height'] ?? $options['target_size'] ?? $config['height'] ?? $targetWidth);
|
||||
|
||||
return [
|
||||
'target_width' => max(1, $targetWidth),
|
||||
'target_height' => max(1, $targetHeight),
|
||||
'quality' => max(1, min(100, (int) ($options['quality'] ?? $config['quality'] ?? 82))),
|
||||
'smart_crop' => (bool) ($options['smart_crop'] ?? $config['smart_crop'] ?? true),
|
||||
'padding_ratio' => max(0.0, min(0.5, (float) ($options['padding_ratio'] ?? $config['padding_ratio'] ?? 0.18))),
|
||||
'allow_upscale' => (bool) ($options['allow_upscale'] ?? $config['allow_upscale'] ?? false),
|
||||
'fallback_strategy' => (string) ($options['fallback_strategy'] ?? $config['fallback_strategy'] ?? 'center'),
|
||||
'log' => (bool) ($options['log'] ?? $config['log'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
private function assertImageManagerAvailable(): void
|
||||
{
|
||||
if ($this->manager === null) {
|
||||
throw new RuntimeException('Square thumbnail generation requires Intervention Image.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Models\ContentModerationActionLog;
|
||||
use App\Models\ContentModerationFinding;
|
||||
use App\Models\User;
|
||||
|
||||
class ContentModerationActionLogService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed>|null $meta
|
||||
*/
|
||||
public function log(
|
||||
?ContentModerationFinding $finding,
|
||||
string $targetType,
|
||||
?int $targetId,
|
||||
string $actionType,
|
||||
?User $actor = null,
|
||||
?string $oldStatus = null,
|
||||
?string $newStatus = null,
|
||||
?string $oldVisibility = null,
|
||||
?string $newVisibility = null,
|
||||
?string $notes = null,
|
||||
?array $meta = null,
|
||||
): ContentModerationActionLog {
|
||||
return ContentModerationActionLog::query()->create([
|
||||
'finding_id' => $finding?->id,
|
||||
'target_type' => $targetType,
|
||||
'target_id' => $targetId,
|
||||
'action_type' => $actionType,
|
||||
'actor_type' => $actor ? 'admin' : 'system',
|
||||
'actor_id' => $actor?->id,
|
||||
'old_status' => $oldStatus,
|
||||
'new_status' => $newStatus,
|
||||
'old_visibility' => $oldVisibility,
|
||||
'new_visibility' => $newVisibility,
|
||||
'notes' => $notes,
|
||||
'meta_json' => $meta,
|
||||
'created_at' => \now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
182
app/Services/Moderation/ContentModerationPersistenceService.php
Normal file
182
app/Services/Moderation/ContentModerationPersistenceService.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Data\Moderation\ModerationResultData;
|
||||
use App\Enums\ModerationEscalationStatus;
|
||||
use App\Enums\ModerationContentType;
|
||||
use App\Enums\ModerationStatus;
|
||||
use App\Models\ContentModerationAiSuggestion;
|
||||
use App\Models\ContentModerationFinding;
|
||||
|
||||
class ContentModerationPersistenceService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContentModerationReviewService $review,
|
||||
private readonly ContentModerationActionLogService $actionLogs,
|
||||
) {
|
||||
}
|
||||
|
||||
public function shouldQueue(ModerationResultData $result): bool
|
||||
{
|
||||
return $result->score >= (int) \app('config')->get('content_moderation.queue_threshold', 30);
|
||||
}
|
||||
|
||||
public function hasCurrentFinding(string $contentType, int $contentId, string $contentHash, string $scannerVersion): bool
|
||||
{
|
||||
return ContentModerationFinding::query()
|
||||
->where('content_type', $contentType)
|
||||
->where('content_id', $contentId)
|
||||
->where('content_hash', $contentHash)
|
||||
->where('scanner_version', $scannerVersion)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array{finding:?ContentModerationFinding, created:bool, updated:bool}
|
||||
*/
|
||||
public function persist(ModerationResultData $result, array $context): array
|
||||
{
|
||||
$contentType = (string) ($context['content_type'] ?? '');
|
||||
$contentId = (int) ($context['content_id'] ?? 0);
|
||||
|
||||
if ($contentType === '' || $contentId <= 0) {
|
||||
return ['finding' => null, 'created' => false, 'updated' => false];
|
||||
}
|
||||
|
||||
$existing = ContentModerationFinding::query()
|
||||
->where('content_type', $contentType)
|
||||
->where('content_id', $contentId)
|
||||
->where('content_hash', $result->contentHash)
|
||||
->where('scanner_version', $result->scannerVersion)
|
||||
->first();
|
||||
|
||||
if (! $this->shouldQueue($result) && $existing === null) {
|
||||
return ['finding' => null, 'created' => false, 'updated' => false];
|
||||
}
|
||||
|
||||
$finding = $existing ?? new ContentModerationFinding();
|
||||
$isNew = ! $finding->exists;
|
||||
|
||||
$finding->fill([
|
||||
'content_type' => $contentType,
|
||||
'content_id' => $contentId,
|
||||
'content_target_type' => $result->contentTargetType,
|
||||
'content_target_id' => $result->contentTargetId,
|
||||
'artwork_id' => $context['artwork_id'] ?? null,
|
||||
'user_id' => $context['user_id'] ?? null,
|
||||
'severity' => $result->severity->value,
|
||||
'score' => $result->score,
|
||||
'content_hash' => $result->contentHash,
|
||||
'scanner_version' => $result->scannerVersion,
|
||||
'reasons_json' => $result->reasons,
|
||||
'matched_links_json' => $result->matchedLinks,
|
||||
'matched_domains_json' => $result->matchedDomains,
|
||||
'matched_keywords_json' => $result->matchedKeywords,
|
||||
'rule_hits_json' => $result->ruleHits,
|
||||
'score_breakdown_json' => $result->scoreBreakdown,
|
||||
'content_hash_normalized' => $result->contentHashNormalized,
|
||||
'group_key' => $result->groupKey,
|
||||
'campaign_key' => $result->campaignKey,
|
||||
'cluster_score' => $result->clusterScore,
|
||||
'cluster_reason' => $result->clusterReason,
|
||||
'priority_score' => $result->priorityScore,
|
||||
'policy_name' => $result->policyName,
|
||||
'review_bucket' => $result->reviewBucket,
|
||||
'escalation_status' => $result->escalationStatus ?? ModerationEscalationStatus::None->value,
|
||||
'ai_provider' => $result->aiProvider,
|
||||
'ai_label' => $result->aiLabel,
|
||||
'ai_suggested_action' => $result->aiSuggestedAction,
|
||||
'ai_confidence' => $result->aiConfidence,
|
||||
'ai_explanation' => $result->aiExplanation,
|
||||
'user_risk_score' => $result->userRiskScore,
|
||||
'content_snapshot' => (string) ($context['content_snapshot'] ?? ''),
|
||||
'auto_action_taken' => $result->autoHideRecommended ? 'recommended' : null,
|
||||
]);
|
||||
|
||||
if ($isNew) {
|
||||
$finding->status = $result->status;
|
||||
} elseif (! $this->shouldQueue($result) && $finding->isPending()) {
|
||||
$finding->status = ModerationStatus::Resolved;
|
||||
$finding->action_taken = 'rescanned_clean';
|
||||
$finding->resolved_at = \now();
|
||||
$finding->resolved_by = null;
|
||||
}
|
||||
|
||||
$finding->save();
|
||||
|
||||
if ($result->aiProvider !== null && ($result->aiLabel !== null || $result->aiExplanation !== null || $result->aiConfidence !== null)) {
|
||||
ContentModerationAiSuggestion::query()->create([
|
||||
'finding_id' => $finding->id,
|
||||
'provider' => $result->aiProvider,
|
||||
'suggested_label' => $result->aiLabel,
|
||||
'suggested_action' => $result->aiSuggestedAction,
|
||||
'confidence' => $result->aiConfidence,
|
||||
'explanation' => $result->aiExplanation,
|
||||
'campaign_tags_json' => $result->campaignKey ? [$result->campaignKey] : [],
|
||||
'raw_response_json' => $result->aiRawResponse,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $isNew && ! $this->shouldQueue($result) && $finding->action_taken === 'rescanned_clean') {
|
||||
$this->actionLogs->log(
|
||||
$finding,
|
||||
'finding',
|
||||
$finding->id,
|
||||
'rescan',
|
||||
null,
|
||||
ModerationStatus::Pending->value,
|
||||
ModerationStatus::Resolved->value,
|
||||
null,
|
||||
null,
|
||||
'Finding resolved automatically after a clean rescan.',
|
||||
['scanner_version' => $result->scannerVersion],
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'finding' => $finding,
|
||||
'created' => $isNew,
|
||||
'updated' => ! $isNew,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function applyAutomatedActionIfNeeded(ContentModerationFinding $finding, ModerationResultData $result, array $context): bool
|
||||
{
|
||||
if (! $result->autoHideRecommended) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($finding->is_auto_hidden) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$supportedTypes = (array) \app('config')->get('content_moderation.auto_hide.supported_types', []);
|
||||
if (! in_array($finding->content_type->value, $supportedTypes, true)) {
|
||||
$finding->forceFill([
|
||||
'auto_action_taken' => 'recommended_review',
|
||||
])->save();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! in_array($finding->content_type, [ModerationContentType::ArtworkComment, ModerationContentType::ArtworkDescription], true)) {
|
||||
if ($finding->content_type !== ModerationContentType::ArtworkTitle) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (! in_array($finding->content_type, [ModerationContentType::ArtworkComment, ModerationContentType::ArtworkDescription, ModerationContentType::ArtworkTitle], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->review->autoHideContent($finding, 'Triggered by automated moderation threshold.');
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Models\ContentModerationFinding;
|
||||
|
||||
class ContentModerationProcessingService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContentModerationService $moderation,
|
||||
private readonly ContentModerationPersistenceService $persistence,
|
||||
private readonly DomainReputationService $domains,
|
||||
private readonly ModerationClusterService $clusters,
|
||||
private readonly DomainIntelligenceService $domainIntelligence,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array{result:\App\Data\Moderation\ModerationResultData,finding:?ContentModerationFinding,created:bool,updated:bool,auto_hidden:bool}
|
||||
*/
|
||||
public function process(string $content, array $context, bool $persist = true): array
|
||||
{
|
||||
$result = $this->moderation->analyze($content, $context);
|
||||
$this->domains->trackDomains(
|
||||
$result->matchedDomains,
|
||||
$this->persistence->shouldQueue($result),
|
||||
false,
|
||||
);
|
||||
|
||||
if (! $persist) {
|
||||
return [
|
||||
'result' => $result,
|
||||
'finding' => null,
|
||||
'created' => false,
|
||||
'updated' => false,
|
||||
'auto_hidden' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$persisted = $this->persistence->persist($result, $context);
|
||||
$finding = $persisted['finding'];
|
||||
|
||||
if ($finding !== null) {
|
||||
$this->domains->attachDomainIds($finding);
|
||||
$autoHidden = $this->persistence->applyAutomatedActionIfNeeded($finding, $result, $context);
|
||||
$this->clusters->syncFinding($finding->fresh());
|
||||
foreach ($result->matchedDomains as $domain) {
|
||||
$this->domainIntelligence->refreshDomain($domain);
|
||||
}
|
||||
|
||||
return [
|
||||
'result' => $result,
|
||||
'finding' => $finding->fresh(),
|
||||
'created' => $persisted['created'],
|
||||
'updated' => $persisted['updated'],
|
||||
'auto_hidden' => $autoHidden,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'result' => $result,
|
||||
'finding' => null,
|
||||
'created' => false,
|
||||
'updated' => false,
|
||||
'auto_hidden' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function rescanFinding(ContentModerationFinding $finding, ContentModerationSourceService $sources): ?ContentModerationFinding
|
||||
{
|
||||
$resolved = $sources->contextForFinding($finding);
|
||||
if ($resolved['context'] === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = $this->process((string) $resolved['context']['content_snapshot'], $resolved['context'], true);
|
||||
|
||||
return $result['finding'];
|
||||
}
|
||||
}
|
||||
292
app/Services/Moderation/ContentModerationReviewService.php
Normal file
292
app/Services/Moderation/ContentModerationReviewService.php
Normal file
@@ -0,0 +1,292 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
203
app/Services/Moderation/ContentModerationService.php
Normal file
203
app/Services/Moderation/ContentModerationService.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Contracts\Moderation\ModerationRuleInterface;
|
||||
use App\Data\Moderation\ModerationResultData;
|
||||
use App\Enums\ModerationSeverity;
|
||||
use App\Enums\ModerationStatus;
|
||||
use App\Services\Moderation\DuplicateDetectionService;
|
||||
use App\Services\Moderation\Rules\LinkPresenceRule;
|
||||
|
||||
class ContentModerationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ModerationPolicyEngineService $policies,
|
||||
private readonly ModerationSuggestionService $suggestions,
|
||||
private readonly ModerationClusterService $clusters,
|
||||
private readonly ModerationPriorityService $priorities,
|
||||
) {
|
||||
}
|
||||
|
||||
public function analyze(string $content, array $context = []): ModerationResultData
|
||||
{
|
||||
$normalized = $this->normalize($content);
|
||||
$campaignNormalized = app(DuplicateDetectionService::class)->campaignText($content);
|
||||
$linkRule = app(LinkPresenceRule::class);
|
||||
$extractedUrls = $linkRule->extractUrls($content);
|
||||
$extractedDomains = array_values(array_unique(array_filter(array_map(
|
||||
static fn (string $url): ?string => $linkRule->extractHost($url),
|
||||
$extractedUrls
|
||||
))));
|
||||
|
||||
$riskAssessment = app(UserRiskScoreService::class)->assess(
|
||||
isset($context['user_id']) ? (int) $context['user_id'] : null,
|
||||
$extractedDomains,
|
||||
);
|
||||
|
||||
$context['extracted_urls'] = $extractedUrls;
|
||||
$context['extracted_domains'] = $extractedDomains;
|
||||
$context['user_risk_assessment'] = $riskAssessment;
|
||||
|
||||
$score = 0;
|
||||
$reasons = [];
|
||||
$matchedLinks = [];
|
||||
$matchedDomains = [];
|
||||
$matchedKeywords = [];
|
||||
$ruleHits = [];
|
||||
$scoreBreakdown = [];
|
||||
|
||||
foreach ($this->rules() as $rule) {
|
||||
foreach ($rule->analyze($content, $normalized, $context) as $finding) {
|
||||
$ruleScore = (int) ($finding['score'] ?? 0);
|
||||
$score += $ruleScore;
|
||||
$reason = (string) ($finding['reason'] ?? 'Flagged by moderation rule');
|
||||
$reasons[] = $reason;
|
||||
$matchedLinks = array_merge($matchedLinks, (array) ($finding['links'] ?? []));
|
||||
$matchedDomains = array_merge($matchedDomains, array_filter((array) ($finding['domains'] ?? [])));
|
||||
$matchedKeywords = array_merge($matchedKeywords, array_filter((array) ($finding['keywords'] ?? [])));
|
||||
$ruleKey = (string) ($finding['rule'] ?? 'unknown');
|
||||
$ruleHits[$ruleKey] = ($ruleHits[$ruleKey] ?? 0) + 1;
|
||||
$scoreBreakdown[] = [
|
||||
'rule' => $ruleKey,
|
||||
'score' => $ruleScore,
|
||||
'reason' => $reason,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$modifier = (int) ($riskAssessment['score_modifier'] ?? 0);
|
||||
if ($modifier !== 0) {
|
||||
$score += $modifier;
|
||||
$reasons[] = $modifier > 0
|
||||
? 'User risk profile increased moderation score by ' . $modifier
|
||||
: 'User trust profile reduced moderation score by ' . abs($modifier);
|
||||
$ruleHits['user_risk_modifier'] = 1;
|
||||
$scoreBreakdown[] = [
|
||||
'rule' => 'user_risk_modifier',
|
||||
'score' => $modifier,
|
||||
'reason' => $modifier > 0
|
||||
? 'User risk profile increased moderation score by ' . $modifier
|
||||
: 'User trust profile reduced moderation score by ' . abs($modifier),
|
||||
];
|
||||
}
|
||||
|
||||
$score = max(0, $score);
|
||||
$severity = ModerationSeverity::fromScore($score);
|
||||
$policy = $this->policies->resolve($context, $riskAssessment);
|
||||
$autoHideRecommended = $this->shouldAutoHide($score, $ruleHits, $matchedDomains ?: $extractedDomains, $policy);
|
||||
$groupKey = app(DuplicateDetectionService::class)->buildGroupKey($content, $matchedDomains ?: $extractedDomains);
|
||||
|
||||
$draft = new ModerationResultData(
|
||||
score: $score,
|
||||
severity: $severity,
|
||||
status: $score >= (int) ($policy['queue_threshold'] ?? app('config')->get('content_moderation.queue_threshold', 30))
|
||||
? ModerationStatus::Pending
|
||||
: ModerationStatus::ReviewedSafe,
|
||||
reasons: array_values(array_unique(array_filter($reasons))),
|
||||
matchedLinks: array_values(array_unique(array_filter($matchedLinks))),
|
||||
matchedDomains: array_values(array_unique(array_filter($matchedDomains))),
|
||||
matchedKeywords: array_values(array_unique(array_filter($matchedKeywords))),
|
||||
contentHash: hash('sha256', $normalized),
|
||||
scannerVersion: (string) app('config')->get('content_moderation.scanner_version', '1.0'),
|
||||
ruleHits: $ruleHits,
|
||||
contentHashNormalized: hash('sha256', $campaignNormalized),
|
||||
groupKey: $groupKey,
|
||||
userRiskScore: (int) ($riskAssessment['risk_score'] ?? 0),
|
||||
autoHideRecommended: $autoHideRecommended,
|
||||
contentTargetType: isset($context['content_target_type']) ? (string) $context['content_target_type'] : null,
|
||||
contentTargetId: isset($context['content_target_id']) ? (int) $context['content_target_id'] : null,
|
||||
policyName: (string) ($policy['name'] ?? 'default'),
|
||||
scoreBreakdown: $scoreBreakdown,
|
||||
);
|
||||
|
||||
$suggestion = $this->suggestions->suggest($content, $draft, $context);
|
||||
$cluster = $this->clusters->classify($content, $draft, $context, [
|
||||
'campaign_tags' => $suggestion->campaignTags,
|
||||
'confidence' => $suggestion->confidence,
|
||||
]);
|
||||
$priority = $this->priorities->score($draft, $context, $policy, [
|
||||
'confidence' => $suggestion->confidence,
|
||||
'campaign_tags' => $suggestion->campaignTags,
|
||||
]);
|
||||
|
||||
return new ModerationResultData(
|
||||
score: $score,
|
||||
severity: $severity,
|
||||
status: $score >= (int) ($policy['queue_threshold'] ?? app('config')->get('content_moderation.queue_threshold', 30))
|
||||
? ModerationStatus::Pending
|
||||
: ModerationStatus::ReviewedSafe,
|
||||
reasons: array_values(array_unique(array_filter($reasons))),
|
||||
matchedLinks: array_values(array_unique(array_filter($matchedLinks))),
|
||||
matchedDomains: array_values(array_unique(array_filter($matchedDomains))),
|
||||
matchedKeywords: array_values(array_unique(array_filter($matchedKeywords))),
|
||||
contentHash: hash('sha256', $normalized),
|
||||
scannerVersion: (string) app('config')->get('content_moderation.scanner_version', '1.0'),
|
||||
ruleHits: $ruleHits,
|
||||
contentHashNormalized: hash('sha256', $campaignNormalized),
|
||||
groupKey: $groupKey,
|
||||
userRiskScore: (int) ($riskAssessment['risk_score'] ?? 0),
|
||||
autoHideRecommended: $autoHideRecommended,
|
||||
contentTargetType: isset($context['content_target_type']) ? (string) $context['content_target_type'] : null,
|
||||
contentTargetId: isset($context['content_target_id']) ? (int) $context['content_target_id'] : null,
|
||||
campaignKey: $cluster['campaign_key'],
|
||||
clusterScore: $cluster['cluster_score'],
|
||||
clusterReason: $cluster['cluster_reason'],
|
||||
policyName: (string) ($policy['name'] ?? 'default'),
|
||||
priorityScore: (int) ($priority['priority_score'] ?? $score),
|
||||
reviewBucket: (string) ($priority['review_bucket'] ?? ($policy['review_bucket'] ?? 'standard')),
|
||||
escalationStatus: (string) ($priority['escalation_status'] ?? 'none'),
|
||||
aiProvider: $suggestion->provider,
|
||||
aiLabel: $suggestion->suggestedLabel,
|
||||
aiSuggestedAction: $suggestion->suggestedAction,
|
||||
aiConfidence: $suggestion->confidence,
|
||||
aiExplanation: $suggestion->explanation,
|
||||
aiRawResponse: $suggestion->rawResponse,
|
||||
scoreBreakdown: $scoreBreakdown,
|
||||
);
|
||||
}
|
||||
|
||||
public function normalize(string $content): string
|
||||
{
|
||||
$normalized = preg_replace('/\s+/u', ' ', trim($content));
|
||||
|
||||
return mb_strtolower((string) $normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, ModerationRuleInterface>
|
||||
*/
|
||||
private function rules(): array
|
||||
{
|
||||
$classes = app('config')->get('content_moderation.rules.enabled', []);
|
||||
|
||||
return array_values(array_filter(array_map(function (string $class): ?ModerationRuleInterface {
|
||||
$rule = app($class);
|
||||
|
||||
return $rule instanceof ModerationRuleInterface ? $rule : null;
|
||||
}, $classes)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $ruleHits
|
||||
* @param array<int, string> $matchedDomains
|
||||
*/
|
||||
private function shouldAutoHide(int $score, array $ruleHits, array $matchedDomains, array $policy = []): bool
|
||||
{
|
||||
if (! app('config')->get('content_moderation.auto_hide.enabled', true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$threshold = (int) ($policy['auto_hide_threshold'] ?? app('config')->get('content_moderation.auto_hide.threshold', 95));
|
||||
if ($score >= $threshold) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$blockedHit = isset($ruleHits['blacklisted_domain']) || isset($ruleHits['blocked_domain']);
|
||||
$severeHitCount = collect($ruleHits)
|
||||
->only(['blacklisted_domain', 'blocked_domain', 'high_risk_keyword', 'near_duplicate_campaign', 'duplicate_comment'])
|
||||
->sum();
|
||||
|
||||
return $blockedHit && $severeHitCount >= 2 && count($matchedDomains) >= 1;
|
||||
}
|
||||
}
|
||||
352
app/Services/Moderation/ContentModerationSourceService.php
Normal file
352
app/Services/Moderation/ContentModerationSourceService.php
Normal file
@@ -0,0 +1,352 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Enums\ModerationContentType;
|
||||
use App\Models\Collection;
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\Story;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\ContentModerationFinding;
|
||||
use App\Models\UserProfile;
|
||||
use App\Models\UserSocialLink;
|
||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||
|
||||
class ContentModerationSourceService
|
||||
{
|
||||
public function queryForType(ModerationContentType $type): EloquentBuilder
|
||||
{
|
||||
return match ($type) {
|
||||
ModerationContentType::ArtworkComment => ArtworkComment::query()
|
||||
->with('artwork:id,title,slug,user_id')
|
||||
->whereNull('deleted_at')
|
||||
->where(function (EloquentBuilder $query): void {
|
||||
$query->whereNotNull('raw_content')->where('raw_content', '!=', '')
|
||||
->orWhere(function (EloquentBuilder $fallback): void {
|
||||
$fallback->whereNotNull('content')->where('content', '!=', '');
|
||||
});
|
||||
})
|
||||
->orderBy('id'),
|
||||
ModerationContentType::ArtworkDescription => Artwork::query()
|
||||
->whereNotNull('description')
|
||||
->where('description', '!=', '')
|
||||
->orderBy('id'),
|
||||
ModerationContentType::ArtworkTitle => Artwork::query()
|
||||
->whereNotNull('title')
|
||||
->where('title', '!=', '')
|
||||
->orderBy('id'),
|
||||
ModerationContentType::UserBio => UserProfile::query()
|
||||
->with('user:id,username,name')
|
||||
->where(function (EloquentBuilder $query): void {
|
||||
$query->whereNotNull('about')->where('about', '!=', '')
|
||||
->orWhere(function (EloquentBuilder $fallback): void {
|
||||
$fallback->whereNotNull('description')->where('description', '!=', '');
|
||||
});
|
||||
})
|
||||
->orderBy('user_id'),
|
||||
ModerationContentType::UserProfileLink => UserSocialLink::query()
|
||||
->with('user:id,username,name')
|
||||
->whereNotNull('url')
|
||||
->where('url', '!=', '')
|
||||
->orderBy('id'),
|
||||
ModerationContentType::CollectionTitle => Collection::query()
|
||||
->with('user:id,username,name')
|
||||
->whereNotNull('title')
|
||||
->where('title', '!=', '')
|
||||
->orderBy('id'),
|
||||
ModerationContentType::CollectionDescription => Collection::query()
|
||||
->with('user:id,username,name')
|
||||
->whereNotNull('description')
|
||||
->where('description', '!=', '')
|
||||
->orderBy('id'),
|
||||
ModerationContentType::StoryTitle => Story::query()
|
||||
->with('creator:id,username,name')
|
||||
->whereNotNull('title')
|
||||
->where('title', '!=', '')
|
||||
->orderBy('id'),
|
||||
ModerationContentType::StoryContent => Story::query()
|
||||
->with('creator:id,username,name')
|
||||
->whereNotNull('content')
|
||||
->where('content', '!=', '')
|
||||
->orderBy('id'),
|
||||
ModerationContentType::CardTitle => NovaCard::query()
|
||||
->with('user:id,username,name')
|
||||
->whereNotNull('title')
|
||||
->where('title', '!=', '')
|
||||
->orderBy('id'),
|
||||
ModerationContentType::CardText => NovaCard::query()
|
||||
->with('user:id,username,name')
|
||||
->where(function (EloquentBuilder $query): void {
|
||||
$query->whereNotNull('quote_text')->where('quote_text', '!=', '')
|
||||
->orWhere(function (EloquentBuilder $description): void {
|
||||
$description->whereNotNull('description')->where('description', '!=', '');
|
||||
});
|
||||
})
|
||||
->orderBy('id'),
|
||||
default => throw new \InvalidArgumentException('Unsupported moderation content type: ' . $type->value),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Artwork|ArtworkComment|Collection|Story|NovaCard|UserProfile|UserSocialLink $row
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildContext(ModerationContentType $type, object $row): array
|
||||
{
|
||||
return match ($type) {
|
||||
ModerationContentType::ArtworkComment => [
|
||||
'content_type' => $type->value,
|
||||
'content_id' => (int) $row->id,
|
||||
'content_target_type' => 'artwork_comment',
|
||||
'content_target_id' => (int) $row->id,
|
||||
'artwork_id' => (int) $row->artwork_id,
|
||||
'user_id' => $row->user_id ? (int) $row->user_id : null,
|
||||
'content_snapshot' => (string) ($row->raw_content ?: $row->content),
|
||||
'is_publicly_exposed' => true,
|
||||
],
|
||||
ModerationContentType::ArtworkDescription => [
|
||||
'content_type' => $type->value,
|
||||
'content_id' => (int) $row->id,
|
||||
'content_target_type' => 'artwork',
|
||||
'content_target_id' => (int) $row->id,
|
||||
'artwork_id' => (int) $row->id,
|
||||
'user_id' => $row->user_id ? (int) $row->user_id : null,
|
||||
'content_snapshot' => (string) ($row->description ?? ''),
|
||||
'is_publicly_exposed' => (bool) ($row->is_public ?? false),
|
||||
],
|
||||
ModerationContentType::ArtworkTitle => [
|
||||
'content_type' => $type->value,
|
||||
'content_id' => (int) $row->id,
|
||||
'content_target_type' => 'artwork',
|
||||
'content_target_id' => (int) $row->id,
|
||||
'artwork_id' => (int) $row->id,
|
||||
'user_id' => $row->user_id ? (int) $row->user_id : null,
|
||||
'content_snapshot' => (string) ($row->title ?? ''),
|
||||
'is_publicly_exposed' => (bool) ($row->is_public ?? false),
|
||||
],
|
||||
ModerationContentType::UserBio => [
|
||||
'content_type' => $type->value,
|
||||
'content_id' => (int) $row->user_id,
|
||||
'content_target_type' => 'user_profile',
|
||||
'content_target_id' => (int) $row->user_id,
|
||||
'user_id' => (int) $row->user_id,
|
||||
'content_snapshot' => trim((string) ($row->about ?: $row->description ?: '')),
|
||||
'is_publicly_exposed' => true,
|
||||
],
|
||||
ModerationContentType::UserProfileLink => [
|
||||
'content_type' => $type->value,
|
||||
'content_id' => (int) $row->id,
|
||||
'content_target_type' => 'user_social_link',
|
||||
'content_target_id' => (int) $row->id,
|
||||
'user_id' => (int) $row->user_id,
|
||||
'content_snapshot' => trim((string) ($row->url ?? '')),
|
||||
'is_publicly_exposed' => true,
|
||||
],
|
||||
ModerationContentType::CollectionTitle => [
|
||||
'content_type' => $type->value,
|
||||
'content_id' => (int) $row->id,
|
||||
'content_target_type' => 'collection',
|
||||
'content_target_id' => (int) $row->id,
|
||||
'user_id' => $row->user_id ? (int) $row->user_id : null,
|
||||
'content_snapshot' => (string) ($row->title ?? ''),
|
||||
'is_publicly_exposed' => in_array((string) ($row->visibility ?? ''), ['public', 'unlisted'], true),
|
||||
],
|
||||
ModerationContentType::CollectionDescription => [
|
||||
'content_type' => $type->value,
|
||||
'content_id' => (int) $row->id,
|
||||
'content_target_type' => 'collection',
|
||||
'content_target_id' => (int) $row->id,
|
||||
'user_id' => $row->user_id ? (int) $row->user_id : null,
|
||||
'content_snapshot' => (string) ($row->description ?? ''),
|
||||
'is_publicly_exposed' => in_array((string) ($row->visibility ?? ''), ['public', 'unlisted'], true),
|
||||
],
|
||||
ModerationContentType::StoryTitle => [
|
||||
'content_type' => $type->value,
|
||||
'content_id' => (int) $row->id,
|
||||
'content_target_type' => 'story',
|
||||
'content_target_id' => (int) $row->id,
|
||||
'user_id' => $row->creator_id ? (int) $row->creator_id : null,
|
||||
'content_snapshot' => (string) ($row->title ?? ''),
|
||||
'is_publicly_exposed' => in_array((string) ($row->status ?? ''), ['published', 'scheduled'], true),
|
||||
],
|
||||
ModerationContentType::StoryContent => [
|
||||
'content_type' => $type->value,
|
||||
'content_id' => (int) $row->id,
|
||||
'content_target_type' => 'story',
|
||||
'content_target_id' => (int) $row->id,
|
||||
'user_id' => $row->creator_id ? (int) $row->creator_id : null,
|
||||
'content_snapshot' => (string) ($row->content ?? ''),
|
||||
'is_publicly_exposed' => in_array((string) ($row->status ?? ''), ['published', 'scheduled'], true),
|
||||
],
|
||||
ModerationContentType::CardTitle => [
|
||||
'content_type' => $type->value,
|
||||
'content_id' => (int) $row->id,
|
||||
'content_target_type' => 'nova_card',
|
||||
'content_target_id' => (int) $row->id,
|
||||
'user_id' => $row->user_id ? (int) $row->user_id : null,
|
||||
'content_snapshot' => (string) ($row->title ?? ''),
|
||||
'is_publicly_exposed' => in_array((string) ($row->visibility ?? ''), ['public', 'unlisted'], true),
|
||||
],
|
||||
ModerationContentType::CardText => [
|
||||
'content_type' => $type->value,
|
||||
'content_id' => (int) $row->id,
|
||||
'content_target_type' => 'nova_card',
|
||||
'content_target_id' => (int) $row->id,
|
||||
'user_id' => $row->user_id ? (int) $row->user_id : null,
|
||||
'content_snapshot' => trim(implode("\n", array_filter([
|
||||
(string) ($row->quote_text ?? ''),
|
||||
(string) ($row->description ?? ''),
|
||||
(string) ($row->quote_author ?? ''),
|
||||
(string) ($row->quote_source ?? ''),
|
||||
]))),
|
||||
'is_publicly_exposed' => in_array((string) ($row->visibility ?? ''), ['public', 'unlisted'], true),
|
||||
],
|
||||
default => throw new \InvalidArgumentException('Unsupported moderation content type: ' . $type->value),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{context:array<string, mixed>|null, type:ModerationContentType|null}
|
||||
*/
|
||||
public function contextForFinding(ContentModerationFinding $finding): array
|
||||
{
|
||||
return match ($finding->content_type) {
|
||||
ModerationContentType::ArtworkComment => $this->commentContextForFinding($finding),
|
||||
ModerationContentType::ArtworkDescription => $this->descriptionContextForFinding($finding),
|
||||
ModerationContentType::ArtworkTitle => $this->artworkTitleContextForFinding($finding),
|
||||
ModerationContentType::UserBio => $this->userBioContextForFinding($finding),
|
||||
ModerationContentType::UserProfileLink => $this->userProfileLinkContextForFinding($finding),
|
||||
ModerationContentType::CollectionTitle => $this->collectionTitleContextForFinding($finding),
|
||||
ModerationContentType::CollectionDescription => $this->collectionDescriptionContextForFinding($finding),
|
||||
ModerationContentType::StoryTitle => $this->storyTitleContextForFinding($finding),
|
||||
ModerationContentType::StoryContent => $this->storyContentContextForFinding($finding),
|
||||
ModerationContentType::CardTitle => $this->cardTitleContextForFinding($finding),
|
||||
ModerationContentType::CardText => $this->cardTextContextForFinding($finding),
|
||||
default => ['context' => null, 'type' => null],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{context:array<string, mixed>|null, type:ModerationContentType|null}
|
||||
*/
|
||||
private function commentContextForFinding(ContentModerationFinding $finding): array
|
||||
{
|
||||
$comment = ArtworkComment::query()->find($finding->content_id);
|
||||
if (! $comment) {
|
||||
return ['context' => null, 'type' => null];
|
||||
}
|
||||
|
||||
return [
|
||||
'context' => $this->buildContext(ModerationContentType::ArtworkComment, $comment),
|
||||
'type' => ModerationContentType::ArtworkComment,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{context:array<string, mixed>|null, type:ModerationContentType|null}
|
||||
*/
|
||||
private function descriptionContextForFinding(ContentModerationFinding $finding): array
|
||||
{
|
||||
$artworkId = (int) ($finding->artwork_id ?? $finding->content_id);
|
||||
$artwork = Artwork::query()->find($artworkId);
|
||||
if (! $artwork) {
|
||||
return ['context' => null, 'type' => null];
|
||||
}
|
||||
|
||||
return [
|
||||
'context' => $this->buildContext(ModerationContentType::ArtworkDescription, $artwork),
|
||||
'type' => ModerationContentType::ArtworkDescription,
|
||||
];
|
||||
}
|
||||
|
||||
private function artworkTitleContextForFinding(ContentModerationFinding $finding): array
|
||||
{
|
||||
$artwork = Artwork::query()->find((int) ($finding->artwork_id ?? $finding->content_id));
|
||||
if (! $artwork) {
|
||||
return ['context' => null, 'type' => null];
|
||||
}
|
||||
|
||||
return ['context' => $this->buildContext(ModerationContentType::ArtworkTitle, $artwork), 'type' => ModerationContentType::ArtworkTitle];
|
||||
}
|
||||
|
||||
private function userBioContextForFinding(ContentModerationFinding $finding): array
|
||||
{
|
||||
$profile = UserProfile::query()->find($finding->content_id);
|
||||
if (! $profile) {
|
||||
return ['context' => null, 'type' => null];
|
||||
}
|
||||
|
||||
return ['context' => $this->buildContext(ModerationContentType::UserBio, $profile), 'type' => ModerationContentType::UserBio];
|
||||
}
|
||||
|
||||
private function userProfileLinkContextForFinding(ContentModerationFinding $finding): array
|
||||
{
|
||||
$link = UserSocialLink::query()->find($finding->content_id);
|
||||
if (! $link) {
|
||||
return ['context' => null, 'type' => null];
|
||||
}
|
||||
|
||||
return ['context' => $this->buildContext(ModerationContentType::UserProfileLink, $link), 'type' => ModerationContentType::UserProfileLink];
|
||||
}
|
||||
|
||||
private function collectionTitleContextForFinding(ContentModerationFinding $finding): array
|
||||
{
|
||||
$collection = Collection::query()->find($finding->content_id);
|
||||
if (! $collection) {
|
||||
return ['context' => null, 'type' => null];
|
||||
}
|
||||
|
||||
return ['context' => $this->buildContext(ModerationContentType::CollectionTitle, $collection), 'type' => ModerationContentType::CollectionTitle];
|
||||
}
|
||||
|
||||
private function collectionDescriptionContextForFinding(ContentModerationFinding $finding): array
|
||||
{
|
||||
$collection = Collection::query()->find($finding->content_id);
|
||||
if (! $collection) {
|
||||
return ['context' => null, 'type' => null];
|
||||
}
|
||||
|
||||
return ['context' => $this->buildContext(ModerationContentType::CollectionDescription, $collection), 'type' => ModerationContentType::CollectionDescription];
|
||||
}
|
||||
|
||||
private function storyTitleContextForFinding(ContentModerationFinding $finding): array
|
||||
{
|
||||
$story = Story::query()->find($finding->content_id);
|
||||
if (! $story) {
|
||||
return ['context' => null, 'type' => null];
|
||||
}
|
||||
|
||||
return ['context' => $this->buildContext(ModerationContentType::StoryTitle, $story), 'type' => ModerationContentType::StoryTitle];
|
||||
}
|
||||
|
||||
private function storyContentContextForFinding(ContentModerationFinding $finding): array
|
||||
{
|
||||
$story = Story::query()->find($finding->content_id);
|
||||
if (! $story) {
|
||||
return ['context' => null, 'type' => null];
|
||||
}
|
||||
|
||||
return ['context' => $this->buildContext(ModerationContentType::StoryContent, $story), 'type' => ModerationContentType::StoryContent];
|
||||
}
|
||||
|
||||
private function cardTitleContextForFinding(ContentModerationFinding $finding): array
|
||||
{
|
||||
$card = NovaCard::query()->find($finding->content_id);
|
||||
if (! $card) {
|
||||
return ['context' => null, 'type' => null];
|
||||
}
|
||||
|
||||
return ['context' => $this->buildContext(ModerationContentType::CardTitle, $card), 'type' => ModerationContentType::CardTitle];
|
||||
}
|
||||
|
||||
private function cardTextContextForFinding(ContentModerationFinding $finding): array
|
||||
{
|
||||
$card = NovaCard::query()->find($finding->content_id);
|
||||
if (! $card) {
|
||||
return ['context' => null, 'type' => null];
|
||||
}
|
||||
|
||||
return ['context' => $this->buildContext(ModerationContentType::CardText, $card), 'type' => ModerationContentType::CardText];
|
||||
}
|
||||
}
|
||||
52
app/Services/Moderation/DomainIntelligenceService.php
Normal file
52
app/Services/Moderation/DomainIntelligenceService.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Models\ContentModerationDomain;
|
||||
use App\Models\ContentModerationFinding;
|
||||
|
||||
class DomainIntelligenceService
|
||||
{
|
||||
public function refreshDomain(string $domain): ?ContentModerationDomain
|
||||
{
|
||||
$record = ContentModerationDomain::query()->where('domain', $domain)->first();
|
||||
if (! $record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$findings = ContentModerationFinding::query()
|
||||
->whereJsonContains('matched_domains_json', $domain)
|
||||
->get(['id', 'user_id', 'campaign_key', 'matched_keywords_json', 'content_type', 'is_false_positive']);
|
||||
|
||||
$topKeywords = $findings
|
||||
->flatMap(static fn (ContentModerationFinding $finding): array => (array) $finding->matched_keywords_json)
|
||||
->filter()
|
||||
->countBy()
|
||||
->sortDesc()
|
||||
->take(8)
|
||||
->keys()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$topContentTypes = $findings
|
||||
->pluck('content_type')
|
||||
->filter()
|
||||
->countBy()
|
||||
->sortDesc()
|
||||
->take(8)
|
||||
->map(static fn (int $count, string $type): array => ['type' => $type, 'count' => $count])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$record->forceFill([
|
||||
'linked_users_count' => $findings->pluck('user_id')->filter()->unique()->count(),
|
||||
'linked_findings_count' => $findings->count(),
|
||||
'linked_clusters_count' => $findings->pluck('campaign_key')->filter()->unique()->count(),
|
||||
'top_keywords_json' => $topKeywords,
|
||||
'top_content_types_json' => $topContentTypes,
|
||||
'false_positive_count' => $findings->where('is_false_positive', true)->count(),
|
||||
])->save();
|
||||
|
||||
return $record->fresh();
|
||||
}
|
||||
}
|
||||
200
app/Services/Moderation/DomainReputationService.php
Normal file
200
app/Services/Moderation/DomainReputationService.php
Normal file
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Enums\ModerationActionType;
|
||||
use App\Enums\ModerationDomainStatus;
|
||||
use App\Models\ContentModerationActionLog;
|
||||
use App\Models\ContentModerationDomain;
|
||||
use App\Models\ContentModerationFinding;
|
||||
use App\Models\User;
|
||||
|
||||
class DomainReputationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DomainIntelligenceService $intelligence,
|
||||
) {
|
||||
}
|
||||
|
||||
public function normalizeDomain(?string $domain): ?string
|
||||
{
|
||||
if (! is_string($domain)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = trim(mb_strtolower($domain));
|
||||
$normalized = preg_replace('/^www\./', '', $normalized);
|
||||
|
||||
return $normalized !== '' ? $normalized : null;
|
||||
}
|
||||
|
||||
public function statusForDomain(string $domain): ModerationDomainStatus
|
||||
{
|
||||
$normalized = $this->normalizeDomain($domain);
|
||||
if ($normalized === null) {
|
||||
return ModerationDomainStatus::Neutral;
|
||||
}
|
||||
|
||||
$record = ContentModerationDomain::query()->where('domain', $normalized)->first();
|
||||
if ($record !== null) {
|
||||
return $record->status;
|
||||
}
|
||||
|
||||
if ($this->matchesAnyPattern($normalized, (array) \app('config')->get('content_moderation.allowed_domains', []))) {
|
||||
return ModerationDomainStatus::Allowed;
|
||||
}
|
||||
|
||||
if ($this->matchesAnyPattern($normalized, (array) \app('config')->get('content_moderation.blacklisted_domains', []))) {
|
||||
return ModerationDomainStatus::Blocked;
|
||||
}
|
||||
|
||||
if ($this->matchesAnyPattern($normalized, (array) \app('config')->get('content_moderation.suspicious_domains', []))) {
|
||||
return ModerationDomainStatus::Suspicious;
|
||||
}
|
||||
|
||||
return ModerationDomainStatus::Neutral;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $domains
|
||||
* @return array<int, ContentModerationDomain>
|
||||
*/
|
||||
public function trackDomains(array $domains, bool $flagged = false, bool $confirmedSpam = false): array
|
||||
{
|
||||
$normalized = \collect($domains)
|
||||
->map(fn (?string $domain): ?string => $this->normalizeDomain($domain))
|
||||
->filter()
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($normalized->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$existing = ContentModerationDomain::query()
|
||||
->whereIn('domain', $normalized->all())
|
||||
->get()
|
||||
->keyBy('domain');
|
||||
|
||||
$records = [];
|
||||
$now = \now();
|
||||
|
||||
foreach ($normalized as $domain) {
|
||||
$defaultStatus = $this->statusForDomain($domain);
|
||||
$record = $existing[$domain] ?? new ContentModerationDomain([
|
||||
'domain' => $domain,
|
||||
'status' => $defaultStatus,
|
||||
'first_seen_at' => $now,
|
||||
]);
|
||||
|
||||
$record->forceFill([
|
||||
'status' => $record->status ?? $defaultStatus,
|
||||
'times_seen' => ((int) $record->times_seen) + 1,
|
||||
'times_flagged' => ((int) $record->times_flagged) + ($flagged ? 1 : 0),
|
||||
'times_confirmed_spam' => ((int) $record->times_confirmed_spam) + ($confirmedSpam ? 1 : 0),
|
||||
'first_seen_at' => $record->first_seen_at ?? $now,
|
||||
'last_seen_at' => $now,
|
||||
])->save();
|
||||
|
||||
$records[] = $record->fresh();
|
||||
}
|
||||
|
||||
return $records;
|
||||
}
|
||||
|
||||
public function updateStatus(string $domain, ModerationDomainStatus $status, ?User $actor = null, ?string $notes = null): ContentModerationDomain
|
||||
{
|
||||
$normalized = $this->normalizeDomain($domain);
|
||||
\abort_unless($normalized !== null, 422, 'Invalid domain.');
|
||||
|
||||
$record = ContentModerationDomain::query()->firstOrNew(['domain' => $normalized]);
|
||||
$previous = $record->status?->value;
|
||||
|
||||
$record->forceFill([
|
||||
'status' => $status,
|
||||
'first_seen_at' => $record->first_seen_at ?? \now(),
|
||||
'last_seen_at' => \now(),
|
||||
'notes' => $notes !== null && trim($notes) !== '' ? trim($notes) : $record->notes,
|
||||
])->save();
|
||||
|
||||
ContentModerationActionLog::query()->create([
|
||||
'target_type' => 'domain',
|
||||
'target_id' => $record->id,
|
||||
'action_type' => match ($status) {
|
||||
ModerationDomainStatus::Blocked => ModerationActionType::BlockDomain->value,
|
||||
ModerationDomainStatus::Suspicious => ModerationActionType::MarkDomainSuspicious->value,
|
||||
ModerationDomainStatus::Escalated => ModerationActionType::Escalate->value,
|
||||
ModerationDomainStatus::ReviewRequired => ModerationActionType::MarkDomainSuspicious->value,
|
||||
ModerationDomainStatus::Allowed, ModerationDomainStatus::Neutral => ModerationActionType::AllowDomain->value,
|
||||
},
|
||||
'actor_type' => $actor ? 'admin' : 'system',
|
||||
'actor_id' => $actor?->id,
|
||||
'notes' => $notes,
|
||||
'old_status' => $previous,
|
||||
'new_status' => $status->value,
|
||||
'meta_json' => ['domain' => $normalized],
|
||||
'created_at' => \now(),
|
||||
]);
|
||||
|
||||
$this->intelligence->refreshDomain($normalized);
|
||||
|
||||
return $record->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function shortenerDomains(): array
|
||||
{
|
||||
return \collect((array) \app('config')->get('content_moderation.shortener_domains', []))
|
||||
->map(fn (string $domain): ?string => $this->normalizeDomain($domain))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function attachDomainIds(ContentModerationFinding $finding): void
|
||||
{
|
||||
$domains = \collect((array) $finding->matched_domains_json)
|
||||
->map(fn (?string $domain): ?string => $this->normalizeDomain($domain))
|
||||
->filter()
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($domains->isEmpty()) {
|
||||
$finding->forceFill(['domain_ids_json' => []])->save();
|
||||
return;
|
||||
}
|
||||
|
||||
$ids = ContentModerationDomain::query()
|
||||
->whereIn('domain', $domains->all())
|
||||
->pluck('id')
|
||||
->map(static fn (int $id): int => $id)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$finding->forceFill(['domain_ids_json' => $ids])->save();
|
||||
|
||||
foreach ($domains as $domain) {
|
||||
$this->intelligence->refreshDomain((string) $domain);
|
||||
}
|
||||
}
|
||||
|
||||
private function matchesAnyPattern(string $domain, array $patterns): bool
|
||||
{
|
||||
foreach ($patterns as $pattern) {
|
||||
$pattern = trim(mb_strtolower((string) $pattern));
|
||||
if ($pattern === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$quoted = preg_quote($pattern, '/');
|
||||
$regex = '/^' . str_replace('\\*', '.*', $quoted) . '$/i';
|
||||
if (preg_match($regex, $domain) === 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
106
app/Services/Moderation/DuplicateDetectionService.php
Normal file
106
app/Services/Moderation/DuplicateDetectionService.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Enums\ModerationContentType;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
|
||||
class DuplicateDetectionService
|
||||
{
|
||||
public function campaignText(string $content): string
|
||||
{
|
||||
$text = mb_strtolower($content);
|
||||
$text = preg_replace('/https?:\/\/\S+/iu', ' [link] ', $text);
|
||||
$text = preg_replace('/www\.\S+/iu', ' [link] ', (string) $text);
|
||||
$text = preg_replace('/[^\p{L}\p{N}\s\[\]]+/u', ' ', (string) $text);
|
||||
$text = preg_replace('/\s+/u', ' ', trim((string) $text));
|
||||
|
||||
return (string) $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $domains
|
||||
*/
|
||||
public function buildGroupKey(string $content, array $domains = []): string
|
||||
{
|
||||
$template = $this->campaignText($content);
|
||||
$tokens = preg_split('/\s+/u', $template, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
$signature = implode(' ', array_slice($tokens, 0, 12));
|
||||
$domainPart = implode('|', array_slice(array_values(array_unique($domains)), 0, 2));
|
||||
|
||||
return hash('sha256', $domainPart . '::' . $signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<int, string> $domains
|
||||
*/
|
||||
public function nearDuplicateCount(string $content, array $context = [], array $domains = []): int
|
||||
{
|
||||
$type = (string) ($context['content_type'] ?? '');
|
||||
$contentId = (int) ($context['content_id'] ?? 0);
|
||||
$artworkId = (int) ($context['artwork_id'] ?? 0);
|
||||
$signature = $this->campaignText($content);
|
||||
if ($signature === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$candidates = match ($type) {
|
||||
ModerationContentType::ArtworkComment->value => ArtworkComment::query()
|
||||
->where('id', '!=', $contentId)
|
||||
->whereNull('deleted_at')
|
||||
->latest('id')
|
||||
->limit(80)
|
||||
->get(['id', 'artwork_id', 'raw_content', 'content']),
|
||||
ModerationContentType::ArtworkDescription->value => Artwork::query()
|
||||
->where('id', '!=', $contentId)
|
||||
->whereNotNull('description')
|
||||
->latest('id')
|
||||
->limit(80)
|
||||
->get(['id', 'description']),
|
||||
default => \collect(),
|
||||
};
|
||||
|
||||
$matches = 0;
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$candidateText = match ($type) {
|
||||
ModerationContentType::ArtworkComment->value => (string) ($candidate->raw_content ?: $candidate->content),
|
||||
ModerationContentType::ArtworkDescription->value => (string) ($candidate->description ?? ''),
|
||||
default => '',
|
||||
};
|
||||
|
||||
if ($candidateText === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidateSignature = $this->campaignText($candidateText);
|
||||
similar_text($signature, $candidateSignature, $similarity);
|
||||
|
||||
$sameArtworkPenalty = $artworkId > 0 && (int) ($candidate->artwork_id ?? $candidate->id ?? 0) === $artworkId ? 4 : 0;
|
||||
|
||||
if ($similarity >= (float) \app('config')->get('content_moderation.duplicate_detection.near_duplicate_similarity', 84) - $sameArtworkPenalty) {
|
||||
$matches++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($domains !== []) {
|
||||
$topDomain = $domains[0] ?? null;
|
||||
if ($topDomain !== null && str_contains(mb_strtolower($candidateText), mb_strtolower($topDomain))) {
|
||||
similar_text($this->stripLinks($signature), $this->stripLinks($candidateSignature), $linklessSimilarity);
|
||||
if ($linklessSimilarity >= 72) {
|
||||
$matches++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $matches;
|
||||
}
|
||||
|
||||
private function stripLinks(string $text): string
|
||||
{
|
||||
return trim(str_replace('[link]', '', $text));
|
||||
}
|
||||
}
|
||||
89
app/Services/Moderation/ModerationClusterService.php
Normal file
89
app/Services/Moderation/ModerationClusterService.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Data\Moderation\ModerationResultData;
|
||||
use App\Models\ContentModerationCluster;
|
||||
use App\Models\ContentModerationFinding;
|
||||
|
||||
class ModerationClusterService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, mixed> $suggestion
|
||||
* @return array{campaign_key:string,cluster_score:int,cluster_reason:string}
|
||||
*/
|
||||
public function classify(string $content, ModerationResultData $result, array $context = [], array $suggestion = []): array
|
||||
{
|
||||
$domains = array_values(array_filter($result->matchedDomains));
|
||||
$keywords = array_values(array_filter($result->matchedKeywords));
|
||||
$reason = 'normalized_content';
|
||||
|
||||
if ($domains !== [] && $keywords !== []) {
|
||||
$reason = 'domain_keyword_cta';
|
||||
$key = 'campaign:' . sha1(implode('|', [implode(',', array_slice($domains, 0, 3)), implode(',', array_slice($keywords, 0, 3))]));
|
||||
} elseif ($domains !== []) {
|
||||
$reason = 'domain_fingerprint';
|
||||
$key = 'campaign:' . sha1(implode(',', array_slice($domains, 0, 3)) . '|' . ($result->contentHashNormalized ?? $result->contentHash));
|
||||
} elseif (! empty($suggestion['campaign_tags'])) {
|
||||
$reason = 'suggested_cluster';
|
||||
$key = 'campaign:' . sha1(implode('|', (array) $suggestion['campaign_tags']));
|
||||
} else {
|
||||
$key = 'campaign:' . sha1((string) ($result->groupKey ?? $result->contentHashNormalized ?? $result->contentHash));
|
||||
}
|
||||
|
||||
$clusterScore = min(100, $result->score + (count($domains) * 8) + (count($keywords) * 4));
|
||||
|
||||
return [
|
||||
'campaign_key' => $key,
|
||||
'cluster_score' => $clusterScore,
|
||||
'cluster_reason' => $reason,
|
||||
];
|
||||
}
|
||||
|
||||
public function syncFinding(ContentModerationFinding $finding): void
|
||||
{
|
||||
if (! $finding->campaign_key) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query = ContentModerationFinding::query()->where('campaign_key', $finding->campaign_key);
|
||||
$findings = $query->get(['id', 'user_id', 'matched_domains_json', 'matched_keywords_json', 'review_bucket', 'cluster_score', 'created_at']);
|
||||
|
||||
$domains = $findings
|
||||
->flatMap(static fn (ContentModerationFinding $item): array => (array) $item->matched_domains_json)
|
||||
->filter()
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$keywords = $findings
|
||||
->flatMap(static fn (ContentModerationFinding $item): array => (array) $item->matched_keywords_json)
|
||||
->filter()
|
||||
->unique()
|
||||
->take(8)
|
||||
->values();
|
||||
|
||||
ContentModerationCluster::query()->updateOrCreate(
|
||||
['campaign_key' => $finding->campaign_key],
|
||||
[
|
||||
'cluster_reason' => $finding->cluster_reason,
|
||||
'review_bucket' => $finding->review_bucket,
|
||||
'escalation_status' => $finding->escalation_status?->value ?? (string) $finding->escalation_status,
|
||||
'cluster_score' => (int) ($findings->max('cluster_score') ?? $finding->cluster_score ?? 0),
|
||||
'findings_count' => $findings->count(),
|
||||
'unique_users_count' => $findings->pluck('user_id')->filter()->unique()->count(),
|
||||
'unique_domains_count' => $domains->count(),
|
||||
'latest_finding_at' => $findings->max('created_at') ?: now(),
|
||||
'summary_json' => [
|
||||
'domains' => $domains->take(8)->all(),
|
||||
'keywords' => $keywords->all(),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$clusterSize = $findings->count();
|
||||
if ($clusterSize > 1) {
|
||||
$query->update(['priority_score' => $finding->priority_score + min(25, ($clusterSize - 1) * 3)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
app/Services/Moderation/ModerationFeedbackService.php
Normal file
25
app/Services/Moderation/ModerationFeedbackService.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Models\ContentModerationFeedback;
|
||||
use App\Models\ContentModerationFinding;
|
||||
use App\Models\User;
|
||||
|
||||
class ModerationFeedbackService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
public function record(ContentModerationFinding $finding, string $feedbackType, ?User $actor = null, ?string $notes = null, array $meta = []): ContentModerationFeedback
|
||||
{
|
||||
return ContentModerationFeedback::query()->create([
|
||||
'finding_id' => $finding->id,
|
||||
'feedback_type' => $feedbackType,
|
||||
'actor_id' => $actor?->id,
|
||||
'notes' => $notes,
|
||||
'meta_json' => $meta,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
51
app/Services/Moderation/ModerationPolicyEngineService.php
Normal file
51
app/Services/Moderation/ModerationPolicyEngineService.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Enums\ModerationContentType;
|
||||
|
||||
class ModerationPolicyEngineService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, mixed> $riskAssessment
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function resolve(array $context, array $riskAssessment = []): array
|
||||
{
|
||||
$policies = (array) app('config')->get('content_moderation.policies', []);
|
||||
$contentType = ModerationContentType::tryFrom((string) ($context['content_type'] ?? ''));
|
||||
$accountAgeDays = (int) data_get($riskAssessment, 'signals.account_age_days', 0);
|
||||
$riskScore = (int) ($riskAssessment['risk_score'] ?? 0);
|
||||
$hasLinks = ! empty($context['extracted_urls'] ?? []) || ! empty($context['extracted_domains'] ?? []);
|
||||
|
||||
$name = 'default';
|
||||
|
||||
if ($riskScore >= 70 || ($accountAgeDays > 0 && $accountAgeDays < 14)) {
|
||||
$name = 'new_user_strict_mode';
|
||||
} elseif ($riskScore <= 8 && $accountAgeDays >= 180) {
|
||||
$name = 'trusted_user_relaxed_mode';
|
||||
}
|
||||
|
||||
if ($contentType === ModerationContentType::ArtworkComment && $riskScore >= 45) {
|
||||
$name = 'comments_high_volume_antispam';
|
||||
}
|
||||
|
||||
if ($hasLinks && in_array($contentType, [
|
||||
ModerationContentType::UserProfileLink,
|
||||
ModerationContentType::CollectionDescription,
|
||||
ModerationContentType::CollectionTitle,
|
||||
ModerationContentType::StoryContent,
|
||||
ModerationContentType::StoryTitle,
|
||||
ModerationContentType::CardText,
|
||||
ModerationContentType::CardTitle,
|
||||
], true)) {
|
||||
$name = 'strict_seo_protection';
|
||||
}
|
||||
|
||||
$policy = $policies[$name] ?? ($policies['default'] ?? []);
|
||||
$policy['name'] = $name;
|
||||
|
||||
return $policy;
|
||||
}
|
||||
}
|
||||
47
app/Services/Moderation/ModerationPriorityService.php
Normal file
47
app/Services/Moderation/ModerationPriorityService.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Data\Moderation\ModerationResultData;
|
||||
|
||||
class ModerationPriorityService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, mixed> $policy
|
||||
* @param array<string, mixed> $suggestion
|
||||
*/
|
||||
public function score(ModerationResultData $result, array $context = [], array $policy = [], array $suggestion = []): array
|
||||
{
|
||||
$score = $result->score;
|
||||
$score += $result->isSuspicious() ? 10 : 0;
|
||||
$score += $result->autoHideRecommended ? 25 : 0;
|
||||
$score += max(0, (int) ($result->userRiskScore ?? 0) / 2);
|
||||
$score += (int) ($policy['priority_bonus'] ?? 0);
|
||||
$score += max(0, (int) (($suggestion['confidence'] ?? 0) / 5));
|
||||
$score += ! empty($context['is_publicly_exposed']) ? 12 : 0;
|
||||
$score += ! empty($result->matchedDomains) ? 10 : 0;
|
||||
$score += isset($result->ruleHits['blocked_domain']) ? 18 : 0;
|
||||
$score += isset($result->ruleHits['near_duplicate_campaign']) ? 14 : 0;
|
||||
|
||||
$bucket = match (true) {
|
||||
$score >= 140 => 'urgent',
|
||||
$score >= 95 => 'high',
|
||||
$score >= 50 => 'priority',
|
||||
default => 'standard',
|
||||
};
|
||||
|
||||
$escalation = match ($bucket) {
|
||||
'urgent' => 'urgent',
|
||||
'high' => 'escalated',
|
||||
'priority' => 'review_required',
|
||||
default => 'none',
|
||||
};
|
||||
|
||||
return [
|
||||
'priority_score' => $score,
|
||||
'review_bucket' => $bucket,
|
||||
'escalation_status' => $escalation,
|
||||
];
|
||||
}
|
||||
}
|
||||
81
app/Services/Moderation/ModerationRuleRegistryService.php
Normal file
81
app/Services/Moderation/ModerationRuleRegistryService.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Enums\ModerationRuleType;
|
||||
use App\Models\ContentModerationRule;
|
||||
|
||||
class ModerationRuleRegistryService
|
||||
{
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function suspiciousKeywords(): array
|
||||
{
|
||||
return $this->mergeValues(
|
||||
(array) \app('config')->get('content_moderation.keywords.suspicious', []),
|
||||
$this->rulesByType(ModerationRuleType::SuspiciousKeyword)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function highRiskKeywords(): array
|
||||
{
|
||||
return $this->mergeValues(
|
||||
(array) \app('config')->get('content_moderation.keywords.high_risk', []),
|
||||
$this->rulesByType(ModerationRuleType::HighRiskKeyword)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{pattern:string,weight:?int,id:int|null}>
|
||||
*/
|
||||
public function regexRules(): array
|
||||
{
|
||||
return ContentModerationRule::query()
|
||||
->where('enabled', true)
|
||||
->where('type', ModerationRuleType::Regex->value)
|
||||
->orderByDesc('id')
|
||||
->get(['id', 'value', 'weight'])
|
||||
->map(static fn (ContentModerationRule $rule): array => [
|
||||
'pattern' => (string) $rule->value,
|
||||
'weight' => $rule->weight,
|
||||
'id' => $rule->id,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function rulesByType(ModerationRuleType $type): array
|
||||
{
|
||||
return ContentModerationRule::query()
|
||||
->where('enabled', true)
|
||||
->where('type', $type->value)
|
||||
->orderByDesc('id')
|
||||
->pluck('value')
|
||||
->map(static fn (string $value): string => trim($value))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $configValues
|
||||
* @param array<int, string> $dbValues
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function mergeValues(array $configValues, array $dbValues): array
|
||||
{
|
||||
return \collect(array_merge($configValues, $dbValues))
|
||||
->map(static fn (string $value): string => trim($value))
|
||||
->filter()
|
||||
->unique(static fn (string $value): string => mb_strtolower($value))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
30
app/Services/Moderation/ModerationSuggestionService.php
Normal file
30
app/Services/Moderation/ModerationSuggestionService.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Contracts\Moderation\ModerationSuggestionProviderInterface;
|
||||
use App\Data\Moderation\ModerationResultData;
|
||||
use App\Data\Moderation\ModerationSuggestionData;
|
||||
use App\Services\Moderation\Providers\HeuristicModerationSuggestionProvider;
|
||||
use App\Services\Moderation\Providers\NullModerationSuggestionProvider;
|
||||
|
||||
class ModerationSuggestionService
|
||||
{
|
||||
public function provider(): ModerationSuggestionProviderInterface
|
||||
{
|
||||
$provider = (string) app('config')->get('content_moderation.suggestions.provider', 'heuristic');
|
||||
|
||||
return match ($provider) {
|
||||
'null' => app(NullModerationSuggestionProvider::class),
|
||||
default => app(HeuristicModerationSuggestionProvider::class),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function suggest(string $content, ModerationResultData $result, array $context = []): ModerationSuggestionData
|
||||
{
|
||||
return $this->provider()->suggest($content, $result, $context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation\Providers;
|
||||
|
||||
use App\Contracts\Moderation\ModerationSuggestionProviderInterface;
|
||||
use App\Data\Moderation\ModerationResultData;
|
||||
use App\Data\Moderation\ModerationSuggestionData;
|
||||
|
||||
class HeuristicModerationSuggestionProvider implements ModerationSuggestionProviderInterface
|
||||
{
|
||||
public function suggest(string $content, ModerationResultData $result, array $context = []): ModerationSuggestionData
|
||||
{
|
||||
$label = null;
|
||||
$action = null;
|
||||
$confidence = null;
|
||||
$reason = null;
|
||||
$campaignTags = [];
|
||||
|
||||
if ($result->score === 0) {
|
||||
return new ModerationSuggestionData(
|
||||
provider: 'heuristic_assist',
|
||||
suggestedLabel: 'likely_safe',
|
||||
suggestedAction: 'mark_safe',
|
||||
confidence: 82,
|
||||
explanation: 'No suspicious signals were detected by the deterministic moderation rules.',
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($result->ruleHits['blocked_domain']) || isset($result->ruleHits['blacklisted_domain'])) {
|
||||
$label = 'seo_spam';
|
||||
$action = $result->autoHideRecommended ? 'auto_hide_review' : 'confirm_spam';
|
||||
$confidence = 94;
|
||||
$reason = 'Blocked-domain activity was detected and strongly correlates with outbound spam campaigns.';
|
||||
$campaignTags[] = 'blocked-domain';
|
||||
} elseif (isset($result->ruleHits['high_risk_keyword'])) {
|
||||
$label = $this->labelFromKeywords($result->matchedKeywords);
|
||||
$action = 'confirm_spam';
|
||||
$confidence = 88;
|
||||
$reason = 'High-risk spam keywords were matched across the content snapshot.';
|
||||
$campaignTags[] = 'high-risk-keywords';
|
||||
} elseif (isset($result->ruleHits['near_duplicate_campaign']) || isset($result->ruleHits['duplicate_comment'])) {
|
||||
$label = 'campaign_spam';
|
||||
$action = 'review_cluster';
|
||||
$confidence = 86;
|
||||
$reason = 'The content appears linked to a repeated spam template or campaign cluster.';
|
||||
$campaignTags[] = 'duplicate-campaign';
|
||||
} else {
|
||||
$label = 'needs_review';
|
||||
$action = 'review';
|
||||
$confidence = max(55, min(84, $result->score));
|
||||
$reason = 'Multiple suspicious signals were detected, but the content should remain human-reviewed.';
|
||||
}
|
||||
|
||||
if ($result->matchedDomains !== []) {
|
||||
$campaignTags[] = 'domains:' . implode(',', array_slice($result->matchedDomains, 0, 3));
|
||||
}
|
||||
|
||||
return new ModerationSuggestionData(
|
||||
provider: 'heuristic_assist',
|
||||
suggestedLabel: $label,
|
||||
suggestedAction: $action,
|
||||
confidence: $confidence,
|
||||
explanation: $reason,
|
||||
campaignTags: array_values(array_unique($campaignTags)),
|
||||
rawResponse: [
|
||||
'rule_hits' => $result->ruleHits,
|
||||
'matched_domains' => $result->matchedDomains,
|
||||
'matched_keywords' => $result->matchedKeywords,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $keywords
|
||||
*/
|
||||
private function labelFromKeywords(array $keywords): string
|
||||
{
|
||||
$joined = mb_strtolower(implode(' ', $keywords));
|
||||
|
||||
return match (true) {
|
||||
str_contains($joined, 'casino'), str_contains($joined, 'bet') => 'casino_spam',
|
||||
str_contains($joined, 'adult'), str_contains($joined, 'webcam') => 'adult_spam',
|
||||
str_contains($joined, 'bitcoin'), str_contains($joined, 'crypto') => 'crypto_spam',
|
||||
str_contains($joined, 'pharma'), str_contains($joined, 'viagra') => 'pharma_spam',
|
||||
default => 'spam',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation\Providers;
|
||||
|
||||
use App\Contracts\Moderation\ModerationSuggestionProviderInterface;
|
||||
use App\Data\Moderation\ModerationResultData;
|
||||
use App\Data\Moderation\ModerationSuggestionData;
|
||||
|
||||
class NullModerationSuggestionProvider implements ModerationSuggestionProviderInterface
|
||||
{
|
||||
public function suggest(string $content, ModerationResultData $result, array $context = []): ModerationSuggestionData
|
||||
{
|
||||
return new ModerationSuggestionData(provider: 'null');
|
||||
}
|
||||
}
|
||||
68
app/Services/Moderation/Rules/DomainBlacklistRule.php
Normal file
68
app/Services/Moderation/Rules/DomainBlacklistRule.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation\Rules;
|
||||
|
||||
use App\Contracts\Moderation\ModerationRuleInterface;
|
||||
use App\Enums\ModerationDomainStatus;
|
||||
use App\Services\Moderation\DomainReputationService;
|
||||
|
||||
class DomainBlacklistRule implements ModerationRuleInterface
|
||||
{
|
||||
public function analyze(string $content, string $normalized, array $context = []): array
|
||||
{
|
||||
$linkRule = app(LinkPresenceRule::class);
|
||||
$urls = (array) ($context['extracted_urls'] ?? $linkRule->extractUrls($content));
|
||||
|
||||
if (empty($urls)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$weights = app('config')->get('content_moderation.weights', []);
|
||||
$domainService = app(DomainReputationService::class);
|
||||
|
||||
$findings = [];
|
||||
$blockedMatches = [];
|
||||
$suspiciousMatches = [];
|
||||
|
||||
foreach ($urls as $url) {
|
||||
$host = $linkRule->extractHost($url);
|
||||
if ($host === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = $domainService->statusForDomain($host);
|
||||
if ($status === ModerationDomainStatus::Blocked) {
|
||||
$blockedMatches[] = $host;
|
||||
} elseif ($status === ModerationDomainStatus::Suspicious) {
|
||||
$suspiciousMatches[] = $host;
|
||||
}
|
||||
}
|
||||
|
||||
$blockedMatches = array_values(array_unique($blockedMatches));
|
||||
$suspiciousMatches = array_values(array_unique($suspiciousMatches));
|
||||
|
||||
if (!empty($blockedMatches)) {
|
||||
$findings[] = [
|
||||
'rule' => 'blocked_domain',
|
||||
'score' => ($weights['blacklisted_domain'] ?? 70) * count($blockedMatches),
|
||||
'reason' => 'Contains blocked domain(s): ' . implode(', ', $blockedMatches),
|
||||
'links' => $urls,
|
||||
'domains' => $blockedMatches,
|
||||
'keywords' => [],
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($suspiciousMatches)) {
|
||||
$findings[] = [
|
||||
'rule' => 'suspicious_domain',
|
||||
'score' => ($weights['suspicious_domain'] ?? 40) * count($suspiciousMatches),
|
||||
'reason' => 'Contains suspicious TLD domain(s): ' . implode(', ', $suspiciousMatches),
|
||||
'links' => $urls,
|
||||
'domains' => $suspiciousMatches,
|
||||
'keywords' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
}
|
||||
41
app/Services/Moderation/Rules/DuplicateCommentRule.php
Normal file
41
app/Services/Moderation/Rules/DuplicateCommentRule.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation\Rules;
|
||||
|
||||
use App\Contracts\Moderation\ModerationRuleInterface;
|
||||
use App\Enums\ModerationContentType;
|
||||
use App\Models\ArtworkComment;
|
||||
|
||||
class DuplicateCommentRule implements ModerationRuleInterface
|
||||
{
|
||||
public function analyze(string $content, string $normalized, array $context = []): array
|
||||
{
|
||||
if (($context['content_type'] ?? null) !== ModerationContentType::ArtworkComment->value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$contentId = (int) ($context['content_id'] ?? 0);
|
||||
if ($contentId <= 0 || $normalized === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$duplicates = ArtworkComment::query()
|
||||
->where('id', '!=', $contentId)
|
||||
->whereNull('deleted_at')
|
||||
->whereRaw('LOWER(TRIM(COALESCE(raw_content, content))) = ?', [$normalized])
|
||||
->count();
|
||||
|
||||
if ($duplicates < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [[
|
||||
'rule' => 'duplicate_comment',
|
||||
'score' => app('config')->get('content_moderation.weights.duplicate_comment', 35),
|
||||
'reason' => 'Matches ' . $duplicates . ' existing comment(s) exactly',
|
||||
'links' => [],
|
||||
'domains' => [],
|
||||
'keywords' => [],
|
||||
]];
|
||||
}
|
||||
}
|
||||
54
app/Services/Moderation/Rules/ExcessivePunctuationRule.php
Normal file
54
app/Services/Moderation/Rules/ExcessivePunctuationRule.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation\Rules;
|
||||
|
||||
use App\Contracts\Moderation\ModerationRuleInterface;
|
||||
|
||||
class ExcessivePunctuationRule implements ModerationRuleInterface
|
||||
{
|
||||
public function analyze(string $content, string $normalized, array $context = []): array
|
||||
{
|
||||
$config = app('config')->get('content_moderation.excessive_punctuation', []);
|
||||
$length = mb_strlen($content);
|
||||
|
||||
if ($length < (int) ($config['min_length'] ?? 20)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$exclamationRatio = substr_count($content, '!') / max($length, 1);
|
||||
$questionRatio = substr_count($content, '?') / max($length, 1);
|
||||
$capsRatio = $this->capsRatio($content);
|
||||
$symbolBurst = preg_match('/[!?$%*@#._\-]{6,}/', $content) === 1;
|
||||
|
||||
if (
|
||||
$exclamationRatio <= (float) ($config['max_exclamation_ratio'] ?? 0.1)
|
||||
&& $questionRatio <= (float) ($config['max_question_ratio'] ?? 0.1)
|
||||
&& $capsRatio <= (float) ($config['max_caps_ratio'] ?? 0.7)
|
||||
&& ! $symbolBurst
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [[
|
||||
'rule' => 'excessive_punctuation',
|
||||
'score' => app('config')->get('content_moderation.weights.excessive_punctuation', 15),
|
||||
'reason' => 'Contains excessive punctuation, all-caps patterns, or symbol spam',
|
||||
'links' => [],
|
||||
'domains' => [],
|
||||
'keywords' => [],
|
||||
]];
|
||||
}
|
||||
|
||||
private function capsRatio(string $content): float
|
||||
{
|
||||
preg_match_all('/\p{Lu}/u', $content, $upperMatches);
|
||||
preg_match_all('/\p{L}/u', $content, $letterMatches);
|
||||
|
||||
$letters = count($letterMatches[0] ?? []);
|
||||
if ($letters === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return count($upperMatches[0] ?? []) / $letters;
|
||||
}
|
||||
}
|
||||
49
app/Services/Moderation/Rules/KeywordStuffingRule.php
Normal file
49
app/Services/Moderation/Rules/KeywordStuffingRule.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation\Rules;
|
||||
|
||||
use App\Contracts\Moderation\ModerationRuleInterface;
|
||||
|
||||
class KeywordStuffingRule implements ModerationRuleInterface
|
||||
{
|
||||
public function analyze(string $content, string $normalized, array $context = []): array
|
||||
{
|
||||
preg_match_all('/[\p{L}\p{N}]+/u', $normalized, $matches);
|
||||
|
||||
$words = array_values(array_filter($matches[0] ?? [], static fn (string $word): bool => mb_strlen($word) > 1));
|
||||
$totalWords = count($words);
|
||||
$config = app('config')->get('content_moderation.keyword_stuffing', []);
|
||||
|
||||
if ($totalWords < (int) ($config['min_word_count'] ?? 20)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$frequencies = array_count_values($words);
|
||||
$uniqueRatio = count($frequencies) / max($totalWords, 1);
|
||||
$topFrequency = max($frequencies);
|
||||
$topWordRatio = $topFrequency / max($totalWords, 1);
|
||||
|
||||
$maxUniqueRatio = (float) ($config['max_unique_ratio'] ?? 0.3);
|
||||
$maxSingleWordFrequency = (float) ($config['max_single_word_frequency'] ?? 0.25);
|
||||
|
||||
if ($uniqueRatio >= $maxUniqueRatio && $topWordRatio <= $maxSingleWordFrequency) {
|
||||
return [];
|
||||
}
|
||||
|
||||
arsort($frequencies);
|
||||
$keywords = array_slice(array_keys($frequencies), 0, 5);
|
||||
|
||||
return [[
|
||||
'rule' => 'keyword_stuffing',
|
||||
'score' => app('config')->get('content_moderation.weights.keyword_stuffing', 20),
|
||||
'reason' => sprintf(
|
||||
'Likely keyword stuffing (unique ratio %.2f, top word ratio %.2f)',
|
||||
$uniqueRatio,
|
||||
$topWordRatio
|
||||
),
|
||||
'links' => [],
|
||||
'domains' => [],
|
||||
'keywords' => $keywords,
|
||||
]];
|
||||
}
|
||||
}
|
||||
118
app/Services/Moderation/Rules/LinkPresenceRule.php
Normal file
118
app/Services/Moderation/Rules/LinkPresenceRule.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation\Rules;
|
||||
|
||||
use App\Contracts\Moderation\ModerationRuleInterface;
|
||||
use App\Enums\ModerationDomainStatus;
|
||||
use App\Services\Moderation\DomainReputationService;
|
||||
|
||||
class LinkPresenceRule implements ModerationRuleInterface
|
||||
{
|
||||
public function analyze(string $content, string $normalized, array $context = []): array
|
||||
{
|
||||
$urls = (array) ($context['extracted_urls'] ?? $this->extractUrls($content));
|
||||
|
||||
if (empty($urls)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$domainService = app(DomainReputationService::class);
|
||||
$shortenerDomains = $domainService->shortenerDomains();
|
||||
|
||||
$externalUrls = [];
|
||||
$shortenerUrls = [];
|
||||
|
||||
foreach ($urls as $url) {
|
||||
$host = $this->extractHost($url);
|
||||
if ($host === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($domainService->statusForDomain($host) === ModerationDomainStatus::Allowed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->isDomainInList($host, $shortenerDomains)) {
|
||||
$shortenerUrls[] = $url;
|
||||
}
|
||||
|
||||
$externalUrls[] = $url;
|
||||
}
|
||||
|
||||
$findings = [];
|
||||
$weights = app('config')->get('content_moderation.weights', []);
|
||||
|
||||
if (count($shortenerUrls) > 0) {
|
||||
$findings[] = [
|
||||
'rule' => 'shortened_link',
|
||||
'score' => $weights['shortened_link'] ?? 30,
|
||||
'reason' => 'Contains ' . count($shortenerUrls) . ' shortened URL(s)',
|
||||
'links' => $shortenerUrls,
|
||||
'domains' => array_map(fn ($u) => $this->extractHost($u), $shortenerUrls),
|
||||
'keywords' => [],
|
||||
];
|
||||
}
|
||||
|
||||
if (count($externalUrls) > 1) {
|
||||
$findings[] = [
|
||||
'rule' => 'multiple_links',
|
||||
'score' => $weights['multiple_links'] ?? 40,
|
||||
'reason' => 'Contains ' . count($externalUrls) . ' external links',
|
||||
'links' => $externalUrls,
|
||||
'domains' => array_values(array_unique(array_filter(array_map(fn ($u) => $this->extractHost($u), $externalUrls)))),
|
||||
'keywords' => [],
|
||||
];
|
||||
} elseif (count($externalUrls) === 1) {
|
||||
$findings[] = [
|
||||
'rule' => 'single_external_link',
|
||||
'score' => $weights['single_external_link'] ?? 20,
|
||||
'reason' => 'Contains an external link',
|
||||
'links' => $externalUrls,
|
||||
'domains' => array_values(array_unique(array_filter(array_map(fn ($u) => $this->extractHost($u), $externalUrls)))),
|
||||
'keywords' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public function extractUrls(string $text): array
|
||||
{
|
||||
$matches = [];
|
||||
|
||||
preg_match_all("#https?://[^\\s<>\\[\\]\"'`\\)]+#iu", $text, $httpMatches);
|
||||
preg_match_all("#\\bwww\.[^\\s<>\\[\\]\"'`\\)]+#iu", $text, $wwwMatches);
|
||||
|
||||
$matches = array_merge($httpMatches[0] ?? [], $wwwMatches[0] ?? []);
|
||||
|
||||
return array_values(array_unique($matches));
|
||||
}
|
||||
|
||||
public function extractHost(string $url): ?string
|
||||
{
|
||||
$normalizedUrl = preg_match('#^https?://#i', $url) ? $url : 'https://' . ltrim($url, '/');
|
||||
$host = parse_url($normalizedUrl, PHP_URL_HOST);
|
||||
if (!is_string($host)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(DomainReputationService::class)->normalizeDomain($host);
|
||||
}
|
||||
|
||||
private function isDomainInList(string $host, array $list): bool
|
||||
{
|
||||
foreach ($list as $entry) {
|
||||
$entry = strtolower($entry);
|
||||
if ($host === $entry) {
|
||||
return true;
|
||||
}
|
||||
// Check if host is a subdomain of the entry
|
||||
if (str_ends_with($host, '.' . $entry)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
28
app/Services/Moderation/Rules/NearDuplicateCampaignRule.php
Normal file
28
app/Services/Moderation/Rules/NearDuplicateCampaignRule.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation\Rules;
|
||||
|
||||
use App\Contracts\Moderation\ModerationRuleInterface;
|
||||
use App\Services\Moderation\DuplicateDetectionService;
|
||||
|
||||
class NearDuplicateCampaignRule implements ModerationRuleInterface
|
||||
{
|
||||
public function analyze(string $content, string $normalized, array $context = []): array
|
||||
{
|
||||
$domains = (array) ($context['extracted_domains'] ?? []);
|
||||
$duplicates = app(DuplicateDetectionService::class)->nearDuplicateCount($content, $context, $domains);
|
||||
|
||||
if ($duplicates < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [[
|
||||
'rule' => 'near_duplicate_campaign',
|
||||
'score' => app('config')->get('content_moderation.weights.near_duplicate_campaign', 30),
|
||||
'reason' => 'Appears to match an existing spam campaign template (' . $duplicates . ' similar item(s))',
|
||||
'links' => (array) ($context['extracted_urls'] ?? []),
|
||||
'domains' => $domains,
|
||||
'keywords' => [],
|
||||
]];
|
||||
}
|
||||
}
|
||||
38
app/Services/Moderation/Rules/RegexPatternRule.php
Normal file
38
app/Services/Moderation/Rules/RegexPatternRule.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation\Rules;
|
||||
|
||||
use App\Contracts\Moderation\ModerationRuleInterface;
|
||||
use App\Services\Moderation\ModerationRuleRegistryService;
|
||||
|
||||
class RegexPatternRule implements ModerationRuleInterface
|
||||
{
|
||||
public function analyze(string $content, string $normalized, array $context = []): array
|
||||
{
|
||||
$registry = \app(ModerationRuleRegistryService::class);
|
||||
$findings = [];
|
||||
|
||||
foreach ($registry->regexRules() as $rule) {
|
||||
$pattern = (string) ($rule['pattern'] ?? '');
|
||||
if ($pattern === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matched = @preg_match($pattern, $content) === 1 || @preg_match($pattern, $normalized) === 1;
|
||||
if (! $matched) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$findings[] = [
|
||||
'rule' => 'regex_pattern',
|
||||
'score' => (int) ($rule['weight'] ?? \app('config')->get('content_moderation.weights.regex_pattern', 30)),
|
||||
'reason' => 'Matched custom moderation regex rule',
|
||||
'links' => [],
|
||||
'domains' => [],
|
||||
'keywords' => [$pattern],
|
||||
];
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
}
|
||||
56
app/Services/Moderation/Rules/RepeatedPhraseRule.php
Normal file
56
app/Services/Moderation/Rules/RepeatedPhraseRule.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation\Rules;
|
||||
|
||||
use App\Contracts\Moderation\ModerationRuleInterface;
|
||||
|
||||
class RepeatedPhraseRule implements ModerationRuleInterface
|
||||
{
|
||||
public function analyze(string $content, string $normalized, array $context = []): array
|
||||
{
|
||||
$config = app('config')->get('content_moderation.repeated_phrase', []);
|
||||
$minPhraseLength = $config['min_phrase_length'] ?? 4;
|
||||
$minRepetitions = $config['min_repetitions'] ?? 3;
|
||||
$weights = app('config')->get('content_moderation.weights', []);
|
||||
|
||||
$words = preg_split('/\s+/', $normalized);
|
||||
if (count($words) < $minPhraseLength * $minRepetitions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$findings = [];
|
||||
$repeatedPhrases = [];
|
||||
|
||||
// Check for repeated n-grams of various lengths
|
||||
for ($phraseLen = $minPhraseLength; $phraseLen <= min(8, intdiv(count($words), 2)); $phraseLen++) {
|
||||
$ngrams = [];
|
||||
for ($i = 0; $i <= count($words) - $phraseLen; $i++) {
|
||||
$ngram = implode(' ', array_slice($words, $i, $phraseLen));
|
||||
$ngrams[$ngram] = ($ngrams[$ngram] ?? 0) + 1;
|
||||
}
|
||||
|
||||
foreach ($ngrams as $phrase => $count) {
|
||||
if ($count >= $minRepetitions) {
|
||||
$repeatedPhrases[$phrase] = $count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($repeatedPhrases)) {
|
||||
$findings[] = [
|
||||
'rule' => 'repeated_phrase',
|
||||
'score' => $weights['repeated_phrase'] ?? 25,
|
||||
'reason' => 'Contains repeated phrases: ' . implode(', ', array_map(
|
||||
fn ($phrase, $count) => "\"{$phrase}\" ({$count}x)",
|
||||
array_keys($repeatedPhrases),
|
||||
array_values($repeatedPhrases)
|
||||
)),
|
||||
'links' => [],
|
||||
'domains' => [],
|
||||
'keywords' => array_keys($repeatedPhrases),
|
||||
];
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
}
|
||||
55
app/Services/Moderation/Rules/SuspiciousKeywordRule.php
Normal file
55
app/Services/Moderation/Rules/SuspiciousKeywordRule.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation\Rules;
|
||||
|
||||
use App\Contracts\Moderation\ModerationRuleInterface;
|
||||
use App\Services\Moderation\ModerationRuleRegistryService;
|
||||
|
||||
class SuspiciousKeywordRule implements ModerationRuleInterface
|
||||
{
|
||||
public function analyze(string $content, string $normalized, array $context = []): array
|
||||
{
|
||||
$registry = app(ModerationRuleRegistryService::class);
|
||||
$weights = app('config')->get('content_moderation.weights', []);
|
||||
$findings = [];
|
||||
|
||||
$highRiskMatched = [];
|
||||
$suspiciousMatched = [];
|
||||
|
||||
foreach ($registry->highRiskKeywords() as $phrase) {
|
||||
if (str_contains($normalized, strtolower($phrase))) {
|
||||
$highRiskMatched[] = $phrase;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($registry->suspiciousKeywords() as $phrase) {
|
||||
if (str_contains($normalized, strtolower($phrase))) {
|
||||
$suspiciousMatched[] = $phrase;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($highRiskMatched)) {
|
||||
$findings[] = [
|
||||
'rule' => 'high_risk_keyword',
|
||||
'score' => ($weights['high_risk_keyword'] ?? 40) * count($highRiskMatched),
|
||||
'reason' => 'Contains high-risk keyword(s): ' . implode(', ', $highRiskMatched),
|
||||
'links' => [],
|
||||
'domains' => [],
|
||||
'keywords' => $highRiskMatched,
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($suspiciousMatched)) {
|
||||
$findings[] = [
|
||||
'rule' => 'suspicious_keyword',
|
||||
'score' => ($weights['suspicious_keyword'] ?? 25) * count($suspiciousMatched),
|
||||
'reason' => 'Contains suspicious keyword(s): ' . implode(', ', $suspiciousMatched),
|
||||
'links' => [],
|
||||
'domains' => [],
|
||||
'keywords' => $suspiciousMatched,
|
||||
];
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
}
|
||||
49
app/Services/Moderation/Rules/UnicodeObfuscationRule.php
Normal file
49
app/Services/Moderation/Rules/UnicodeObfuscationRule.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation\Rules;
|
||||
|
||||
use App\Contracts\Moderation\ModerationRuleInterface;
|
||||
|
||||
class UnicodeObfuscationRule implements ModerationRuleInterface
|
||||
{
|
||||
public function analyze(string $content, string $normalized, array $context = []): array
|
||||
{
|
||||
$findings = [];
|
||||
$weights = app('config')->get('content_moderation.weights', []);
|
||||
|
||||
// Detect homoglyph / lookalike characters
|
||||
// Common spam tactic: replace Latin chars with Cyrillic, Greek, or special Unicode
|
||||
$suspiciousPatterns = [
|
||||
// Mixed script detection: Latin + Cyrillic in same word
|
||||
'/\b(?=\S*[\x{0400}-\x{04FF}])(?=\S*[a-zA-Z])\S+\b/u',
|
||||
// Zero-width characters
|
||||
'/[\x{200B}\x{200C}\x{200D}\x{FEFF}\x{00AD}]/u',
|
||||
// Invisible formatting characters
|
||||
'/[\x{2060}\x{2061}\x{2062}\x{2063}\x{2064}]/u',
|
||||
// Fullwidth Latin letters (used to bypass filters)
|
||||
'/[\x{FF01}-\x{FF5E}]/u',
|
||||
// Mathematical alphanumeric symbols used as text
|
||||
'/[\x{1D400}-\x{1D7FF}]/u',
|
||||
];
|
||||
|
||||
$matchCount = 0;
|
||||
foreach ($suspiciousPatterns as $pattern) {
|
||||
if (preg_match($pattern, $content)) {
|
||||
$matchCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($matchCount > 0) {
|
||||
$findings[] = [
|
||||
'rule' => 'unicode_obfuscation',
|
||||
'score' => ($weights['unicode_obfuscation'] ?? 30) * $matchCount,
|
||||
'reason' => 'Contains suspicious Unicode characters/obfuscation (' . $matchCount . ' pattern(s) matched)',
|
||||
'links' => [],
|
||||
'domains' => [],
|
||||
'keywords' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
}
|
||||
66
app/Services/Moderation/UserModerationProfileService.php
Normal file
66
app/Services/Moderation/UserModerationProfileService.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Enums\ModerationStatus;
|
||||
use App\Models\ContentModerationFinding;
|
||||
use App\Models\User;
|
||||
|
||||
class UserModerationProfileService
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function profile(?int $userId): ?array
|
||||
{
|
||||
if (! $userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = User::query()->with('statistics:user_id,uploads_count')->find($userId);
|
||||
if (! $user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$findings = ContentModerationFinding::query()->where('user_id', $userId)->get([
|
||||
'id', 'status', 'is_auto_hidden', 'is_false_positive', 'matched_domains_json', 'campaign_key', 'priority_score', 'created_at',
|
||||
]);
|
||||
|
||||
$confirmedSpam = $findings->where('status', ModerationStatus::ConfirmedSpam)->count();
|
||||
$safeCount = $findings->where('status', ModerationStatus::ReviewedSafe)->count();
|
||||
$autoHidden = $findings->where('is_auto_hidden', true)->count();
|
||||
$falsePositives = $findings->where('is_false_positive', true)->count();
|
||||
$clusters = $findings->pluck('campaign_key')->filter()->unique()->count();
|
||||
$domains = $findings->flatMap(static fn (ContentModerationFinding $finding): array => (array) $finding->matched_domains_json)->filter()->unique()->values();
|
||||
$risk = app(UserRiskScoreService::class)->assess($userId, $domains->all());
|
||||
|
||||
$tier = match (true) {
|
||||
$risk['risk_score'] >= 75 => 'high_risk',
|
||||
$risk['risk_score'] >= 45 => 'monitored',
|
||||
$risk['risk_score'] <= 8 && $safeCount >= 2 => 'trusted',
|
||||
default => 'normal',
|
||||
};
|
||||
|
||||
$recommendedPolicy = match ($tier) {
|
||||
'high_risk' => 'new_user_strict_mode',
|
||||
'monitored' => 'comments_high_volume_antispam',
|
||||
'trusted' => 'trusted_user_relaxed_mode',
|
||||
default => 'default',
|
||||
};
|
||||
|
||||
return [
|
||||
'user' => $user,
|
||||
'risk_score' => $risk['risk_score'],
|
||||
'trust_score' => max(0, 100 - $risk['risk_score'] + ($safeCount * 3) - ($falsePositives * 2)),
|
||||
'tier' => $tier,
|
||||
'recommended_policy' => $recommendedPolicy,
|
||||
'confirmed_spam_count' => $confirmedSpam,
|
||||
'safe_count' => $safeCount,
|
||||
'auto_hidden_count' => $autoHidden,
|
||||
'false_positive_count' => $falsePositives,
|
||||
'cluster_memberships' => $clusters,
|
||||
'promoted_domains' => $domains->take(12)->all(),
|
||||
'signals' => $risk['signals'],
|
||||
];
|
||||
}
|
||||
}
|
||||
83
app/Services/Moderation/UserRiskScoreService.php
Normal file
83
app/Services/Moderation/UserRiskScoreService.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Enums\ModerationStatus;
|
||||
use App\Models\ContentModerationFinding;
|
||||
use App\Models\User;
|
||||
|
||||
class UserRiskScoreService
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $domains
|
||||
* @return array{risk_score:int,score_modifier:int,signals:array<string, int>}
|
||||
*/
|
||||
public function assess(?int $userId, array $domains = []): array
|
||||
{
|
||||
if (! $userId) {
|
||||
return ['risk_score' => 0, 'score_modifier' => 0, 'signals' => []];
|
||||
}
|
||||
|
||||
$user = User::query()->with('statistics:user_id,uploads_count')->find($userId);
|
||||
if (! $user) {
|
||||
return ['risk_score' => 0, 'score_modifier' => 0, 'signals' => []];
|
||||
}
|
||||
|
||||
$summary = ContentModerationFinding::query()
|
||||
->where('user_id', $userId)
|
||||
->selectRaw('COUNT(*) as total_findings')
|
||||
->selectRaw("SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as confirmed_spam_count", [ModerationStatus::ConfirmedSpam->value])
|
||||
->selectRaw("SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as safe_count", [ModerationStatus::ReviewedSafe->value])
|
||||
->selectRaw("SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as pending_count", [ModerationStatus::Pending->value])
|
||||
->first();
|
||||
|
||||
$confirmedSpam = (int) ($summary?->confirmed_spam_count ?? 0);
|
||||
$safeCount = (int) ($summary?->safe_count ?? 0);
|
||||
$pendingCount = (int) ($summary?->pending_count ?? 0);
|
||||
$domainRepeatCount = ContentModerationFinding::query()
|
||||
->where('user_id', $userId)
|
||||
->whereNotNull('matched_domains_json')
|
||||
->get(['matched_domains_json'])
|
||||
->reduce(function (int $carry, ContentModerationFinding $finding) use ($domains): int {
|
||||
return $carry + (empty(array_intersect((array) $finding->matched_domains_json, $domains)) ? 0 : 1);
|
||||
}, 0);
|
||||
|
||||
$accountAgeDays = max(0, \now()->diffInDays($user->created_at));
|
||||
$uploadsCount = (int) ($user->statistics?->uploads_count ?? 0);
|
||||
|
||||
$riskScore = 0;
|
||||
$riskScore += $confirmedSpam * 18;
|
||||
$riskScore += $pendingCount * 5;
|
||||
$riskScore += min(20, $domainRepeatCount * 6);
|
||||
$riskScore -= min(18, $safeCount * 4);
|
||||
$riskScore -= $accountAgeDays >= 365 ? 8 : ($accountAgeDays >= 90 ? 4 : 0);
|
||||
$riskScore -= $uploadsCount >= 25 ? 6 : ($uploadsCount >= 10 ? 3 : 0);
|
||||
$riskScore = max(0, min(100, $riskScore));
|
||||
|
||||
$modifier = 0;
|
||||
if ($riskScore >= 75) {
|
||||
$modifier = (int) \app('config')->get('content_moderation.user_risk.high_modifier', 18);
|
||||
} elseif ($riskScore >= 50) {
|
||||
$modifier = (int) \app('config')->get('content_moderation.user_risk.medium_modifier', 10);
|
||||
} elseif ($riskScore >= 25) {
|
||||
$modifier = (int) \app('config')->get('content_moderation.user_risk.low_modifier', 4);
|
||||
} elseif ($riskScore <= 5 && $accountAgeDays >= 180 && $uploadsCount >= 10) {
|
||||
$modifier = (int) \app('config')->get('content_moderation.user_risk.trusted_modifier', -6);
|
||||
} elseif ($riskScore <= 12 && $safeCount >= 2) {
|
||||
$modifier = -3;
|
||||
}
|
||||
|
||||
return [
|
||||
'risk_score' => $riskScore,
|
||||
'score_modifier' => $modifier,
|
||||
'signals' => [
|
||||
'confirmed_spam_count' => $confirmedSpam,
|
||||
'safe_count' => $safeCount,
|
||||
'pending_count' => $pendingCount,
|
||||
'domain_repeat_count' => $domainRepeatCount,
|
||||
'account_age_days' => $accountAgeDays,
|
||||
'uploads_count' => $uploadsCount,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ class NovaCardBackgroundService
|
||||
throw new RuntimeException('Nova card background processing requires Intervention Image.');
|
||||
}
|
||||
|
||||
$sourcePath = $this->resolveUploadPath($file);
|
||||
$binary = $this->readUploadedBinary($sourcePath);
|
||||
|
||||
$uuid = (string) Str::uuid();
|
||||
$extension = strtolower($file->getClientOriginalExtension() ?: 'jpg');
|
||||
$originalDisk = Storage::disk((string) config('nova_cards.storage.private_disk', 'local'));
|
||||
@@ -43,9 +46,9 @@ class NovaCardBackgroundService
|
||||
$processedPath = trim((string) config('nova_cards.storage.background_processed_prefix', 'cards/backgrounds/processed'), '/')
|
||||
. '/' . $user->id . '/' . $uuid . '.webp';
|
||||
|
||||
$originalDisk->put($originalPath, file_get_contents($file->getRealPath()) ?: '');
|
||||
$originalDisk->put($originalPath, $binary);
|
||||
|
||||
$image = $this->manager->read($file->getRealPath())->scaleDown(width: 2200, height: 2200);
|
||||
$image = $this->manager->read($binary)->scaleDown(width: 2200, height: 2200);
|
||||
$encoded = (string) $image->encode(new WebpEncoder(88));
|
||||
$processedDisk->put($processedPath, $encoded);
|
||||
|
||||
@@ -57,8 +60,30 @@ class NovaCardBackgroundService
|
||||
'height' => $image->height(),
|
||||
'mime_type' => (string) ($file->getMimeType() ?: 'image/jpeg'),
|
||||
'file_size' => (int) $file->getSize(),
|
||||
'sha256' => hash_file('sha256', $file->getRealPath()) ?: null,
|
||||
'sha256' => hash('sha256', $binary),
|
||||
'visibility' => 'card-only',
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveUploadPath(UploadedFile $file): string
|
||||
{
|
||||
$path = $file->getRealPath() ?: $file->getPathname();
|
||||
|
||||
if (! is_string($path) || trim($path) === '' || ! is_readable($path)) {
|
||||
throw new RuntimeException('Unable to resolve uploaded background path.');
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function readUploadedBinary(string $path): string
|
||||
{
|
||||
$binary = @file_get_contents($path);
|
||||
|
||||
if ($binary === false || $binary === '') {
|
||||
throw new RuntimeException('Unable to read uploaded background image.');
|
||||
}
|
||||
|
||||
return $binary;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ class NovaCardDraftService
|
||||
'palette_family' => Arr::get($attributes, 'palette_family'),
|
||||
'original_card_id' => $originalCardId,
|
||||
'root_card_id' => $rootCardId,
|
||||
'scheduling_timezone' => Arr::get($attributes, 'scheduling_timezone'),
|
||||
]);
|
||||
|
||||
$this->tagService->syncTags($card, Arr::wrap(Arr::get($attributes, 'tags', [])));
|
||||
@@ -137,6 +138,7 @@ class NovaCardDraftService
|
||||
'style_family' => Arr::get($payload, 'style_family', $card->style_family),
|
||||
'palette_family' => Arr::get($payload, 'palette_family', $card->palette_family),
|
||||
'editor_mode_last_used' => Arr::get($payload, 'editor_mode_last_used', $card->editor_mode_last_used),
|
||||
'scheduling_timezone' => Arr::get($payload, 'scheduling_timezone', $card->scheduling_timezone),
|
||||
]);
|
||||
|
||||
if ($card->isDirty('title')) {
|
||||
|
||||
127
app/Services/NovaCards/NovaCardPlaywrightRenderService.php
Normal file
127
app/Services/NovaCards/NovaCardPlaywrightRenderService.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\NovaCards;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Renders a Nova Card by loading the editor's React canvas preview inside a
|
||||
* headless Chromium (via Playwright) and screenshotting it.
|
||||
*
|
||||
* This produces a pixel-perfect image that matches what users see in the editor,
|
||||
* including web fonts, CSS container queries, and CSS gradients/blend modes.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - Node.js must be in PATH.
|
||||
* - Playwright browsers must be installed (`npx playwright install chromium`).
|
||||
* - NOVA_CARDS_PLAYWRIGHT_RENDER=true in .env (default: false).
|
||||
* - APP_URL must be reachable from the queue worker (used for the signed URL).
|
||||
*/
|
||||
class NovaCardPlaywrightRenderService
|
||||
{
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
if (! config('nova_cards.playwright_render', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Quick check: verify `node` is executable.
|
||||
$probe = new Process(['node', '--version']);
|
||||
$probe->run();
|
||||
|
||||
return $probe->isSuccessful();
|
||||
}
|
||||
|
||||
public function render(NovaCard $card): array
|
||||
{
|
||||
if (! $this->isAvailable()) {
|
||||
throw new RuntimeException('Playwright rendering is disabled or Node.js is not available.');
|
||||
}
|
||||
|
||||
$format = Arr::get(config('nova_cards.formats'), $card->format, config('nova_cards.formats.square'));
|
||||
$width = (int) Arr::get($format, 'width', 1080);
|
||||
$height = (int) Arr::get($format, 'height', 1080);
|
||||
|
||||
// Signed URL is valid for 10 minutes — enough for the queue job.
|
||||
$signedUrl = URL::temporarySignedRoute(
|
||||
'nova-cards.render-frame',
|
||||
now()->addMinutes(10),
|
||||
['uuid' => $card->uuid],
|
||||
);
|
||||
|
||||
$tmpDir = rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . 'nova-render-' . $card->uuid;
|
||||
$pngPath = $tmpDir . DIRECTORY_SEPARATOR . 'canvas.png';
|
||||
|
||||
if (! is_dir($tmpDir)) {
|
||||
mkdir($tmpDir, 0755, true);
|
||||
}
|
||||
|
||||
$script = base_path('scripts/render-nova-card.cjs');
|
||||
$process = new Process(
|
||||
['node', $script, "--url={$signedUrl}", "--out={$pngPath}", "--width={$width}", "--height={$height}"],
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
60, // seconds
|
||||
);
|
||||
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
throw new RuntimeException('Playwright render failed: ' . trim($process->getErrorOutput()));
|
||||
}
|
||||
|
||||
if (! file_exists($pngPath) || filesize($pngPath) < 100) {
|
||||
throw new RuntimeException('Playwright render produced no output or an empty file.');
|
||||
}
|
||||
|
||||
$pngBlob = (string) file_get_contents($pngPath);
|
||||
@unlink($pngPath);
|
||||
@rmdir($tmpDir);
|
||||
|
||||
// Convert the PNG to WebP + JPEG using GD (already a project dependency).
|
||||
$img = @imagecreatefromstring($pngBlob);
|
||||
if ($img === false) {
|
||||
throw new RuntimeException('Could not decode Playwright PNG output with GD.');
|
||||
}
|
||||
|
||||
ob_start();
|
||||
imagewebp($img, null, (int) config('nova_cards.render.preview_quality', 86));
|
||||
$webpBinary = (string) ob_get_clean();
|
||||
|
||||
ob_start();
|
||||
imagejpeg($img, null, (int) config('nova_cards.render.og_quality', 88));
|
||||
$jpgBinary = (string) ob_get_clean();
|
||||
|
||||
imagedestroy($img);
|
||||
|
||||
$disk = Storage::disk((string) config('nova_cards.storage.public_disk', 'public'));
|
||||
$basePath = trim((string) config('nova_cards.storage.preview_prefix', 'cards/previews'), '/') . '/' . $card->user_id;
|
||||
$previewPath = $basePath . '/' . $card->uuid . '.webp';
|
||||
$ogPath = $basePath . '/' . $card->uuid . '-og.jpg';
|
||||
|
||||
$disk->put($previewPath, $webpBinary, 'public');
|
||||
$disk->put($ogPath, $jpgBinary, 'public');
|
||||
|
||||
$card->forceFill([
|
||||
'preview_path' => $previewPath,
|
||||
'preview_width' => $width,
|
||||
'preview_height' => $height,
|
||||
'last_rendered_at' => now(),
|
||||
])->save();
|
||||
|
||||
return [
|
||||
'preview_path' => $previewPath,
|
||||
'og_path' => $ogPath,
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,8 @@ class NovaCardPresenter
|
||||
'og_preview_url' => $card->ogPreviewUrl(),
|
||||
'public_url' => $card->publicUrl(),
|
||||
'published_at' => optional($card->published_at)?->toISOString(),
|
||||
'scheduled_for' => optional($card->scheduled_for)?->toISOString(),
|
||||
'scheduling_timezone' => $card->scheduling_timezone,
|
||||
'render_version' => (int) $card->render_version,
|
||||
'schema_version' => (int) $card->schema_version,
|
||||
'views_count' => (int) $card->views_count,
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Jobs\NovaCards\RenderNovaCardPreviewJob;
|
||||
use App\Models\NovaCard;
|
||||
use App\Services\NovaCards\NovaCardPublishModerationService;
|
||||
use Illuminate\Support\Carbon;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class NovaCardPublishService
|
||||
{
|
||||
@@ -24,6 +25,8 @@ class NovaCardPublishService
|
||||
'status' => NovaCard::STATUS_PROCESSING,
|
||||
'moderation_status' => NovaCard::MOD_PENDING,
|
||||
'published_at' => $card->published_at ?? Carbon::now(),
|
||||
'scheduled_for' => null,
|
||||
'scheduling_timezone' => null,
|
||||
'render_version' => (int) $card->render_version + 1,
|
||||
])->save();
|
||||
|
||||
@@ -39,6 +42,8 @@ class NovaCardPublishService
|
||||
'status' => NovaCard::STATUS_PROCESSING,
|
||||
'moderation_status' => NovaCard::MOD_PENDING,
|
||||
'published_at' => $card->published_at ?? Carbon::now(),
|
||||
'scheduled_for' => null,
|
||||
'scheduling_timezone' => null,
|
||||
'render_version' => (int) $card->render_version + 1,
|
||||
])->save();
|
||||
|
||||
@@ -52,4 +57,33 @@ class NovaCardPublishService
|
||||
|
||||
return $card->refresh()->load(['category', 'template', 'tags', 'backgroundImage']);
|
||||
}
|
||||
|
||||
public function schedule(NovaCard $card, Carbon $scheduledFor, ?string $timezone = null): NovaCard
|
||||
{
|
||||
$scheduledFor = $scheduledFor->copy()->utc();
|
||||
|
||||
if ($scheduledFor->lte(now()->addMinute())) {
|
||||
throw new InvalidArgumentException('Scheduled publish time must be at least 1 minute in the future.');
|
||||
}
|
||||
|
||||
$card->forceFill([
|
||||
'status' => NovaCard::STATUS_SCHEDULED,
|
||||
'scheduled_for' => $scheduledFor,
|
||||
'published_at' => $scheduledFor,
|
||||
'scheduling_timezone' => $timezone,
|
||||
])->save();
|
||||
|
||||
return $card->refresh()->load(['category', 'template', 'tags', 'backgroundImage']);
|
||||
}
|
||||
|
||||
public function clearSchedule(NovaCard $card): NovaCard
|
||||
{
|
||||
$card->forceFill([
|
||||
'status' => NovaCard::STATUS_DRAFT,
|
||||
'scheduled_for' => null,
|
||||
'published_at' => null,
|
||||
])->save();
|
||||
|
||||
return $card->refresh()->load(['category', 'template', 'tags', 'backgroundImage']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace App\Services\NovaCards;
|
||||
use App\Models\NovaCard;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class NovaCardRenderService
|
||||
@@ -48,23 +47,27 @@ class NovaCardRenderService
|
||||
|
||||
imagedestroy($image);
|
||||
|
||||
$disk->put($previewPath, $webpBinary);
|
||||
$disk->put($ogPath, $jpgBinary);
|
||||
// Store publicly so CDN / direct S3 URLs are accessible without signing.
|
||||
$disk->put($previewPath, $webpBinary, 'public');
|
||||
$disk->put($ogPath, $jpgBinary, 'public');
|
||||
|
||||
$card->forceFill([
|
||||
'preview_path' => $previewPath,
|
||||
'preview_width' => $width,
|
||||
'preview_height' => $height,
|
||||
'preview_path' => $previewPath,
|
||||
'preview_width' => $width,
|
||||
'preview_height' => $height,
|
||||
'last_rendered_at' => now(),
|
||||
])->save();
|
||||
|
||||
return [
|
||||
'preview_path' => $previewPath,
|
||||
'og_path' => $ogPath,
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'og_path' => $ogPath,
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Background ──────────────────────────────────────────────────────────
|
||||
|
||||
private function paintBackground($image, NovaCard $card, int $width, int $height, array $project): void
|
||||
{
|
||||
$background = Arr::get($project, 'background', []);
|
||||
@@ -78,7 +81,9 @@ class NovaCardRenderService
|
||||
}
|
||||
|
||||
if ($type === 'upload' && $card->backgroundImage?->processed_path) {
|
||||
$this->paintImageBackground($image, $card->backgroundImage->processed_path, $width, $height);
|
||||
$focalPosition = (string) Arr::get($project, 'background.focal_position', 'center');
|
||||
$blurLevel = (int) Arr::get($project, 'background.blur_level', 0);
|
||||
$this->paintImageBackground($image, $card->backgroundImage->processed_path, $width, $height, $focalPosition, $blurLevel);
|
||||
} else {
|
||||
$colors = Arr::wrap(Arr::get($background, 'gradient_colors', ['#0f172a', '#1d4ed8']));
|
||||
$from = (string) Arr::get($colors, 0, '#0f172a');
|
||||
@@ -87,7 +92,7 @@ class NovaCardRenderService
|
||||
}
|
||||
}
|
||||
|
||||
private function paintImageBackground($image, string $path, int $width, int $height): void
|
||||
private function paintImageBackground($image, string $path, int $width, int $height, string $focalPosition = 'center', int $blurLevel = 0): void
|
||||
{
|
||||
$disk = Storage::disk((string) config('nova_cards.storage.public_disk', 'public'));
|
||||
if (! $disk->exists($path)) {
|
||||
@@ -97,6 +102,12 @@ class NovaCardRenderService
|
||||
}
|
||||
|
||||
$blob = $disk->get($path);
|
||||
if (! $blob) {
|
||||
$this->paintVerticalGradient($image, $width, $height, '#0f172a', '#1d4ed8');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$background = @imagecreatefromstring($blob);
|
||||
if ($background === false) {
|
||||
$this->paintVerticalGradient($image, $width, $height, '#0f172a', '#1d4ed8');
|
||||
@@ -104,23 +115,26 @@ class NovaCardRenderService
|
||||
return;
|
||||
}
|
||||
|
||||
$focalPosition = (string) Arr::get($card->project_json, 'background.focal_position', 'center');
|
||||
[$srcX, $srcY] = $this->resolveFocalSourceOrigin($focalPosition, imagesx($background), imagesy($background));
|
||||
$srcW = imagesx($background);
|
||||
$srcH = imagesy($background);
|
||||
|
||||
imagecopyresampled(
|
||||
$image,
|
||||
$background,
|
||||
0,
|
||||
0,
|
||||
$srcX,
|
||||
$srcY,
|
||||
$width,
|
||||
$height,
|
||||
max(1, imagesx($background) - $srcX),
|
||||
max(1, imagesy($background) - $srcY)
|
||||
);
|
||||
// Implement CSS background-size: cover / object-fit: cover:
|
||||
// scale the source so both dimensions FILL the destination, then crop to focal point.
|
||||
$scaleX = $width / max(1, $srcW);
|
||||
$scaleY = $height / max(1, $srcH);
|
||||
$scale = max($scaleX, $scaleY);
|
||||
|
||||
// How many source pixels map to the entire destination.
|
||||
$sampleW = max(1, (int) round($width / $scale));
|
||||
$sampleH = max(1, (int) round($height / $scale));
|
||||
|
||||
// Focal ratios (0 = left/top, 0.5 = centre, 1 = right/bottom).
|
||||
[$focalX, $focalY] = $this->resolveFocalRatios($focalPosition);
|
||||
$srcX = max(0, min($srcW - $sampleW, (int) round(($srcW - $sampleW) * $focalX)));
|
||||
$srcY = max(0, min($srcH - $sampleH, (int) round(($srcH - $sampleH) * $focalY)));
|
||||
|
||||
imagecopyresampled($image, $background, 0, 0, $srcX, $srcY, $width, $height, $sampleW, $sampleH);
|
||||
|
||||
$blurLevel = (int) Arr::get($card->project_json, 'background.blur_level', 0);
|
||||
for ($index = 0; $index < (int) floor($blurLevel / 4); $index++) {
|
||||
imagefilter($image, IMG_FILTER_GAUSSIAN_BLUR);
|
||||
}
|
||||
@@ -133,9 +147,9 @@ class NovaCardRenderService
|
||||
$style = (string) Arr::get($project, 'background.overlay_style', 'dark-soft');
|
||||
$alpha = match ($style) {
|
||||
'dark-strong' => 72,
|
||||
'dark-soft' => 92,
|
||||
'light-soft' => 108,
|
||||
default => null,
|
||||
'dark-soft' => 92,
|
||||
'light-soft' => 108,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($alpha === null) {
|
||||
@@ -147,75 +161,272 @@ class NovaCardRenderService
|
||||
imagefilledrectangle($image, 0, 0, $width, $height, $overlay);
|
||||
}
|
||||
|
||||
// ─── Text rendering (FreeType / GD fallback) ─────────────────────────────
|
||||
|
||||
private function paintText($image, NovaCard $card, array $project, int $width, int $height): void
|
||||
{
|
||||
$textColor = $this->allocateHex($image, (string) Arr::get($project, 'typography.text_color', '#ffffff'));
|
||||
$authorColor = $this->allocateHex($image, (string) Arr::get($project, 'typography.accent_color', Arr::get($project, 'typography.text_color', '#ffffff')));
|
||||
$alignment = (string) Arr::get($project, 'layout.alignment', 'center');
|
||||
$lineHeightMultiplier = (float) Arr::get($project, 'typography.line_height', 1.2);
|
||||
$shadowPreset = (string) Arr::get($project, 'typography.shadow_preset', 'soft');
|
||||
$fontPreset = (string) Arr::get($project, 'typography.font_preset', 'modern-sans');
|
||||
$fontFile = $this->resolveFont($fontPreset);
|
||||
$textColor = $this->allocateHex($image, (string) Arr::get($project, 'typography.text_color', '#ffffff'));
|
||||
$accentColor = $this->allocateHex($image, (string) Arr::get($project, 'typography.accent_color', Arr::get($project, 'typography.text_color', '#ffffff')));
|
||||
$alignment = (string) Arr::get($project, 'layout.alignment', 'center');
|
||||
$lhMulti = (float) Arr::get($project, 'typography.line_height', 1.35);
|
||||
$shadow = (string) Arr::get($project, 'typography.shadow_preset', 'soft');
|
||||
|
||||
$paddingRatio = match ((string) Arr::get($project, 'layout.padding', 'comfortable')) {
|
||||
'tight' => 0.08,
|
||||
'airy' => 0.15,
|
||||
'airy' => 0.15,
|
||||
default => 0.11,
|
||||
};
|
||||
$xPadding = (int) round($width * $paddingRatio);
|
||||
$maxLineWidth = match ((string) Arr::get($project, 'layout.max_width', 'balanced')) {
|
||||
'compact' => (int) round($width * 0.5),
|
||||
'wide' => (int) round($width * 0.78),
|
||||
default => (int) round($width * 0.64),
|
||||
};
|
||||
|
||||
$textBlocks = $this->resolveTextBlocks($card, $project);
|
||||
$charWidth = imagefontwidth(5);
|
||||
$lineHeight = max(imagefontheight(5) + 4, (int) round((imagefontheight(5) + 2) * $lineHeightMultiplier));
|
||||
$charsPerLine = max(14, (int) floor($maxLineWidth / max(1, $charWidth)));
|
||||
$textBlockHeight = 0;
|
||||
foreach ($textBlocks as $block) {
|
||||
$font = $this->fontForBlockType((string) ($block['type'] ?? 'body'));
|
||||
$wrapped = preg_split('/\r\n|\r|\n/', wordwrap((string) ($block['text'] ?? ''), max(10, $charsPerLine - ($font === 3 ? 4 : 0)), "\n", true)) ?: [(string) ($block['text'] ?? '')];
|
||||
$textBlockHeight += count($wrapped) * max(imagefontheight($font) + 4, (int) round((imagefontheight($font) + 2) * $lineHeightMultiplier));
|
||||
$textBlockHeight += 18;
|
||||
// Respect the quote_width typography slider first; fall back to the layout max_width preset.
|
||||
$quoteWidthPct = (float) Arr::get($project, 'typography.quote_width', 0);
|
||||
$maxLineWidth = ($quoteWidthPct >= 30 && $quoteWidthPct <= 100)
|
||||
? (int) round($width * $quoteWidthPct / 100)
|
||||
: match ((string) Arr::get($project, 'layout.max_width', 'balanced')) {
|
||||
'compact' => (int) round($width * 0.52),
|
||||
'wide' => (int) round($width * 0.80),
|
||||
default => (int) round($width * 0.66),
|
||||
};
|
||||
|
||||
// Font sizes come from the editor sliders (stored in project_json.typography).
|
||||
// Slider values are normalised to a 1080-px-wide canvas; scale up for wider formats
|
||||
// (e.g. landscape 1920 px) so proportions match the CSS preview at any container size.
|
||||
$fontScale = $width / 1080.0;
|
||||
$quoteSize = (float) max(10, (float) Arr::get($project, 'typography.quote_size', 72)) * $fontScale;
|
||||
$authorSize = (float) max(14, (float) Arr::get($project, 'typography.author_size', 28)) * $fontScale;
|
||||
|
||||
$sizeMap = [
|
||||
'title' => max(16, $quoteSize * 0.48),
|
||||
'quote' => $quoteSize,
|
||||
'author' => $authorSize,
|
||||
'source' => max(12, $authorSize * 0.82),
|
||||
'body' => max(16, $quoteSize * 0.54),
|
||||
'caption' => max(12, $authorSize * 0.74),
|
||||
];
|
||||
|
||||
$allBlocks = $this->resolveTextBlocks($card, $project);
|
||||
// Blocks with both pos_x and pos_y set were dragged to a free position; others flow normally.
|
||||
$flowBlocks = array_values(array_filter($allBlocks, fn ($b) => Arr::get($b, 'pos_x') === null || Arr::get($b, 'pos_y') === null));
|
||||
$freeBlocks = array_values(array_filter($allBlocks, fn ($b) => Arr::get($b, 'pos_x') !== null && Arr::get($b, 'pos_y') !== null));
|
||||
|
||||
// ── Flow blocks: vertically stacked, centred by the layout position ───
|
||||
$blockData = [];
|
||||
$totalHeight = 0;
|
||||
foreach ($flowBlocks as $block) {
|
||||
$type = (string) ($block['type'] ?? 'quote');
|
||||
$size = (float) ($sizeMap[$type] ?? $quoteSize);
|
||||
$prefix = $type === 'author' ? '— ' : '';
|
||||
$raw = $type === 'title' ? strtoupper($prefix . (string) ($block['text'] ?? '')) : $prefix . (string) ($block['text'] ?? '');
|
||||
$lines = $this->wrapLines($raw, $size, $fontFile, $maxLineWidth);
|
||||
$lineH = $this->lineHeight($size, $fontFile, $lhMulti);
|
||||
$gap = (int) round($size * 0.55);
|
||||
$totalHeight += count($lines) * $lineH + $gap;
|
||||
$blockData[] = compact('type', 'size', 'lines', 'lineH', 'gap');
|
||||
}
|
||||
|
||||
$position = (string) Arr::get($project, 'layout.position', 'center');
|
||||
$startY = match ($position) {
|
||||
'top' => (int) round($height * 0.14),
|
||||
'upper-middle' => (int) round($height * 0.26),
|
||||
'lower-middle' => (int) round($height * 0.58),
|
||||
'bottom' => max($xPadding, $height - $textBlockHeight - (int) round($height * 0.12)),
|
||||
default => (int) round(($height - $textBlockHeight) / 2),
|
||||
'top' => (int) round($height * 0.13),
|
||||
'upper-middle' => (int) round($height * 0.27),
|
||||
'lower-middle' => (int) round($height * 0.55),
|
||||
'bottom' => max($xPadding, $height - $totalHeight - (int) round($height * 0.10)),
|
||||
default => (int) round(($height - $totalHeight) / 2),
|
||||
};
|
||||
|
||||
foreach ($textBlocks as $block) {
|
||||
$type = (string) ($block['type'] ?? 'body');
|
||||
$font = $this->fontForBlockType($type);
|
||||
$color = in_array($type, ['author', 'source', 'title'], true) ? $authorColor : $textColor;
|
||||
$prefix = $type === 'author' ? '— ' : '';
|
||||
$value = $prefix . (string) ($block['text'] ?? '');
|
||||
$wrapped = preg_split('/\r\n|\r|\n/', wordwrap($type === 'title' ? strtoupper($value) : $value, max(10, $charsPerLine - ($font === 3 ? 4 : 0)), "\n", true)) ?: [$value];
|
||||
$blockLineHeight = max(imagefontheight($font) + 4, (int) round((imagefontheight($font) + 2) * $lineHeightMultiplier));
|
||||
|
||||
foreach ($wrapped as $line) {
|
||||
$lineWidth = imagefontwidth($font) * strlen($line);
|
||||
$x = $this->resolveAlignedX($alignment, $width, $xPadding, $lineWidth);
|
||||
$this->drawText($image, $font, $x, $startY, $line, $color, $shadowPreset);
|
||||
$startY += $blockLineHeight;
|
||||
foreach ($blockData as $bdata) {
|
||||
$color = in_array($bdata['type'], ['author', 'source', 'title'], true) ? $accentColor : $textColor;
|
||||
foreach ($bdata['lines'] as $line) {
|
||||
$lw = $this->measureLine($line, $bdata['size'], $fontFile);
|
||||
$x = $this->resolveAlignedX($alignment, $width, $xPadding, $lw);
|
||||
$this->drawLine($image, $bdata['size'], $x, $startY, $line, $color, $fontFile, $shadow);
|
||||
$startY += $bdata['lineH'];
|
||||
}
|
||||
$startY += $bdata['gap'];
|
||||
}
|
||||
|
||||
$startY += 18;
|
||||
// ── Free-positioned blocks: absolute x/y percentages from canvas origin ─
|
||||
foreach ($freeBlocks as $block) {
|
||||
$type = (string) ($block['type'] ?? 'quote');
|
||||
$size = (float) ($sizeMap[$type] ?? $quoteSize);
|
||||
$prefix = $type === 'author' ? '— ' : '';
|
||||
$raw = $type === 'title' ? strtoupper($prefix . (string) ($block['text'] ?? '')) : $prefix . (string) ($block['text'] ?? '');
|
||||
$blockMaxW = Arr::get($block, 'pos_width') !== null
|
||||
? max(20, (int) round((float) $block['pos_width'] / 100 * $width))
|
||||
: $maxLineWidth;
|
||||
$lines = $this->wrapLines($raw, $size, $fontFile, $blockMaxW);
|
||||
$lineH = $this->lineHeight($size, $fontFile, $lhMulti);
|
||||
$x = (int) round((float) Arr::get($block, 'pos_x', 0) / 100 * $width);
|
||||
$y = (int) round((float) Arr::get($block, 'pos_y', 0) / 100 * $height);
|
||||
$color = in_array($type, ['author', 'source', 'title'], true) ? $accentColor : $textColor;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$this->drawLine($image, $size, $x, $y, $line, $color, $fontFile, $shadow);
|
||||
$y += $lineH;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the TTF font file path for a given preset key. */
|
||||
private function resolveFont(string $preset): string
|
||||
{
|
||||
$dir = rtrim((string) config('nova_cards.render.fonts_dir', storage_path('app/fonts')), '/\\');
|
||||
$custom = $dir . DIRECTORY_SEPARATOR . $preset . '.ttf';
|
||||
if (file_exists($custom) && function_exists('imagettftext')) {
|
||||
return $custom;
|
||||
}
|
||||
|
||||
$default = $dir . DIRECTORY_SEPARATOR . 'default.ttf';
|
||||
if (file_exists($default) && function_exists('imagettftext')) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return ''; // falls back to GD built-in fonts
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the best TTF font for Unicode decoration glyphs (✦ ♥ ☾ …).
|
||||
* Looks for symbols.ttf first (place a NotoSansSymbols or similar file there),
|
||||
* then falls back to the preset font or default.ttf.
|
||||
*/
|
||||
private function resolveSymbolFont(string $preset): string
|
||||
{
|
||||
$dir = rtrim((string) config('nova_cards.render.fonts_dir', storage_path('app/fonts')), '/\\');
|
||||
foreach (['symbols.ttf', $preset . '.ttf', 'default.ttf'] as $candidate) {
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $candidate;
|
||||
if (file_exists($path) && function_exists('imagettftext')) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Wrap text into lines that fit $maxWidth pixels. */
|
||||
private function wrapLines(string $text, float $size, string $fontFile, int $maxWidth): array
|
||||
{
|
||||
if ($text === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Split on explicit newlines first, then wrap each segment.
|
||||
$paragraphs = preg_split('/\r\n|\r|\n/', $text) ?: [$text];
|
||||
$lines = [];
|
||||
foreach ($paragraphs as $para) {
|
||||
$words = preg_split('/\s+/u', trim($para)) ?: [trim($para)];
|
||||
$current = '';
|
||||
foreach ($words as $word) {
|
||||
$candidate = $current === '' ? $word : $current . ' ' . $word;
|
||||
if ($this->measureLine($candidate, $size, $fontFile) <= $maxWidth) {
|
||||
$current = $candidate;
|
||||
} else {
|
||||
if ($current !== '') {
|
||||
$lines[] = $current;
|
||||
}
|
||||
$current = $word;
|
||||
}
|
||||
}
|
||||
if ($current !== '') {
|
||||
$lines[] = $current;
|
||||
}
|
||||
}
|
||||
|
||||
return $lines ?: [$text];
|
||||
}
|
||||
|
||||
/** Measure the pixel width of a single line of text. */
|
||||
private function measureLine(string $text, float $size, string $fontFile): int
|
||||
{
|
||||
if ($fontFile !== '' && function_exists('imagettfbbox')) {
|
||||
$bbox = @imagettfbbox($size, 0, $fontFile, $text);
|
||||
if ($bbox !== false) {
|
||||
return abs($bbox[4] - $bbox[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// GD built-in fallback: font 5 is ~9px wide per character.
|
||||
return strlen($text) * imagefontwidth(5);
|
||||
}
|
||||
|
||||
/** Calculate the line height (pixels) for a given font size. */
|
||||
private function lineHeight(float $size, string $fontFile, float $multiplier): int
|
||||
{
|
||||
if ($fontFile !== '' && function_exists('imagettfbbox')) {
|
||||
$bbox = @imagettfbbox($size, 0, $fontFile, 'Ágjy');
|
||||
if ($bbox !== false) {
|
||||
return (int) round(abs($bbox[1] - $bbox[7]) * $multiplier);
|
||||
}
|
||||
}
|
||||
|
||||
return (int) round($size * $multiplier);
|
||||
}
|
||||
|
||||
/** Draw a single text line with optional shadow. Baseline is $y. */
|
||||
private function drawLine($image, float $size, int $x, int $y, string $text, int $color, string $fontFile, string $shadowPreset): void
|
||||
{
|
||||
if ($fontFile !== '' && function_exists('imagettftext')) {
|
||||
// FreeType: $y is the baseline.
|
||||
$baseline = $y + (int) round($size);
|
||||
if ($shadowPreset !== 'none') {
|
||||
$offset = $shadowPreset === 'strong' ? 4 : 2;
|
||||
$shadowAlpha = $shadowPreset === 'strong' ? 46 : 80;
|
||||
$shadowColor = imagecolorallocatealpha($image, 0, 0, 0, $shadowAlpha);
|
||||
imagettftext($image, $size, 0, $x + $offset, $baseline + $offset, $shadowColor, $fontFile, $text);
|
||||
}
|
||||
imagettftext($image, $size, 0, $x, $baseline, $color, $fontFile, $text);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// GD built-in fallback (bitmap fonts, $y is the top of the glyph).
|
||||
if ($shadowPreset !== 'none') {
|
||||
$offset = $shadowPreset === 'strong' ? 3 : 1;
|
||||
$shadowColor = imagecolorallocatealpha($image, 0, 0, 0, $shadowPreset === 'strong' ? 46 : 80);
|
||||
imagestring($image, 5, $x + $offset, $y + $offset, $text, $shadowColor);
|
||||
}
|
||||
imagestring($image, 5, $x, $y, $text, $color);
|
||||
}
|
||||
|
||||
// ─── Decorations & assets ────────────────────────────────────────────────
|
||||
|
||||
private function paintDecorations($image, array $project, int $width, int $height): void
|
||||
{
|
||||
$decorations = Arr::wrap(Arr::get($project, 'decorations', []));
|
||||
$accent = $this->allocateHex($image, (string) Arr::get($project, 'typography.accent_color', '#ffffff'));
|
||||
$fontScale = $width / 1080.0;
|
||||
$symbolFont = $this->resolveSymbolFont((string) Arr::get($project, 'typography.font_preset', 'modern-sans'));
|
||||
[$accentR, $accentG, $accentB] = $this->hexToRgb((string) Arr::get($project, 'typography.accent_color', '#ffffff'));
|
||||
|
||||
foreach (array_slice($decorations, 0, (int) config('nova_cards.validation.max_decorations', 6)) as $index => $decoration) {
|
||||
$x = (int) Arr::get($decoration, 'x', ($index % 2 === 0 ? 0.12 : 0.82) * $width);
|
||||
$y = (int) Arr::get($decoration, 'y', (0.14 + ($index * 0.1)) * $height);
|
||||
$size = max(2, (int) Arr::get($decoration, 'size', 6));
|
||||
imagefilledellipse($image, $x, $y, $size, $size, $accent);
|
||||
$glyph = (string) Arr::get($decoration, 'glyph', '•');
|
||||
|
||||
// pos_x / pos_y are stored as percentages (0–100); fall back to sensible defaults.
|
||||
$xPct = Arr::get($decoration, 'pos_x');
|
||||
$yPct = Arr::get($decoration, 'pos_y');
|
||||
$x = $xPct !== null
|
||||
? (int) round((float) $xPct / 100 * $width)
|
||||
: (int) round(($index % 2 === 0 ? 0.12 : 0.82) * $width);
|
||||
$y = $yPct !== null
|
||||
? (int) round((float) $yPct / 100 * $height)
|
||||
: (int) round((0.14 + ($index * 0.1)) * $height);
|
||||
|
||||
// Canvas clamp: max(18, min(size, 64)) matching NovaCardCanvasPreview.
|
||||
$rawSize = max(18, min((int) Arr::get($decoration, 'size', 28), 64));
|
||||
$size = (float) ($rawSize * $fontScale);
|
||||
|
||||
// Opacity: 10–100 integer percent → GD alpha 0 (opaque)–127 (transparent).
|
||||
$opacityPct = max(10, min(100, (int) Arr::get($decoration, 'opacity', 85)));
|
||||
$alpha = (int) round((1 - $opacityPct / 100) * 127);
|
||||
$color = imagecolorallocatealpha($image, $accentR, $accentG, $accentB, $alpha);
|
||||
|
||||
if ($symbolFont !== '' && function_exists('imagettftext')) {
|
||||
// Render the Unicode glyph; baseline = y + size (same as drawLine).
|
||||
imagettftext($image, $size, 0, $x, (int) ($y + $size), $color, $symbolFont, $glyph);
|
||||
} else {
|
||||
// No TTF font available — draw a small filled ellipse as a generic marker.
|
||||
$d = max(4, (int) round($size * 0.6));
|
||||
imagefilledellipse($image, $x, (int) ($y + $size / 2), $d, $d, $color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,12 +448,14 @@ class NovaCardRenderService
|
||||
}
|
||||
|
||||
if ($type === 'frame') {
|
||||
$y = $index % 2 === 0 ? (int) round($height * 0.08) : (int) round($height * 0.92);
|
||||
imageline($image, (int) round($width * 0.12), $y, (int) round($width * 0.88), $y, $accent);
|
||||
$lineY = $index % 2 === 0 ? (int) round($height * 0.08) : (int) round($height * 0.92);
|
||||
imageline($image, (int) round($width * 0.12), $lineY, (int) round($width * 0.88), $lineY, $accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function resolveTextBlocks(NovaCard $card, array $project): array
|
||||
{
|
||||
$blocks = collect(Arr::wrap(Arr::get($project, 'text_blocks', [])))
|
||||
@@ -253,24 +466,40 @@ class NovaCardRenderService
|
||||
return $blocks->all();
|
||||
}
|
||||
|
||||
return [
|
||||
['type' => 'title', 'text' => trim((string) $card->title)],
|
||||
['type' => 'quote', 'text' => trim((string) $card->quote_text)],
|
||||
['type' => 'author', 'text' => trim((string) $card->quote_author)],
|
||||
['type' => 'source', 'text' => trim((string) $card->quote_source)],
|
||||
];
|
||||
// Fallback: use top-level card fields.
|
||||
return array_values(array_filter([
|
||||
trim((string) $card->title) !== '' ? ['type' => 'title', 'text' => trim((string) $card->title)] : null,
|
||||
trim((string) $card->quote_text) !== '' ? ['type' => 'quote', 'text' => trim((string) $card->quote_text)] : null,
|
||||
trim((string) $card->quote_author) !== '' ? ['type' => 'author', 'text' => trim((string) $card->quote_author)] : null,
|
||||
trim((string) $card->quote_source) !== '' ? ['type' => 'source', 'text' => trim((string) $card->quote_source)] : null,
|
||||
]));
|
||||
}
|
||||
|
||||
private function fontForBlockType(string $type): int
|
||||
private function resolveAlignedX(string $alignment, int $width, int $padding, int $lineWidth): int
|
||||
{
|
||||
return match ($type) {
|
||||
'title', 'source' => 3,
|
||||
'author', 'body' => 4,
|
||||
'caption' => 2,
|
||||
default => 5,
|
||||
return match ($alignment) {
|
||||
'left' => $padding,
|
||||
'right' => max($padding, $width - $padding - $lineWidth),
|
||||
default => max(0, (int) round(($width - $lineWidth) / 2)),
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveFocalRatios(string $focalPosition): array
|
||||
{
|
||||
$x = match ($focalPosition) {
|
||||
'left', 'top-left', 'bottom-left' => 0.0,
|
||||
'right', 'top-right', 'bottom-right' => 1.0,
|
||||
default => 0.5,
|
||||
};
|
||||
$y = match ($focalPosition) {
|
||||
'top', 'top-left', 'top-right' => 0.0,
|
||||
'bottom', 'bottom-left', 'bottom-right' => 1.0,
|
||||
default => 0.5,
|
||||
};
|
||||
|
||||
return [$x, $y];
|
||||
}
|
||||
|
||||
private function paintVerticalGradient($image, int $width, int $height, string $fromHex, string $toHex): void
|
||||
{
|
||||
[$r1, $g1, $b1] = $this->hexToRgb($fromHex);
|
||||
@@ -278,15 +507,15 @@ class NovaCardRenderService
|
||||
|
||||
for ($y = 0; $y < $height; $y++) {
|
||||
$ratio = $height > 1 ? $y / ($height - 1) : 0;
|
||||
$red = (int) round($r1 + (($r2 - $r1) * $ratio));
|
||||
$red = (int) round($r1 + (($r2 - $r1) * $ratio));
|
||||
$green = (int) round($g1 + (($g2 - $g1) * $ratio));
|
||||
$blue = (int) round($b1 + (($b2 - $b1) * $ratio));
|
||||
$blue = (int) round($b1 + (($b2 - $b1) * $ratio));
|
||||
$color = imagecolorallocate($image, $red, $green, $blue);
|
||||
imageline($image, 0, $y, $width, $y, $color);
|
||||
}
|
||||
}
|
||||
|
||||
private function allocateHex($image, string $hex)
|
||||
private function allocateHex($image, string $hex): int
|
||||
{
|
||||
[$r, $g, $b] = $this->hexToRgb($hex);
|
||||
|
||||
@@ -305,46 +534,9 @@ class NovaCardRenderService
|
||||
}
|
||||
|
||||
return [
|
||||
hexdec(substr($normalized, 0, 2)),
|
||||
hexdec(substr($normalized, 2, 2)),
|
||||
hexdec(substr($normalized, 4, 2)),
|
||||
(int) hexdec(substr($normalized, 0, 2)),
|
||||
(int) hexdec(substr($normalized, 2, 2)),
|
||||
(int) hexdec(substr($normalized, 4, 2)),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveAlignedX(string $alignment, int $width, int $padding, int $lineWidth): int
|
||||
{
|
||||
return match ($alignment) {
|
||||
'left' => $padding,
|
||||
'right' => max($padding, $width - $padding - $lineWidth),
|
||||
default => max($padding, (int) round(($width - $lineWidth) / 2)),
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveFocalSourceOrigin(string $focalPosition, int $sourceWidth, int $sourceHeight): array
|
||||
{
|
||||
$x = match ($focalPosition) {
|
||||
'left', 'top-left', 'bottom-left' => 0,
|
||||
'right', 'top-right', 'bottom-right' => max(0, (int) round($sourceWidth * 0.18)),
|
||||
default => max(0, (int) round($sourceWidth * 0.09)),
|
||||
};
|
||||
|
||||
$y = match ($focalPosition) {
|
||||
'top', 'top-left', 'top-right' => 0,
|
||||
'bottom', 'bottom-left', 'bottom-right' => max(0, (int) round($sourceHeight * 0.18)),
|
||||
default => max(0, (int) round($sourceHeight * 0.09)),
|
||||
};
|
||||
|
||||
return [$x, $y];
|
||||
}
|
||||
|
||||
private function drawText($image, int $font, int $x, int $y, string $text, int $color, string $shadowPreset): void
|
||||
{
|
||||
if ($shadowPreset !== 'none') {
|
||||
$offset = $shadowPreset === 'strong' ? 3 : 1;
|
||||
$shadow = imagecolorallocatealpha($image, 2, 6, 23, $shadowPreset === 'strong' ? 46 : 78);
|
||||
imagestring($image, $font, $x + $offset, $y + $offset, $text, $shadow);
|
||||
}
|
||||
|
||||
imagestring($image, $font, $x, $y, $text, $color);
|
||||
}
|
||||
}
|
||||
|
||||
82
app/Services/Sitemaps/AbstractSitemapBuilder.php
Normal file
82
app/Services/Sitemaps/AbstractSitemapBuilder.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
abstract class AbstractSitemapBuilder implements SitemapBuilder
|
||||
{
|
||||
protected function contentTypeSlugs(): array
|
||||
{
|
||||
return array_values((array) config('sitemaps.content_type_slugs', []));
|
||||
}
|
||||
|
||||
protected function newest(mixed ...$timestamps): ?DateTimeInterface
|
||||
{
|
||||
$filtered = array_values(array_filter(array_map(fn (mixed $value): ?Carbon => $this->dateTime($value), $timestamps)));
|
||||
|
||||
if ($filtered === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($filtered, static fn (DateTimeInterface $left, DateTimeInterface $right): int => $left < $right ? 1 : -1);
|
||||
|
||||
return $filtered[0];
|
||||
}
|
||||
|
||||
protected function dateTime(mixed $value): ?Carbon
|
||||
{
|
||||
if ($value instanceof Carbon) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return Carbon::instance($value);
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return Carbon::parse($value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function absoluteUrl(?string $url): ?string
|
||||
{
|
||||
$value = trim((string) $url);
|
||||
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Str::startsWith($value, ['http://', 'https://'])) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return url($value);
|
||||
}
|
||||
|
||||
protected function image(?string $url, ?string $title = null): ?SitemapImage
|
||||
{
|
||||
$absolute = $this->absoluteUrl($url);
|
||||
|
||||
if ($absolute === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SitemapImage($absolute, $title !== '' ? $title : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, SitemapImage|null> $images
|
||||
* @return list<SitemapImage>
|
||||
*/
|
||||
protected function images(array $images): array
|
||||
{
|
||||
return array_values(array_filter($images, static fn (mixed $image): bool => $image instanceof SitemapImage));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Services\Sitemaps\AbstractSitemapBuilder;
|
||||
use App\Services\Sitemaps\ShardableSitemapBuilder;
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
abstract class AbstractIdShardableSitemapBuilder extends AbstractSitemapBuilder implements ShardableSitemapBuilder
|
||||
{
|
||||
/**
|
||||
* @return Builder<Model>
|
||||
*/
|
||||
abstract protected function query(): Builder;
|
||||
|
||||
abstract protected function shardConfigKey(): string;
|
||||
|
||||
abstract protected function mapRecord(Model $record): ?SitemapUrl;
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
return $this->query()
|
||||
->orderBy($this->idColumn())
|
||||
->cursor()
|
||||
->map(fn (Model $record): ?SitemapUrl => $this->mapRecord($record))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function lastModified(): ?DateTimeInterface
|
||||
{
|
||||
return $this->newest(...array_map(
|
||||
fn (SitemapUrl $item): ?DateTimeInterface => $item->lastModified,
|
||||
$this->items(),
|
||||
));
|
||||
}
|
||||
|
||||
public function totalItems(): int
|
||||
{
|
||||
return (clone $this->query())->count();
|
||||
}
|
||||
|
||||
public function shardSize(): int
|
||||
{
|
||||
return max(1, (int) \data_get(\config('sitemaps.shards', []), $this->shardConfigKey() . '.size', 10000));
|
||||
}
|
||||
|
||||
public function itemsForShard(int $shard): array
|
||||
{
|
||||
$window = $this->shardWindow($shard);
|
||||
|
||||
if ($window === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->applyShardWindow($window['from'], $window['to'])
|
||||
->get()
|
||||
->map(fn (Model $record): ?SitemapUrl => $this->mapRecord($record))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function lastModifiedForShard(int $shard): ?DateTimeInterface
|
||||
{
|
||||
return $this->newest(...array_map(
|
||||
fn (SitemapUrl $item): ?DateTimeInterface => $item->lastModified,
|
||||
$this->itemsForShard($shard),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{from: int, to: int}|null
|
||||
*/
|
||||
protected function shardWindow(int $shard): ?array
|
||||
{
|
||||
if ($shard < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$size = $this->shardSize();
|
||||
$current = 0;
|
||||
$from = null;
|
||||
$to = null;
|
||||
|
||||
$windowQuery = (clone $this->query())
|
||||
->setEagerLoads([])
|
||||
->select([$this->idColumn()])
|
||||
->orderBy($this->idColumn());
|
||||
|
||||
foreach ($windowQuery->cursor() as $record) {
|
||||
$current++;
|
||||
|
||||
if ((int) ceil($current / $size) !== $shard) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$recordId = (int) $record->getAttribute($this->idColumn());
|
||||
$from ??= $recordId;
|
||||
$to = $recordId;
|
||||
}
|
||||
|
||||
if ($from === null || $to === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['from' => $from, 'to' => $to];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<Model>
|
||||
*/
|
||||
protected function applyShardWindow(int $from, int $to): Builder
|
||||
{
|
||||
return (clone $this->query())
|
||||
->whereBetween($this->qualifiedIdColumn(), [$from, $to])
|
||||
->orderBy($this->idColumn());
|
||||
}
|
||||
|
||||
protected function idColumn(): string
|
||||
{
|
||||
return 'id';
|
||||
}
|
||||
|
||||
protected function qualifiedIdColumn(): string
|
||||
{
|
||||
return $this->idColumn();
|
||||
}
|
||||
}
|
||||
45
app/Services/Sitemaps/Builders/ArtworksSitemapBuilder.php
Normal file
45
app/Services/Sitemaps/Builders/ArtworksSitemapBuilder.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class ArtworksSitemapBuilder extends AbstractIdShardableSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'artworks';
|
||||
}
|
||||
|
||||
protected function shardConfigKey(): string
|
||||
{
|
||||
return 'artworks';
|
||||
}
|
||||
|
||||
protected function mapRecord(Model $record): ?SitemapUrl
|
||||
{
|
||||
return $this->urls->artwork($record);
|
||||
}
|
||||
|
||||
protected function query(): Builder
|
||||
{
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published();
|
||||
}
|
||||
|
||||
protected function qualifiedIdColumn(): string
|
||||
{
|
||||
return 'artworks.id';
|
||||
}
|
||||
}
|
||||
45
app/Services/Sitemaps/Builders/CardsSitemapBuilder.php
Normal file
45
app/Services/Sitemaps/Builders/CardsSitemapBuilder.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class CardsSitemapBuilder extends AbstractIdShardableSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'cards';
|
||||
}
|
||||
|
||||
protected function shardConfigKey(): string
|
||||
{
|
||||
return 'cards';
|
||||
}
|
||||
|
||||
protected function mapRecord(Model $record): ?SitemapUrl
|
||||
{
|
||||
return $this->urls->card($record);
|
||||
}
|
||||
|
||||
protected function query(): Builder
|
||||
{
|
||||
return NovaCard::query()
|
||||
->publiclyVisible()
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
protected function qualifiedIdColumn(): string
|
||||
{
|
||||
return 'nova_cards.id';
|
||||
}
|
||||
}
|
||||
60
app/Services/Sitemaps/Builders/CategoriesSitemapBuilder.php
Normal file
60
app/Services/Sitemaps/Builders/CategoriesSitemapBuilder.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Services\Sitemaps\AbstractSitemapBuilder;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use DateTimeInterface;
|
||||
|
||||
final class CategoriesSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'categories';
|
||||
}
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
$items = [$this->urls->categoryDirectory()];
|
||||
|
||||
$contentTypes = ContentType::query()
|
||||
->whereIn('slug', $this->contentTypeSlugs())
|
||||
->ordered()
|
||||
->get();
|
||||
|
||||
foreach ($contentTypes as $contentType) {
|
||||
$items[] = $this->urls->contentType($contentType);
|
||||
}
|
||||
|
||||
$categories = Category::query()
|
||||
->with('contentType')
|
||||
->active()
|
||||
->whereHas('contentType', fn ($query) => $query->whereIn('slug', $this->contentTypeSlugs()))
|
||||
->orderBy('content_type_id')
|
||||
->orderBy('parent_id')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
foreach ($categories as $category) {
|
||||
$items[] = $this->urls->category($category);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
public function lastModified(): ?DateTimeInterface
|
||||
{
|
||||
return $this->dateTime(Category::query()
|
||||
->active()
|
||||
->max('updated_at'));
|
||||
}
|
||||
}
|
||||
47
app/Services/Sitemaps/Builders/CollectionsSitemapBuilder.php
Normal file
47
app/Services/Sitemaps/Builders/CollectionsSitemapBuilder.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Models\Collection;
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class CollectionsSitemapBuilder extends AbstractIdShardableSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'collections';
|
||||
}
|
||||
|
||||
protected function shardConfigKey(): string
|
||||
{
|
||||
return 'collections';
|
||||
}
|
||||
|
||||
protected function mapRecord(Model $record): ?SitemapUrl
|
||||
{
|
||||
return $this->urls->collection($record);
|
||||
}
|
||||
|
||||
protected function query(): Builder
|
||||
{
|
||||
return Collection::query()
|
||||
->with('user:id,username')
|
||||
->public()
|
||||
->whereNull('canonical_collection_id')
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
protected function qualifiedIdColumn(): string
|
||||
{
|
||||
return 'collections.id';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Services\Sitemaps\AbstractSitemapBuilder;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use DateTimeInterface;
|
||||
use cPad\Plugins\Forum\Models\ForumBoard;
|
||||
use cPad\Plugins\Forum\Models\ForumCategory;
|
||||
|
||||
final class ForumCategoriesSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'forum-categories';
|
||||
}
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
foreach (ForumCategory::query()->active()->ordered()->get() as $category) {
|
||||
$items[] = $this->urls->forumCategory($category);
|
||||
}
|
||||
|
||||
foreach (ForumBoard::query()->active()->ordered()->get() as $board) {
|
||||
$items[] = $this->urls->forumBoard($board);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
public function lastModified(): ?DateTimeInterface
|
||||
{
|
||||
return $this->newest(
|
||||
ForumCategory::query()->active()->max('updated_at'),
|
||||
ForumBoard::query()->active()->max('updated_at'),
|
||||
);
|
||||
}
|
||||
}
|
||||
31
app/Services/Sitemaps/Builders/ForumIndexSitemapBuilder.php
Normal file
31
app/Services/Sitemaps/Builders/ForumIndexSitemapBuilder.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Services\Sitemaps\AbstractSitemapBuilder;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use DateTimeInterface;
|
||||
|
||||
final class ForumIndexSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'forum-index';
|
||||
}
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
return [$this->urls->forumIndex()];
|
||||
}
|
||||
|
||||
public function lastModified(): ?DateTimeInterface
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use cPad\Plugins\Forum\Models\ForumTopic;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class ForumThreadsSitemapBuilder extends AbstractIdShardableSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'forum-threads';
|
||||
}
|
||||
|
||||
protected function shardConfigKey(): string
|
||||
{
|
||||
return 'forum-threads';
|
||||
}
|
||||
|
||||
protected function mapRecord(Model $record): ?SitemapUrl
|
||||
{
|
||||
return $this->urls->forumTopic($record);
|
||||
}
|
||||
|
||||
protected function query(): Builder
|
||||
{
|
||||
return ForumTopic::query()
|
||||
->visible()
|
||||
->whereHas('board', fn ($query) => $query->active())
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
protected function qualifiedIdColumn(): string
|
||||
{
|
||||
return 'forum_topics.id';
|
||||
}
|
||||
}
|
||||
52
app/Services/Sitemaps/Builders/GoogleNewsSitemapBuilder.php
Normal file
52
app/Services/Sitemaps/Builders/GoogleNewsSitemapBuilder.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Services\Sitemaps\AbstractSitemapBuilder;
|
||||
use App\Services\Sitemaps\GoogleNewsSitemapUrl;
|
||||
use DateTimeInterface;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
|
||||
final class GoogleNewsSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return (string) \config('sitemaps.news.google_variant_name', 'news-google');
|
||||
}
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
return NewsArticle::query()
|
||||
->published()
|
||||
->where('published_at', '>=', now()->subHours(max(1, (int) \config('sitemaps.news.google_lookback_hours', 48))))
|
||||
->orderByDesc('published_at')
|
||||
->limit(max(1, (int) \config('sitemaps.news.google_max_items', 1000)))
|
||||
->get()
|
||||
->map(function (NewsArticle $article): ?GoogleNewsSitemapUrl {
|
||||
if (trim((string) $article->slug) === '' || $article->published_at === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new GoogleNewsSitemapUrl(
|
||||
route('news.show', ['slug' => $article->slug]),
|
||||
trim((string) $article->title),
|
||||
$article->published_at,
|
||||
(string) \config('sitemaps.news.google_publication_name', 'Skinbase Nova'),
|
||||
(string) \config('sitemaps.news.google_language', 'en'),
|
||||
);
|
||||
})
|
||||
->filter(fn (?GoogleNewsSitemapUrl $item): bool => $item !== null && $item->title !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function lastModified(): ?DateTimeInterface
|
||||
{
|
||||
return $this->dateTime(NewsArticle::query()
|
||||
->published()
|
||||
->where('published_at', '>=', now()->subHours(max(1, (int) \config('sitemaps.news.google_lookback_hours', 48))))
|
||||
->max('published_at'));
|
||||
}
|
||||
}
|
||||
42
app/Services/Sitemaps/Builders/NewsSitemapBuilder.php
Normal file
42
app/Services/Sitemaps/Builders/NewsSitemapBuilder.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Services\Sitemaps\AbstractSitemapBuilder;
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use DateTimeInterface;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
|
||||
final class NewsSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'news';
|
||||
}
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
return NewsArticle::query()
|
||||
->published()
|
||||
->orderBy('id')
|
||||
->cursor()
|
||||
->map(fn (NewsArticle $article): ?SitemapUrl => $this->urls->news($article))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function lastModified(): ?DateTimeInterface
|
||||
{
|
||||
return $this->dateTime(NewsArticle::query()
|
||||
->published()
|
||||
->max('updated_at'));
|
||||
}
|
||||
}
|
||||
61
app/Services/Sitemaps/Builders/StaticPagesSitemapBuilder.php
Normal file
61
app/Services/Sitemaps/Builders/StaticPagesSitemapBuilder.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Models\Page;
|
||||
use App\Services\Sitemaps\AbstractSitemapBuilder;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use DateTimeInterface;
|
||||
|
||||
final class StaticPagesSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'static-pages';
|
||||
}
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
$items = [
|
||||
$this->urls->staticRoute('/'),
|
||||
$this->urls->staticRoute('/faq'),
|
||||
$this->urls->staticRoute('/rules-and-guidelines'),
|
||||
$this->urls->staticRoute('/privacy-policy'),
|
||||
$this->urls->staticRoute('/terms-of-service'),
|
||||
$this->urls->staticRoute('/staff'),
|
||||
];
|
||||
|
||||
$marketingPages = Page::query()
|
||||
->published()
|
||||
->whereIn('slug', ['about', 'help'])
|
||||
->get()
|
||||
->keyBy('slug');
|
||||
|
||||
if ($marketingPages->has('about')) {
|
||||
$items[] = $this->urls->page($marketingPages['about'], '/about');
|
||||
}
|
||||
|
||||
if ($marketingPages->has('help')) {
|
||||
$items[] = $this->urls->page($marketingPages['help'], '/help');
|
||||
}
|
||||
|
||||
$excluded = array_values((array) config('sitemaps.static_page_excluded_slugs', []));
|
||||
|
||||
foreach (Page::query()->published()->whereNotIn('slug', $excluded)->orderBy('slug')->get() as $page) {
|
||||
$items[] = $this->urls->page($page, '/pages/' . $page->slug);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
public function lastModified(): ?DateTimeInterface
|
||||
{
|
||||
return $this->dateTime(Page::query()->published()->max('updated_at'));
|
||||
}
|
||||
}
|
||||
45
app/Services/Sitemaps/Builders/StoriesSitemapBuilder.php
Normal file
45
app/Services/Sitemaps/Builders/StoriesSitemapBuilder.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Models\Story;
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class StoriesSitemapBuilder extends AbstractIdShardableSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'stories';
|
||||
}
|
||||
|
||||
protected function shardConfigKey(): string
|
||||
{
|
||||
return 'stories';
|
||||
}
|
||||
|
||||
protected function mapRecord(Model $record): ?SitemapUrl
|
||||
{
|
||||
return $this->urls->story($record);
|
||||
}
|
||||
|
||||
protected function query(): Builder
|
||||
{
|
||||
return Story::query()
|
||||
->published()
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
protected function qualifiedIdColumn(): string
|
||||
{
|
||||
return 'stories.id';
|
||||
}
|
||||
}
|
||||
45
app/Services/Sitemaps/Builders/TagsSitemapBuilder.php
Normal file
45
app/Services/Sitemaps/Builders/TagsSitemapBuilder.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Models\Tag;
|
||||
use App\Services\Sitemaps\AbstractSitemapBuilder;
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use DateTimeInterface;
|
||||
|
||||
final class TagsSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'tags';
|
||||
}
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
return Tag::query()
|
||||
->where('is_active', true)
|
||||
->where('usage_count', '>', 0)
|
||||
->whereHas('artworks', fn ($query) => $query->public()->published())
|
||||
->orderByDesc('usage_count')
|
||||
->orderBy('slug')
|
||||
->get()
|
||||
->map(fn (Tag $tag): SitemapUrl => $this->urls->tag($tag))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function lastModified(): ?DateTimeInterface
|
||||
{
|
||||
return $this->dateTime(Tag::query()
|
||||
->where('is_active', true)
|
||||
->where('usage_count', '>', 0)
|
||||
->max('updated_at'));
|
||||
}
|
||||
}
|
||||
76
app/Services/Sitemaps/Builders/UsersSitemapBuilder.php
Normal file
76
app/Services/Sitemaps/Builders/UsersSitemapBuilder.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Collection;
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\Story;
|
||||
use App\Models\User;
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class UsersSitemapBuilder extends AbstractIdShardableSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'users';
|
||||
}
|
||||
|
||||
protected function shardConfigKey(): string
|
||||
{
|
||||
return 'users';
|
||||
}
|
||||
|
||||
protected function mapRecord(Model $record): ?SitemapUrl
|
||||
{
|
||||
return $this->urls->profile($record, $record->updated_at);
|
||||
}
|
||||
|
||||
protected function query(): Builder
|
||||
{
|
||||
return User::query()
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('username')
|
||||
->where('username', '!=', '')
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->whereExists(
|
||||
Artwork::query()
|
||||
->selectRaw('1')
|
||||
->public()
|
||||
->published()
|
||||
->whereColumn('artworks.user_id', 'users.id')
|
||||
)->orWhereExists(
|
||||
Collection::query()
|
||||
->selectRaw('1')
|
||||
->public()
|
||||
->whereNull('canonical_collection_id')
|
||||
->whereColumn('collections.user_id', 'users.id')
|
||||
)->orWhereExists(
|
||||
NovaCard::query()
|
||||
->selectRaw('1')
|
||||
->publiclyVisible()
|
||||
->whereColumn('nova_cards.user_id', 'users.id')
|
||||
)->orWhereExists(
|
||||
Story::query()
|
||||
->selectRaw('1')
|
||||
->published()
|
||||
->whereColumn('stories.creator_id', 'users.id')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
protected function qualifiedIdColumn(): string
|
||||
{
|
||||
return 'users.id';
|
||||
}
|
||||
}
|
||||
19
app/Services/Sitemaps/GoogleNewsSitemapUrl.php
Normal file
19
app/Services/Sitemaps/GoogleNewsSitemapUrl.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use DateTimeInterface;
|
||||
|
||||
final class GoogleNewsSitemapUrl
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $loc,
|
||||
public readonly string $title,
|
||||
public readonly DateTimeInterface $publicationDate,
|
||||
public readonly string $publicationName,
|
||||
public readonly string $publicationLanguage,
|
||||
) {
|
||||
}
|
||||
}
|
||||
77
app/Services/Sitemaps/PublishedSitemapResolver.php
Normal file
77
app/Services/Sitemaps/PublishedSitemapResolver.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
final class PublishedSitemapResolver
|
||||
{
|
||||
public function __construct(private readonly SitemapReleaseManager $releases)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{content: string, release_id: string, document_name: string}|null
|
||||
*/
|
||||
public function resolveIndex(): ?array
|
||||
{
|
||||
return $this->resolveDocumentName(SitemapCacheService::INDEX_DOCUMENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{content: string, release_id: string, document_name: string}|null
|
||||
*/
|
||||
public function resolveNamed(string $requestedName): ?array
|
||||
{
|
||||
$manifest = $this->releases->activeManifest();
|
||||
|
||||
if ($manifest === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$documentName = $this->canonicalDocumentName($requestedName, $manifest);
|
||||
|
||||
return $documentName !== null ? $this->resolveDocumentName($documentName) : null;
|
||||
}
|
||||
|
||||
private function resolveDocumentName(string $documentName): ?array
|
||||
{
|
||||
$releaseId = $this->releases->activeReleaseId();
|
||||
|
||||
if ($releaseId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = $this->releases->getDocument($releaseId, $documentName);
|
||||
|
||||
return is_string($content) && $content !== ''
|
||||
? ['content' => $content, 'release_id' => $releaseId, 'document_name' => $documentName]
|
||||
: null;
|
||||
}
|
||||
|
||||
private function canonicalDocumentName(string $requestedName, array $manifest): ?string
|
||||
{
|
||||
$documents = (array) ($manifest['documents'] ?? []);
|
||||
|
||||
if (isset($documents[$requestedName])) {
|
||||
return $requestedName;
|
||||
}
|
||||
|
||||
foreach ((array) ($manifest['families'] ?? []) as $familyName => $family) {
|
||||
$entryName = (string) ($family['entry_name'] ?? '');
|
||||
if ($requestedName === $familyName && $entryName !== '') {
|
||||
return $entryName;
|
||||
}
|
||||
|
||||
if (preg_match('/^' . preg_quote((string) $familyName, '/') . '-([0-9]+)$/', $requestedName, $matches)) {
|
||||
$number = (int) $matches[1];
|
||||
$candidate = sprintf('%s-%04d', $familyName, $number);
|
||||
if (in_array($candidate, (array) ($family['shards'] ?? []), true)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
21
app/Services/Sitemaps/ShardableSitemapBuilder.php
Normal file
21
app/Services/Sitemaps/ShardableSitemapBuilder.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use DateTimeInterface;
|
||||
|
||||
interface ShardableSitemapBuilder extends SitemapBuilder
|
||||
{
|
||||
public function totalItems(): int;
|
||||
|
||||
public function shardSize(): int;
|
||||
|
||||
/**
|
||||
* @return list<SitemapUrl>
|
||||
*/
|
||||
public function itemsForShard(int $shard): array;
|
||||
|
||||
public function lastModifiedForShard(int $shard): ?DateTimeInterface;
|
||||
}
|
||||
143
app/Services/Sitemaps/SitemapBuildService.php
Normal file
143
app/Services/Sitemaps/SitemapBuildService.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use App\Services\Sitemaps\Builders\GoogleNewsSitemapBuilder;
|
||||
|
||||
final class SitemapBuildService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SitemapCacheService $cache,
|
||||
private readonly SitemapIndexService $index,
|
||||
private readonly SitemapRegistry $registry,
|
||||
private readonly SitemapShardService $shards,
|
||||
private readonly SitemapXmlRenderer $renderer,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{content: string, source: string, type: string, url_count: int, shard_count: int, name: string}
|
||||
*/
|
||||
public function buildIndex(bool $force = false, bool $persist = true, ?array $families = null): array
|
||||
{
|
||||
$built = $this->cache->remember(
|
||||
SitemapCacheService::INDEX_DOCUMENT,
|
||||
fn (): string => $this->renderer->renderIndex($this->index->items($families)),
|
||||
$force,
|
||||
$persist,
|
||||
);
|
||||
|
||||
return $built + [
|
||||
'type' => SitemapTarget::TYPE_INDEX,
|
||||
'url_count' => count($this->index->items($families)),
|
||||
'shard_count' => 0,
|
||||
'name' => SitemapCacheService::INDEX_DOCUMENT,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{content: string, source: string, type: string, url_count: int, shard_count: int, name: string}|null
|
||||
*/
|
||||
public function buildNamed(string $name, bool $force = false, bool $persist = true): ?array
|
||||
{
|
||||
$target = $this->shards->resolve($this->registry, $name);
|
||||
|
||||
if ($target === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$built = $this->cache->remember(
|
||||
$target->documentName,
|
||||
fn (): string => $this->renderTarget($target),
|
||||
$force,
|
||||
$persist,
|
||||
);
|
||||
|
||||
return $built + [
|
||||
'type' => $target->type,
|
||||
'url_count' => $this->urlCount($target),
|
||||
'shard_count' => $target->totalShards,
|
||||
'name' => $target->documentName,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function documentNamesForFamily(string $family, bool $includeCompatibilityIndex = true): array
|
||||
{
|
||||
$builder = $this->registry->get($family);
|
||||
|
||||
if ($builder === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$names = $this->shards->canonicalDocumentNamesForBuilder($builder);
|
||||
|
||||
if ($includeCompatibilityIndex && $builder instanceof ShardableSitemapBuilder && $this->shards->shardCount($builder) > 1) {
|
||||
array_unshift($names, $builder->name());
|
||||
foreach (range(1, $this->shards->shardCount($builder)) as $shard) {
|
||||
array_unshift($names, $builder->name() . '-' . $shard);
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($names));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function canonicalDocumentNamesForFamily(string $family): array
|
||||
{
|
||||
$builder = $this->registry->get($family);
|
||||
|
||||
if ($builder === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->shards->canonicalDocumentNamesForBuilder($builder);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function enabledFamilies(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
(array) config('sitemaps.enabled', []),
|
||||
fn (mixed $name): bool => is_string($name) && $this->registry->get($name) !== null,
|
||||
));
|
||||
}
|
||||
|
||||
private function renderTarget(SitemapTarget $target): string
|
||||
{
|
||||
if ($target->type === SitemapTarget::TYPE_INDEX) {
|
||||
return $this->renderer->renderIndex($this->index->itemsForBuilder($target->builder));
|
||||
}
|
||||
|
||||
if ($target->builder instanceof GoogleNewsSitemapBuilder || $target->type === SitemapTarget::TYPE_GOOGLE_NEWS) {
|
||||
return $this->renderer->renderGoogleNewsUrlset($target->builder->items());
|
||||
}
|
||||
|
||||
if ($target->builder instanceof ShardableSitemapBuilder && $target->shardNumber !== null) {
|
||||
return $this->renderer->renderUrlset($target->builder->itemsForShard($target->shardNumber));
|
||||
}
|
||||
|
||||
return $this->renderer->renderUrlset($target->builder->items());
|
||||
}
|
||||
|
||||
private function urlCount(SitemapTarget $target): int
|
||||
{
|
||||
if ($target->type === SitemapTarget::TYPE_INDEX) {
|
||||
return count($this->index->itemsForBuilder($target->builder));
|
||||
}
|
||||
|
||||
if ($target->builder instanceof ShardableSitemapBuilder && $target->shardNumber !== null) {
|
||||
return count($target->builder->itemsForShard($target->shardNumber));
|
||||
}
|
||||
|
||||
return count($target->builder->items());
|
||||
}
|
||||
}
|
||||
19
app/Services/Sitemaps/SitemapBuilder.php
Normal file
19
app/Services/Sitemaps/SitemapBuilder.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use DateTimeInterface;
|
||||
|
||||
interface SitemapBuilder
|
||||
{
|
||||
public function name(): string;
|
||||
|
||||
/**
|
||||
* @return list<SitemapUrl>
|
||||
*/
|
||||
public function items(): array;
|
||||
|
||||
public function lastModified(): ?DateTimeInterface;
|
||||
}
|
||||
145
app/Services/Sitemaps/SitemapCacheService.php
Normal file
145
app/Services/Sitemaps/SitemapCacheService.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
final class SitemapCacheService
|
||||
{
|
||||
public const INDEX_DOCUMENT = 'index';
|
||||
|
||||
/**
|
||||
* @return array{content: string, source: string}|null
|
||||
*/
|
||||
public function get(string $name): ?array
|
||||
{
|
||||
if ($this->preferPreGenerated()) {
|
||||
$preGenerated = $this->getPreGenerated($name);
|
||||
if ($preGenerated !== null) {
|
||||
return $preGenerated;
|
||||
}
|
||||
}
|
||||
|
||||
$cached = Cache::get($this->cacheKey($name));
|
||||
if (is_string($cached) && $cached !== '') {
|
||||
return ['content' => $cached, 'source' => 'cache'];
|
||||
}
|
||||
|
||||
if (! $this->preferPreGenerated()) {
|
||||
return $this->getPreGenerated($name);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{content: string, source: string}
|
||||
*/
|
||||
public function remember(string $name, Closure $builder, bool $force = false, bool $persist = true): array
|
||||
{
|
||||
if (! $force) {
|
||||
$existing = $this->get($name);
|
||||
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
}
|
||||
|
||||
$content = (string) $builder();
|
||||
|
||||
if ($persist) {
|
||||
$this->store($name, $content);
|
||||
}
|
||||
|
||||
return ['content' => $content, 'source' => 'built'];
|
||||
}
|
||||
|
||||
public function store(string $name, string $content): void
|
||||
{
|
||||
Cache::put(
|
||||
$this->cacheKey($name),
|
||||
$content,
|
||||
now()->addSeconds(max(60, (int) config('sitemaps.cache_ttl_seconds', 900))),
|
||||
);
|
||||
|
||||
if ($this->preGeneratedEnabled()) {
|
||||
Storage::disk($this->disk())->put($this->documentPath($name), $content);
|
||||
}
|
||||
}
|
||||
|
||||
public function clear(array $names): int
|
||||
{
|
||||
$cleared = 0;
|
||||
|
||||
foreach (array_values(array_unique($names)) as $name) {
|
||||
Cache::forget($this->cacheKey($name));
|
||||
|
||||
if ($this->preGeneratedEnabled()) {
|
||||
Storage::disk($this->disk())->delete($this->documentPath($name));
|
||||
}
|
||||
|
||||
$cleared++;
|
||||
}
|
||||
|
||||
return $cleared;
|
||||
}
|
||||
|
||||
public function documentPath(string $name): string
|
||||
{
|
||||
$prefix = trim((string) config('sitemaps.pre_generated.path', 'generated-sitemaps'), '/');
|
||||
$segments = $name === self::INDEX_DOCUMENT
|
||||
? [$prefix, 'sitemap.xml']
|
||||
: [$prefix, 'sitemaps', $name . '.xml'];
|
||||
|
||||
return implode('/', array_values(array_filter($segments, static fn (string $segment): bool => $segment !== '')));
|
||||
}
|
||||
|
||||
private function cacheKey(string $name): string
|
||||
{
|
||||
return 'sitemaps:v2:' . $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{content: string, source: string}|null
|
||||
*/
|
||||
private function getPreGenerated(string $name): ?array
|
||||
{
|
||||
if (! $this->preGeneratedEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$disk = Storage::disk($this->disk());
|
||||
$path = $this->documentPath($name);
|
||||
|
||||
if (! $disk->exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = $disk->get($path);
|
||||
|
||||
if (! is_string($content) || $content === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['content' => $content, 'source' => 'pre-generated'];
|
||||
}
|
||||
|
||||
private function disk(): string
|
||||
{
|
||||
return (string) config('sitemaps.pre_generated.disk', 'local');
|
||||
}
|
||||
|
||||
private function preGeneratedEnabled(): bool
|
||||
{
|
||||
return (bool) config('sitemaps.pre_generated.enabled', true);
|
||||
}
|
||||
|
||||
private function preferPreGenerated(): bool
|
||||
{
|
||||
return $this->preGeneratedEnabled() && (bool) config('sitemaps.pre_generated.prefer', false);
|
||||
}
|
||||
}
|
||||
13
app/Services/Sitemaps/SitemapImage.php
Normal file
13
app/Services/Sitemaps/SitemapImage.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
final readonly class SitemapImage
|
||||
{
|
||||
public function __construct(
|
||||
public string $loc,
|
||||
public ?string $title = null,
|
||||
) {}
|
||||
}
|
||||
15
app/Services/Sitemaps/SitemapIndexItem.php
Normal file
15
app/Services/Sitemaps/SitemapIndexItem.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use DateTimeInterface;
|
||||
|
||||
final readonly class SitemapIndexItem
|
||||
{
|
||||
public function __construct(
|
||||
public string $loc,
|
||||
public ?DateTimeInterface $lastModified = null,
|
||||
) {}
|
||||
}
|
||||
61
app/Services/Sitemaps/SitemapIndexService.php
Normal file
61
app/Services/Sitemaps/SitemapIndexService.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
final class SitemapIndexService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SitemapRegistry $registry,
|
||||
private readonly SitemapShardService $shards,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<SitemapIndexItem>
|
||||
*/
|
||||
public function items(?array $families = null): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
foreach ($families ?? (array) config('sitemaps.enabled', []) as $name) {
|
||||
$builder = $this->registry->get((string) $name);
|
||||
|
||||
if ($builder === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = new SitemapIndexItem(
|
||||
url('/sitemaps/' . $this->shards->rootEntryName($builder) . '.xml'),
|
||||
$builder->lastModified(),
|
||||
);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<SitemapIndexItem>
|
||||
*/
|
||||
public function itemsForBuilder(SitemapBuilder $builder): array
|
||||
{
|
||||
if ($builder instanceof ShardableSitemapBuilder && $this->shards->shardCount($builder) > 1) {
|
||||
$items = [];
|
||||
|
||||
foreach (range(1, $this->shards->shardCount($builder)) as $shard) {
|
||||
$items[] = new SitemapIndexItem(
|
||||
url('/sitemaps/' . $this->shards->canonicalShardName($builder->name(), $shard) . '.xml'),
|
||||
$builder->lastModifiedForShard($shard),
|
||||
);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
return [new SitemapIndexItem(
|
||||
url('/sitemaps/' . $this->shards->rootEntryName($builder) . '.xml'),
|
||||
$builder->lastModified(),
|
||||
)];
|
||||
}
|
||||
}
|
||||
201
app/Services/Sitemaps/SitemapPublishService.php
Normal file
201
app/Services/Sitemaps/SitemapPublishService.php
Normal file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
final class SitemapPublishService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SitemapBuildService $build,
|
||||
private readonly SitemapReleaseCleanupService $cleanup,
|
||||
private readonly SitemapReleaseManager $releases,
|
||||
private readonly SitemapReleaseValidator $validator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string>|null $families
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildRelease(?array $families = null, ?string $releaseId = null): array
|
||||
{
|
||||
return $this->withLock(function () use ($families, $releaseId): array {
|
||||
return $this->buildReleaseUnlocked($families, $releaseId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function publish(?string $releaseId = null): array
|
||||
{
|
||||
return $this->withLock(function () use ($releaseId): array {
|
||||
$manifest = $releaseId !== null
|
||||
? $this->releases->readManifest($releaseId)
|
||||
: $this->buildReleaseUnlocked();
|
||||
|
||||
if ($manifest === null) {
|
||||
throw new \RuntimeException('Sitemap release [' . $releaseId . '] does not exist.');
|
||||
}
|
||||
|
||||
$releaseId = (string) $manifest['release_id'];
|
||||
$validation = $this->validator->validate($releaseId);
|
||||
|
||||
if (! ($validation['ok'] ?? false)) {
|
||||
$manifest['status'] = 'failed';
|
||||
$manifest['validation'] = $validation;
|
||||
$this->releases->writeManifest($releaseId, $manifest);
|
||||
|
||||
throw new \RuntimeException('Sitemap release validation failed.');
|
||||
}
|
||||
|
||||
$manifest['status'] = 'published';
|
||||
$manifest['published_at'] = now()->toAtomString();
|
||||
$manifest['validation'] = $validation;
|
||||
$this->releases->writeManifest($releaseId, $manifest);
|
||||
$this->releases->activate($releaseId);
|
||||
$deleted = $this->cleanup->cleanup();
|
||||
|
||||
return $manifest + ['cleanup_deleted' => $deleted];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rollback(?string $releaseId = null): array
|
||||
{
|
||||
return $this->withLock(function () use ($releaseId): array {
|
||||
if ($releaseId === null) {
|
||||
$activeReleaseId = $this->releases->activeReleaseId();
|
||||
foreach ($this->releases->listReleases() as $release) {
|
||||
if ((string) ($release['status'] ?? '') === 'published' && (string) ($release['release_id'] ?? '') !== $activeReleaseId) {
|
||||
$releaseId = (string) $release['release_id'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! is_string($releaseId) || $releaseId === '') {
|
||||
throw new \RuntimeException('No rollback release is available.');
|
||||
}
|
||||
|
||||
$manifest = $this->releases->readManifest($releaseId);
|
||||
if ($manifest === null) {
|
||||
throw new \RuntimeException('Rollback release [' . $releaseId . '] not found.');
|
||||
}
|
||||
|
||||
$this->releases->activate($releaseId);
|
||||
|
||||
return $manifest + ['rolled_back_at' => now()->toAtomString()];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @template TReturn
|
||||
* @param callable(): TReturn $callback
|
||||
* @return TReturn
|
||||
*/
|
||||
private function withLock(callable $callback): mixed
|
||||
{
|
||||
$lock = Cache::lock('sitemaps:publish-flow', max(30, (int) config('sitemaps.releases.lock_seconds', 900)));
|
||||
|
||||
if (! $lock->get()) {
|
||||
throw new \RuntimeException('Another sitemap build or publish operation is already running.');
|
||||
}
|
||||
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string>|null $families
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildReleaseUnlocked(?array $families = null, ?string $releaseId = null): array
|
||||
{
|
||||
$selectedFamilies = $families ?: $this->build->enabledFamilies();
|
||||
$releaseId ??= $this->releases->generateReleaseId();
|
||||
|
||||
$familyManifest = [];
|
||||
$documents = [
|
||||
SitemapCacheService::INDEX_DOCUMENT => $this->releases->documentRelativePath(SitemapCacheService::INDEX_DOCUMENT),
|
||||
];
|
||||
$totalUrls = 0;
|
||||
|
||||
$rootIndex = $this->build->buildIndex(true, false, $selectedFamilies);
|
||||
$this->releases->putDocument($releaseId, SitemapCacheService::INDEX_DOCUMENT, (string) $rootIndex['content']);
|
||||
|
||||
foreach ($selectedFamilies as $family) {
|
||||
$builder = app(SitemapRegistry::class)->get($family);
|
||||
if ($builder === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$canonicalNames = $this->build->canonicalDocumentNamesForFamily($family);
|
||||
$shardNames = [];
|
||||
$urlCount = 0;
|
||||
|
||||
foreach ($canonicalNames as $documentName) {
|
||||
$built = $this->build->buildNamed($documentName, true, false);
|
||||
if ($built === null) {
|
||||
throw new \RuntimeException('Failed to build sitemap document [' . $documentName . '].');
|
||||
}
|
||||
|
||||
$this->releases->putDocument($releaseId, $documentName, (string) $built['content']);
|
||||
$documents[$documentName] = $this->releases->documentRelativePath($documentName);
|
||||
|
||||
if ($built['type'] !== SitemapTarget::TYPE_INDEX) {
|
||||
$urlCount += (int) $built['url_count'];
|
||||
}
|
||||
|
||||
if (str_starts_with($documentName, $family . '-') && ! str_ends_with($documentName, '-index')) {
|
||||
$shardNames[] = $documentName;
|
||||
}
|
||||
}
|
||||
|
||||
$totalUrls += $urlCount;
|
||||
$familyManifest[$family] = [
|
||||
'family' => $family,
|
||||
'entry_name' => app(SitemapShardService::class)->rootEntryName($builder),
|
||||
'documents' => $canonicalNames,
|
||||
'shards' => $shardNames,
|
||||
'url_count' => $urlCount,
|
||||
'shard_count' => count($shardNames),
|
||||
'type' => $builder->name() === (string) config('sitemaps.news.google_variant_name', 'news-google')
|
||||
? SitemapTarget::TYPE_GOOGLE_NEWS
|
||||
: (count($shardNames) > 0 ? SitemapTarget::TYPE_INDEX : SitemapTarget::TYPE_URLSET),
|
||||
];
|
||||
}
|
||||
|
||||
$manifest = [
|
||||
'release_id' => $releaseId,
|
||||
'status' => 'built',
|
||||
'built_at' => now()->toAtomString(),
|
||||
'published_at' => null,
|
||||
'families' => $familyManifest,
|
||||
'documents' => $documents,
|
||||
'totals' => [
|
||||
'families' => count($familyManifest),
|
||||
'documents' => count($documents),
|
||||
'urls' => $totalUrls,
|
||||
],
|
||||
];
|
||||
|
||||
$this->releases->writeManifest($releaseId, $manifest);
|
||||
|
||||
$validation = $this->validator->validate($releaseId);
|
||||
$manifest['status'] = ($validation['ok'] ?? false) ? 'validated' : 'failed';
|
||||
$manifest['validation'] = $validation;
|
||||
$this->releases->writeManifest($releaseId, $manifest);
|
||||
$this->releases->writeBuildReport($releaseId, $validation);
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
}
|
||||
72
app/Services/Sitemaps/SitemapRegistry.php
Normal file
72
app/Services/Sitemaps/SitemapRegistry.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use App\Services\Sitemaps\Builders\ArtworksSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\CardsSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\CategoriesSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\CollectionsSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\ForumCategoriesSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\ForumIndexSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\ForumThreadsSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\GoogleNewsSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\NewsSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\StaticPagesSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\StoriesSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\TagsSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\UsersSitemapBuilder;
|
||||
|
||||
final class SitemapRegistry
|
||||
{
|
||||
/**
|
||||
* @var array<string, SitemapBuilder>
|
||||
*/
|
||||
private array $builders;
|
||||
|
||||
public function __construct(
|
||||
ArtworksSitemapBuilder $artworks,
|
||||
UsersSitemapBuilder $users,
|
||||
TagsSitemapBuilder $tags,
|
||||
CategoriesSitemapBuilder $categories,
|
||||
CollectionsSitemapBuilder $collections,
|
||||
CardsSitemapBuilder $cards,
|
||||
StoriesSitemapBuilder $stories,
|
||||
NewsSitemapBuilder $news,
|
||||
GoogleNewsSitemapBuilder $googleNews,
|
||||
ForumIndexSitemapBuilder $forumIndex,
|
||||
ForumCategoriesSitemapBuilder $forumCategories,
|
||||
ForumThreadsSitemapBuilder $forumThreads,
|
||||
StaticPagesSitemapBuilder $staticPages,
|
||||
) {
|
||||
$this->builders = [
|
||||
$artworks->name() => $artworks,
|
||||
$users->name() => $users,
|
||||
$tags->name() => $tags,
|
||||
$categories->name() => $categories,
|
||||
$collections->name() => $collections,
|
||||
$cards->name() => $cards,
|
||||
$stories->name() => $stories,
|
||||
$news->name() => $news,
|
||||
$googleNews->name() => $googleNews,
|
||||
$forumIndex->name() => $forumIndex,
|
||||
$forumCategories->name() => $forumCategories,
|
||||
$forumThreads->name() => $forumThreads,
|
||||
$staticPages->name() => $staticPages,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, SitemapBuilder>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->builders;
|
||||
}
|
||||
|
||||
public function get(string $name): ?SitemapBuilder
|
||||
{
|
||||
return $this->builders[$name] ?? null;
|
||||
}
|
||||
}
|
||||
51
app/Services/Sitemaps/SitemapReleaseCleanupService.php
Normal file
51
app/Services/Sitemaps/SitemapReleaseCleanupService.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
final class SitemapReleaseCleanupService
|
||||
{
|
||||
public function __construct(private readonly SitemapReleaseManager $releases)
|
||||
{
|
||||
}
|
||||
|
||||
public function cleanup(): int
|
||||
{
|
||||
$activeReleaseId = $this->releases->activeReleaseId();
|
||||
$successfulKeep = max(1, (int) config('sitemaps.releases.retain_successful', 3));
|
||||
$failedKeep = max(0, (int) config('sitemaps.releases.retain_failed', 2));
|
||||
|
||||
$successfulSeen = 0;
|
||||
$failedSeen = 0;
|
||||
$deleted = 0;
|
||||
|
||||
foreach ($this->releases->listReleases() as $release) {
|
||||
$releaseId = (string) ($release['release_id'] ?? '');
|
||||
|
||||
if ($releaseId === '' || $releaseId === $activeReleaseId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = (string) ($release['status'] ?? 'built');
|
||||
|
||||
if ($status === 'published') {
|
||||
$successfulSeen++;
|
||||
if ($successfulSeen > $successfulKeep) {
|
||||
$this->releases->deleteRelease($releaseId);
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$failedSeen++;
|
||||
if ($failedSeen > $failedKeep) {
|
||||
$this->releases->deleteRelease($releaseId);
|
||||
$deleted++;
|
||||
}
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
}
|
||||
186
app/Services/Sitemaps/SitemapReleaseManager.php
Normal file
186
app/Services/Sitemaps/SitemapReleaseManager.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class SitemapReleaseManager
|
||||
{
|
||||
public function generateReleaseId(): string
|
||||
{
|
||||
return now()->utc()->format('YmdHis') . '-' . Str::lower((string) Str::ulid());
|
||||
}
|
||||
|
||||
public function releaseExists(string $releaseId): bool
|
||||
{
|
||||
return Storage::disk($this->disk())->exists($this->manifestPath($releaseId));
|
||||
}
|
||||
|
||||
public function putDocument(string $releaseId, string $documentName, string $content): void
|
||||
{
|
||||
Storage::disk($this->disk())->put($this->releaseDocumentPath($releaseId, $documentName), $content);
|
||||
}
|
||||
|
||||
public function getDocument(string $releaseId, string $documentName): ?string
|
||||
{
|
||||
$disk = Storage::disk($this->disk());
|
||||
$path = $this->releaseDocumentPath($releaseId, $documentName);
|
||||
|
||||
if (! $disk->exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = $disk->get($path);
|
||||
|
||||
return is_string($content) && $content !== '' ? $content : null;
|
||||
}
|
||||
|
||||
public function writeManifest(string $releaseId, array $manifest): void
|
||||
{
|
||||
Storage::disk($this->disk())->put($this->manifestPath($releaseId), json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
public function readManifest(string $releaseId): ?array
|
||||
{
|
||||
$disk = Storage::disk($this->disk());
|
||||
$path = $this->manifestPath($releaseId);
|
||||
|
||||
if (! $disk->exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $disk->get($path), true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
public function writeBuildReport(string $releaseId, array $report): void
|
||||
{
|
||||
Storage::disk($this->disk())->put($this->buildReportPath($releaseId), json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
public function activate(string $releaseId): void
|
||||
{
|
||||
$payload = [
|
||||
'release_id' => $releaseId,
|
||||
'activated_at' => now()->toAtomString(),
|
||||
];
|
||||
|
||||
$this->atomicJsonWrite($this->activePointerPath(), $payload);
|
||||
}
|
||||
|
||||
public function activeReleaseId(): ?string
|
||||
{
|
||||
$disk = Storage::disk($this->disk());
|
||||
$path = $this->activePointerPath();
|
||||
|
||||
if (! $disk->exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $disk->get($path), true);
|
||||
|
||||
return is_array($decoded) && is_string($decoded['release_id'] ?? null)
|
||||
? $decoded['release_id']
|
||||
: null;
|
||||
}
|
||||
|
||||
public function activeManifest(): ?array
|
||||
{
|
||||
$releaseId = $this->activeReleaseId();
|
||||
|
||||
return $releaseId !== null ? $this->readManifest($releaseId) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listReleases(): array
|
||||
{
|
||||
$releases = [];
|
||||
|
||||
foreach (Storage::disk($this->disk())->allDirectories($this->releasesRootPath()) as $directory) {
|
||||
$releaseId = basename($directory);
|
||||
$manifest = $this->readManifest($releaseId);
|
||||
|
||||
if ($manifest !== null) {
|
||||
$releases[] = $manifest;
|
||||
}
|
||||
}
|
||||
|
||||
usort($releases, static fn (array $left, array $right): int => strcmp((string) ($right['release_id'] ?? ''), (string) ($left['release_id'] ?? '')));
|
||||
|
||||
return $releases;
|
||||
}
|
||||
|
||||
public function deleteRelease(string $releaseId): void
|
||||
{
|
||||
Storage::disk($this->disk())->deleteDirectory($this->releaseRootPath($releaseId));
|
||||
}
|
||||
|
||||
public function documentRelativePath(string $documentName): string
|
||||
{
|
||||
return $documentName === SitemapCacheService::INDEX_DOCUMENT
|
||||
? 'sitemap.xml'
|
||||
: 'sitemaps/' . $documentName . '.xml';
|
||||
}
|
||||
|
||||
public function releaseDocumentPath(string $releaseId, string $documentName): string
|
||||
{
|
||||
return $this->releaseRootPath($releaseId) . '/' . $this->documentRelativePath($documentName);
|
||||
}
|
||||
|
||||
public function manifestPath(string $releaseId): string
|
||||
{
|
||||
return $this->releaseRootPath($releaseId) . '/manifest.json';
|
||||
}
|
||||
|
||||
public function buildReportPath(string $releaseId): string
|
||||
{
|
||||
return $this->releaseRootPath($releaseId) . '/build-report.json';
|
||||
}
|
||||
|
||||
private function releaseRootPath(string $releaseId): string
|
||||
{
|
||||
return $this->releasesRootPath() . '/' . $releaseId;
|
||||
}
|
||||
|
||||
private function releasesRootPath(): string
|
||||
{
|
||||
return trim((string) config('sitemaps.releases.path', 'sitemaps'), '/') . '/releases';
|
||||
}
|
||||
|
||||
private function activePointerPath(): string
|
||||
{
|
||||
return trim((string) config('sitemaps.releases.path', 'sitemaps'), '/') . '/active.json';
|
||||
}
|
||||
|
||||
private function disk(): string
|
||||
{
|
||||
return (string) config('sitemaps.releases.disk', 'local');
|
||||
}
|
||||
|
||||
private function atomicJsonWrite(string $relativePath, array $payload): void
|
||||
{
|
||||
$disk = Storage::disk($this->disk());
|
||||
$json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
try {
|
||||
$absolutePath = $disk->path($relativePath);
|
||||
$directory = dirname($absolutePath);
|
||||
|
||||
if (! is_dir($directory)) {
|
||||
mkdir($directory, 0755, true);
|
||||
}
|
||||
|
||||
$temporaryPath = $absolutePath . '.tmp';
|
||||
file_put_contents($temporaryPath, $json, LOCK_EX);
|
||||
rename($temporaryPath, $absolutePath);
|
||||
} catch (\Throwable) {
|
||||
$disk->put($relativePath, $json);
|
||||
}
|
||||
}
|
||||
}
|
||||
258
app/Services/Sitemaps/SitemapReleaseValidator.php
Normal file
258
app/Services/Sitemaps/SitemapReleaseValidator.php
Normal file
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use DOMDocument;
|
||||
use DOMXPath;
|
||||
|
||||
final class SitemapReleaseValidator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SitemapBuildService $build,
|
||||
private readonly SitemapReleaseManager $releases,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function validate(string $releaseId): array
|
||||
{
|
||||
$manifest = $this->releases->readManifest($releaseId);
|
||||
|
||||
if ($manifest === null) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'release_id' => $releaseId,
|
||||
'errors' => ['Release manifest not found.'],
|
||||
];
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$families = (array) ($manifest['families'] ?? []);
|
||||
$documents = (array) ($manifest['documents'] ?? []);
|
||||
|
||||
$rootContent = $this->releases->getDocument($releaseId, SitemapCacheService::INDEX_DOCUMENT);
|
||||
$rootXml = is_string($rootContent) ? $this->loadXml($rootContent) : null;
|
||||
|
||||
if ($rootXml === null) {
|
||||
$errors[] = 'Root sitemap.xml is missing or invalid.';
|
||||
} else {
|
||||
$rootLocs = $this->extractLocs($rootXml, 'sitemap');
|
||||
$expectedRootLocs = array_map(
|
||||
fn (string $entryName): string => url('/sitemaps/' . $entryName . '.xml'),
|
||||
array_values(array_map(static fn (array $family): string => (string) ($family['entry_name'] ?? ''), $families)),
|
||||
);
|
||||
|
||||
if ($rootLocs !== $expectedRootLocs) {
|
||||
$errors[] = 'Root sitemap index does not match the manifest family entries.';
|
||||
}
|
||||
}
|
||||
|
||||
$reports = [];
|
||||
|
||||
foreach ($families as $familyName => $family) {
|
||||
$familyErrors = [];
|
||||
$familyWarnings = [];
|
||||
$seenLocs = [];
|
||||
$duplicates = [];
|
||||
|
||||
foreach ((array) ($family['documents'] ?? []) as $documentName) {
|
||||
$artifact = $this->releases->getDocument($releaseId, (string) $documentName);
|
||||
|
||||
if (! is_string($artifact) || $artifact === '') {
|
||||
$familyErrors[] = 'Missing artifact [' . $documentName . '].';
|
||||
continue;
|
||||
}
|
||||
|
||||
$artifactXml = $this->loadXml($artifact);
|
||||
if ($artifactXml === null) {
|
||||
$familyErrors[] = 'Invalid XML in artifact [' . $documentName . '].';
|
||||
continue;
|
||||
}
|
||||
|
||||
$expected = $documentName === SitemapCacheService::INDEX_DOCUMENT
|
||||
? $this->build->buildIndex(true, false, array_keys($families))
|
||||
: $this->build->buildNamed((string) $documentName, true, false);
|
||||
|
||||
if ($expected === null) {
|
||||
$familyErrors[] = 'Unable to rebuild expected document [' . $documentName . '] for validation.';
|
||||
continue;
|
||||
}
|
||||
|
||||
$expectedXml = $this->loadXml((string) $expected['content']);
|
||||
if ($expectedXml === null) {
|
||||
$familyErrors[] = 'Expected document [' . $documentName . '] could not be parsed.';
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((string) $expected['type'] === SitemapTarget::TYPE_INDEX) {
|
||||
if ($this->extractLocs($artifactXml, 'sitemap') !== $this->extractLocs($expectedXml, 'sitemap')) {
|
||||
$familyErrors[] = 'Index artifact [' . $documentName . '] does not match expected sitemap references.';
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$artifactLocs = $this->extractLocs($artifactXml, 'url');
|
||||
$expectedLocs = $this->extractLocs($expectedXml, 'url');
|
||||
|
||||
if ($artifactLocs !== $expectedLocs) {
|
||||
$familyErrors[] = 'URL artifact [' . $documentName . '] does not match expected canonical URLs.';
|
||||
}
|
||||
|
||||
foreach ($artifactLocs as $loc) {
|
||||
if (isset($seenLocs[$loc])) {
|
||||
$duplicates[$loc] = true;
|
||||
}
|
||||
|
||||
$seenLocs[$loc] = true;
|
||||
|
||||
$urlError = $this->urlError($loc);
|
||||
if ($urlError !== null) {
|
||||
$familyErrors[] = $urlError . ' [' . $loc . ']';
|
||||
}
|
||||
}
|
||||
|
||||
if ((string) $familyName === (string) config('sitemaps.news.google_variant_name', 'news-google')) {
|
||||
if ($this->extractNewsTitles($artifactXml) === []) {
|
||||
$familyErrors[] = 'Google News sitemap contains no valid news:title elements.';
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->extractImageLocs($artifactXml) as $imageLoc) {
|
||||
if (! preg_match('/^https?:\/\//i', $imageLoc)) {
|
||||
$familyWarnings[] = 'Non-absolute image URL [' . $imageLoc . ']';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($duplicates !== []) {
|
||||
$familyErrors[] = 'Duplicate URLs detected across family artifacts.';
|
||||
}
|
||||
|
||||
$reports[] = [
|
||||
'family' => $familyName,
|
||||
'documents' => count((array) ($family['documents'] ?? [])),
|
||||
'url_count' => (int) ($family['url_count'] ?? 0),
|
||||
'shard_count' => (int) ($family['shard_count'] ?? 0),
|
||||
'errors' => $familyErrors,
|
||||
'warnings' => $familyWarnings,
|
||||
];
|
||||
|
||||
foreach ($familyErrors as $familyError) {
|
||||
$errors[] = $familyName . ': ' . $familyError;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => $errors === [],
|
||||
'release_id' => $releaseId,
|
||||
'errors' => $errors,
|
||||
'families' => $reports,
|
||||
'totals' => [
|
||||
'families' => count($families),
|
||||
'documents' => count($documents),
|
||||
'urls' => array_sum(array_map(static fn (array $family): int => (int) ($family['url_count'] ?? 0), $families)),
|
||||
'shards' => array_sum(array_map(static fn (array $family): int => (int) ($family['shard_count'] ?? 0), $families)),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function loadXml(string $content): ?DOMDocument
|
||||
{
|
||||
$document = new DOMDocument();
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
$loaded = $document->loadXML($content);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
|
||||
return $loaded ? $document : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractLocs(DOMDocument $document, string $nodeName): array
|
||||
{
|
||||
$xpath = new DOMXPath($document);
|
||||
$nodes = $xpath->query('//*[local-name()="' . $nodeName . '"]/*[local-name()="loc"]');
|
||||
$locs = [];
|
||||
|
||||
foreach ($nodes ?: [] as $node) {
|
||||
$value = trim((string) $node->textContent);
|
||||
if ($value !== '') {
|
||||
$locs[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $locs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractImageLocs(DOMDocument $document): array
|
||||
{
|
||||
$xpath = new DOMXPath($document);
|
||||
$nodes = $xpath->query('//*[local-name()="image"]/*[local-name()="loc"]');
|
||||
$locs = [];
|
||||
|
||||
foreach ($nodes ?: [] as $node) {
|
||||
$value = trim((string) $node->textContent);
|
||||
if ($value !== '') {
|
||||
$locs[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $locs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractNewsTitles(DOMDocument $document): array
|
||||
{
|
||||
$xpath = new DOMXPath($document);
|
||||
$nodes = $xpath->query('//*[local-name()="title"]');
|
||||
$titles = [];
|
||||
|
||||
foreach ($nodes ?: [] as $node) {
|
||||
$value = trim((string) $node->textContent);
|
||||
if ($value !== '') {
|
||||
$titles[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $titles;
|
||||
}
|
||||
|
||||
private function urlError(string $loc): ?string
|
||||
{
|
||||
$parts = parse_url($loc);
|
||||
|
||||
if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) {
|
||||
return 'Non-absolute URL emitted';
|
||||
}
|
||||
|
||||
if (($parts['query'] ?? '') !== '') {
|
||||
return 'Query-string URL emitted';
|
||||
}
|
||||
|
||||
if (($parts['fragment'] ?? '') !== '') {
|
||||
return 'Fragment URL emitted';
|
||||
}
|
||||
|
||||
$path = '/' . ltrim((string) ($parts['path'] ?? '/'), '/');
|
||||
|
||||
foreach ((array) config('sitemaps.validation.forbidden_paths', []) as $forbidden) {
|
||||
if ($forbidden !== '/' && str_contains($path, (string) $forbidden)) {
|
||||
return 'Non-public path emitted';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
149
app/Services/Sitemaps/SitemapShardService.php
Normal file
149
app/Services/Sitemaps/SitemapShardService.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
final class SitemapShardService
|
||||
{
|
||||
public function rootEntryName(SitemapBuilder $builder): string
|
||||
{
|
||||
$shardCount = $this->shardCount($builder);
|
||||
|
||||
if ($builder instanceof ShardableSitemapBuilder && ($shardCount > 1 || $this->forceFamilyIndexes())) {
|
||||
return $this->familyIndexName($builder->name());
|
||||
}
|
||||
|
||||
return $builder->name();
|
||||
}
|
||||
|
||||
public function shardCount(SitemapBuilder $builder): int
|
||||
{
|
||||
if (! $builder instanceof ShardableSitemapBuilder || ! $this->enabledFor($builder->name())) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$totalItems = $builder->totalItems();
|
||||
if ($totalItems <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return max(1, (int) ceil($totalItems / max(1, $builder->shardSize())));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function indexNamesForBuilder(SitemapBuilder $builder): array
|
||||
{
|
||||
$shardCount = $this->shardCount($builder);
|
||||
|
||||
if ($builder instanceof ShardableSitemapBuilder && $shardCount > 1) {
|
||||
return array_map(fn (int $shard): string => $this->canonicalShardName($builder->name(), $shard), range(1, $shardCount));
|
||||
}
|
||||
|
||||
return [$builder->name()];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function canonicalDocumentNamesForBuilder(SitemapBuilder $builder): array
|
||||
{
|
||||
$names = $this->indexNamesForBuilder($builder);
|
||||
|
||||
if ($builder instanceof ShardableSitemapBuilder && ($this->shardCount($builder) > 1 || $this->forceFamilyIndexes())) {
|
||||
array_unshift($names, $this->familyIndexName($builder->name()));
|
||||
}
|
||||
|
||||
return array_values(array_unique($names));
|
||||
}
|
||||
|
||||
public function familyIndexName(string $baseName): string
|
||||
{
|
||||
return $baseName . '-index';
|
||||
}
|
||||
|
||||
public function canonicalShardName(string $baseName, int $shard): string
|
||||
{
|
||||
return sprintf('%s-%s', $baseName, str_pad((string) $shard, $this->padLength(), '0', STR_PAD_LEFT));
|
||||
}
|
||||
|
||||
public function resolve(SitemapRegistry $registry, string $name): ?SitemapTarget
|
||||
{
|
||||
$builder = $registry->get($name);
|
||||
|
||||
if ($builder !== null) {
|
||||
$shardCount = $this->shardCount($builder);
|
||||
|
||||
if ($builder instanceof ShardableSitemapBuilder && ($shardCount > 1 || $this->forceFamilyIndexes())) {
|
||||
return new SitemapTarget($name, $this->familyIndexName($builder->name()), $builder->name(), SitemapTarget::TYPE_INDEX, $builder, null, $shardCount);
|
||||
}
|
||||
|
||||
return new SitemapTarget($name, $builder->name(), $builder->name(), $this->targetType($builder), $builder);
|
||||
}
|
||||
|
||||
if (preg_match('/^(.+)-index$/', $name, $indexMatches)) {
|
||||
$baseName = (string) $indexMatches[1];
|
||||
$builder = $registry->get($baseName);
|
||||
|
||||
if (! $builder instanceof ShardableSitemapBuilder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$shardCount = $this->shardCount($builder);
|
||||
|
||||
if ($shardCount < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SitemapTarget($name, $this->familyIndexName($baseName), $baseName, SitemapTarget::TYPE_INDEX, $builder, null, $shardCount);
|
||||
}
|
||||
|
||||
if (! preg_match('/^(.+)-([0-9]{1,})$/', $name, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$baseName = (string) $matches[1];
|
||||
$shardNumber = (int) $matches[2];
|
||||
$builder = $registry->get($baseName);
|
||||
|
||||
if (! $builder instanceof ShardableSitemapBuilder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$shardCount = $this->shardCount($builder);
|
||||
|
||||
if ($shardCount < 2 || $shardNumber > $shardCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SitemapTarget($name, $this->canonicalShardName($baseName, $shardNumber), $baseName, SitemapTarget::TYPE_URLSET, $builder, $shardNumber, $shardCount);
|
||||
}
|
||||
|
||||
private function forceFamilyIndexes(): bool
|
||||
{
|
||||
return (bool) config('sitemaps.shards.force_family_indexes', false);
|
||||
}
|
||||
|
||||
private function padLength(): int
|
||||
{
|
||||
return max(1, (int) config('sitemaps.shards.zero_pad_length', 4));
|
||||
}
|
||||
|
||||
private function targetType(SitemapBuilder $builder): string
|
||||
{
|
||||
return $builder->name() === (string) config('sitemaps.news.google_variant_name', 'news-google')
|
||||
? SitemapTarget::TYPE_GOOGLE_NEWS
|
||||
: SitemapTarget::TYPE_URLSET;
|
||||
}
|
||||
|
||||
private function enabledFor(string $family): bool
|
||||
{
|
||||
if (! (bool) config('sitemaps.shards.enabled', true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) data_get(config('sitemaps.shards', []), $family . '.size', 0) > 0;
|
||||
}
|
||||
}
|
||||
23
app/Services/Sitemaps/SitemapTarget.php
Normal file
23
app/Services/Sitemaps/SitemapTarget.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
final class SitemapTarget
|
||||
{
|
||||
public const TYPE_INDEX = 'index';
|
||||
public const TYPE_URLSET = 'urlset';
|
||||
public const TYPE_GOOGLE_NEWS = 'google-news';
|
||||
|
||||
public function __construct(
|
||||
public readonly string $requestedName,
|
||||
public readonly string $documentName,
|
||||
public readonly string $baseName,
|
||||
public readonly string $type,
|
||||
public readonly SitemapBuilder $builder,
|
||||
public readonly ?int $shardNumber = null,
|
||||
public readonly int $totalShards = 1,
|
||||
) {
|
||||
}
|
||||
}
|
||||
19
app/Services/Sitemaps/SitemapUrl.php
Normal file
19
app/Services/Sitemaps/SitemapUrl.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use DateTimeInterface;
|
||||
|
||||
final readonly class SitemapUrl
|
||||
{
|
||||
/**
|
||||
* @param list<SitemapImage> $images
|
||||
*/
|
||||
public function __construct(
|
||||
public string $loc,
|
||||
public ?DateTimeInterface $lastModified = null,
|
||||
public array $images = [],
|
||||
) {}
|
||||
}
|
||||
208
app/Services/Sitemaps/SitemapUrlBuilder.php
Normal file
208
app/Services/Sitemaps/SitemapUrlBuilder.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use DateTimeInterface;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\Collection;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\Page;
|
||||
use App\Models\Story;
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use cPad\Plugins\Forum\Models\ForumBoard;
|
||||
use cPad\Plugins\Forum\Models\ForumCategory;
|
||||
use cPad\Plugins\Forum\Models\ForumTopic;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class SitemapUrlBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'url-builder';
|
||||
}
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function lastModified(): ?\Carbon\CarbonInterface
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function artwork(Artwork $artwork): ?SitemapUrl
|
||||
{
|
||||
$slug = Str::slug((string) ($artwork->slug ?: $artwork->title));
|
||||
if ($slug === '') {
|
||||
$slug = (string) $artwork->id;
|
||||
}
|
||||
|
||||
$preview = ThumbnailPresenter::present($artwork, 'xl');
|
||||
|
||||
return new SitemapUrl(
|
||||
route('art.show', ['id' => (int) $artwork->id, 'slug' => $slug]),
|
||||
$this->newest($artwork->updated_at, $artwork->published_at, $artwork->created_at),
|
||||
$this->images([
|
||||
$this->image($preview['url'] ?? null, (string) $artwork->title),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
public function profile(User $user, ?DateTimeInterface $lastModified = null): ?SitemapUrl
|
||||
{
|
||||
$username = strtolower(trim((string) $user->username));
|
||||
|
||||
if ($username === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SitemapUrl(
|
||||
route('profile.show', ['username' => $username]),
|
||||
$this->newest($lastModified, $user->updated_at, $user->created_at),
|
||||
);
|
||||
}
|
||||
|
||||
public function tag(Tag $tag): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl(
|
||||
route('tags.show', ['tag' => $tag->slug]),
|
||||
$this->newest($tag->updated_at, $tag->created_at),
|
||||
);
|
||||
}
|
||||
|
||||
public function categoryDirectory(): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl(route('categories.index'));
|
||||
}
|
||||
|
||||
public function contentType(ContentType $contentType): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl(
|
||||
url('/' . strtolower((string) $contentType->slug)),
|
||||
$this->newest($contentType->updated_at, $contentType->created_at),
|
||||
);
|
||||
}
|
||||
|
||||
public function category(Category $category): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl(
|
||||
$this->absoluteUrl($category->url) ?? url('/'),
|
||||
$this->newest($category->updated_at, $category->created_at),
|
||||
);
|
||||
}
|
||||
|
||||
public function collection(Collection $collection): ?SitemapUrl
|
||||
{
|
||||
$username = strtolower(trim((string) $collection->user?->username));
|
||||
|
||||
if ($username === '' || trim((string) $collection->slug) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SitemapUrl(
|
||||
route('profile.collections.show', [
|
||||
'username' => $username,
|
||||
'slug' => $collection->slug,
|
||||
]),
|
||||
$this->newest($collection->updated_at, $collection->published_at, $collection->created_at),
|
||||
);
|
||||
}
|
||||
|
||||
public function card(NovaCard $card): ?SitemapUrl
|
||||
{
|
||||
if (trim((string) $card->slug) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SitemapUrl(
|
||||
$card->publicUrl(),
|
||||
$this->newest($card->updated_at, $card->published_at, $card->created_at),
|
||||
$this->images([
|
||||
$this->image($card->ogPreviewUrl() ?: $card->previewUrl(), (string) $card->title),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
public function story(Story $story): ?SitemapUrl
|
||||
{
|
||||
if (trim((string) $story->slug) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SitemapUrl(
|
||||
$story->url,
|
||||
$this->newest($story->updated_at, $story->published_at, $story->created_at),
|
||||
$this->images([
|
||||
$this->image($story->cover_url ?: $story->og_image, (string) $story->title),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
public function news(NewsArticle $article): ?SitemapUrl
|
||||
{
|
||||
if (trim((string) $article->slug) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SitemapUrl(
|
||||
route('news.show', ['slug' => $article->slug]),
|
||||
$this->newest($article->updated_at, $article->published_at, $article->created_at),
|
||||
$this->images([
|
||||
$this->image($article->cover_url ?: $article->effectiveOgImage, (string) $article->title),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
public function forumIndex(): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl(route('forum.index'));
|
||||
}
|
||||
|
||||
public function forumCategory(ForumCategory $category): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl(
|
||||
route('forum.category.show', ['categorySlug' => $category->slug]),
|
||||
$this->newest($category->updated_at, $category->created_at),
|
||||
);
|
||||
}
|
||||
|
||||
public function forumBoard(ForumBoard $board): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl(
|
||||
route('forum.board.show', ['boardSlug' => $board->slug]),
|
||||
$this->newest($board->updated_at, $board->created_at),
|
||||
);
|
||||
}
|
||||
|
||||
public function forumTopic(ForumTopic $topic): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl(
|
||||
route('forum.topic.show', ['topic' => $topic->slug]),
|
||||
$this->newest($topic->last_post_at, $topic->updated_at, $topic->created_at),
|
||||
);
|
||||
}
|
||||
|
||||
public function staticRoute(string $path, ?\Carbon\CarbonInterface $lastModified = null): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl(
|
||||
url($path),
|
||||
$lastModified,
|
||||
);
|
||||
}
|
||||
|
||||
public function page(Page $page, string $path): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl(
|
||||
url($path),
|
||||
$this->newest($page->updated_at, $page->published_at, $page->created_at),
|
||||
);
|
||||
}
|
||||
}
|
||||
286
app/Services/Sitemaps/SitemapValidationService.php
Normal file
286
app/Services/Sitemaps/SitemapValidationService.php
Normal file
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use DOMDocument;
|
||||
use DOMXPath;
|
||||
|
||||
final class SitemapValidationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SitemapBuildService $build,
|
||||
private readonly SitemapIndexService $index,
|
||||
private readonly SitemapRegistry $registry,
|
||||
private readonly SitemapShardService $shards,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $onlyFamilies
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function validate(array $onlyFamilies = []): array
|
||||
{
|
||||
$families = $onlyFamilies !== []
|
||||
? array_values(array_filter($onlyFamilies, fn (string $family): bool => $this->registry->get($family) !== null))
|
||||
: $this->build->enabledFamilies();
|
||||
|
||||
$expectedIndexLocs = array_map(
|
||||
static fn (SitemapIndexItem $item): string => $item->loc,
|
||||
array_values(array_filter(
|
||||
$this->index->items(),
|
||||
fn (SitemapIndexItem $item): bool => $this->isFamilySelected($families, $item->loc),
|
||||
)),
|
||||
);
|
||||
|
||||
$indexBuild = $this->build->buildIndex(true, false);
|
||||
$indexErrors = [];
|
||||
$indexXml = $this->loadXml($indexBuild['content']);
|
||||
|
||||
if ($indexXml === null) {
|
||||
$indexErrors[] = 'The main sitemap index XML could not be parsed.';
|
||||
}
|
||||
|
||||
$actualIndexLocs = $indexXml ? $this->extractLocs($indexXml, 'sitemap') : [];
|
||||
if ($indexXml !== null && $actualIndexLocs !== $expectedIndexLocs) {
|
||||
$indexErrors[] = 'Main sitemap index child references do not match the expected shard-aware manifest.';
|
||||
}
|
||||
|
||||
$familyReports = [];
|
||||
$duplicates = [];
|
||||
$seenUrls = [];
|
||||
$totalUrlCount = 0;
|
||||
$totalShardCount = 0;
|
||||
|
||||
foreach ($families as $family) {
|
||||
$builder = $this->registry->get($family);
|
||||
|
||||
if ($builder === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$report = [
|
||||
'family' => $family,
|
||||
'documents' => 0,
|
||||
'url_count' => 0,
|
||||
'shard_count' => max(1, $this->shards->shardCount($builder)),
|
||||
'errors' => [],
|
||||
'warnings' => [],
|
||||
];
|
||||
|
||||
$totalShardCount += $report['shard_count'];
|
||||
|
||||
foreach ($this->build->documentNamesForFamily($family, true) as $name) {
|
||||
$built = $this->build->buildNamed($name, true, false);
|
||||
|
||||
if ($built === null) {
|
||||
$report['errors'][] = 'Unable to resolve sitemap [' . $name . '].';
|
||||
continue;
|
||||
}
|
||||
|
||||
$document = $this->loadXml($built['content']);
|
||||
if ($document === null) {
|
||||
$report['errors'][] = 'Invalid XML emitted for [' . $name . '].';
|
||||
continue;
|
||||
}
|
||||
|
||||
$report['documents']++;
|
||||
|
||||
if ($built['type'] === SitemapTarget::TYPE_INDEX) {
|
||||
$expectedFamilyLocs = array_map(
|
||||
static fn (SitemapIndexItem $item): string => $item->loc,
|
||||
$this->index->itemsForBuilder($builder),
|
||||
);
|
||||
|
||||
$actualFamilyLocs = $this->extractLocs($document, 'sitemap');
|
||||
if ($actualFamilyLocs !== $expectedFamilyLocs) {
|
||||
$report['errors'][] = 'Shard compatibility index [' . $name . '] does not reference the expected shard URLs.';
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$locs = $this->extractLocs($document, 'url');
|
||||
$report['url_count'] += count($locs);
|
||||
$totalUrlCount += count($locs);
|
||||
|
||||
foreach ($locs as $loc) {
|
||||
if (isset($seenUrls[$loc])) {
|
||||
$duplicates[$loc] = ($duplicates[$loc] ?? 1) + 1;
|
||||
}
|
||||
|
||||
$seenUrls[$loc] = true;
|
||||
|
||||
$reason = $this->urlError($family, $loc);
|
||||
if ($reason !== null) {
|
||||
$report['errors'][] = $reason . ' [' . $loc . ']';
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->extractImageLocs($document) as $imageLoc) {
|
||||
if (! preg_match('/^https?:\/\//i', $imageLoc)) {
|
||||
$report['warnings'][] = 'Non-absolute image URL found [' . $imageLoc . ']';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$familyReports[] = $report;
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => $indexErrors === [] && $this->familyErrors($familyReports) === [] && $duplicates === [],
|
||||
'index' => [
|
||||
'errors' => $indexErrors,
|
||||
'url_count' => count($actualIndexLocs),
|
||||
],
|
||||
'families' => $familyReports,
|
||||
'duplicates' => array_keys($duplicates),
|
||||
'totals' => [
|
||||
'families' => count($familyReports),
|
||||
'documents' => array_sum(array_map(static fn (array $report): int => (int) $report['documents'], $familyReports)),
|
||||
'urls' => $totalUrlCount,
|
||||
'shards' => $totalShardCount,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $reports
|
||||
* @return list<string>
|
||||
*/
|
||||
private function familyErrors(array $reports): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
foreach ($reports as $report) {
|
||||
foreach ((array) ($report['errors'] ?? []) as $error) {
|
||||
$errors[] = (string) $error;
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
private function loadXml(string $content): ?DOMDocument
|
||||
{
|
||||
$document = new DOMDocument();
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
$loaded = $document->loadXML($content);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
|
||||
return $loaded ? $document : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractLocs(DOMDocument $document, string $nodeName): array
|
||||
{
|
||||
$xpath = new DOMXPath($document);
|
||||
$nodes = $xpath->query('//*[local-name()="' . $nodeName . '"]/*[local-name()="loc"]');
|
||||
$locs = [];
|
||||
|
||||
foreach ($nodes ?: [] as $node) {
|
||||
$value = trim((string) $node->textContent);
|
||||
if ($value !== '') {
|
||||
$locs[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $locs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractImageLocs(DOMDocument $document): array
|
||||
{
|
||||
$xpath = new DOMXPath($document);
|
||||
$nodes = $xpath->query('//*[local-name()="image"]/*[local-name()="loc"]');
|
||||
$locs = [];
|
||||
|
||||
foreach ($nodes ?: [] as $node) {
|
||||
$value = trim((string) $node->textContent);
|
||||
if ($value !== '') {
|
||||
$locs[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $locs;
|
||||
}
|
||||
|
||||
private function isFamilySelected(array $families, string $loc): bool
|
||||
{
|
||||
foreach ($families as $family) {
|
||||
if (str_contains($loc, '/sitemaps/' . $family . '.xml') || str_contains($loc, '/sitemaps/' . $family . '-')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function urlError(string $family, string $loc): ?string
|
||||
{
|
||||
$parts = parse_url($loc);
|
||||
|
||||
if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) {
|
||||
return 'Non-absolute URL emitted';
|
||||
}
|
||||
|
||||
if (($parts['query'] ?? '') !== '') {
|
||||
return 'Query-string URL emitted';
|
||||
}
|
||||
|
||||
if (($parts['fragment'] ?? '') !== '') {
|
||||
return 'Fragment URL emitted';
|
||||
}
|
||||
|
||||
$path = '/' . ltrim((string) ($parts['path'] ?? '/'), '/');
|
||||
|
||||
foreach ((array) config('sitemaps.validation.forbidden_paths', []) as $forbidden) {
|
||||
if ($forbidden !== '/' && str_contains($path, (string) $forbidden)) {
|
||||
return 'Non-public path emitted';
|
||||
}
|
||||
}
|
||||
|
||||
return match ($family) {
|
||||
'artworks' => $this->validateArtworkUrl($path),
|
||||
'users' => $this->validateUserUrl($path),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function validateArtworkUrl(string $path): ?string
|
||||
{
|
||||
if (! preg_match('~^/art/(\d+)(?:/[^/?#]+)?$~', $path, $matches)) {
|
||||
return 'Non-canonical artwork URL emitted';
|
||||
}
|
||||
|
||||
$artwork = Artwork::query()->public()->published()->find((int) $matches[1]);
|
||||
|
||||
return $artwork === null ? 'Non-public artwork URL emitted' : null;
|
||||
}
|
||||
|
||||
private function validateUserUrl(string $path): ?string
|
||||
{
|
||||
if (! preg_match('#^/@([A-Za-z0-9_\-]+)$#', $path, $matches)) {
|
||||
return 'Non-canonical user URL emitted';
|
||||
}
|
||||
|
||||
$username = strtolower((string) $matches[1]);
|
||||
|
||||
$user = User::query()
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->whereRaw('LOWER(username) = ?', [$username])
|
||||
->first();
|
||||
|
||||
return $user === null ? 'Non-public user URL emitted' : null;
|
||||
}
|
||||
}
|
||||
49
app/Services/Sitemaps/SitemapXmlRenderer.php
Normal file
49
app/Services/Sitemaps/SitemapXmlRenderer.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
final class SitemapXmlRenderer
|
||||
{
|
||||
/**
|
||||
* @param list<SitemapIndexItem> $items
|
||||
*/
|
||||
public function renderIndex(array $items): string
|
||||
{
|
||||
return \view('sitemaps.index', [
|
||||
'items' => $items,
|
||||
])->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<SitemapUrl> $items
|
||||
*/
|
||||
public function renderUrlset(array $items): string
|
||||
{
|
||||
return \view('sitemaps.urlset', [
|
||||
'items' => $items,
|
||||
'hasImages' => \collect($items)->contains(fn (SitemapUrl $item): bool => $item->images !== []),
|
||||
])->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<GoogleNewsSitemapUrl> $items
|
||||
*/
|
||||
public function renderGoogleNewsUrlset(array $items): string
|
||||
{
|
||||
return \view('sitemaps.news-urlset', [
|
||||
'items' => $items,
|
||||
])->render();
|
||||
}
|
||||
|
||||
public function xmlResponse(string $content): Response
|
||||
{
|
||||
return \response($content, 200, [
|
||||
'Content-Type' => 'application/xml; charset=UTF-8',
|
||||
'Cache-Control' => 'public, max-age=' . max(60, (int) \config('sitemaps.cache_ttl_seconds', 900)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
31
app/Services/Studio/Contracts/CreatorStudioProvider.php
Normal file
31
app/Services/Studio/Contracts/CreatorStudioProvider.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio\Contracts;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
interface CreatorStudioProvider
|
||||
{
|
||||
public function key(): string;
|
||||
|
||||
public function label(): string;
|
||||
|
||||
public function icon(): string;
|
||||
|
||||
public function createUrl(): string;
|
||||
|
||||
public function indexUrl(): string;
|
||||
|
||||
public function summary(User $user): array;
|
||||
|
||||
public function items(User $user, string $bucket = 'all', int $limit = 200): Collection;
|
||||
|
||||
public function topItems(User $user, int $limit = 5): Collection;
|
||||
|
||||
public function analytics(User $user): array;
|
||||
|
||||
public function scheduledItems(User $user, int $limit = 50): Collection;
|
||||
}
|
||||
354
app/Services/Studio/CreatorStudioActivityService.php
Normal file
354
app/Services/Studio/CreatorStudioActivityService.php
Normal file
@@ -0,0 +1,354 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\CollectionComment;
|
||||
use App\Models\NovaCardComment;
|
||||
use App\Models\StoryComment;
|
||||
use App\Models\User;
|
||||
use App\Services\NotificationService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class CreatorStudioActivityService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NotificationService $notifications,
|
||||
private readonly CreatorStudioPreferenceService $preferences,
|
||||
) {
|
||||
}
|
||||
|
||||
public function recent(User $user, int $limit = 12): array
|
||||
{
|
||||
return $this->feed($user)
|
||||
->take($limit)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function feed(User $user)
|
||||
{
|
||||
return $this->mergedFeed($user)
|
||||
->sortByDesc(fn (array $item): int => $this->timestamp($item['created_at'] ?? null))
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
public function list(User $user, array $filters = []): array
|
||||
{
|
||||
$type = $this->normalizeType((string) ($filters['type'] ?? 'all'));
|
||||
$module = $this->normalizeModule((string) ($filters['module'] ?? 'all'));
|
||||
$q = trim((string) ($filters['q'] ?? ''));
|
||||
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||
$perPage = min(max((int) ($filters['per_page'] ?? 24), 12), 48);
|
||||
$preferences = $this->preferences->forUser($user);
|
||||
|
||||
$items = $this->feed($user);
|
||||
|
||||
if ($type !== 'all') {
|
||||
$items = $items->where('type', $type)->values();
|
||||
}
|
||||
|
||||
if ($module !== 'all') {
|
||||
$items = $items->where('module', $module)->values();
|
||||
}
|
||||
|
||||
if ($q !== '') {
|
||||
$needle = mb_strtolower($q);
|
||||
$items = $items->filter(function (array $item) use ($needle): bool {
|
||||
return collect([
|
||||
$item['title'] ?? '',
|
||||
$item['body'] ?? '',
|
||||
$item['actor']['name'] ?? '',
|
||||
$item['module_label'] ?? '',
|
||||
])->contains(fn ($value): bool => is_string($value) && str_contains(mb_strtolower($value), $needle));
|
||||
})->values();
|
||||
}
|
||||
|
||||
$items = $items->sortByDesc(fn (array $item): int => $this->timestamp($item['created_at'] ?? null))->values();
|
||||
$total = $items->count();
|
||||
$lastPage = max(1, (int) ceil($total / $perPage));
|
||||
$page = min($page, $lastPage);
|
||||
$lastReadAt = $preferences['activity_last_read_at'] ?? null;
|
||||
$lastReadTimestamp = $this->timestamp($lastReadAt);
|
||||
|
||||
return [
|
||||
'items' => $items->forPage($page, $perPage)->map(function (array $item) use ($lastReadTimestamp): array {
|
||||
$item['is_new'] = $this->timestamp($item['created_at'] ?? null) > $lastReadTimestamp;
|
||||
|
||||
return $item;
|
||||
})->values()->all(),
|
||||
'meta' => [
|
||||
'current_page' => $page,
|
||||
'last_page' => $lastPage,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
],
|
||||
'filters' => [
|
||||
'type' => $type,
|
||||
'module' => $module,
|
||||
'q' => $q,
|
||||
],
|
||||
'type_options' => [
|
||||
['value' => 'all', 'label' => 'Everything'],
|
||||
['value' => 'notification', 'label' => 'Notifications'],
|
||||
['value' => 'comment', 'label' => 'Comments'],
|
||||
['value' => 'follower', 'label' => 'Followers'],
|
||||
],
|
||||
'module_options' => [
|
||||
['value' => 'all', 'label' => 'All content types'],
|
||||
['value' => 'artworks', 'label' => 'Artworks'],
|
||||
['value' => 'cards', 'label' => 'Cards'],
|
||||
['value' => 'collections', 'label' => 'Collections'],
|
||||
['value' => 'stories', 'label' => 'Stories'],
|
||||
['value' => 'followers', 'label' => 'Followers'],
|
||||
['value' => 'system', 'label' => 'System'],
|
||||
],
|
||||
'summary' => [
|
||||
'unread_notifications' => (int) $user->unreadNotifications()->count(),
|
||||
'last_read_at' => $lastReadAt,
|
||||
'new_items' => $items->filter(fn (array $item): bool => $this->timestamp($item['created_at'] ?? null) > $lastReadTimestamp)->count(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function markAllRead(User $user): array
|
||||
{
|
||||
$this->notifications->markAllRead($user);
|
||||
$updated = $this->preferences->update($user, [
|
||||
'activity_last_read_at' => now()->toIso8601String(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'activity_last_read_at' => $updated['activity_last_read_at'],
|
||||
];
|
||||
}
|
||||
|
||||
private function mergedFeed(User $user)
|
||||
{
|
||||
return collect($this->notificationItems($user))
|
||||
->concat($this->commentItems($user))
|
||||
->concat($this->followerItems($user));
|
||||
}
|
||||
|
||||
private function notificationItems(User $user): array
|
||||
{
|
||||
return collect($this->notifications->listForUser($user, 1, 30)['data'] ?? [])
|
||||
->map(fn (array $item): array => [
|
||||
'id' => 'notification:' . $item['id'],
|
||||
'type' => 'notification',
|
||||
'module' => 'system',
|
||||
'module_label' => 'Notification',
|
||||
'title' => $item['message'],
|
||||
'body' => $item['message'],
|
||||
'created_at' => $item['created_at'],
|
||||
'time_ago' => $item['time_ago'] ?? null,
|
||||
'url' => $item['url'] ?? route('studio.activity'),
|
||||
'actor' => $item['actor'] ?? null,
|
||||
'read' => (bool) ($item['read'] ?? false),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function commentItems(User $user): array
|
||||
{
|
||||
$artworkComments = DB::table('artwork_comments')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_comments.artwork_id')
|
||||
->join('users', 'users.id', '=', 'artwork_comments.user_id')
|
||||
->leftJoin('user_profiles', 'user_profiles.user_id', '=', 'users.id')
|
||||
->where('artworks.user_id', $user->id)
|
||||
->whereNull('artwork_comments.deleted_at')
|
||||
->orderByDesc('artwork_comments.created_at')
|
||||
->limit(20)
|
||||
->get([
|
||||
'artwork_comments.id',
|
||||
'artwork_comments.content as body',
|
||||
'artwork_comments.created_at',
|
||||
'users.id as actor_id',
|
||||
'users.name as actor_name',
|
||||
'users.username as actor_username',
|
||||
'user_profiles.avatar_hash',
|
||||
'artworks.title as item_title',
|
||||
'artworks.slug as item_slug',
|
||||
'artworks.id as item_id',
|
||||
])
|
||||
->map(fn ($row): array => [
|
||||
'id' => 'comment:artworks:' . $row->id,
|
||||
'type' => 'comment',
|
||||
'module' => 'artworks',
|
||||
'module_label' => 'Artwork comment',
|
||||
'title' => 'New comment on ' . $row->item_title,
|
||||
'body' => (string) $row->body,
|
||||
'created_at' => $this->normalizeDate($row->created_at),
|
||||
'time_ago' => null,
|
||||
'url' => route('art.show', ['id' => $row->item_id, 'slug' => $row->item_slug]) . '#comment-' . $row->id,
|
||||
'actor' => [
|
||||
'id' => (int) $row->actor_id,
|
||||
'name' => $row->actor_name ?: $row->actor_username ?: 'Creator',
|
||||
'username' => $row->actor_username,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->actor_id, $row->avatar_hash, 64),
|
||||
],
|
||||
]);
|
||||
|
||||
$cardComments = NovaCardComment::query()
|
||||
->with(['user.profile', 'card'])
|
||||
->whereNull('deleted_at')
|
||||
->whereHas('card', fn ($query) => $query->where('user_id', $user->id))
|
||||
->latest('created_at')
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(fn (NovaCardComment $comment): array => [
|
||||
'id' => 'comment:cards:' . $comment->id,
|
||||
'type' => 'comment',
|
||||
'module' => 'cards',
|
||||
'module_label' => 'Card comment',
|
||||
'title' => 'New comment on ' . ($comment->card?->title ?? 'card'),
|
||||
'body' => (string) $comment->body,
|
||||
'created_at' => $comment->created_at?->toIso8601String(),
|
||||
'time_ago' => $comment->created_at?->diffForHumans(),
|
||||
'url' => $comment->card ? $comment->card->publicUrl() . '#comment-' . $comment->id : route('studio.activity'),
|
||||
'actor' => $comment->user ? [
|
||||
'id' => (int) $comment->user->id,
|
||||
'name' => $comment->user->name ?: $comment->user->username ?: 'Creator',
|
||||
'username' => $comment->user->username,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $comment->user->id, $comment->user->profile?->avatar_hash, 64),
|
||||
] : null,
|
||||
]);
|
||||
|
||||
$collectionComments = CollectionComment::query()
|
||||
->with(['user.profile', 'collection'])
|
||||
->whereNull('deleted_at')
|
||||
->whereHas('collection', fn ($query) => $query->where('user_id', $user->id))
|
||||
->latest('created_at')
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(fn (CollectionComment $comment): array => [
|
||||
'id' => 'comment:collections:' . $comment->id,
|
||||
'type' => 'comment',
|
||||
'module' => 'collections',
|
||||
'module_label' => 'Collection comment',
|
||||
'title' => 'New comment on ' . ($comment->collection?->title ?? 'collection'),
|
||||
'body' => (string) $comment->body,
|
||||
'created_at' => $comment->created_at?->toIso8601String(),
|
||||
'time_ago' => $comment->created_at?->diffForHumans(),
|
||||
'url' => $comment->collection ? route('settings.collections.show', ['collection' => $comment->collection->id]) : route('studio.activity'),
|
||||
'actor' => $comment->user ? [
|
||||
'id' => (int) $comment->user->id,
|
||||
'name' => $comment->user->name ?: $comment->user->username ?: 'Creator',
|
||||
'username' => $comment->user->username,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $comment->user->id, $comment->user->profile?->avatar_hash, 64),
|
||||
] : null,
|
||||
]);
|
||||
|
||||
$storyComments = StoryComment::query()
|
||||
->with(['user.profile', 'story'])
|
||||
->whereNull('deleted_at')
|
||||
->whereHas('story', fn ($query) => $query->where('creator_id', $user->id))
|
||||
->latest('created_at')
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(fn (StoryComment $comment): array => [
|
||||
'id' => 'comment:stories:' . $comment->id,
|
||||
'type' => 'comment',
|
||||
'module' => 'stories',
|
||||
'module_label' => 'Story comment',
|
||||
'title' => 'New comment on ' . ($comment->story?->title ?? 'story'),
|
||||
'body' => (string) ($comment->raw_content ?: $comment->content),
|
||||
'created_at' => $comment->created_at?->toIso8601String(),
|
||||
'time_ago' => $comment->created_at?->diffForHumans(),
|
||||
'url' => $comment->story ? route('stories.show', ['slug' => $comment->story->slug]) . '#comment-' . $comment->id : route('studio.activity'),
|
||||
'actor' => $comment->user ? [
|
||||
'id' => (int) $comment->user->id,
|
||||
'name' => $comment->user->name ?: $comment->user->username ?: 'Creator',
|
||||
'username' => $comment->user->username,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $comment->user->id, $comment->user->profile?->avatar_hash, 64),
|
||||
] : null,
|
||||
]);
|
||||
|
||||
return $artworkComments
|
||||
->concat($cardComments)
|
||||
->concat($collectionComments)
|
||||
->concat($storyComments)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function followerItems(User $user): array
|
||||
{
|
||||
return DB::table('user_followers as uf')
|
||||
->join('users as follower', 'follower.id', '=', 'uf.follower_id')
|
||||
->leftJoin('user_profiles as profile', 'profile.user_id', '=', 'follower.id')
|
||||
->where('uf.user_id', $user->id)
|
||||
->whereNull('follower.deleted_at')
|
||||
->orderByDesc('uf.created_at')
|
||||
->limit(20)
|
||||
->get([
|
||||
'uf.created_at',
|
||||
'follower.id',
|
||||
'follower.username',
|
||||
'follower.name',
|
||||
'profile.avatar_hash',
|
||||
])
|
||||
->map(fn ($row): array => [
|
||||
'id' => 'follower:' . $row->id . ':' . strtotime((string) $row->created_at),
|
||||
'type' => 'follower',
|
||||
'module' => 'followers',
|
||||
'module_label' => 'Follower',
|
||||
'title' => ($row->name ?: $row->username ?: 'Someone') . ' followed you',
|
||||
'body' => 'New audience activity in Creator Studio.',
|
||||
'created_at' => $this->normalizeDate($row->created_at),
|
||||
'time_ago' => null,
|
||||
'url' => '/@' . strtolower((string) $row->username),
|
||||
'actor' => [
|
||||
'id' => (int) $row->id,
|
||||
'name' => $row->name ?: $row->username ?: 'Creator',
|
||||
'username' => $row->username,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64),
|
||||
],
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function normalizeType(string $type): string
|
||||
{
|
||||
return in_array($type, ['all', 'notification', 'comment', 'follower'], true)
|
||||
? $type
|
||||
: 'all';
|
||||
}
|
||||
|
||||
private function normalizeModule(string $module): string
|
||||
{
|
||||
return in_array($module, ['all', 'artworks', 'cards', 'collections', 'stories', 'followers', 'system'], true)
|
||||
? $module
|
||||
: 'all';
|
||||
}
|
||||
|
||||
private function timestamp(mixed $value): int
|
||||
{
|
||||
if (! is_string($value) || $value === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return strtotime($value) ?: 0;
|
||||
}
|
||||
|
||||
private function normalizeDate(mixed $value): ?string
|
||||
{
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format(DATE_ATOM);
|
||||
}
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
242
app/Services/Studio/CreatorStudioAnalyticsService.php
Normal file
242
app/Services/Studio/CreatorStudioAnalyticsService.php
Normal file
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Studio\Contracts\CreatorStudioProvider;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use function collect;
|
||||
use function now;
|
||||
|
||||
final class CreatorStudioAnalyticsService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreatorStudioContentService $content,
|
||||
) {
|
||||
}
|
||||
|
||||
public function overview(User $user, int $days = 30): array
|
||||
{
|
||||
$providers = collect($this->content->providers());
|
||||
$moduleBreakdown = $providers->map(function (CreatorStudioProvider $provider) use ($user): array {
|
||||
$summary = $provider->summary($user);
|
||||
$analytics = $provider->analytics($user);
|
||||
|
||||
return [
|
||||
'key' => $provider->key(),
|
||||
'label' => $provider->label(),
|
||||
'icon' => $provider->icon(),
|
||||
'count' => $summary['count'],
|
||||
'draft_count' => $summary['draft_count'],
|
||||
'published_count' => $summary['published_count'],
|
||||
'archived_count' => $summary['archived_count'],
|
||||
'views' => $analytics['views'],
|
||||
'appreciation' => $analytics['appreciation'],
|
||||
'shares' => $analytics['shares'],
|
||||
'comments' => $analytics['comments'],
|
||||
'saves' => $analytics['saves'],
|
||||
'index_url' => $provider->indexUrl(),
|
||||
];
|
||||
})->values();
|
||||
|
||||
$followers = (int) DB::table('user_followers')->where('user_id', $user->id)->count();
|
||||
|
||||
$totals = [
|
||||
'views' => (int) $moduleBreakdown->sum('views'),
|
||||
'appreciation' => (int) $moduleBreakdown->sum('appreciation'),
|
||||
'shares' => (int) $moduleBreakdown->sum('shares'),
|
||||
'comments' => (int) $moduleBreakdown->sum('comments'),
|
||||
'saves' => (int) $moduleBreakdown->sum('saves'),
|
||||
'followers' => $followers,
|
||||
'content_count' => (int) $moduleBreakdown->sum('count'),
|
||||
];
|
||||
|
||||
$topContent = $providers
|
||||
->flatMap(fn (CreatorStudioProvider $provider) => $provider->topItems($user, 4))
|
||||
->sortByDesc(fn (array $item): int => (int) ($item['engagement_score'] ?? 0))
|
||||
->take(8)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'totals' => $totals,
|
||||
'module_breakdown' => $moduleBreakdown->all(),
|
||||
'top_content' => $topContent,
|
||||
'views_trend' => $this->trendSeries($user, $days, 'views'),
|
||||
'engagement_trend' => $this->trendSeries($user, $days, 'engagement'),
|
||||
'publishing_timeline' => $this->publishingTimeline($user, $days),
|
||||
'comparison' => $this->comparison($user, $days),
|
||||
'insight_blocks' => $this->insightBlocks($moduleBreakdown, $totals, $days),
|
||||
'range_days' => $days,
|
||||
];
|
||||
}
|
||||
|
||||
private function insightBlocks($moduleBreakdown, array $totals, int $days): array
|
||||
{
|
||||
$strongest = $moduleBreakdown->sortByDesc('appreciation')->first();
|
||||
$busiest = $moduleBreakdown->sortByDesc('count')->first();
|
||||
$conversation = $moduleBreakdown->sortByDesc('comments')->first();
|
||||
|
||||
$insights = [];
|
||||
|
||||
if ($strongest) {
|
||||
$insights[] = [
|
||||
'key' => 'strongest_module',
|
||||
'title' => $strongest['label'] . ' is driving the strongest reaction',
|
||||
'body' => sprintf(
|
||||
'%s generated %s reactions in the last %d days, making it the strongest appreciation surface in Studio right now.',
|
||||
$strongest['label'],
|
||||
number_format((int) $strongest['appreciation']),
|
||||
$days,
|
||||
),
|
||||
'tone' => 'positive',
|
||||
'icon' => 'fa-solid fa-sparkles',
|
||||
'href' => $strongest['index_url'],
|
||||
'cta' => 'Open module',
|
||||
];
|
||||
}
|
||||
|
||||
if ($conversation && (int) ($conversation['comments'] ?? 0) > 0) {
|
||||
$insights[] = [
|
||||
'key' => 'conversation_module',
|
||||
'title' => 'Conversation is concentrating in ' . $conversation['label'],
|
||||
'body' => sprintf(
|
||||
'%s collected %s comments in this window. That is the clearest place to check for follow-up and community signals.',
|
||||
$conversation['label'],
|
||||
number_format((int) $conversation['comments']),
|
||||
),
|
||||
'tone' => 'action',
|
||||
'icon' => 'fa-solid fa-comments',
|
||||
'href' => route('studio.inbox', ['module' => $conversation['key'], 'type' => 'comment']),
|
||||
'cta' => 'Open inbox',
|
||||
];
|
||||
}
|
||||
|
||||
if ($busiest && (int) ($busiest['draft_count'] ?? 0) > 0) {
|
||||
$insights[] = [
|
||||
'key' => 'draft_pressure',
|
||||
'title' => $busiest['label'] . ' has the heaviest backlog',
|
||||
'body' => sprintf(
|
||||
'%s currently has %s drafts. That is the best candidate for your next cleanup, publish, or scheduling pass.',
|
||||
$busiest['label'],
|
||||
number_format((int) $busiest['draft_count']),
|
||||
),
|
||||
'tone' => 'warning',
|
||||
'icon' => 'fa-solid fa-layer-group',
|
||||
'href' => route('studio.drafts', ['module' => $busiest['key']]),
|
||||
'cta' => 'Review drafts',
|
||||
];
|
||||
}
|
||||
|
||||
if ((int) ($totals['followers'] ?? 0) > 0) {
|
||||
$insights[] = [
|
||||
'key' => 'audience_presence',
|
||||
'title' => 'Audience footprint is active across the workspace',
|
||||
'body' => sprintf(
|
||||
'You now have %s followers connected to this creator profile. Keep featured content and your publishing cadence aligned with that audience.',
|
||||
number_format((int) $totals['followers']),
|
||||
),
|
||||
'tone' => 'neutral',
|
||||
'icon' => 'fa-solid fa-user-group',
|
||||
'href' => route('studio.featured'),
|
||||
'cta' => 'Manage featured',
|
||||
];
|
||||
}
|
||||
|
||||
return collect($insights)->take(4)->values()->all();
|
||||
}
|
||||
|
||||
private function publishingTimeline(User $user, int $days): array
|
||||
{
|
||||
$timeline = collect(range($days - 1, 0))->map(function (int $offset): array {
|
||||
$date = now()->subDays($offset)->startOfDay();
|
||||
|
||||
return [
|
||||
'date' => $date->toDateString(),
|
||||
'count' => 0,
|
||||
];
|
||||
})->keyBy('date');
|
||||
|
||||
collect($this->content->recentPublished($user, 120))
|
||||
->each(function (array $item) use ($timeline): void {
|
||||
$publishedAt = $item['published_at'] ?? $item['updated_at'] ?? null;
|
||||
if (! $publishedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
$date = date('Y-m-d', strtotime((string) $publishedAt));
|
||||
if (! $timeline->has($date)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$row = $timeline->get($date);
|
||||
$row['count']++;
|
||||
$timeline->put($date, $row);
|
||||
});
|
||||
|
||||
return $timeline->values()->all();
|
||||
}
|
||||
|
||||
private function trendSeries(User $user, int $days, string $metric): array
|
||||
{
|
||||
$series = collect(range($days - 1, 0))->map(function (int $offset): array {
|
||||
$date = now()->subDays($offset)->toDateString();
|
||||
|
||||
return [
|
||||
'date' => $date,
|
||||
'value' => 0,
|
||||
];
|
||||
})->keyBy('date');
|
||||
|
||||
collect($this->content->providers())
|
||||
->flatMap(fn (CreatorStudioProvider $provider) => $provider->items($user, 'all', 240))
|
||||
->each(function (array $item) use ($series, $metric): void {
|
||||
$dateSource = $item['published_at'] ?? $item['updated_at'] ?? $item['created_at'] ?? null;
|
||||
if (! $dateSource) {
|
||||
return;
|
||||
}
|
||||
|
||||
$date = date('Y-m-d', strtotime((string) $dateSource));
|
||||
if (! $series->has($date)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$row = $series->get($date);
|
||||
$increment = $metric === 'views'
|
||||
? (int) ($item['metrics']['views'] ?? 0)
|
||||
: (int) ($item['engagement_score'] ?? 0);
|
||||
$row['value'] += $increment;
|
||||
$series->put($date, $row);
|
||||
});
|
||||
|
||||
return $series->values()->all();
|
||||
}
|
||||
|
||||
private function comparison(User $user, int $days): array
|
||||
{
|
||||
$windowStart = now()->subDays($days)->getTimestamp();
|
||||
|
||||
return collect($this->content->providers())
|
||||
->map(function (CreatorStudioProvider $provider) use ($user, $windowStart): array {
|
||||
$items = $provider->items($user, 'all', 240)
|
||||
->filter(function (array $item) use ($windowStart): bool {
|
||||
$date = $item['published_at'] ?? $item['updated_at'] ?? $item['created_at'] ?? null;
|
||||
|
||||
return $date !== null && strtotime((string) $date) >= $windowStart;
|
||||
});
|
||||
|
||||
return [
|
||||
'key' => $provider->key(),
|
||||
'label' => $provider->label(),
|
||||
'icon' => $provider->icon(),
|
||||
'views' => (int) $items->sum(fn (array $item): int => (int) ($item['metrics']['views'] ?? 0)),
|
||||
'engagement' => (int) $items->sum(fn (array $item): int => (int) ($item['engagement_score'] ?? 0)),
|
||||
'published_count' => (int) $items->filter(fn (array $item): bool => ($item['status'] ?? null) === 'published')->count(),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
300
app/Services/Studio/CreatorStudioAssetService.php
Normal file
300
app/Services/Studio/CreatorStudioAssetService.php
Normal file
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Collection;
|
||||
use App\Models\NovaCardBackground;
|
||||
use App\Models\Story;
|
||||
use App\Models\User;
|
||||
use App\Support\CoverUrl;
|
||||
use Illuminate\Support\Collection as SupportCollection;
|
||||
|
||||
final class CreatorStudioAssetService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
public function library(User $user, array $filters = []): array
|
||||
{
|
||||
$type = $this->normalizeType((string) ($filters['type'] ?? 'all'));
|
||||
$source = $this->normalizeSource((string) ($filters['source'] ?? 'all'));
|
||||
$sort = $this->normalizeSort((string) ($filters['sort'] ?? 'recent'));
|
||||
$query = trim((string) ($filters['q'] ?? ''));
|
||||
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||
$perPage = min(max((int) ($filters['per_page'] ?? 18), 12), 36);
|
||||
|
||||
$items = $this->allAssets($user);
|
||||
|
||||
if ($type !== 'all') {
|
||||
$items = $items->where('type', $type)->values();
|
||||
}
|
||||
|
||||
if ($source !== 'all') {
|
||||
$items = $items->where('source_key', $source)->values();
|
||||
}
|
||||
|
||||
if ($query !== '') {
|
||||
$needle = mb_strtolower($query);
|
||||
$items = $items->filter(function (array $item) use ($needle): bool {
|
||||
return collect([
|
||||
$item['title'] ?? '',
|
||||
$item['type_label'] ?? '',
|
||||
$item['source_label'] ?? '',
|
||||
$item['description'] ?? '',
|
||||
])->contains(fn ($value): bool => is_string($value) && str_contains(mb_strtolower($value), $needle));
|
||||
})->values();
|
||||
}
|
||||
|
||||
$items = $this->sortAssets($items, $sort);
|
||||
|
||||
$total = $items->count();
|
||||
$lastPage = max(1, (int) ceil($total / $perPage));
|
||||
$page = min($page, $lastPage);
|
||||
|
||||
return [
|
||||
'items' => $items->forPage($page, $perPage)->values()->all(),
|
||||
'meta' => [
|
||||
'current_page' => $page,
|
||||
'last_page' => $lastPage,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
],
|
||||
'filters' => [
|
||||
'type' => $type,
|
||||
'source' => $source,
|
||||
'sort' => $sort,
|
||||
'q' => $query,
|
||||
],
|
||||
'type_options' => [
|
||||
['value' => 'all', 'label' => 'All assets'],
|
||||
['value' => 'card_backgrounds', 'label' => 'Card backgrounds'],
|
||||
['value' => 'story_covers', 'label' => 'Story covers'],
|
||||
['value' => 'collection_covers', 'label' => 'Collection covers'],
|
||||
['value' => 'artwork_previews', 'label' => 'Artwork previews'],
|
||||
['value' => 'profile_branding', 'label' => 'Profile branding'],
|
||||
],
|
||||
'source_options' => [
|
||||
['value' => 'all', 'label' => 'All sources'],
|
||||
['value' => 'cards', 'label' => 'Nova Cards'],
|
||||
['value' => 'stories', 'label' => 'Stories'],
|
||||
['value' => 'collections', 'label' => 'Collections'],
|
||||
['value' => 'artworks', 'label' => 'Artworks'],
|
||||
['value' => 'profile', 'label' => 'Profile'],
|
||||
],
|
||||
'sort_options' => [
|
||||
['value' => 'recent', 'label' => 'Recently updated'],
|
||||
['value' => 'usage', 'label' => 'Most reused'],
|
||||
['value' => 'title', 'label' => 'Title A-Z'],
|
||||
],
|
||||
'summary' => $this->summary($items),
|
||||
'highlights' => [
|
||||
'recent_uploads' => $items->take(5)->values()->all(),
|
||||
'most_used' => $items->sortByDesc(fn (array $item): int => (int) ($item['usage_count'] ?? 0))->take(5)->values()->all(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function allAssets(User $user): SupportCollection
|
||||
{
|
||||
return $this->cardBackgrounds($user)
|
||||
->concat($this->storyCovers($user))
|
||||
->concat($this->collectionCovers($user))
|
||||
->concat($this->artworkPreviews($user))
|
||||
->concat($this->profileBranding($user))
|
||||
->sortByDesc(fn (array $item): int => strtotime((string) ($item['created_at'] ?? '')) ?: 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function cardBackgrounds(User $user): SupportCollection
|
||||
{
|
||||
return NovaCardBackground::query()
|
||||
->withCount('cards')
|
||||
->where('user_id', $user->id)
|
||||
->latest('created_at')
|
||||
->limit(120)
|
||||
->get()
|
||||
->map(fn (NovaCardBackground $background): array => [
|
||||
'id' => 'card-background:' . $background->id,
|
||||
'numeric_id' => (int) $background->id,
|
||||
'type' => 'card_backgrounds',
|
||||
'type_label' => 'Card background',
|
||||
'source_key' => 'cards',
|
||||
'title' => 'Background #' . $background->id,
|
||||
'description' => 'Reusable upload for Nova Card drafts and remixes.',
|
||||
'image_url' => $background->processedUrl(),
|
||||
'source_label' => 'Nova Cards',
|
||||
'usage_count' => (int) ($background->cards_count ?? 0),
|
||||
'usage_references' => [
|
||||
['label' => 'Nova Cards editor', 'href' => route('studio.cards.create')],
|
||||
['label' => 'Cards library', 'href' => route('studio.cards.index')],
|
||||
],
|
||||
'created_at' => $background->created_at?->toIso8601String(),
|
||||
'manage_url' => route('studio.cards.index'),
|
||||
'view_url' => route('studio.cards.create'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function storyCovers(User $user): SupportCollection
|
||||
{
|
||||
return Story::query()
|
||||
->where('creator_id', $user->id)
|
||||
->whereNotNull('cover_image')
|
||||
->latest('updated_at')
|
||||
->limit(120)
|
||||
->get()
|
||||
->map(fn (Story $story): array => [
|
||||
'id' => 'story-cover:' . $story->id,
|
||||
'numeric_id' => (int) $story->id,
|
||||
'type' => 'story_covers',
|
||||
'type_label' => 'Story cover',
|
||||
'source_key' => 'stories',
|
||||
'title' => $story->title,
|
||||
'description' => $story->excerpt ?: 'Cover art attached to a story draft or publication.',
|
||||
'image_url' => $story->cover_url,
|
||||
'source_label' => 'Stories',
|
||||
'usage_count' => 1,
|
||||
'usage_references' => [
|
||||
['label' => 'Story editor', 'href' => route('creator.stories.edit', ['story' => $story->id])],
|
||||
],
|
||||
'created_at' => $story->updated_at?->toIso8601String(),
|
||||
'manage_url' => route('creator.stories.edit', ['story' => $story->id]),
|
||||
'view_url' => in_array($story->status, ['published', 'scheduled'], true)
|
||||
? route('stories.show', ['slug' => $story->slug])
|
||||
: route('creator.stories.preview', ['story' => $story->id]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function collectionCovers(User $user): SupportCollection
|
||||
{
|
||||
return Collection::query()
|
||||
->with('coverArtwork')
|
||||
->where('user_id', $user->id)
|
||||
->latest('updated_at')
|
||||
->limit(120)
|
||||
->get()
|
||||
->filter(fn (Collection $collection): bool => $collection->coverArtwork !== null)
|
||||
->map(fn (Collection $collection): array => [
|
||||
'id' => 'collection-cover:' . $collection->id,
|
||||
'numeric_id' => (int) $collection->id,
|
||||
'type' => 'collection_covers',
|
||||
'type_label' => 'Collection cover',
|
||||
'source_key' => 'collections',
|
||||
'title' => $collection->title,
|
||||
'description' => $collection->summary ?: $collection->description ?: 'Cover artwork used for a collection shelf.',
|
||||
'image_url' => $collection->coverArtwork?->thumbUrl('md'),
|
||||
'source_label' => 'Collections',
|
||||
'usage_count' => 1,
|
||||
'usage_references' => [
|
||||
['label' => 'Collection dashboard', 'href' => route('settings.collections.show', ['collection' => $collection->id])],
|
||||
],
|
||||
'created_at' => $collection->updated_at?->toIso8601String(),
|
||||
'manage_url' => route('settings.collections.show', ['collection' => $collection->id]),
|
||||
'view_url' => route('profile.collections.show', [
|
||||
'username' => strtolower((string) $user->username),
|
||||
'slug' => $collection->slug,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function artworkPreviews(User $user): SupportCollection
|
||||
{
|
||||
return Artwork::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at')
|
||||
->latest('updated_at')
|
||||
->limit(120)
|
||||
->get()
|
||||
->map(fn (Artwork $artwork): array => [
|
||||
'id' => 'artwork-preview:' . $artwork->id,
|
||||
'numeric_id' => (int) $artwork->id,
|
||||
'type' => 'artwork_previews',
|
||||
'type_label' => 'Artwork preview',
|
||||
'source_key' => 'artworks',
|
||||
'title' => $artwork->title ?: 'Untitled artwork',
|
||||
'description' => $artwork->description ?: 'Reusable preview image from your artwork library.',
|
||||
'image_url' => $artwork->thumbUrl('md'),
|
||||
'source_label' => 'Artworks',
|
||||
'usage_count' => 1,
|
||||
'usage_references' => [
|
||||
['label' => 'Artwork editor', 'href' => route('studio.artworks.edit', ['id' => $artwork->id])],
|
||||
['label' => 'Artwork page', 'href' => $artwork->published_at ? route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]) : route('studio.artworks.edit', ['id' => $artwork->id])],
|
||||
],
|
||||
'created_at' => $artwork->updated_at?->toIso8601String(),
|
||||
'manage_url' => route('studio.artworks.edit', ['id' => $artwork->id]),
|
||||
'view_url' => $artwork->published_at
|
||||
? route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug])
|
||||
: route('studio.artworks.edit', ['id' => $artwork->id]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function profileBranding(User $user): SupportCollection
|
||||
{
|
||||
$items = [];
|
||||
|
||||
if ($user->cover_hash && $user->cover_ext) {
|
||||
$items[] = [
|
||||
'id' => 'profile-cover:' . $user->id,
|
||||
'numeric_id' => (int) $user->id,
|
||||
'type' => 'profile_branding',
|
||||
'type_label' => 'Profile banner',
|
||||
'source_key' => 'profile',
|
||||
'title' => 'Profile banner',
|
||||
'description' => 'Banner image used on your public creator profile.',
|
||||
'image_url' => CoverUrl::forUser($user->cover_hash, $user->cover_ext, time()),
|
||||
'source_label' => 'Profile',
|
||||
'usage_count' => 1,
|
||||
'usage_references' => [
|
||||
['label' => 'Studio profile', 'href' => route('studio.profile')],
|
||||
['label' => 'Public profile', 'href' => '/@' . strtolower((string) $user->username)],
|
||||
],
|
||||
'created_at' => now()->toIso8601String(),
|
||||
'manage_url' => route('studio.profile'),
|
||||
'view_url' => '/@' . strtolower((string) $user->username),
|
||||
];
|
||||
}
|
||||
|
||||
return collect($items);
|
||||
}
|
||||
|
||||
private function summary(SupportCollection $items): array
|
||||
{
|
||||
return [
|
||||
['key' => 'card_backgrounds', 'label' => 'Card backgrounds', 'count' => $items->where('type', 'card_backgrounds')->count(), 'icon' => 'fa-solid fa-panorama'],
|
||||
['key' => 'story_covers', 'label' => 'Story covers', 'count' => $items->where('type', 'story_covers')->count(), 'icon' => 'fa-solid fa-book-open-cover'],
|
||||
['key' => 'collection_covers', 'label' => 'Collection covers', 'count' => $items->where('type', 'collection_covers')->count(), 'icon' => 'fa-solid fa-layer-group'],
|
||||
['key' => 'artwork_previews', 'label' => 'Artwork previews', 'count' => $items->where('type', 'artwork_previews')->count(), 'icon' => 'fa-solid fa-image'],
|
||||
['key' => 'profile_branding', 'label' => 'Profile branding', 'count' => $items->where('type', 'profile_branding')->count(), 'icon' => 'fa-solid fa-id-badge'],
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeType(string $type): string
|
||||
{
|
||||
$allowed = ['all', 'card_backgrounds', 'story_covers', 'collection_covers', 'artwork_previews', 'profile_branding'];
|
||||
|
||||
return in_array($type, $allowed, true) ? $type : 'all';
|
||||
}
|
||||
|
||||
private function normalizeSource(string $source): string
|
||||
{
|
||||
$allowed = ['all', 'cards', 'stories', 'collections', 'artworks', 'profile'];
|
||||
|
||||
return in_array($source, $allowed, true) ? $source : 'all';
|
||||
}
|
||||
|
||||
private function normalizeSort(string $sort): string
|
||||
{
|
||||
return in_array($sort, ['recent', 'usage', 'title'], true) ? $sort : 'recent';
|
||||
}
|
||||
|
||||
private function sortAssets(SupportCollection $items, string $sort): SupportCollection
|
||||
{
|
||||
return match ($sort) {
|
||||
'usage' => $items->sortByDesc(fn (array $item): int => (int) ($item['usage_count'] ?? 0))->values(),
|
||||
'title' => $items->sortBy(fn (array $item): string => mb_strtolower((string) ($item['title'] ?? '')))->values(),
|
||||
default => $items->sortByDesc(fn (array $item): int => strtotime((string) ($item['created_at'] ?? '')) ?: 0)->values(),
|
||||
};
|
||||
}
|
||||
}
|
||||
224
app/Services/Studio/CreatorStudioCalendarService.php
Normal file
224
app/Services/Studio/CreatorStudioCalendarService.php
Normal file
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class CreatorStudioCalendarService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreatorStudioContentService $content,
|
||||
private readonly CreatorStudioScheduledService $scheduled,
|
||||
) {
|
||||
}
|
||||
|
||||
public function build(User $user, array $filters = []): array
|
||||
{
|
||||
$view = $this->normalizeView((string) ($filters['view'] ?? 'month'));
|
||||
$module = $this->normalizeModule((string) ($filters['module'] ?? 'all'));
|
||||
$status = $this->normalizeStatus((string) ($filters['status'] ?? 'scheduled'));
|
||||
$query = trim((string) ($filters['q'] ?? ''));
|
||||
$focusDate = $this->normalizeFocusDate((string) ($filters['focus_date'] ?? ''));
|
||||
|
||||
$scheduledItems = $this->scheduledItems($user, $module, $query);
|
||||
$unscheduledItems = $this->unscheduledItems($user, $module, $query);
|
||||
|
||||
return [
|
||||
'filters' => [
|
||||
'view' => $view,
|
||||
'module' => $module,
|
||||
'status' => $status,
|
||||
'q' => $query,
|
||||
'focus_date' => $focusDate->toDateString(),
|
||||
],
|
||||
'view_options' => [
|
||||
['value' => 'month', 'label' => 'Month'],
|
||||
['value' => 'week', 'label' => 'Week'],
|
||||
['value' => 'agenda', 'label' => 'Agenda'],
|
||||
],
|
||||
'status_options' => [
|
||||
['value' => 'scheduled', 'label' => 'Scheduled only'],
|
||||
['value' => 'unscheduled', 'label' => 'Unscheduled queue'],
|
||||
['value' => 'all', 'label' => 'Everything'],
|
||||
],
|
||||
'module_options' => array_merge([
|
||||
['value' => 'all', 'label' => 'All content'],
|
||||
], collect($this->content->moduleSummaries($user))->map(fn (array $summary): array => [
|
||||
'value' => $summary['key'],
|
||||
'label' => $summary['label'],
|
||||
])->all()),
|
||||
'summary' => $this->summary($scheduledItems, $unscheduledItems),
|
||||
'month' => $this->monthGrid($scheduledItems, $focusDate),
|
||||
'week' => $this->weekGrid($scheduledItems, $focusDate),
|
||||
'agenda' => $this->agenda($scheduledItems),
|
||||
'scheduled_items' => $status === 'unscheduled' ? [] : $scheduledItems->take(18)->values()->all(),
|
||||
'unscheduled_items' => $status === 'scheduled' ? [] : $unscheduledItems->take(12)->values()->all(),
|
||||
'gaps' => $this->gaps($scheduledItems, $focusDate),
|
||||
];
|
||||
}
|
||||
|
||||
private function scheduledItems(User $user, string $module, string $query): Collection
|
||||
{
|
||||
$items = $module === 'all'
|
||||
? collect($this->content->providers())->flatMap(fn ($provider) => $provider->scheduledItems($user, 320))
|
||||
: ($this->content->provider($module)?->scheduledItems($user, 320) ?? collect());
|
||||
|
||||
if ($query !== '') {
|
||||
$needle = mb_strtolower($query);
|
||||
$items = $items->filter(fn (array $item): bool => str_contains(mb_strtolower((string) ($item['title'] ?? '')), $needle));
|
||||
}
|
||||
|
||||
return $items
|
||||
->sortBy(fn (array $item): int => strtotime((string) ($item['scheduled_at'] ?? $item['published_at'] ?? '')) ?: PHP_INT_MAX)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function unscheduledItems(User $user, string $module, string $query): Collection
|
||||
{
|
||||
$items = $module === 'all'
|
||||
? collect($this->content->providers())->flatMap(fn ($provider) => $provider->items($user, 'all', 240))
|
||||
: ($this->content->provider($module)?->items($user, 'all', 240) ?? collect());
|
||||
|
||||
return $items
|
||||
->filter(function (array $item) use ($query): bool {
|
||||
if (filled($item['scheduled_at'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (in_array((string) ($item['status'] ?? ''), ['archived', 'hidden', 'rejected'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($query === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_contains(mb_strtolower((string) ($item['title'] ?? '')), mb_strtolower($query));
|
||||
})
|
||||
->sortByDesc(fn (array $item): int => strtotime((string) ($item['updated_at'] ?? $item['created_at'] ?? '')) ?: 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function summary(Collection $scheduledItems, Collection $unscheduledItems): array
|
||||
{
|
||||
$days = $scheduledItems
|
||||
->groupBy(fn (array $item): string => date('Y-m-d', strtotime((string) ($item['scheduled_at'] ?? $item['published_at'] ?? now()->toIso8601String()))))
|
||||
->map(fn (Collection $items): int => $items->count());
|
||||
|
||||
return [
|
||||
'scheduled_total' => $scheduledItems->count(),
|
||||
'unscheduled_total' => $unscheduledItems->count(),
|
||||
'overloaded_days' => $days->filter(fn (int $count): bool => $count >= 3)->count(),
|
||||
'next_publish_at' => $scheduledItems->first()['scheduled_at'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function monthGrid(Collection $scheduledItems, Carbon $focusDate): array
|
||||
{
|
||||
$start = $focusDate->copy()->startOfMonth()->startOfWeek();
|
||||
$end = $focusDate->copy()->endOfMonth()->endOfWeek();
|
||||
$days = [];
|
||||
|
||||
for ($date = $start->copy(); $date->lte($end); $date->addDay()) {
|
||||
$key = $date->toDateString();
|
||||
$items = $scheduledItems->filter(fn (array $item): bool => str_starts_with((string) ($item['scheduled_at'] ?? ''), $key))->values();
|
||||
$days[] = [
|
||||
'date' => $key,
|
||||
'day' => $date->day,
|
||||
'is_current_month' => $date->month === $focusDate->month,
|
||||
'count' => $items->count(),
|
||||
'items' => $items->take(3)->all(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => $focusDate->format('F Y'),
|
||||
'days' => $days,
|
||||
];
|
||||
}
|
||||
|
||||
private function weekGrid(Collection $scheduledItems, Carbon $focusDate): array
|
||||
{
|
||||
$start = $focusDate->copy()->startOfWeek();
|
||||
|
||||
return [
|
||||
'label' => $start->format('M j') . ' - ' . $start->copy()->endOfWeek()->format('M j'),
|
||||
'days' => collect(range(0, 6))->map(function (int $offset) use ($start, $scheduledItems): array {
|
||||
$date = $start->copy()->addDays($offset);
|
||||
$key = $date->toDateString();
|
||||
$items = $scheduledItems->filter(fn (array $item): bool => str_starts_with((string) ($item['scheduled_at'] ?? ''), $key))->values();
|
||||
|
||||
return [
|
||||
'date' => $key,
|
||||
'label' => $date->format('D j'),
|
||||
'items' => $items->all(),
|
||||
];
|
||||
})->all(),
|
||||
];
|
||||
}
|
||||
|
||||
private function agenda(Collection $scheduledItems): array
|
||||
{
|
||||
return $scheduledItems
|
||||
->groupBy(fn (array $item): string => date('Y-m-d', strtotime((string) ($item['scheduled_at'] ?? $item['published_at'] ?? now()->toIso8601String()))))
|
||||
->map(fn (Collection $items, string $date): array => [
|
||||
'date' => $date,
|
||||
'label' => Carbon::parse($date)->format('M j'),
|
||||
'count' => $items->count(),
|
||||
'items' => $items->values()->all(),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function gaps(Collection $scheduledItems, Carbon $focusDate): array
|
||||
{
|
||||
return collect(range(0, 13))
|
||||
->map(function (int $offset) use ($focusDate, $scheduledItems): ?array {
|
||||
$date = $focusDate->copy()->startOfDay()->addDays($offset);
|
||||
$key = $date->toDateString();
|
||||
$count = $scheduledItems->filter(fn (array $item): bool => str_starts_with((string) ($item['scheduled_at'] ?? ''), $key))->count();
|
||||
|
||||
if ($count > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'date' => $key,
|
||||
'label' => $date->format('D, M j'),
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->take(6)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function normalizeView(string $view): string
|
||||
{
|
||||
return in_array($view, ['month', 'week', 'agenda'], true) ? $view : 'month';
|
||||
}
|
||||
|
||||
private function normalizeStatus(string $status): string
|
||||
{
|
||||
return in_array($status, ['scheduled', 'unscheduled', 'all'], true) ? $status : 'scheduled';
|
||||
}
|
||||
|
||||
private function normalizeModule(string $module): string
|
||||
{
|
||||
return in_array($module, ['all', 'artworks', 'cards', 'collections', 'stories'], true) ? $module : 'all';
|
||||
}
|
||||
|
||||
private function normalizeFocusDate(string $value): Carbon
|
||||
{
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1) {
|
||||
return Carbon::parse($value);
|
||||
}
|
||||
|
||||
return now();
|
||||
}
|
||||
}
|
||||
200
app/Services/Studio/CreatorStudioChallengeService.php
Normal file
200
app/Services/Studio/CreatorStudioChallengeService.php
Normal file
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\NovaCardChallenge;
|
||||
use App\Models\NovaCardChallengeEntry;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class CreatorStudioChallengeService
|
||||
{
|
||||
public function build(User $user): array
|
||||
{
|
||||
$entryCounts = NovaCardChallengeEntry::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNotIn('status', [NovaCardChallengeEntry::STATUS_HIDDEN, NovaCardChallengeEntry::STATUS_REJECTED])
|
||||
->selectRaw('challenge_id, COUNT(*) as aggregate')
|
||||
->groupBy('challenge_id')
|
||||
->pluck('aggregate', 'challenge_id');
|
||||
|
||||
$recentEntriesQuery = NovaCardChallengeEntry::query()
|
||||
->with(['challenge', 'card'])
|
||||
->where('user_id', $user->id)
|
||||
->whereNotIn('status', [NovaCardChallengeEntry::STATUS_HIDDEN, NovaCardChallengeEntry::STATUS_REJECTED])
|
||||
->whereHas('challenge')
|
||||
->whereHas('card');
|
||||
|
||||
$recentEntries = $recentEntriesQuery
|
||||
->latest('created_at')
|
||||
->limit(8)
|
||||
->get()
|
||||
->map(fn (NovaCardChallengeEntry $entry): array => $this->mapEntry($entry))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$activeChallenges = NovaCardChallenge::query()
|
||||
->whereIn('status', [NovaCardChallenge::STATUS_ACTIVE, NovaCardChallenge::STATUS_COMPLETED])
|
||||
->orderByRaw('CASE WHEN featured = 1 THEN 0 ELSE 1 END')
|
||||
->orderByRaw("CASE WHEN status = 'active' THEN 0 ELSE 1 END")
|
||||
->orderBy('ends_at')
|
||||
->orderByDesc('starts_at')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn (NovaCardChallenge $challenge): array => $this->mapChallenge($challenge, $entryCounts))
|
||||
->values();
|
||||
|
||||
$spotlight = $activeChallenges->first();
|
||||
|
||||
$cardLeaders = NovaCard::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at')
|
||||
->where('challenge_entries_count', '>', 0)
|
||||
->orderByDesc('challenge_entries_count')
|
||||
->orderByDesc('published_at')
|
||||
->limit(6)
|
||||
->get()
|
||||
->map(fn (NovaCard $card): array => [
|
||||
'id' => (int) $card->id,
|
||||
'title' => (string) $card->title,
|
||||
'status' => (string) $card->status,
|
||||
'challenge_entries_count' => (int) $card->challenge_entries_count,
|
||||
'views_count' => (int) $card->views_count,
|
||||
'comments_count' => (int) $card->comments_count,
|
||||
'preview_url' => route('studio.cards.preview', ['id' => $card->id]),
|
||||
'edit_url' => route('studio.cards.edit', ['id' => $card->id]),
|
||||
'analytics_url' => route('studio.cards.analytics', ['id' => $card->id]),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$cardsAvailable = (int) NovaCard::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotIn('status', [NovaCard::STATUS_HIDDEN, NovaCard::STATUS_REJECTED])
|
||||
->count();
|
||||
|
||||
$entryCount = (int) $entryCounts->sum();
|
||||
$featuredEntries = (int) (clone $recentEntriesQuery)
|
||||
->whereIn('status', [NovaCardChallengeEntry::STATUS_FEATURED, NovaCardChallengeEntry::STATUS_WINNER])
|
||||
->count();
|
||||
$winnerEntries = (int) (clone $recentEntriesQuery)
|
||||
->where('status', NovaCardChallengeEntry::STATUS_WINNER)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'summary' => [
|
||||
'active_challenges' => (int) $activeChallenges->where('status', NovaCardChallenge::STATUS_ACTIVE)->count(),
|
||||
'joined_challenges' => (int) $entryCounts->keys()->count(),
|
||||
'entries_submitted' => $entryCount,
|
||||
'featured_entries' => $featuredEntries,
|
||||
'winner_entries' => $winnerEntries,
|
||||
'cards_available' => $cardsAvailable,
|
||||
],
|
||||
'spotlight' => $spotlight,
|
||||
'active_challenges' => $activeChallenges->all(),
|
||||
'recent_entries' => $recentEntries,
|
||||
'card_leaders' => $cardLeaders,
|
||||
'reminders' => $this->reminders($cardsAvailable, $entryCount, $activeChallenges, $featuredEntries, $winnerEntries),
|
||||
];
|
||||
}
|
||||
|
||||
private function mapChallenge(NovaCardChallenge $challenge, Collection $entryCounts): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $challenge->id,
|
||||
'slug' => (string) $challenge->slug,
|
||||
'title' => (string) $challenge->title,
|
||||
'description' => $challenge->description,
|
||||
'prompt' => $challenge->prompt,
|
||||
'status' => (string) $challenge->status,
|
||||
'official' => (bool) $challenge->official,
|
||||
'featured' => (bool) $challenge->featured,
|
||||
'entries_count' => (int) $challenge->entries_count,
|
||||
'starts_at' => $challenge->starts_at?->toIso8601String(),
|
||||
'ends_at' => $challenge->ends_at?->toIso8601String(),
|
||||
'is_joined' => $entryCounts->has($challenge->id),
|
||||
'submission_count' => (int) ($entryCounts->get($challenge->id) ?? 0),
|
||||
'url' => route('cards.challenges.show', ['slug' => $challenge->slug]),
|
||||
];
|
||||
}
|
||||
|
||||
private function mapEntry(NovaCardChallengeEntry $entry): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $entry->id,
|
||||
'status' => (string) $entry->status,
|
||||
'submitted_at' => $entry->created_at?->toIso8601String(),
|
||||
'note' => $entry->note,
|
||||
'challenge' => [
|
||||
'id' => (int) $entry->challenge_id,
|
||||
'title' => (string) ($entry->challenge?->title ?? 'Challenge'),
|
||||
'status' => (string) ($entry->challenge?->status ?? ''),
|
||||
'official' => (bool) ($entry->challenge?->official ?? false),
|
||||
'url' => $entry->challenge ? route('cards.challenges.show', ['slug' => $entry->challenge->slug]) : route('cards.challenges'),
|
||||
],
|
||||
'card' => [
|
||||
'id' => (int) $entry->card_id,
|
||||
'title' => (string) ($entry->card?->title ?? 'Card'),
|
||||
'preview_url' => $entry->card ? route('studio.cards.preview', ['id' => $entry->card->id]) : route('studio.cards.index'),
|
||||
'edit_url' => $entry->card ? route('studio.cards.edit', ['id' => $entry->card->id]) : route('studio.cards.create'),
|
||||
'analytics_url' => $entry->card ? route('studio.cards.analytics', ['id' => $entry->card->id]) : route('studio.cards.index'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function reminders(int $cardsAvailable, int $entryCount, Collection $activeChallenges, int $featuredEntries, int $winnerEntries): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
if ($cardsAvailable === 0) {
|
||||
$items[] = [
|
||||
'title' => 'Create a card to join challenges',
|
||||
'body' => 'Challenge participation starts from published or ready-to-share cards inside Studio.',
|
||||
'href' => route('studio.cards.create'),
|
||||
'cta' => 'Create card',
|
||||
];
|
||||
}
|
||||
|
||||
if ($cardsAvailable > 0 && $entryCount === 0 && $activeChallenges->where('status', NovaCardChallenge::STATUS_ACTIVE)->isNotEmpty()) {
|
||||
$items[] = [
|
||||
'title' => 'You have active challenge windows open',
|
||||
'body' => 'Submit an existing card to the current prompt lineup before the next window closes.',
|
||||
'href' => route('studio.cards.index'),
|
||||
'cta' => 'Open cards',
|
||||
];
|
||||
}
|
||||
|
||||
if ($featuredEntries > 0) {
|
||||
$items[] = [
|
||||
'title' => 'Featured challenge entries are live',
|
||||
'body' => 'Review promoted submissions and keep those cards ready for profile, editorial, or follow-up pushes.',
|
||||
'href' => route('studio.featured'),
|
||||
'cta' => 'Manage featured',
|
||||
];
|
||||
}
|
||||
|
||||
if ($winnerEntries > 0) {
|
||||
$items[] = [
|
||||
'title' => 'Winning challenge work deserves a spotlight',
|
||||
'body' => 'Use featured content and profile curation to extend the reach of cards that already placed well.',
|
||||
'href' => route('studio.profile'),
|
||||
'cta' => 'Open profile',
|
||||
];
|
||||
}
|
||||
|
||||
if ($activeChallenges->isNotEmpty()) {
|
||||
$items[] = [
|
||||
'title' => 'Public challenge archive stays one click away',
|
||||
'body' => 'Use the public challenge directory to review prompts, reference past winners, and see how new runs are framed.',
|
||||
'href' => route('cards.challenges'),
|
||||
'cta' => 'Browse challenges',
|
||||
];
|
||||
}
|
||||
|
||||
return collect($items)->take(4)->values()->all();
|
||||
}
|
||||
}
|
||||
379
app/Services/Studio/CreatorStudioCommentService.php
Normal file
379
app/Services/Studio/CreatorStudioCommentService.php
Normal file
@@ -0,0 +1,379 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\Collection;
|
||||
use App\Models\CollectionComment;
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\NovaCardComment;
|
||||
use App\Models\Report;
|
||||
use App\Models\Story;
|
||||
use App\Models\StoryComment;
|
||||
use App\Models\User;
|
||||
use App\Services\CollectionCommentService;
|
||||
use App\Services\NovaCards\NovaCardCommentService;
|
||||
use App\Services\SocialService;
|
||||
use App\Support\AvatarUrl;
|
||||
use App\Support\ContentSanitizer;
|
||||
use App\Support\Moderation\ReportTargetResolver;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class CreatorStudioCommentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NovaCardCommentService $cardComments,
|
||||
private readonly CollectionCommentService $collectionComments,
|
||||
private readonly SocialService $social,
|
||||
private readonly ReportTargetResolver $reports,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
public function list(User $user, array $filters = []): array
|
||||
{
|
||||
$module = $this->normalizeModule((string) ($filters['module'] ?? 'all'));
|
||||
$query = trim((string) ($filters['q'] ?? ''));
|
||||
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||
$perPage = min(max((int) ($filters['per_page'] ?? 18), 12), 36);
|
||||
|
||||
$items = $this->artworkComments($user)
|
||||
->concat($this->cardComments($user))
|
||||
->concat($this->collectionComments($user))
|
||||
->concat($this->storyComments($user))
|
||||
->sortByDesc(fn (array $item): int => strtotime((string) ($item['created_at'] ?? '')) ?: 0)
|
||||
->values();
|
||||
|
||||
if ($module !== 'all') {
|
||||
$items = $items->where('module', $module)->values();
|
||||
}
|
||||
|
||||
if ($query !== '') {
|
||||
$needle = mb_strtolower($query);
|
||||
$items = $items->filter(function (array $item) use ($needle): bool {
|
||||
return collect([
|
||||
$item['author_name'] ?? '',
|
||||
$item['item_title'] ?? '',
|
||||
$item['body'] ?? '',
|
||||
$item['module_label'] ?? '',
|
||||
])->contains(fn ($value): bool => is_string($value) && str_contains(mb_strtolower($value), $needle));
|
||||
})->values();
|
||||
}
|
||||
|
||||
$total = $items->count();
|
||||
$lastPage = max(1, (int) ceil($total / $perPage));
|
||||
$page = min($page, $lastPage);
|
||||
|
||||
return [
|
||||
'items' => $items->forPage($page, $perPage)->values()->all(),
|
||||
'meta' => [
|
||||
'current_page' => $page,
|
||||
'last_page' => $lastPage,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
],
|
||||
'filters' => [
|
||||
'module' => $module,
|
||||
'q' => $query,
|
||||
],
|
||||
'module_options' => [
|
||||
['value' => 'all', 'label' => 'All comments'],
|
||||
['value' => 'artworks', 'label' => 'Artworks'],
|
||||
['value' => 'cards', 'label' => 'Cards'],
|
||||
['value' => 'collections', 'label' => 'Collections'],
|
||||
['value' => 'stories', 'label' => 'Stories'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function reply(User $user, string $module, int $commentId, string $content): void
|
||||
{
|
||||
$trimmed = trim($content);
|
||||
abort_if($trimmed === '' || mb_strlen($trimmed) > 10000, 422, 'Reply content is invalid.');
|
||||
|
||||
match ($this->normalizeModule($module)) {
|
||||
'artworks' => $this->replyToArtworkComment($user, $commentId, $trimmed),
|
||||
'cards' => $this->replyToCardComment($user, $commentId, $trimmed),
|
||||
'collections' => $this->replyToCollectionComment($user, $commentId, $trimmed),
|
||||
'stories' => $this->replyToStoryComment($user, $commentId, $trimmed),
|
||||
default => abort(404),
|
||||
};
|
||||
}
|
||||
|
||||
public function moderate(User $user, string $module, int $commentId): void
|
||||
{
|
||||
match ($this->normalizeModule($module)) {
|
||||
'artworks' => $this->deleteArtworkComment($user, $commentId),
|
||||
'cards' => $this->deleteCardComment($user, $commentId),
|
||||
'collections' => $this->deleteCollectionComment($user, $commentId),
|
||||
'stories' => $this->deleteStoryComment($user, $commentId),
|
||||
default => abort(404),
|
||||
};
|
||||
}
|
||||
|
||||
public function report(User $user, string $module, int $commentId, string $reason, ?string $details = null): array
|
||||
{
|
||||
$targetType = match ($this->normalizeModule($module)) {
|
||||
'artworks' => 'artwork_comment',
|
||||
'cards' => 'nova_card_comment',
|
||||
'collections' => 'collection_comment',
|
||||
'stories' => 'story_comment',
|
||||
default => abort(404),
|
||||
};
|
||||
|
||||
$this->reports->validateForReporter($user, $targetType, $commentId);
|
||||
|
||||
$report = Report::query()->create([
|
||||
'reporter_id' => $user->id,
|
||||
'target_type' => $targetType,
|
||||
'target_id' => $commentId,
|
||||
'reason' => trim($reason),
|
||||
'details' => $details ? trim($details) : null,
|
||||
'status' => 'open',
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => (int) $report->id,
|
||||
'status' => (string) $report->status,
|
||||
];
|
||||
}
|
||||
|
||||
private function artworkComments(User $user)
|
||||
{
|
||||
return ArtworkComment::query()
|
||||
->with(['user.profile', 'artwork'])
|
||||
->whereNull('deleted_at')
|
||||
->whereHas('artwork', fn ($query) => $query->where('user_id', $user->id))
|
||||
->latest('created_at')
|
||||
->limit(120)
|
||||
->get()
|
||||
->map(fn (ArtworkComment $comment): array => [
|
||||
'id' => 'artworks:' . $comment->id,
|
||||
'comment_id' => (int) $comment->id,
|
||||
'module' => 'artworks',
|
||||
'module_label' => 'Artworks',
|
||||
'target_type' => 'artwork_comment',
|
||||
'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator',
|
||||
'author_username' => $comment->user?->username,
|
||||
'author_avatar_url' => AvatarUrl::forUser((int) ($comment->user?->id ?? 0), $comment->user?->profile?->avatar_hash, 64),
|
||||
'item_title' => $comment->artwork?->title,
|
||||
'item_id' => (int) ($comment->artwork?->id ?? 0),
|
||||
'body' => (string) ($comment->raw_content ?: $comment->content),
|
||||
'created_at' => $comment->created_at?->toIso8601String(),
|
||||
'time_ago' => $comment->created_at?->diffForHumans(),
|
||||
'context_url' => $comment->artwork
|
||||
? route('art.show', ['id' => $comment->artwork->id, 'slug' => $comment->artwork->slug]) . '#comment-' . $comment->id
|
||||
: route('studio.comments'),
|
||||
'preview_url' => $comment->artwork
|
||||
? route('art.show', ['id' => $comment->artwork->id, 'slug' => $comment->artwork->slug])
|
||||
: null,
|
||||
'reply_supported' => true,
|
||||
'moderate_supported' => true,
|
||||
'report_supported' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function cardComments(User $user)
|
||||
{
|
||||
return NovaCardComment::query()
|
||||
->with(['user.profile', 'card'])
|
||||
->whereNull('deleted_at')
|
||||
->whereHas('card', fn ($query) => $query->where('user_id', $user->id))
|
||||
->latest('created_at')
|
||||
->limit(120)
|
||||
->get()
|
||||
->map(fn (NovaCardComment $comment): array => [
|
||||
'id' => 'cards:' . $comment->id,
|
||||
'comment_id' => (int) $comment->id,
|
||||
'module' => 'cards',
|
||||
'module_label' => 'Cards',
|
||||
'target_type' => 'nova_card_comment',
|
||||
'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator',
|
||||
'author_username' => $comment->user?->username,
|
||||
'author_avatar_url' => AvatarUrl::forUser((int) ($comment->user?->id ?? 0), $comment->user?->profile?->avatar_hash, 64),
|
||||
'item_title' => $comment->card?->title,
|
||||
'item_id' => (int) ($comment->card?->id ?? 0),
|
||||
'body' => (string) $comment->body,
|
||||
'created_at' => $comment->created_at?->toIso8601String(),
|
||||
'time_ago' => $comment->created_at?->diffForHumans(),
|
||||
'context_url' => $comment->card ? $comment->card->publicUrl() . '#comment-' . $comment->id : route('studio.comments'),
|
||||
'preview_url' => $comment->card ? route('studio.cards.preview', ['id' => $comment->card->id]) : null,
|
||||
'reply_supported' => true,
|
||||
'moderate_supported' => true,
|
||||
'report_supported' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function collectionComments(User $user)
|
||||
{
|
||||
return CollectionComment::query()
|
||||
->with(['user.profile', 'collection'])
|
||||
->whereNull('deleted_at')
|
||||
->whereHas('collection', fn ($query) => $query->where('user_id', $user->id))
|
||||
->latest('created_at')
|
||||
->limit(120)
|
||||
->get()
|
||||
->map(fn (CollectionComment $comment): array => [
|
||||
'id' => 'collections:' . $comment->id,
|
||||
'comment_id' => (int) $comment->id,
|
||||
'module' => 'collections',
|
||||
'module_label' => 'Collections',
|
||||
'target_type' => 'collection_comment',
|
||||
'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator',
|
||||
'author_username' => $comment->user?->username,
|
||||
'author_avatar_url' => AvatarUrl::forUser((int) ($comment->user?->id ?? 0), $comment->user?->profile?->avatar_hash, 64),
|
||||
'item_title' => $comment->collection?->title,
|
||||
'item_id' => (int) ($comment->collection?->id ?? 0),
|
||||
'body' => (string) $comment->body,
|
||||
'created_at' => $comment->created_at?->toIso8601String(),
|
||||
'time_ago' => $comment->created_at?->diffForHumans(),
|
||||
'context_url' => $comment->collection
|
||||
? route('profile.collections.show', ['username' => strtolower((string) $user->username), 'slug' => $comment->collection->slug]) . '#comment-' . $comment->id
|
||||
: route('studio.comments'),
|
||||
'preview_url' => $comment->collection
|
||||
? route('profile.collections.show', ['username' => strtolower((string) $user->username), 'slug' => $comment->collection->slug])
|
||||
: null,
|
||||
'reply_supported' => true,
|
||||
'moderate_supported' => true,
|
||||
'report_supported' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function storyComments(User $user)
|
||||
{
|
||||
return StoryComment::query()
|
||||
->with(['user.profile', 'story'])
|
||||
->whereNull('deleted_at')
|
||||
->whereHas('story', fn ($query) => $query->where('creator_id', $user->id))
|
||||
->latest('created_at')
|
||||
->limit(120)
|
||||
->get()
|
||||
->map(fn (StoryComment $comment): array => [
|
||||
'id' => 'stories:' . $comment->id,
|
||||
'comment_id' => (int) $comment->id,
|
||||
'module' => 'stories',
|
||||
'module_label' => 'Stories',
|
||||
'target_type' => 'story_comment',
|
||||
'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator',
|
||||
'author_username' => $comment->user?->username,
|
||||
'author_avatar_url' => AvatarUrl::forUser((int) ($comment->user?->id ?? 0), $comment->user?->profile?->avatar_hash, 64),
|
||||
'item_title' => $comment->story?->title,
|
||||
'item_id' => (int) ($comment->story?->id ?? 0),
|
||||
'body' => (string) ($comment->raw_content ?: $comment->content),
|
||||
'created_at' => $comment->created_at?->toIso8601String(),
|
||||
'time_ago' => $comment->created_at?->diffForHumans(),
|
||||
'context_url' => $comment->story ? route('stories.show', ['slug' => $comment->story->slug]) . '#comment-' . $comment->id : route('studio.comments'),
|
||||
'preview_url' => $comment->story ? route('creator.stories.preview', ['story' => $comment->story->id]) : null,
|
||||
'reply_supported' => true,
|
||||
'moderate_supported' => true,
|
||||
'report_supported' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function replyToArtworkComment(User $user, int $commentId, string $content): void
|
||||
{
|
||||
$comment = ArtworkComment::query()
|
||||
->with('artwork')
|
||||
->findOrFail($commentId);
|
||||
|
||||
abort_unless((int) ($comment->artwork?->user_id ?? 0) === (int) $user->id, 403);
|
||||
|
||||
$errors = ContentSanitizer::validate($content);
|
||||
if ($errors !== []) {
|
||||
abort(422, implode(' ', $errors));
|
||||
}
|
||||
|
||||
ArtworkComment::query()->create([
|
||||
'artwork_id' => $comment->artwork_id,
|
||||
'user_id' => $user->id,
|
||||
'parent_id' => $comment->id,
|
||||
'content' => $content,
|
||||
'raw_content' => $content,
|
||||
'rendered_content' => ContentSanitizer::render($content),
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
$this->syncArtworkCommentCount((int) $comment->artwork_id);
|
||||
}
|
||||
|
||||
private function replyToCardComment(User $user, int $commentId, string $content): void
|
||||
{
|
||||
$comment = NovaCardComment::query()->with(['card.user'])->findOrFail($commentId);
|
||||
abort_unless((int) ($comment->card?->user_id ?? 0) === (int) $user->id, 403);
|
||||
$this->cardComments->create($comment->card->loadMissing('user'), $user, $content, $comment);
|
||||
}
|
||||
|
||||
private function replyToCollectionComment(User $user, int $commentId, string $content): void
|
||||
{
|
||||
$comment = CollectionComment::query()->with(['collection.user'])->findOrFail($commentId);
|
||||
abort_unless((int) ($comment->collection?->user_id ?? 0) === (int) $user->id, 403);
|
||||
$this->collectionComments->create($comment->collection->loadMissing('user'), $user, $content, $comment);
|
||||
}
|
||||
|
||||
private function replyToStoryComment(User $user, int $commentId, string $content): void
|
||||
{
|
||||
$comment = StoryComment::query()->with('story')->findOrFail($commentId);
|
||||
abort_unless((int) ($comment->story?->creator_id ?? 0) === (int) $user->id, 403);
|
||||
$this->social->addStoryComment($user, $comment->story, $content, $comment->id);
|
||||
}
|
||||
|
||||
private function deleteArtworkComment(User $user, int $commentId): void
|
||||
{
|
||||
$comment = ArtworkComment::query()->with('artwork')->findOrFail($commentId);
|
||||
abort_unless((int) ($comment->artwork?->user_id ?? 0) === (int) $user->id, 403);
|
||||
|
||||
if (! $comment->trashed()) {
|
||||
$comment->delete();
|
||||
}
|
||||
|
||||
$this->syncArtworkCommentCount((int) $comment->artwork_id);
|
||||
}
|
||||
|
||||
private function deleteCardComment(User $user, int $commentId): void
|
||||
{
|
||||
$comment = NovaCardComment::query()->with('card')->findOrFail($commentId);
|
||||
abort_unless((int) ($comment->card?->user_id ?? 0) === (int) $user->id, 403);
|
||||
$this->cardComments->delete($comment, $user);
|
||||
}
|
||||
|
||||
private function deleteCollectionComment(User $user, int $commentId): void
|
||||
{
|
||||
$comment = CollectionComment::query()->with('collection')->findOrFail($commentId);
|
||||
abort_unless((int) ($comment->collection?->user_id ?? 0) === (int) $user->id, 403);
|
||||
$this->collectionComments->delete($comment, $user);
|
||||
}
|
||||
|
||||
private function deleteStoryComment(User $user, int $commentId): void
|
||||
{
|
||||
$comment = StoryComment::query()->with('story')->findOrFail($commentId);
|
||||
abort_unless((int) ($comment->story?->creator_id ?? 0) === (int) $user->id, 403);
|
||||
$this->social->deleteStoryComment($user, $comment);
|
||||
}
|
||||
|
||||
private function syncArtworkCommentCount(int $artworkId): void
|
||||
{
|
||||
$count = ArtworkComment::query()
|
||||
->where('artwork_id', $artworkId)
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
if (DB::table('artwork_stats')->where('artwork_id', $artworkId)->exists()) {
|
||||
DB::table('artwork_stats')
|
||||
->where('artwork_id', $artworkId)
|
||||
->update(['comments_count' => $count, 'updated_at' => now()]);
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeModule(string $module): string
|
||||
{
|
||||
return in_array($module, ['all', 'artworks', 'cards', 'collections', 'stories'], true)
|
||||
? $module
|
||||
: 'all';
|
||||
}
|
||||
}
|
||||
486
app/Services/Studio/CreatorStudioContentService.php
Normal file
486
app/Services/Studio/CreatorStudioContentService.php
Normal file
@@ -0,0 +1,486 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Studio\Contracts\CreatorStudioProvider;
|
||||
use App\Services\Studio\Providers\ArtworkStudioProvider;
|
||||
use App\Services\Studio\Providers\CardStudioProvider;
|
||||
use App\Services\Studio\Providers\CollectionStudioProvider;
|
||||
use App\Services\Studio\Providers\StoryStudioProvider;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection as SupportCollection;
|
||||
|
||||
final class CreatorStudioContentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtworkStudioProvider $artworks,
|
||||
private readonly CardStudioProvider $cards,
|
||||
private readonly CollectionStudioProvider $collections,
|
||||
private readonly StoryStudioProvider $stories,
|
||||
) {
|
||||
}
|
||||
|
||||
public function moduleSummaries(User $user): array
|
||||
{
|
||||
return SupportCollection::make($this->providers())
|
||||
->map(fn (CreatorStudioProvider $provider): array => $provider->summary($user))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function quickCreate(): array
|
||||
{
|
||||
$preferredOrder = ['artworks', 'cards', 'stories', 'collections'];
|
||||
|
||||
return SupportCollection::make($this->providers())
|
||||
->sortBy(fn (CreatorStudioProvider $provider): int => array_search($provider->key(), $preferredOrder, true))
|
||||
->map(fn (CreatorStudioProvider $provider): array => [
|
||||
'key' => $provider->key(),
|
||||
'label' => rtrim($provider->label(), 's'),
|
||||
'icon' => $provider->icon(),
|
||||
'url' => $provider->createUrl(),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function list(User $user, array $filters = [], ?string $fixedBucket = null, ?string $fixedModule = null): array
|
||||
{
|
||||
$module = $fixedModule ?: $this->normalizeModule((string) ($filters['module'] ?? 'all'));
|
||||
$bucket = $fixedBucket ?: $this->normalizeBucket((string) ($filters['bucket'] ?? 'all'));
|
||||
$search = trim((string) ($filters['q'] ?? ''));
|
||||
$sort = $this->normalizeSort((string) ($filters['sort'] ?? 'updated_desc'));
|
||||
$category = (string) ($filters['category'] ?? 'all');
|
||||
$tag = trim((string) ($filters['tag'] ?? ''));
|
||||
$visibility = (string) ($filters['visibility'] ?? 'all');
|
||||
$activityState = (string) ($filters['activity_state'] ?? 'all');
|
||||
$stale = (string) ($filters['stale'] ?? 'all');
|
||||
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||
$perPage = min(max((int) ($filters['per_page'] ?? 24), 12), 48);
|
||||
|
||||
$items = $module === 'all'
|
||||
? SupportCollection::make($this->providers())->flatMap(fn (CreatorStudioProvider $provider) => $provider->items($user, $this->providerBucket($bucket), 200))
|
||||
: $this->provider($module)?->items($user, $this->providerBucket($bucket), 240) ?? SupportCollection::make();
|
||||
|
||||
if ($bucket === 'featured') {
|
||||
$items = $items->filter(fn (array $item): bool => (bool) ($item['featured'] ?? false));
|
||||
} elseif ($bucket === 'recent') {
|
||||
$items = $items->filter(function (array $item): bool {
|
||||
$date = $item['published_at'] ?? $item['updated_at'] ?? $item['created_at'] ?? null;
|
||||
|
||||
return $date !== null && strtotime((string) $date) >= Carbon::now()->subDays(30)->getTimestamp();
|
||||
});
|
||||
}
|
||||
|
||||
if ($search !== '') {
|
||||
$needle = mb_strtolower($search);
|
||||
$items = $items->filter(function (array $item) use ($needle): bool {
|
||||
$haystacks = [
|
||||
$item['title'] ?? '',
|
||||
$item['subtitle'] ?? '',
|
||||
$item['description'] ?? '',
|
||||
$item['module_label'] ?? '',
|
||||
];
|
||||
|
||||
return SupportCollection::make($haystacks)
|
||||
->filter(fn ($value): bool => is_string($value) && $value !== '')
|
||||
->contains(fn (string $value): bool => str_contains(mb_strtolower($value), $needle));
|
||||
});
|
||||
}
|
||||
|
||||
if ($module === 'artworks' && $category !== 'all') {
|
||||
$items = $items->filter(function (array $item) use ($category): bool {
|
||||
return SupportCollection::make($item['taxonomies']['categories'] ?? [])
|
||||
->contains(fn (array $entry): bool => (string) ($entry['slug'] ?? '') === $category);
|
||||
});
|
||||
}
|
||||
|
||||
if ($module === 'artworks' && $tag !== '') {
|
||||
$needle = mb_strtolower($tag);
|
||||
$items = $items->filter(function (array $item) use ($needle): bool {
|
||||
return SupportCollection::make($item['taxonomies']['tags'] ?? [])
|
||||
->contains(fn (array $entry): bool => str_contains(mb_strtolower((string) ($entry['name'] ?? '')), $needle));
|
||||
});
|
||||
}
|
||||
|
||||
if ($module === 'collections' && $visibility !== 'all') {
|
||||
$items = $items->filter(fn (array $item): bool => (string) ($item['visibility'] ?? '') === $visibility);
|
||||
}
|
||||
|
||||
if ($module === 'stories' && $activityState !== 'all') {
|
||||
$items = $items->filter(fn (array $item): bool => (string) ($item['activity_state'] ?? 'all') === $activityState);
|
||||
}
|
||||
|
||||
if ($stale === 'only') {
|
||||
$threshold = Carbon::now()->subDays(3)->getTimestamp();
|
||||
$items = $items->filter(function (array $item) use ($threshold): bool {
|
||||
$updatedAt = strtotime((string) ($item['updated_at'] ?? $item['created_at'] ?? ''));
|
||||
|
||||
return $updatedAt > 0 && $updatedAt <= $threshold;
|
||||
});
|
||||
}
|
||||
|
||||
$items = $this->annotateItems($this->sortItems($items, $sort)->values());
|
||||
$total = $items->count();
|
||||
$lastPage = max(1, (int) ceil($total / $perPage));
|
||||
$page = min($page, $lastPage);
|
||||
|
||||
return [
|
||||
'items' => $items->forPage($page, $perPage)->values()->all(),
|
||||
'meta' => [
|
||||
'current_page' => $page,
|
||||
'last_page' => $lastPage,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
],
|
||||
'filters' => [
|
||||
'module' => $module,
|
||||
'bucket' => $bucket,
|
||||
'q' => $search,
|
||||
'sort' => $sort,
|
||||
'category' => $category,
|
||||
'tag' => $tag,
|
||||
'visibility' => $visibility,
|
||||
'activity_state' => $activityState,
|
||||
'stale' => $stale,
|
||||
],
|
||||
'module_options' => array_merge([
|
||||
['value' => 'all', 'label' => 'All content'],
|
||||
], SupportCollection::make($this->moduleSummaries($user))->map(fn (array $summary): array => [
|
||||
'value' => $summary['key'],
|
||||
'label' => $summary['label'],
|
||||
])->all()),
|
||||
'bucket_options' => [
|
||||
['value' => 'all', 'label' => 'All'],
|
||||
['value' => 'published', 'label' => 'Published'],
|
||||
['value' => 'drafts', 'label' => 'Drafts'],
|
||||
['value' => 'scheduled', 'label' => 'Scheduled'],
|
||||
['value' => 'archived', 'label' => 'Archived'],
|
||||
['value' => 'featured', 'label' => 'Featured'],
|
||||
['value' => 'recent', 'label' => 'Recent'],
|
||||
],
|
||||
'sort_options' => [
|
||||
['value' => 'updated_desc', 'label' => 'Recently updated'],
|
||||
['value' => 'updated_asc', 'label' => 'Oldest untouched'],
|
||||
['value' => 'created_desc', 'label' => 'Newest created'],
|
||||
['value' => 'published_desc', 'label' => 'Newest published'],
|
||||
['value' => 'views_desc', 'label' => 'Most viewed'],
|
||||
['value' => 'appreciation_desc', 'label' => 'Most liked'],
|
||||
['value' => 'comments_desc', 'label' => 'Most commented'],
|
||||
['value' => 'engagement_desc', 'label' => 'Best engagement'],
|
||||
['value' => 'title_asc', 'label' => 'Title A-Z'],
|
||||
],
|
||||
'advanced_filters' => $this->advancedFilters($module, $items, [
|
||||
'category' => $category,
|
||||
'tag' => $tag,
|
||||
'visibility' => $visibility,
|
||||
'activity_state' => $activityState,
|
||||
'stale' => $stale,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
public function draftReminders(User $user, int $limit = 4): array
|
||||
{
|
||||
return $this->annotateItems(SupportCollection::make($this->providers())
|
||||
->flatMap(fn (CreatorStudioProvider $provider) => $provider->items($user, 'drafts', $limit))
|
||||
->sortByDesc(fn (array $item): int => strtotime((string) ($item['updated_at'] ?? $item['created_at'] ?? Carbon::now()->toIso8601String())))
|
||||
->take($limit)
|
||||
->values())
|
||||
->all();
|
||||
}
|
||||
|
||||
public function staleDrafts(User $user, int $limit = 4): array
|
||||
{
|
||||
return $this->annotateItems(SupportCollection::make($this->providers())
|
||||
->flatMap(fn (CreatorStudioProvider $provider) => $provider->items($user, 'drafts', $limit * 4))
|
||||
->filter(function (array $item): bool {
|
||||
$updatedAt = strtotime((string) ($item['updated_at'] ?? $item['created_at'] ?? Carbon::now()->toIso8601String()));
|
||||
|
||||
return $updatedAt <= Carbon::now()->subDays(3)->getTimestamp();
|
||||
})
|
||||
->sortBy(fn (array $item): int => strtotime((string) ($item['updated_at'] ?? $item['created_at'] ?? Carbon::now()->toIso8601String())))
|
||||
->take($limit)
|
||||
->values())
|
||||
->all();
|
||||
}
|
||||
|
||||
public function continueWorking(User $user, string $draftBehavior = 'resume-last', int $limit = 3): array
|
||||
{
|
||||
if ($draftBehavior === 'focus-published') {
|
||||
return $this->recentPublished($user, $limit);
|
||||
}
|
||||
|
||||
return $this->annotateItems(SupportCollection::make($this->providers())
|
||||
->flatMap(fn (CreatorStudioProvider $provider) => $provider->items($user, 'drafts', $limit * 4))
|
||||
->sortByDesc(fn (array $item): int => strtotime((string) ($item['updated_at'] ?? $item['created_at'] ?? Carbon::now()->toIso8601String())))
|
||||
->take($limit)
|
||||
->values())
|
||||
->all();
|
||||
}
|
||||
|
||||
public function recentPublished(User $user, int $limit = 6): array
|
||||
{
|
||||
return $this->annotateItems(SupportCollection::make($this->providers())
|
||||
->flatMap(fn (CreatorStudioProvider $provider) => $provider->items($user, 'published', $limit * 2))
|
||||
->sortByDesc(fn (array $item): int => strtotime((string) ($item['published_at'] ?? $item['updated_at'] ?? Carbon::now()->toIso8601String())))
|
||||
->take($limit)
|
||||
->values())
|
||||
->all();
|
||||
}
|
||||
|
||||
public function featuredCandidates(User $user, int $limit = 8): array
|
||||
{
|
||||
return $this->annotateItems(SupportCollection::make($this->providers())
|
||||
->flatMap(fn (CreatorStudioProvider $provider) => $provider->items($user, 'published', $limit * 2))
|
||||
->filter(fn (array $item): bool => ($item['status'] ?? null) === 'published')
|
||||
->sortByDesc(fn (array $item): int => (int) ($item['engagement_score'] ?? 0))
|
||||
->take($limit)
|
||||
->values())
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $featuredContent
|
||||
* @return array<string, array<string, mixed>|null>
|
||||
*/
|
||||
public function selectedItems(User $user, array $featuredContent): array
|
||||
{
|
||||
return SupportCollection::make(['artworks', 'cards', 'collections', 'stories'])
|
||||
->mapWithKeys(function (string $module) use ($user, $featuredContent): array {
|
||||
$selectedId = (int) ($featuredContent[$module] ?? 0);
|
||||
if ($selectedId < 1) {
|
||||
return [$module => null];
|
||||
}
|
||||
|
||||
$item = $this->provider($module)?->items($user, 'all', 400)
|
||||
->first(fn (array $entry): bool => (int) ($entry['numeric_id'] ?? 0) === $selectedId);
|
||||
|
||||
return [$module => $item ?: null];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
public function provider(string $module): ?CreatorStudioProvider
|
||||
{
|
||||
return SupportCollection::make($this->providers())->first(fn (CreatorStudioProvider $provider): bool => $provider->key() === $module);
|
||||
}
|
||||
|
||||
public function providers(): array
|
||||
{
|
||||
return [
|
||||
$this->artworks,
|
||||
$this->cards,
|
||||
$this->collections,
|
||||
$this->stories,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeModule(string $module): string
|
||||
{
|
||||
$allowed = ['all', 'artworks', 'cards', 'collections', 'stories'];
|
||||
|
||||
return in_array($module, $allowed, true) ? $module : 'all';
|
||||
}
|
||||
|
||||
private function normalizeBucket(string $bucket): string
|
||||
{
|
||||
$allowed = ['all', 'published', 'drafts', 'scheduled', 'archived', 'featured', 'recent'];
|
||||
|
||||
return in_array($bucket, $allowed, true) ? $bucket : 'all';
|
||||
}
|
||||
|
||||
private function normalizeSort(string $sort): string
|
||||
{
|
||||
$allowed = ['updated_desc', 'updated_asc', 'created_desc', 'published_desc', 'views_desc', 'appreciation_desc', 'comments_desc', 'engagement_desc', 'title_asc'];
|
||||
|
||||
return in_array($sort, $allowed, true) ? $sort : 'updated_desc';
|
||||
}
|
||||
|
||||
private function providerBucket(string $bucket): string
|
||||
{
|
||||
return $bucket === 'featured' ? 'published' : $bucket;
|
||||
}
|
||||
|
||||
private function sortItems(SupportCollection $items, string $sort): SupportCollection
|
||||
{
|
||||
return match ($sort) {
|
||||
'created_desc' => $items->sortByDesc(fn (array $item): int => strtotime((string) ($item['created_at'] ?? ''))),
|
||||
'updated_asc' => $items->sortBy(fn (array $item): int => strtotime((string) ($item['updated_at'] ?? $item['created_at'] ?? ''))),
|
||||
'published_desc' => $items->sortByDesc(fn (array $item): int => strtotime((string) ($item['published_at'] ?? $item['updated_at'] ?? ''))),
|
||||
'views_desc' => $items->sortByDesc(fn (array $item): int => (int) (($item['metrics']['views'] ?? 0))),
|
||||
'appreciation_desc' => $items->sortByDesc(fn (array $item): int => (int) (($item['metrics']['appreciation'] ?? 0))),
|
||||
'comments_desc' => $items->sortByDesc(fn (array $item): int => (int) (($item['metrics']['comments'] ?? 0))),
|
||||
'engagement_desc' => $items->sortByDesc(fn (array $item): int => (int) ($item['engagement_score'] ?? 0)),
|
||||
'title_asc' => $items->sortBy(fn (array $item): string => mb_strtolower((string) ($item['title'] ?? ''))),
|
||||
default => $items->sortByDesc(fn (array $item): int => strtotime((string) ($item['updated_at'] ?? $item['created_at'] ?? ''))),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $currentFilters
|
||||
*/
|
||||
private function advancedFilters(string $module, SupportCollection $items, array $currentFilters): array
|
||||
{
|
||||
return match ($module) {
|
||||
'artworks' => [
|
||||
[
|
||||
'key' => 'category',
|
||||
'label' => 'Category',
|
||||
'type' => 'select',
|
||||
'value' => $currentFilters['category'] ?? 'all',
|
||||
'options' => array_merge([
|
||||
['value' => 'all', 'label' => 'All categories'],
|
||||
], $items
|
||||
->flatMap(fn (array $item) => $item['taxonomies']['categories'] ?? [])
|
||||
->unique('slug')
|
||||
->sortBy('name')
|
||||
->map(fn (array $entry): array => [
|
||||
'value' => (string) ($entry['slug'] ?? ''),
|
||||
'label' => (string) ($entry['name'] ?? 'Category'),
|
||||
])->values()->all()),
|
||||
],
|
||||
[
|
||||
'key' => 'tag',
|
||||
'label' => 'Tag',
|
||||
'type' => 'search',
|
||||
'value' => $currentFilters['tag'] ?? '',
|
||||
'placeholder' => 'Filter by tag',
|
||||
],
|
||||
],
|
||||
'collections' => [[
|
||||
'key' => 'visibility',
|
||||
'label' => 'Visibility',
|
||||
'type' => 'select',
|
||||
'value' => $currentFilters['visibility'] ?? 'all',
|
||||
'options' => [
|
||||
['value' => 'all', 'label' => 'All visibility'],
|
||||
['value' => 'public', 'label' => 'Public'],
|
||||
['value' => 'unlisted', 'label' => 'Unlisted'],
|
||||
['value' => 'private', 'label' => 'Private'],
|
||||
],
|
||||
]],
|
||||
'stories' => [[
|
||||
'key' => 'activity_state',
|
||||
'label' => 'Activity',
|
||||
'type' => 'select',
|
||||
'value' => $currentFilters['activity_state'] ?? 'all',
|
||||
'options' => [
|
||||
['value' => 'all', 'label' => 'All states'],
|
||||
['value' => 'active', 'label' => 'Active'],
|
||||
['value' => 'inactive', 'label' => 'Inactive'],
|
||||
['value' => 'archived', 'label' => 'Archived'],
|
||||
],
|
||||
]],
|
||||
'all' => [[
|
||||
'key' => 'stale',
|
||||
'label' => 'Draft freshness',
|
||||
'type' => 'select',
|
||||
'value' => $currentFilters['stale'] ?? 'all',
|
||||
'options' => [
|
||||
['value' => 'all', 'label' => 'All drafts'],
|
||||
['value' => 'only', 'label' => 'Stale drafts'],
|
||||
],
|
||||
]],
|
||||
default => [[
|
||||
'key' => 'stale',
|
||||
'label' => 'Draft freshness',
|
||||
'type' => 'select',
|
||||
'value' => $currentFilters['stale'] ?? 'all',
|
||||
'options' => [
|
||||
['value' => 'all', 'label' => 'All drafts'],
|
||||
['value' => 'only', 'label' => 'Stale drafts'],
|
||||
],
|
||||
]],
|
||||
};
|
||||
}
|
||||
|
||||
private function annotateItems(SupportCollection $items): SupportCollection
|
||||
{
|
||||
return $items->map(fn (array $item): array => $this->annotateItem($item))->values();
|
||||
}
|
||||
|
||||
private function annotateItem(array $item): array
|
||||
{
|
||||
$now = Carbon::now();
|
||||
$updatedAt = Carbon::parse((string) ($item['updated_at'] ?? $item['created_at'] ?? $now->toIso8601String()));
|
||||
$isDraft = ($item['status'] ?? null) === 'draft';
|
||||
$missing = [];
|
||||
$score = 0;
|
||||
|
||||
if ($this->hasValue($item['title'] ?? null)) {
|
||||
$score++;
|
||||
} else {
|
||||
$missing[] = 'Add a title';
|
||||
}
|
||||
|
||||
if ($this->hasValue($item['description'] ?? null)) {
|
||||
$score++;
|
||||
} else {
|
||||
$missing[] = 'Add a description';
|
||||
}
|
||||
|
||||
if ($this->hasValue($item['image_url'] ?? null)) {
|
||||
$score++;
|
||||
} else {
|
||||
$missing[] = 'Add a preview image';
|
||||
}
|
||||
|
||||
if (! empty($item['taxonomies']['categories'] ?? []) || $this->hasValue($item['subtitle'] ?? null)) {
|
||||
$score++;
|
||||
} else {
|
||||
$missing[] = 'Choose a category or content context';
|
||||
}
|
||||
|
||||
$label = match (true) {
|
||||
$score >= 4 => 'Ready to publish',
|
||||
$score === 3 => 'Almost ready',
|
||||
default => 'Needs more work',
|
||||
};
|
||||
|
||||
$workflowActions = match ((string) ($item['module'] ?? '')) {
|
||||
'artworks' => [
|
||||
['label' => 'Create card', 'href' => route('studio.cards.create'), 'icon' => 'fa-solid fa-id-card'],
|
||||
['label' => 'Curate collection', 'href' => route('settings.collections.create'), 'icon' => 'fa-solid fa-layer-group'],
|
||||
['label' => 'Tell story', 'href' => route('creator.stories.create'), 'icon' => 'fa-solid fa-feather-pointed'],
|
||||
],
|
||||
'cards' => [
|
||||
['label' => 'Build collection', 'href' => route('settings.collections.create'), 'icon' => 'fa-solid fa-layer-group'],
|
||||
['label' => 'Open stories', 'href' => route('studio.stories'), 'icon' => 'fa-solid fa-feather-pointed'],
|
||||
],
|
||||
'stories' => [
|
||||
['label' => 'Create card', 'href' => route('studio.cards.create'), 'icon' => 'fa-solid fa-id-card'],
|
||||
['label' => 'Curate collection', 'href' => route('settings.collections.create'), 'icon' => 'fa-solid fa-layer-group'],
|
||||
],
|
||||
'collections' => [
|
||||
['label' => 'Review artworks', 'href' => route('studio.artworks'), 'icon' => 'fa-solid fa-images'],
|
||||
['label' => 'Review cards', 'href' => route('studio.cards.index'), 'icon' => 'fa-solid fa-id-card'],
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
|
||||
$item['workflow'] = [
|
||||
'is_stale_draft' => $isDraft && $updatedAt->lte($now->copy()->subDays(3)),
|
||||
'last_touched_days' => max(0, $updatedAt->diffInDays($now)),
|
||||
'resume_label' => $isDraft ? 'Resume draft' : 'Open item',
|
||||
'readiness' => [
|
||||
'score' => $score,
|
||||
'max' => 4,
|
||||
'label' => $label,
|
||||
'can_publish' => $score >= 3,
|
||||
'missing' => $missing,
|
||||
],
|
||||
'cross_module_actions' => $workflowActions,
|
||||
];
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
private function hasValue(mixed $value): bool
|
||||
{
|
||||
return is_string($value) ? trim($value) !== '' : ! empty($value);
|
||||
}
|
||||
}
|
||||
58
app/Services/Studio/CreatorStudioEventService.php
Normal file
58
app/Services/Studio/CreatorStudioEventService.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
final class CreatorStudioEventService
|
||||
{
|
||||
private const ALLOWED_EVENTS = [
|
||||
'studio_opened',
|
||||
'studio_module_opened',
|
||||
'studio_quick_create_used',
|
||||
'studio_filter_used',
|
||||
'studio_item_edited',
|
||||
'studio_item_archived',
|
||||
'studio_item_restored',
|
||||
'studio_activity_opened',
|
||||
'studio_scheduled_opened',
|
||||
'studio_asset_opened',
|
||||
'studio_continue_working_used',
|
||||
'studio_schedule_created',
|
||||
'studio_schedule_updated',
|
||||
'studio_schedule_cleared',
|
||||
'studio_calendar_item_rescheduled',
|
||||
'studio_widget_customized',
|
||||
'studio_widget_reordered',
|
||||
'studio_asset_reused',
|
||||
'studio_comment_replied',
|
||||
'studio_comment_moderated',
|
||||
'studio_comment_reported',
|
||||
'studio_challenge_action_taken',
|
||||
'studio_insight_clicked',
|
||||
'studio_stale_draft_archived',
|
||||
];
|
||||
|
||||
public function allowedEvents(): array
|
||||
{
|
||||
return self::ALLOWED_EVENTS;
|
||||
}
|
||||
|
||||
public function record(User $user, array $payload): void
|
||||
{
|
||||
Log::info('creator_studio_event', [
|
||||
'user_id' => (int) $user->id,
|
||||
'event_type' => (string) $payload['event_type'],
|
||||
'module' => Arr::get($payload, 'module'),
|
||||
'surface' => Arr::get($payload, 'surface'),
|
||||
'item_module' => Arr::get($payload, 'item_module'),
|
||||
'item_id' => Arr::get($payload, 'item_id'),
|
||||
'meta' => Arr::get($payload, 'meta', []),
|
||||
'occurred_at' => now()->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
115
app/Services/Studio/CreatorStudioFollowersService.php
Normal file
115
app/Services/Studio/CreatorStudioFollowersService.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class CreatorStudioFollowersService
|
||||
{
|
||||
public function list(User $user, array $filters = []): array
|
||||
{
|
||||
$perPage = 30;
|
||||
$search = trim((string) ($filters['q'] ?? ''));
|
||||
$sort = (string) ($filters['sort'] ?? 'recent');
|
||||
$relationship = (string) ($filters['relationship'] ?? 'all');
|
||||
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||
|
||||
$allowedSorts = ['recent', 'oldest', 'name', 'uploads', 'followers'];
|
||||
$allowedRelationships = ['all', 'following-back', 'not-followed'];
|
||||
|
||||
if (! in_array($sort, $allowedSorts, true)) {
|
||||
$sort = 'recent';
|
||||
}
|
||||
|
||||
if (! in_array($relationship, $allowedRelationships, true)) {
|
||||
$relationship = 'all';
|
||||
}
|
||||
|
||||
$baseQuery = DB::table('user_followers as uf')
|
||||
->join('users as u', 'u.id', '=', 'uf.follower_id')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
|
||||
->leftJoin('user_followers as mutual', function ($join) use ($user): void {
|
||||
$join->on('mutual.user_id', '=', 'uf.follower_id')
|
||||
->where('mutual.follower_id', '=', $user->id);
|
||||
})
|
||||
->where('uf.user_id', $user->id)
|
||||
->whereNull('u.deleted_at')
|
||||
->when($search !== '', function ($query) use ($search): void {
|
||||
$query->where(function ($inner) use ($search): void {
|
||||
$inner->where('u.username', 'like', '%' . $search . '%')
|
||||
->orWhere('u.name', 'like', '%' . $search . '%');
|
||||
});
|
||||
})
|
||||
->when($relationship === 'following-back', fn ($query) => $query->whereNotNull('mutual.created_at'))
|
||||
->when($relationship === 'not-followed', fn ($query) => $query->whereNull('mutual.created_at'));
|
||||
|
||||
$summaryBaseQuery = clone $baseQuery;
|
||||
|
||||
$followers = $baseQuery
|
||||
->when($sort === 'recent', fn ($query) => $query->orderByDesc('uf.created_at'))
|
||||
->when($sort === 'oldest', fn ($query) => $query->orderBy('uf.created_at'))
|
||||
->when($sort === 'name', fn ($query) => $query->orderByRaw('COALESCE(u.username, u.name) asc'))
|
||||
->when($sort === 'uploads', fn ($query) => $query->orderByDesc('us.uploads_count')->orderByRaw('COALESCE(u.username, u.name) asc'))
|
||||
->when($sort === 'followers', fn ($query) => $query->orderByDesc('us.followers_count')->orderByRaw('COALESCE(u.username, u.name) asc'))
|
||||
->select([
|
||||
'u.id',
|
||||
'u.username',
|
||||
'u.name',
|
||||
'up.avatar_hash',
|
||||
'us.uploads_count',
|
||||
'us.followers_count',
|
||||
'uf.created_at as followed_at',
|
||||
'mutual.created_at as followed_back_at',
|
||||
])
|
||||
->paginate($perPage, ['*'], 'page', $page)
|
||||
->withQueryString();
|
||||
|
||||
return [
|
||||
'items' => collect($followers->items())->map(fn ($row): array => [
|
||||
'id' => (int) $row->id,
|
||||
'name' => $row->name ?: '@' . $row->username,
|
||||
'username' => $row->username,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64),
|
||||
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
|
||||
'uploads_count' => (int) ($row->uploads_count ?? 0),
|
||||
'followers_count' => (int) ($row->followers_count ?? 0),
|
||||
'is_following_back' => $row->followed_back_at !== null,
|
||||
'followed_back_at' => $row->followed_back_at,
|
||||
'followed_at' => $row->followed_at,
|
||||
])->values()->all(),
|
||||
'meta' => [
|
||||
'current_page' => $followers->currentPage(),
|
||||
'last_page' => $followers->lastPage(),
|
||||
'per_page' => $followers->perPage(),
|
||||
'total' => $followers->total(),
|
||||
],
|
||||
'filters' => [
|
||||
'q' => $search,
|
||||
'sort' => $sort,
|
||||
'relationship' => $relationship,
|
||||
],
|
||||
'summary' => [
|
||||
'total_followers' => (clone $summaryBaseQuery)->count(),
|
||||
'following_back' => (clone $summaryBaseQuery)->whereNotNull('mutual.created_at')->count(),
|
||||
'not_followed' => (clone $summaryBaseQuery)->whereNull('mutual.created_at')->count(),
|
||||
],
|
||||
'sort_options' => [
|
||||
['value' => 'recent', 'label' => 'Most recent'],
|
||||
['value' => 'oldest', 'label' => 'Oldest first'],
|
||||
['value' => 'name', 'label' => 'Name A-Z'],
|
||||
['value' => 'uploads', 'label' => 'Most uploads'],
|
||||
['value' => 'followers', 'label' => 'Most followers'],
|
||||
],
|
||||
'relationship_options' => [
|
||||
['value' => 'all', 'label' => 'All followers'],
|
||||
['value' => 'following-back', 'label' => 'Following back'],
|
||||
['value' => 'not-followed', 'label' => 'Not followed yet'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
243
app/Services/Studio/CreatorStudioGrowthService.php
Normal file
243
app/Services/Studio/CreatorStudioGrowthService.php
Normal file
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use function collect;
|
||||
|
||||
final class CreatorStudioGrowthService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreatorStudioAnalyticsService $analytics,
|
||||
private readonly CreatorStudioContentService $content,
|
||||
private readonly CreatorStudioPreferenceService $preferences,
|
||||
) {
|
||||
}
|
||||
|
||||
public function build(User $user, int $days = 30): array
|
||||
{
|
||||
$analytics = $this->analytics->overview($user, $days);
|
||||
$preferences = $this->preferences->forUser($user);
|
||||
$user->loadMissing(['profile', 'statistics']);
|
||||
|
||||
$socialLinksCount = (int) DB::table('user_social_links')->where('user_id', $user->id)->count();
|
||||
$publishedInRange = (int) collect($analytics['comparison'])->sum('published_count');
|
||||
$challengeEntries = (int) DB::table('nova_cards')
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at')
|
||||
->sum('challenge_entries_count');
|
||||
$profileScore = $this->profileScore($user, $socialLinksCount);
|
||||
$cadenceScore = $this->ratioScore($publishedInRange, 6);
|
||||
$engagementScore = $this->ratioScore(
|
||||
(int) ($analytics['totals']['appreciation'] ?? 0)
|
||||
+ (int) ($analytics['totals']['comments'] ?? 0)
|
||||
+ (int) ($analytics['totals']['shares'] ?? 0)
|
||||
+ (int) ($analytics['totals']['saves'] ?? 0),
|
||||
max(3, (int) ($analytics['totals']['content_count'] ?? 0) * 12)
|
||||
);
|
||||
$curationScore = $this->ratioScore(count($preferences['featured_modules']), 4);
|
||||
|
||||
return [
|
||||
'summary' => [
|
||||
'followers' => (int) ($analytics['totals']['followers'] ?? 0),
|
||||
'published_in_range' => $publishedInRange,
|
||||
'engagement_actions' => (int) ($analytics['totals']['appreciation'] ?? 0)
|
||||
+ (int) ($analytics['totals']['comments'] ?? 0)
|
||||
+ (int) ($analytics['totals']['shares'] ?? 0)
|
||||
+ (int) ($analytics['totals']['saves'] ?? 0),
|
||||
'profile_completion' => $profileScore,
|
||||
'challenge_entries' => $challengeEntries,
|
||||
'featured_modules' => count($preferences['featured_modules']),
|
||||
],
|
||||
'module_focus' => $this->moduleFocus($analytics),
|
||||
'checkpoints' => [
|
||||
$this->checkpoint('profile', 'Profile presentation', $profileScore, 'Profile, cover, and social details shape how new visitors read your work.', route('studio.profile'), 'Update profile'),
|
||||
$this->checkpoint('cadence', 'Publishing cadence', $cadenceScore, sprintf('You published %d items in the last %d days.', $publishedInRange, $days), route('studio.calendar'), 'Open calendar'),
|
||||
$this->checkpoint('engagement', 'Audience response', $engagementScore, 'Comments, reactions, saves, and shares show whether your output is creating momentum.', route('studio.inbox'), 'Open inbox'),
|
||||
$this->checkpoint('curation', 'Featured curation', $curationScore, 'Featured modules make your strongest work easier to discover across the profile surface.', route('studio.featured'), 'Manage featured'),
|
||||
$this->checkpoint('challenges', 'Challenge participation', $this->ratioScore($challengeEntries, 5), 'Challenge submissions create discoverable surfaces beyond your core publishing flow.', route('studio.challenges'), 'Open challenges'),
|
||||
],
|
||||
'opportunities' => $this->opportunities($profileScore, $publishedInRange, $challengeEntries, $preferences),
|
||||
'milestones' => [
|
||||
$this->milestone('followers', 'Follower milestone', (int) ($analytics['totals']['followers'] ?? 0), $this->nextMilestone((int) ($analytics['totals']['followers'] ?? 0), [10, 25, 50, 100, 250, 500, 1000, 2500, 5000])),
|
||||
$this->milestone('publishing', sprintf('Published in %d days', $days), $publishedInRange, $this->nextMilestone($publishedInRange, [3, 5, 8, 12, 20, 30])),
|
||||
$this->milestone('challenges', 'Challenge submissions', $challengeEntries, $this->nextMilestone($challengeEntries, [1, 3, 5, 10, 25, 50])),
|
||||
],
|
||||
'momentum' => [
|
||||
'views_trend' => $analytics['views_trend'],
|
||||
'engagement_trend' => $analytics['engagement_trend'],
|
||||
'publishing_timeline' => $analytics['publishing_timeline'],
|
||||
],
|
||||
'top_content' => collect($analytics['top_content'])->take(5)->values()->all(),
|
||||
'range_days' => $days,
|
||||
];
|
||||
}
|
||||
|
||||
private function moduleFocus(array $analytics): array
|
||||
{
|
||||
$totalViews = max(1, (int) ($analytics['totals']['views'] ?? 0));
|
||||
$totalEngagement = max(1,
|
||||
(int) ($analytics['totals']['appreciation'] ?? 0)
|
||||
+ (int) ($analytics['totals']['comments'] ?? 0)
|
||||
+ (int) ($analytics['totals']['shares'] ?? 0)
|
||||
+ (int) ($analytics['totals']['saves'] ?? 0)
|
||||
);
|
||||
$comparison = collect($analytics['comparison'] ?? [])->keyBy('key');
|
||||
|
||||
return collect($analytics['module_breakdown'] ?? [])
|
||||
->map(function (array $item) use ($comparison, $totalViews, $totalEngagement): array {
|
||||
$engagementValue = (int) ($item['appreciation'] ?? 0)
|
||||
+ (int) ($item['comments'] ?? 0)
|
||||
+ (int) ($item['shares'] ?? 0)
|
||||
+ (int) ($item['saves'] ?? 0);
|
||||
$publishedCount = (int) ($comparison->get($item['key'])['published_count'] ?? 0);
|
||||
|
||||
return [
|
||||
'key' => $item['key'],
|
||||
'label' => $item['label'],
|
||||
'icon' => $item['icon'],
|
||||
'views' => (int) ($item['views'] ?? 0),
|
||||
'engagement' => $engagementValue,
|
||||
'published_count' => $publishedCount,
|
||||
'draft_count' => (int) ($item['draft_count'] ?? 0),
|
||||
'view_share' => (int) round(((int) ($item['views'] ?? 0) / $totalViews) * 100),
|
||||
'engagement_share' => (int) round(($engagementValue / $totalEngagement) * 100),
|
||||
'href' => $item['index_url'],
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function opportunities(int $profileScore, int $publishedInRange, int $challengeEntries, array $preferences): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
if ($profileScore < 80) {
|
||||
$items[] = [
|
||||
'title' => 'Tighten creator presentation',
|
||||
'body' => 'A more complete profile helps new followers understand the work behind the metrics.',
|
||||
'href' => route('studio.profile'),
|
||||
'cta' => 'Update profile',
|
||||
];
|
||||
}
|
||||
|
||||
if ($publishedInRange < 3) {
|
||||
$items[] = [
|
||||
'title' => 'Increase publishing cadence',
|
||||
'body' => 'The calendar is still the clearest place to turn draft backlog into visible output.',
|
||||
'href' => route('studio.calendar'),
|
||||
'cta' => 'Plan schedule',
|
||||
];
|
||||
}
|
||||
|
||||
if (count($preferences['featured_modules'] ?? []) < 3) {
|
||||
$items[] = [
|
||||
'title' => 'Expand featured module coverage',
|
||||
'body' => 'Featured content gives your strongest modules a cleaner discovery path from the public profile.',
|
||||
'href' => route('studio.featured'),
|
||||
'cta' => 'Manage featured',
|
||||
];
|
||||
}
|
||||
|
||||
if ($challengeEntries === 0) {
|
||||
$items[] = [
|
||||
'title' => 'Use challenges as a growth surface',
|
||||
'body' => 'Challenge runs create another discovery path for cards beyond your normal publishing feed.',
|
||||
'href' => route('studio.challenges'),
|
||||
'cta' => 'Review challenges',
|
||||
];
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'title' => 'Stay close to response signals',
|
||||
'body' => 'Inbox and comments are still the fastest route from passive reach to actual creator retention.',
|
||||
'href' => route('studio.inbox'),
|
||||
'cta' => 'Open inbox',
|
||||
];
|
||||
|
||||
return collect($items)->take(4)->values()->all();
|
||||
}
|
||||
|
||||
private function checkpoint(string $key, string $label, int $score, string $detail, string $href, string $cta): array
|
||||
{
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'score' => $score,
|
||||
'status' => $score >= 80 ? 'strong' : ($score >= 55 ? 'building' : 'needs_attention'),
|
||||
'detail' => $detail,
|
||||
'href' => $href,
|
||||
'cta' => $cta,
|
||||
];
|
||||
}
|
||||
|
||||
private function milestone(string $key, string $label, int $current, int $target): array
|
||||
{
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'current' => $current,
|
||||
'target' => $target,
|
||||
'progress' => $target > 0 ? min(100, (int) round(($current / $target) * 100)) : 100,
|
||||
];
|
||||
}
|
||||
|
||||
private function ratioScore(int $current, int $target): int
|
||||
{
|
||||
if ($target <= 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return max(0, min(100, (int) round(($current / $target) * 100)));
|
||||
}
|
||||
|
||||
private function nextMilestone(int $current, array $steps): int
|
||||
{
|
||||
foreach ($steps as $step) {
|
||||
if ($current < $step) {
|
||||
return $step;
|
||||
}
|
||||
}
|
||||
|
||||
return max($current, 1);
|
||||
}
|
||||
|
||||
private function profileScore(User $user, int $socialLinksCount): int
|
||||
{
|
||||
$score = 0;
|
||||
|
||||
if (filled($user->name)) {
|
||||
$score += 15;
|
||||
}
|
||||
|
||||
if (filled($user->profile?->description)) {
|
||||
$score += 20;
|
||||
}
|
||||
|
||||
if (filled($user->profile?->about)) {
|
||||
$score += 20;
|
||||
}
|
||||
|
||||
if (filled($user->profile?->website)) {
|
||||
$score += 15;
|
||||
}
|
||||
|
||||
if (filled($user->profile?->avatar_url)) {
|
||||
$score += 10;
|
||||
}
|
||||
|
||||
if (filled($user->cover_hash) && filled($user->cover_ext)) {
|
||||
$score += 10;
|
||||
}
|
||||
|
||||
if ($socialLinksCount > 0) {
|
||||
$score += 10;
|
||||
}
|
||||
|
||||
return min(100, $score);
|
||||
}
|
||||
}
|
||||
156
app/Services/Studio/CreatorStudioInboxService.php
Normal file
156
app/Services/Studio/CreatorStudioInboxService.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
final class CreatorStudioInboxService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreatorStudioActivityService $activity,
|
||||
private readonly CreatorStudioPreferenceService $preferences,
|
||||
) {
|
||||
}
|
||||
|
||||
public function build(User $user, array $filters = []): array
|
||||
{
|
||||
$type = $this->normalizeType((string) ($filters['type'] ?? 'all'));
|
||||
$module = $this->normalizeModule((string) ($filters['module'] ?? 'all'));
|
||||
$readState = $this->normalizeReadState((string) ($filters['read_state'] ?? 'all'));
|
||||
$priority = $this->normalizePriority((string) ($filters['priority'] ?? 'all'));
|
||||
$query = trim((string) ($filters['q'] ?? ''));
|
||||
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||
$perPage = min(max((int) ($filters['per_page'] ?? 20), 12), 40);
|
||||
|
||||
$lastReadAt = $this->preferences->forUser($user)['activity_last_read_at'] ?? null;
|
||||
$lastReadTimestamp = strtotime((string) $lastReadAt) ?: 0;
|
||||
|
||||
$items = $this->activity->feed($user)->map(function (array $item) use ($lastReadTimestamp): array {
|
||||
$timestamp = strtotime((string) ($item['created_at'] ?? '')) ?: 0;
|
||||
$item['is_new'] = $timestamp > $lastReadTimestamp;
|
||||
$item['priority'] = $this->priorityFor($item);
|
||||
|
||||
return $item;
|
||||
});
|
||||
|
||||
if ($type !== 'all') {
|
||||
$items = $items->where('type', $type)->values();
|
||||
}
|
||||
|
||||
if ($module !== 'all') {
|
||||
$items = $items->where('module', $module)->values();
|
||||
}
|
||||
|
||||
if ($readState === 'unread') {
|
||||
$items = $items->filter(fn (array $item): bool => (bool) ($item['is_new'] ?? false))->values();
|
||||
} elseif ($readState === 'read') {
|
||||
$items = $items->filter(fn (array $item): bool => ! (bool) ($item['is_new'] ?? false))->values();
|
||||
}
|
||||
|
||||
if ($priority !== 'all') {
|
||||
$items = $items->where('priority', $priority)->values();
|
||||
}
|
||||
|
||||
if ($query !== '') {
|
||||
$needle = mb_strtolower($query);
|
||||
$items = $items->filter(function (array $item) use ($needle): bool {
|
||||
return collect([
|
||||
$item['title'] ?? '',
|
||||
$item['body'] ?? '',
|
||||
$item['actor']['name'] ?? '',
|
||||
$item['module_label'] ?? '',
|
||||
])->contains(fn ($value): bool => is_string($value) && str_contains(mb_strtolower($value), $needle));
|
||||
})->values();
|
||||
}
|
||||
|
||||
$total = $items->count();
|
||||
$lastPage = max(1, (int) ceil($total / $perPage));
|
||||
$page = min($page, $lastPage);
|
||||
|
||||
return [
|
||||
'items' => $items->forPage($page, $perPage)->values()->all(),
|
||||
'meta' => [
|
||||
'current_page' => $page,
|
||||
'last_page' => $lastPage,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
],
|
||||
'filters' => [
|
||||
'type' => $type,
|
||||
'module' => $module,
|
||||
'read_state' => $readState,
|
||||
'priority' => $priority,
|
||||
'q' => $query,
|
||||
],
|
||||
'type_options' => [
|
||||
['value' => 'all', 'label' => 'Everything'],
|
||||
['value' => 'notification', 'label' => 'Notifications'],
|
||||
['value' => 'comment', 'label' => 'Comments'],
|
||||
['value' => 'follower', 'label' => 'Followers'],
|
||||
],
|
||||
'module_options' => [
|
||||
['value' => 'all', 'label' => 'All modules'],
|
||||
['value' => 'artworks', 'label' => 'Artworks'],
|
||||
['value' => 'cards', 'label' => 'Cards'],
|
||||
['value' => 'collections', 'label' => 'Collections'],
|
||||
['value' => 'stories', 'label' => 'Stories'],
|
||||
['value' => 'followers', 'label' => 'Followers'],
|
||||
['value' => 'system', 'label' => 'System'],
|
||||
],
|
||||
'read_state_options' => [
|
||||
['value' => 'all', 'label' => 'All'],
|
||||
['value' => 'unread', 'label' => 'Unread'],
|
||||
['value' => 'read', 'label' => 'Read'],
|
||||
],
|
||||
'priority_options' => [
|
||||
['value' => 'all', 'label' => 'All priorities'],
|
||||
['value' => 'high', 'label' => 'High priority'],
|
||||
['value' => 'medium', 'label' => 'Medium priority'],
|
||||
['value' => 'low', 'label' => 'Low priority'],
|
||||
],
|
||||
'summary' => [
|
||||
'unread_count' => $items->filter(fn (array $item): bool => (bool) ($item['is_new'] ?? false))->count(),
|
||||
'high_priority_count' => $items->where('priority', 'high')->count(),
|
||||
'comment_count' => $items->where('type', 'comment')->count(),
|
||||
'follower_count' => $items->where('type', 'follower')->count(),
|
||||
'last_read_at' => $lastReadAt,
|
||||
],
|
||||
'panels' => [
|
||||
'attention_now' => $items->filter(fn (array $item): bool => ($item['priority'] ?? 'low') === 'high')->take(5)->values()->all(),
|
||||
'follow_up_queue' => $items->filter(fn (array $item): bool => (bool) ($item['is_new'] ?? false))->take(5)->values()->all(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function priorityFor(array $item): string
|
||||
{
|
||||
return match ((string) ($item['type'] ?? '')) {
|
||||
'comment' => 'high',
|
||||
'notification' => (bool) ($item['read'] ?? false) ? 'medium' : 'high',
|
||||
'follower' => 'medium',
|
||||
default => 'low',
|
||||
};
|
||||
}
|
||||
|
||||
private function normalizeType(string $value): string
|
||||
{
|
||||
return in_array($value, ['all', 'notification', 'comment', 'follower'], true) ? $value : 'all';
|
||||
}
|
||||
|
||||
private function normalizeModule(string $value): string
|
||||
{
|
||||
return in_array($value, ['all', 'artworks', 'cards', 'collections', 'stories', 'followers', 'system'], true) ? $value : 'all';
|
||||
}
|
||||
|
||||
private function normalizeReadState(string $value): string
|
||||
{
|
||||
return in_array($value, ['all', 'read', 'unread'], true) ? $value : 'all';
|
||||
}
|
||||
|
||||
private function normalizePriority(string $value): string
|
||||
{
|
||||
return in_array($value, ['all', 'high', 'medium', 'low'], true) ? $value : 'all';
|
||||
}
|
||||
}
|
||||
322
app/Services/Studio/CreatorStudioOverviewService.php
Normal file
322
app/Services/Studio/CreatorStudioOverviewService.php
Normal file
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\CollectionComment;
|
||||
use App\Models\NovaCardComment;
|
||||
use App\Models\StoryComment;
|
||||
use App\Models\User;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class CreatorStudioOverviewService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreatorStudioContentService $content,
|
||||
private readonly CreatorStudioAnalyticsService $analytics,
|
||||
private readonly CreatorStudioScheduledService $scheduled,
|
||||
private readonly CreatorStudioActivityService $activity,
|
||||
private readonly CreatorStudioPreferenceService $preferences,
|
||||
private readonly CreatorStudioChallengeService $challenges,
|
||||
private readonly CreatorStudioGrowthService $growth,
|
||||
) {
|
||||
}
|
||||
|
||||
public function build(User $user): array
|
||||
{
|
||||
$analytics = $this->analytics->overview($user);
|
||||
$moduleSummaries = $this->content->moduleSummaries($user);
|
||||
$preferences = $this->preferences->forUser($user);
|
||||
$challengeData = $this->challenges->build($user);
|
||||
$growthData = $this->growth->build($user, $preferences['analytics_range_days']);
|
||||
$featuredContent = $this->content->selectedItems($user, $preferences['featured_content']);
|
||||
|
||||
return [
|
||||
'kpis' => [
|
||||
'total_content' => $analytics['totals']['content_count'],
|
||||
'views_30d' => $analytics['totals']['views'],
|
||||
'appreciation_30d' => $analytics['totals']['appreciation'],
|
||||
'shares_30d' => $analytics['totals']['shares'],
|
||||
'comments_30d' => $analytics['totals']['comments'],
|
||||
'followers' => $analytics['totals']['followers'],
|
||||
],
|
||||
'module_summaries' => $moduleSummaries,
|
||||
'quick_create' => $this->content->quickCreate(),
|
||||
'continue_working' => $this->content->continueWorking($user, $preferences['draft_behavior']),
|
||||
'scheduled_items' => $this->scheduled->upcoming($user, 5),
|
||||
'recent_activity' => $this->activity->recent($user, 6),
|
||||
'top_performers' => $analytics['top_content'],
|
||||
'recent_comments' => $this->recentComments($user),
|
||||
'recent_followers' => $this->recentFollowers($user),
|
||||
'draft_reminders' => $this->content->draftReminders($user),
|
||||
'stale_drafts' => $this->content->staleDrafts($user),
|
||||
'recent_publishes' => $this->content->recentPublished($user, 6),
|
||||
'growth_hints' => $this->growthHints($user, $moduleSummaries),
|
||||
'active_challenges' => [
|
||||
'summary' => $challengeData['summary'],
|
||||
'spotlight' => $challengeData['spotlight'],
|
||||
'items' => collect($challengeData['active_challenges'] ?? [])->take(3)->values()->all(),
|
||||
],
|
||||
'creator_health' => [
|
||||
'score' => (int) round(collect($growthData['checkpoints'] ?? [])->avg('score') ?? 0),
|
||||
'summary' => $growthData['summary'],
|
||||
'checkpoints' => collect($growthData['checkpoints'] ?? [])->take(3)->values()->all(),
|
||||
],
|
||||
'featured_status' => $this->featuredStatus($preferences, $featuredContent),
|
||||
'workflow_focus' => $this->workflowFocus($user),
|
||||
'command_center' => $this->commandCenter($user),
|
||||
'insight_blocks' => $analytics['insight_blocks'] ?? [],
|
||||
'preferences' => [
|
||||
'widget_visibility' => $preferences['widget_visibility'],
|
||||
'widget_order' => $preferences['widget_order'],
|
||||
'card_density' => $preferences['card_density'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function featuredStatus(array $preferences, array $featuredContent): array
|
||||
{
|
||||
$modules = ['artworks', 'cards', 'collections', 'stories'];
|
||||
$selectedModules = array_values(array_filter($modules, fn (string $module): bool => isset($featuredContent[$module]) && is_array($featuredContent[$module])));
|
||||
$missingModules = array_values(array_diff($modules, $selectedModules));
|
||||
|
||||
return [
|
||||
'selected_count' => count($selectedModules),
|
||||
'target_count' => count($modules),
|
||||
'featured_modules' => $preferences['featured_modules'],
|
||||
'missing_modules' => $missingModules,
|
||||
'items' => collect($featuredContent)
|
||||
->filter(fn ($item): bool => is_array($item))
|
||||
->values()
|
||||
->take(4)
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
private function workflowFocus(User $user): array
|
||||
{
|
||||
$continue = collect($this->content->continueWorking($user, 'resume-last', 6));
|
||||
|
||||
return [
|
||||
'priority_drafts' => $continue
|
||||
->filter(fn (array $item): bool => (bool) ($item['workflow']['is_stale_draft'] ?? false) || ! (bool) ($item['workflow']['readiness']['can_publish'] ?? false))
|
||||
->take(3)
|
||||
->values()
|
||||
->all(),
|
||||
'ready_to_schedule' => $continue
|
||||
->filter(fn (array $item): bool => (bool) ($item['workflow']['readiness']['can_publish'] ?? false))
|
||||
->take(3)
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
private function commandCenter(User $user): array
|
||||
{
|
||||
$scheduled = collect($this->scheduled->upcoming($user, 16));
|
||||
$inbox = collect($this->activity->recent($user, 16));
|
||||
$todayStart = now()->startOfDay();
|
||||
$todayEnd = now()->endOfDay();
|
||||
|
||||
return [
|
||||
'publishing_today' => $scheduled->filter(function (array $item) use ($todayStart, $todayEnd): bool {
|
||||
$timestamp = strtotime((string) ($item['scheduled_at'] ?? $item['published_at'] ?? '')) ?: 0;
|
||||
|
||||
return $timestamp >= $todayStart->getTimestamp() && $timestamp <= $todayEnd->getTimestamp();
|
||||
})->values()->all(),
|
||||
'attention_now' => $inbox->filter(fn (array $item): bool => in_array((string) ($item['type'] ?? ''), ['comment', 'notification'], true))->take(4)->values()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
private function growthHints(User $user, array $moduleSummaries): array
|
||||
{
|
||||
$user->loadMissing('profile');
|
||||
$summaries = collect($moduleSummaries)->keyBy('key');
|
||||
$hints = [];
|
||||
|
||||
if (blank($user->profile?->bio) || blank($user->profile?->tagline)) {
|
||||
$hints[] = [
|
||||
'title' => 'Complete your creator profile',
|
||||
'body' => 'Add a tagline and bio so your public presence matches the work you are publishing.',
|
||||
'url' => route('studio.profile'),
|
||||
'label' => 'Update profile',
|
||||
];
|
||||
}
|
||||
|
||||
if (((int) ($summaries->get('cards')['count'] ?? 0)) === 0) {
|
||||
$hints[] = [
|
||||
'title' => 'Publish your first card',
|
||||
'body' => 'Cards now live inside Creator Studio, making short-form publishing a first-class workflow.',
|
||||
'url' => route('studio.cards.create'),
|
||||
'label' => 'Create card',
|
||||
];
|
||||
}
|
||||
|
||||
if (((int) ($summaries->get('collections')['count'] ?? 0)) === 0) {
|
||||
$hints[] = [
|
||||
'title' => 'Create a featured collection',
|
||||
'body' => 'Curated collections give your profile a stronger editorial shape and a better publishing shelf.',
|
||||
'url' => route('settings.collections.create'),
|
||||
'label' => 'Start collection',
|
||||
];
|
||||
}
|
||||
|
||||
if (((int) ($summaries->get('artworks')['count'] ?? 0)) === 0) {
|
||||
$hints[] = [
|
||||
'title' => 'Upload your first artwork',
|
||||
'body' => 'Seed the workspace with a first long-form piece so analytics, drafts, and collections have something to build on.',
|
||||
'url' => '/upload',
|
||||
'label' => 'Upload artwork',
|
||||
];
|
||||
}
|
||||
|
||||
return collect($hints)->take(3)->values()->all();
|
||||
}
|
||||
|
||||
public function recentComments(User $user, int $limit = 12): array
|
||||
{
|
||||
$artworkComments = DB::table('artwork_comments')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_comments.artwork_id')
|
||||
->join('users', 'users.id', '=', 'artwork_comments.user_id')
|
||||
->where('artworks.user_id', $user->id)
|
||||
->whereNull('artwork_comments.deleted_at')
|
||||
->select([
|
||||
'artwork_comments.id',
|
||||
'artwork_comments.content as body',
|
||||
'artwork_comments.created_at',
|
||||
'users.name as author_name',
|
||||
'artworks.title as item_title',
|
||||
'artworks.slug as item_slug',
|
||||
'artworks.id as item_id',
|
||||
])
|
||||
->orderByDesc('artwork_comments.created_at')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn ($row): array => [
|
||||
'id' => sprintf('artworks:%d', (int) $row->id),
|
||||
'module' => 'artworks',
|
||||
'module_label' => 'Artworks',
|
||||
'author_name' => $row->author_name,
|
||||
'item_title' => $row->item_title,
|
||||
'body' => $row->body,
|
||||
'created_at' => $this->normalizeDate($row->created_at),
|
||||
'context_url' => route('art.show', ['id' => $row->item_id, 'slug' => $row->item_slug]),
|
||||
]);
|
||||
|
||||
$cardComments = NovaCardComment::query()
|
||||
->with(['user:id,name,username', 'card:id,title,slug'])
|
||||
->whereNull('deleted_at')
|
||||
->whereHas('card', fn ($query) => $query->where('user_id', $user->id))
|
||||
->latest('created_at')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (NovaCardComment $comment): array => [
|
||||
'id' => sprintf('cards:%d', (int) $comment->id),
|
||||
'module' => 'cards',
|
||||
'module_label' => 'Cards',
|
||||
'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator',
|
||||
'item_title' => $comment->card?->title,
|
||||
'body' => $comment->body,
|
||||
'created_at' => $comment->created_at?->toIso8601String(),
|
||||
'context_url' => $comment->card ? route('studio.cards.analytics', ['id' => $comment->card->id]) : route('studio.cards.index'),
|
||||
]);
|
||||
|
||||
$collectionComments = CollectionComment::query()
|
||||
->with(['user:id,name,username', 'collection:id,title'])
|
||||
->whereNull('deleted_at')
|
||||
->whereHas('collection', fn ($query) => $query->where('user_id', $user->id))
|
||||
->latest('created_at')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (CollectionComment $comment): array => [
|
||||
'id' => sprintf('collections:%d', (int) $comment->id),
|
||||
'module' => 'collections',
|
||||
'module_label' => 'Collections',
|
||||
'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator',
|
||||
'item_title' => $comment->collection?->title,
|
||||
'body' => $comment->body,
|
||||
'created_at' => $comment->created_at?->toIso8601String(),
|
||||
'context_url' => $comment->collection ? route('settings.collections.show', ['collection' => $comment->collection->id]) : route('studio.collections'),
|
||||
]);
|
||||
|
||||
$storyComments = StoryComment::query()
|
||||
->with(['user:id,name,username', 'story:id,title'])
|
||||
->whereNull('deleted_at')
|
||||
->where('is_approved', true)
|
||||
->whereHas('story', fn ($query) => $query->where('creator_id', $user->id))
|
||||
->latest('created_at')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (StoryComment $comment): array => [
|
||||
'id' => sprintf('stories:%d', (int) $comment->id),
|
||||
'module' => 'stories',
|
||||
'module_label' => 'Stories',
|
||||
'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator',
|
||||
'item_title' => $comment->story?->title,
|
||||
'body' => $comment->content,
|
||||
'created_at' => $comment->created_at?->toIso8601String(),
|
||||
'context_url' => $comment->story ? route('creator.stories.analytics', ['story' => $comment->story->id]) : route('studio.stories'),
|
||||
]);
|
||||
|
||||
return $artworkComments
|
||||
->concat($cardComments)
|
||||
->concat($collectionComments)
|
||||
->concat($storyComments)
|
||||
->sortByDesc(fn (array $comment): int => $this->timestamp($comment['created_at'] ?? null))
|
||||
->take($limit)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function recentFollowers(User $user, int $limit = 8): array
|
||||
{
|
||||
return DB::table('user_followers as uf')
|
||||
->join('users as follower', 'follower.id', '=', 'uf.follower_id')
|
||||
->leftJoin('user_profiles as profile', 'profile.user_id', '=', 'follower.id')
|
||||
->where('uf.user_id', $user->id)
|
||||
->whereNull('follower.deleted_at')
|
||||
->orderByDesc('uf.created_at')
|
||||
->limit($limit)
|
||||
->get([
|
||||
'follower.id',
|
||||
'follower.username',
|
||||
'follower.name',
|
||||
'profile.avatar_hash',
|
||||
'uf.created_at',
|
||||
])
|
||||
->map(fn ($row): array => [
|
||||
'id' => (int) $row->id,
|
||||
'name' => $row->name ?: '@' . $row->username,
|
||||
'username' => $row->username,
|
||||
'profile_url' => '/@' . strtolower((string) $row->username),
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64),
|
||||
'created_at' => $this->normalizeDate($row->created_at),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function normalizeDate(mixed $value): ?string
|
||||
{
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format(DATE_ATOM);
|
||||
}
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function timestamp(mixed $value): int
|
||||
{
|
||||
if (! is_string($value) || $value === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return strtotime($value) ?: 0;
|
||||
}
|
||||
}
|
||||
260
app/Services/Studio/CreatorStudioPreferenceService.php
Normal file
260
app/Services/Studio/CreatorStudioPreferenceService.php
Normal file
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\DashboardPreference;
|
||||
use App\Models\User;
|
||||
|
||||
final class CreatorStudioPreferenceService
|
||||
{
|
||||
/**
|
||||
* @return array{
|
||||
* default_content_view: string,
|
||||
* analytics_range_days: int,
|
||||
* dashboard_shortcuts: array<int, string>,
|
||||
* featured_modules: array<int, string>,
|
||||
* featured_content: array<string, int>,
|
||||
* draft_behavior: string,
|
||||
* default_landing_page: string,
|
||||
* widget_visibility: array<string, bool>,
|
||||
* widget_order: array<int, string>,
|
||||
* card_density: string,
|
||||
* scheduling_timezone: string|null,
|
||||
* activity_last_read_at: string|null
|
||||
* }
|
||||
*/
|
||||
public function forUser(User $user): array
|
||||
{
|
||||
$record = DashboardPreference::query()->find($user->id);
|
||||
$stored = is_array($record?->studio_preferences) ? $record->studio_preferences : [];
|
||||
|
||||
return [
|
||||
'default_content_view' => $this->normalizeView((string) ($stored['default_content_view'] ?? 'grid')),
|
||||
'analytics_range_days' => $this->normalizeRange((int) ($stored['analytics_range_days'] ?? 30)),
|
||||
'dashboard_shortcuts' => DashboardPreference::pinnedSpacesForUser($user),
|
||||
'featured_modules' => $this->normalizeModules($stored['featured_modules'] ?? []),
|
||||
'featured_content' => $this->normalizeFeaturedContent($stored['featured_content'] ?? []),
|
||||
'draft_behavior' => $this->normalizeDraftBehavior((string) ($stored['draft_behavior'] ?? 'resume-last')),
|
||||
'default_landing_page' => $this->normalizeLandingPage((string) ($stored['default_landing_page'] ?? 'overview')),
|
||||
'widget_visibility' => $this->normalizeWidgetVisibility($stored['widget_visibility'] ?? []),
|
||||
'widget_order' => $this->normalizeWidgetOrder($stored['widget_order'] ?? []),
|
||||
'card_density' => $this->normalizeDensity((string) ($stored['card_density'] ?? 'comfortable')),
|
||||
'scheduling_timezone' => $this->normalizeTimezone($stored['scheduling_timezone'] ?? null),
|
||||
'activity_last_read_at' => $this->normalizeActivityLastReadAt($stored['activity_last_read_at'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array{
|
||||
* default_content_view: string,
|
||||
* analytics_range_days: int,
|
||||
* dashboard_shortcuts: array<int, string>,
|
||||
* featured_modules: array<int, string>,
|
||||
* featured_content: array<string, int>,
|
||||
* draft_behavior: string,
|
||||
* default_landing_page: string,
|
||||
* widget_visibility: array<string, bool>,
|
||||
* widget_order: array<int, string>,
|
||||
* card_density: string,
|
||||
* scheduling_timezone: string|null,
|
||||
* activity_last_read_at: string|null
|
||||
* }
|
||||
*/
|
||||
public function update(User $user, array $attributes): array
|
||||
{
|
||||
$current = $this->forUser($user);
|
||||
$payload = [
|
||||
'default_content_view' => array_key_exists('default_content_view', $attributes)
|
||||
? $this->normalizeView((string) $attributes['default_content_view'])
|
||||
: $current['default_content_view'],
|
||||
'analytics_range_days' => array_key_exists('analytics_range_days', $attributes)
|
||||
? $this->normalizeRange((int) $attributes['analytics_range_days'])
|
||||
: $current['analytics_range_days'],
|
||||
'featured_modules' => array_key_exists('featured_modules', $attributes)
|
||||
? $this->normalizeModules($attributes['featured_modules'])
|
||||
: $current['featured_modules'],
|
||||
'featured_content' => array_key_exists('featured_content', $attributes)
|
||||
? $this->normalizeFeaturedContent($attributes['featured_content'])
|
||||
: $current['featured_content'],
|
||||
'draft_behavior' => array_key_exists('draft_behavior', $attributes)
|
||||
? $this->normalizeDraftBehavior((string) $attributes['draft_behavior'])
|
||||
: $current['draft_behavior'],
|
||||
'default_landing_page' => array_key_exists('default_landing_page', $attributes)
|
||||
? $this->normalizeLandingPage((string) $attributes['default_landing_page'])
|
||||
: $current['default_landing_page'],
|
||||
'widget_visibility' => array_key_exists('widget_visibility', $attributes)
|
||||
? $this->normalizeWidgetVisibility($attributes['widget_visibility'])
|
||||
: $current['widget_visibility'],
|
||||
'widget_order' => array_key_exists('widget_order', $attributes)
|
||||
? $this->normalizeWidgetOrder($attributes['widget_order'])
|
||||
: $current['widget_order'],
|
||||
'card_density' => array_key_exists('card_density', $attributes)
|
||||
? $this->normalizeDensity((string) $attributes['card_density'])
|
||||
: $current['card_density'],
|
||||
'scheduling_timezone' => array_key_exists('scheduling_timezone', $attributes)
|
||||
? $this->normalizeTimezone($attributes['scheduling_timezone'])
|
||||
: $current['scheduling_timezone'],
|
||||
'activity_last_read_at' => array_key_exists('activity_last_read_at', $attributes)
|
||||
? $this->normalizeActivityLastReadAt($attributes['activity_last_read_at'])
|
||||
: $current['activity_last_read_at'],
|
||||
];
|
||||
|
||||
$record = DashboardPreference::query()->firstOrNew(['user_id' => $user->id]);
|
||||
$record->pinned_spaces = array_key_exists('dashboard_shortcuts', $attributes)
|
||||
? DashboardPreference::sanitizePinnedSpaces(is_array($attributes['dashboard_shortcuts']) ? $attributes['dashboard_shortcuts'] : [])
|
||||
: $current['dashboard_shortcuts'];
|
||||
$record->studio_preferences = $payload;
|
||||
$record->save();
|
||||
|
||||
return $this->forUser($user);
|
||||
}
|
||||
|
||||
private function normalizeView(string $view): string
|
||||
{
|
||||
return in_array($view, ['grid', 'list'], true) ? $view : 'grid';
|
||||
}
|
||||
|
||||
private function normalizeRange(int $days): int
|
||||
{
|
||||
return in_array($days, [7, 14, 30, 60, 90], true) ? $days : 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $modules
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeModules(mixed $modules): array
|
||||
{
|
||||
$allowed = ['artworks', 'cards', 'collections', 'stories'];
|
||||
|
||||
return collect(is_array($modules) ? $modules : [])
|
||||
->map(fn ($module): string => (string) $module)
|
||||
->filter(fn (string $module): bool => in_array($module, $allowed, true))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $featuredContent
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function normalizeFeaturedContent(mixed $featuredContent): array
|
||||
{
|
||||
$allowed = ['artworks', 'cards', 'collections', 'stories'];
|
||||
|
||||
return collect(is_array($featuredContent) ? $featuredContent : [])
|
||||
->mapWithKeys(function ($id, $module) use ($allowed): array {
|
||||
$moduleKey = (string) $module;
|
||||
$normalizedId = (int) $id;
|
||||
|
||||
if (! in_array($moduleKey, $allowed, true) || $normalizedId < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$moduleKey => $normalizedId];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
private function normalizeDraftBehavior(string $value): string
|
||||
{
|
||||
return in_array($value, ['resume-last', 'open-drafts', 'focus-published'], true)
|
||||
? $value
|
||||
: 'resume-last';
|
||||
}
|
||||
|
||||
private function normalizeLandingPage(string $value): string
|
||||
{
|
||||
return in_array($value, ['overview', 'content', 'drafts', 'scheduled', 'analytics', 'activity', 'calendar', 'inbox', 'search', 'growth', 'challenges', 'preferences'], true)
|
||||
? $value
|
||||
: 'overview';
|
||||
}
|
||||
|
||||
private function normalizeDensity(string $value): string
|
||||
{
|
||||
return in_array($value, ['compact', 'comfortable'], true)
|
||||
? $value
|
||||
: 'comfortable';
|
||||
}
|
||||
|
||||
private function normalizeTimezone(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
private function normalizeActivityLastReadAt(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return array<string, bool>
|
||||
*/
|
||||
private function normalizeWidgetVisibility(mixed $value): array
|
||||
{
|
||||
$defaults = collect($this->allowedWidgets())->mapWithKeys(fn (string $widget): array => [$widget => true]);
|
||||
|
||||
return $defaults->merge(
|
||||
collect(is_array($value) ? $value : [])
|
||||
->filter(fn ($enabled, $widget): bool => in_array((string) $widget, $this->allowedWidgets(), true))
|
||||
->map(fn ($enabled): bool => (bool) $enabled)
|
||||
)->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeWidgetOrder(mixed $value): array
|
||||
{
|
||||
$requested = collect(is_array($value) ? $value : [])
|
||||
->map(fn ($widget): string => (string) $widget)
|
||||
->filter(fn (string $widget): bool => in_array($widget, $this->allowedWidgets(), true))
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
return $requested
|
||||
->concat(collect($this->allowedWidgets())->reject(fn (string $widget): bool => $requested->contains($widget)))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function allowedWidgets(): array
|
||||
{
|
||||
return [
|
||||
'quick_stats',
|
||||
'continue_working',
|
||||
'scheduled_items',
|
||||
'recent_activity',
|
||||
'top_performers',
|
||||
'draft_reminders',
|
||||
'module_summaries',
|
||||
'growth_hints',
|
||||
'active_challenges',
|
||||
'creator_health',
|
||||
'featured_status',
|
||||
'comments_snapshot',
|
||||
'stale_drafts',
|
||||
];
|
||||
}
|
||||
}
|
||||
194
app/Services/Studio/CreatorStudioScheduledService.php
Normal file
194
app/Services/Studio/CreatorStudioScheduledService.php
Normal file
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Studio\Contracts\CreatorStudioProvider;
|
||||
|
||||
final class CreatorStudioScheduledService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreatorStudioContentService $content,
|
||||
) {
|
||||
}
|
||||
|
||||
public function upcoming(User $user, int $limit = 20): array
|
||||
{
|
||||
return collect($this->content->providers())
|
||||
->flatMap(fn (CreatorStudioProvider $provider) => $provider->scheduledItems($user, $limit * 2))
|
||||
->sortBy(fn (array $item): int => $this->scheduledTimestamp($item))
|
||||
->take($limit)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
public function list(User $user, array $filters = []): array
|
||||
{
|
||||
$module = $this->normalizeModule((string) ($filters['module'] ?? 'all'));
|
||||
$q = trim((string) ($filters['q'] ?? ''));
|
||||
$range = $this->normalizeRange((string) ($filters['range'] ?? 'upcoming'));
|
||||
$startDate = $this->normalizeDate((string) ($filters['start_date'] ?? ''));
|
||||
$endDate = $this->normalizeDate((string) ($filters['end_date'] ?? ''));
|
||||
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||
$perPage = min(max((int) ($filters['per_page'] ?? 24), 12), 48);
|
||||
|
||||
$items = $module === 'all'
|
||||
? collect($this->content->providers())->flatMap(fn (CreatorStudioProvider $provider) => $provider->scheduledItems($user, 120))
|
||||
: ($this->content->provider($module)?->scheduledItems($user, 120) ?? collect());
|
||||
|
||||
if ($q !== '') {
|
||||
$needle = mb_strtolower($q);
|
||||
$items = $items->filter(function (array $item) use ($needle): bool {
|
||||
return collect([
|
||||
$item['title'] ?? '',
|
||||
$item['subtitle'] ?? '',
|
||||
$item['module_label'] ?? '',
|
||||
])->contains(fn ($value): bool => is_string($value) && str_contains(mb_strtolower($value), $needle));
|
||||
});
|
||||
}
|
||||
|
||||
$items = $items->filter(function (array $item) use ($range, $startDate, $endDate): bool {
|
||||
$timestamp = $this->scheduledTimestamp($item);
|
||||
if ($timestamp === PHP_INT_MAX) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($range === 'today') {
|
||||
return $timestamp >= now()->startOfDay()->getTimestamp() && $timestamp <= now()->endOfDay()->getTimestamp();
|
||||
}
|
||||
|
||||
if ($range === 'week') {
|
||||
return $timestamp >= now()->startOfDay()->getTimestamp() && $timestamp <= now()->addDays(7)->endOfDay()->getTimestamp();
|
||||
}
|
||||
|
||||
if ($range === 'month') {
|
||||
return $timestamp >= now()->startOfDay()->getTimestamp() && $timestamp <= now()->addDays(30)->endOfDay()->getTimestamp();
|
||||
}
|
||||
|
||||
if ($startDate !== null && $timestamp < strtotime($startDate . ' 00:00:00')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($endDate !== null && $timestamp > strtotime($endDate . ' 23:59:59')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$items = $items
|
||||
->sortBy(fn (array $item): int => $this->scheduledTimestamp($item))
|
||||
->values();
|
||||
|
||||
$total = $items->count();
|
||||
$lastPage = max(1, (int) ceil($total / $perPage));
|
||||
$page = min($page, $lastPage);
|
||||
|
||||
return [
|
||||
'items' => $items->forPage($page, $perPage)->values()->all(),
|
||||
'meta' => [
|
||||
'current_page' => $page,
|
||||
'last_page' => $lastPage,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
],
|
||||
'filters' => [
|
||||
'module' => $module,
|
||||
'q' => $q,
|
||||
'range' => $range,
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
],
|
||||
'module_options' => array_merge([
|
||||
['value' => 'all', 'label' => 'All scheduled content'],
|
||||
], collect($this->content->moduleSummaries($user))->map(fn (array $summary): array => [
|
||||
'value' => $summary['key'],
|
||||
'label' => $summary['label'],
|
||||
])->all()),
|
||||
'range_options' => [
|
||||
['value' => 'upcoming', 'label' => 'All upcoming'],
|
||||
['value' => 'today', 'label' => 'Today'],
|
||||
['value' => 'week', 'label' => 'Next 7 days'],
|
||||
['value' => 'month', 'label' => 'Next 30 days'],
|
||||
['value' => 'custom', 'label' => 'Custom range'],
|
||||
],
|
||||
'summary' => $this->summary($user),
|
||||
'agenda' => $this->agenda($user, 14),
|
||||
];
|
||||
}
|
||||
|
||||
public function summary(User $user): array
|
||||
{
|
||||
$items = collect($this->upcoming($user, 200));
|
||||
|
||||
return [
|
||||
'total' => $items->count(),
|
||||
'next_publish_at' => $items->first()['scheduled_at'] ?? null,
|
||||
'by_module' => collect($this->content->moduleSummaries($user))
|
||||
->map(fn (array $summary): array => [
|
||||
'key' => $summary['key'],
|
||||
'label' => $summary['label'],
|
||||
'count' => $items->where('module', $summary['key'])->count(),
|
||||
'icon' => $summary['icon'],
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
public function agenda(User $user, int $days = 14): array
|
||||
{
|
||||
return collect($this->upcoming($user, 200))
|
||||
->filter(fn (array $item): bool => $this->scheduledTimestamp($item) <= now()->addDays($days)->getTimestamp())
|
||||
->groupBy(function (array $item): string {
|
||||
$value = (string) ($item['scheduled_at'] ?? $item['published_at'] ?? now()->toIso8601String());
|
||||
|
||||
return date('Y-m-d', strtotime($value));
|
||||
})
|
||||
->map(fn ($group, string $date): array => [
|
||||
'date' => $date,
|
||||
'label' => date('M j', strtotime($date)),
|
||||
'count' => $group->count(),
|
||||
'items' => $group->values()->all(),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function normalizeModule(string $module): string
|
||||
{
|
||||
return in_array($module, ['all', 'artworks', 'cards', 'collections', 'stories'], true)
|
||||
? $module
|
||||
: 'all';
|
||||
}
|
||||
|
||||
private function normalizeRange(string $range): string
|
||||
{
|
||||
return in_array($range, ['upcoming', 'today', 'week', 'month', 'custom'], true)
|
||||
? $range
|
||||
: 'upcoming';
|
||||
}
|
||||
|
||||
private function normalizeDate(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
*/
|
||||
private function scheduledTimestamp(array $item): int
|
||||
{
|
||||
return strtotime((string) ($item['scheduled_at'] ?? $item['published_at'] ?? $item['updated_at'] ?? now()->toIso8601String())) ?: PHP_INT_MAX;
|
||||
}
|
||||
}
|
||||
154
app/Services/Studio/CreatorStudioSearchService.php
Normal file
154
app/Services/Studio/CreatorStudioSearchService.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
final class CreatorStudioSearchService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreatorStudioContentService $content,
|
||||
private readonly CreatorStudioCommentService $comments,
|
||||
private readonly CreatorStudioActivityService $activity,
|
||||
private readonly CreatorStudioAssetService $assets,
|
||||
) {
|
||||
}
|
||||
|
||||
public function build(User $user, array $filters = []): array
|
||||
{
|
||||
$query = trim((string) ($filters['q'] ?? ''));
|
||||
$module = $this->normalizeModule((string) ($filters['module'] ?? 'all'));
|
||||
$type = $this->normalizeType((string) ($filters['type'] ?? 'all'));
|
||||
|
||||
if ($query === '') {
|
||||
return [
|
||||
'filters' => ['q' => '', 'module' => $module, 'type' => $type],
|
||||
'sections' => [],
|
||||
'summary' => [
|
||||
'total' => 0,
|
||||
'query' => '',
|
||||
],
|
||||
'empty_state' => [
|
||||
'continue_working' => $this->content->continueWorking($user, 'resume-last', 5),
|
||||
'stale_drafts' => $this->content->staleDrafts($user, 5),
|
||||
'scheduled' => $this->content->providers() ? collect($this->content->providers())->flatMap(fn ($provider) => $provider->scheduledItems($user, 3))->take(5)->values()->all() : [],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$sections = collect();
|
||||
|
||||
if (in_array($type, ['all', 'content'], true)) {
|
||||
$content = $this->content->list($user, ['module' => $module, 'q' => $query, 'per_page' => 12]);
|
||||
$sections->push([
|
||||
'key' => 'content',
|
||||
'label' => 'Content',
|
||||
'count' => count($content['items']),
|
||||
'items' => collect($content['items'])->map(fn (array $item): array => [
|
||||
'id' => $item['id'],
|
||||
'title' => $item['title'],
|
||||
'subtitle' => $item['module_label'] . ' · ' . ($item['status'] ?? 'draft'),
|
||||
'description' => $item['description'],
|
||||
'href' => $item['edit_url'] ?? $item['manage_url'] ?? $item['view_url'],
|
||||
'icon' => $item['module_icon'] ?? 'fa-solid fa-table-cells-large',
|
||||
'module' => $item['module'],
|
||||
'kind' => 'content',
|
||||
])->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array($type, ['all', 'comments'], true)) {
|
||||
$comments = $this->comments->list($user, ['module' => $module, 'q' => $query, 'per_page' => 8]);
|
||||
$sections->push([
|
||||
'key' => 'comments',
|
||||
'label' => 'Comments',
|
||||
'count' => count($comments['items']),
|
||||
'items' => collect($comments['items'])->map(fn (array $item): array => [
|
||||
'id' => $item['id'],
|
||||
'title' => $item['author_name'] . ' on ' . ($item['item_title'] ?? 'Untitled'),
|
||||
'subtitle' => $item['module_label'],
|
||||
'description' => $item['body'],
|
||||
'href' => $item['context_url'],
|
||||
'icon' => 'fa-solid fa-comments',
|
||||
'module' => $item['module'],
|
||||
'kind' => 'comment',
|
||||
])->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array($type, ['all', 'inbox'], true)) {
|
||||
$activity = $this->activity->list($user, ['module' => $module, 'q' => $query, 'per_page' => 8]);
|
||||
$sections->push([
|
||||
'key' => 'inbox',
|
||||
'label' => 'Inbox',
|
||||
'count' => count($activity['items']),
|
||||
'items' => collect($activity['items'])->map(fn (array $item): array => [
|
||||
'id' => $item['id'],
|
||||
'title' => $item['title'],
|
||||
'subtitle' => $item['module_label'],
|
||||
'description' => $item['body'],
|
||||
'href' => $item['url'],
|
||||
'icon' => 'fa-solid fa-bell',
|
||||
'module' => $item['module'],
|
||||
'kind' => 'inbox',
|
||||
])->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array($type, ['all', 'assets'], true)) {
|
||||
$assets = $this->assets->library($user, ['q' => $query, 'per_page' => 8]);
|
||||
$sections->push([
|
||||
'key' => 'assets',
|
||||
'label' => 'Assets',
|
||||
'count' => count($assets['items']),
|
||||
'items' => collect($assets['items'])->map(fn (array $item): array => [
|
||||
'id' => $item['id'],
|
||||
'title' => $item['title'],
|
||||
'subtitle' => $item['type_label'],
|
||||
'description' => $item['description'],
|
||||
'href' => $item['manage_url'] ?? $item['view_url'],
|
||||
'icon' => 'fa-solid fa-photo-film',
|
||||
'module' => $item['source_key'] ?? 'assets',
|
||||
'kind' => 'asset',
|
||||
])->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
$sections = $sections->filter(fn (array $section): bool => $section['count'] > 0)->values();
|
||||
|
||||
return [
|
||||
'filters' => ['q' => $query, 'module' => $module, 'type' => $type],
|
||||
'sections' => $sections->all(),
|
||||
'summary' => [
|
||||
'total' => $sections->sum('count'),
|
||||
'query' => $query,
|
||||
],
|
||||
'type_options' => [
|
||||
['value' => 'all', 'label' => 'Everywhere'],
|
||||
['value' => 'content', 'label' => 'Content'],
|
||||
['value' => 'comments', 'label' => 'Comments'],
|
||||
['value' => 'inbox', 'label' => 'Inbox'],
|
||||
['value' => 'assets', 'label' => 'Assets'],
|
||||
],
|
||||
'module_options' => [
|
||||
['value' => 'all', 'label' => 'All modules'],
|
||||
['value' => 'artworks', 'label' => 'Artworks'],
|
||||
['value' => 'cards', 'label' => 'Cards'],
|
||||
['value' => 'collections', 'label' => 'Collections'],
|
||||
['value' => 'stories', 'label' => 'Stories'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeModule(string $value): string
|
||||
{
|
||||
return in_array($value, ['all', 'artworks', 'cards', 'collections', 'stories'], true) ? $value : 'all';
|
||||
}
|
||||
|
||||
private function normalizeType(string $value): string
|
||||
{
|
||||
return in_array($value, ['all', 'content', 'comments', 'inbox', 'assets'], true) ? $value : 'all';
|
||||
}
|
||||
}
|
||||
280
app/Services/Studio/Providers/ArtworkStudioProvider.php
Normal file
280
app/Services/Studio/Providers/ArtworkStudioProvider.php
Normal file
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio\Providers;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkStats;
|
||||
use App\Models\User;
|
||||
use App\Services\Studio\Contracts\CreatorStudioProvider;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class ArtworkStudioProvider implements CreatorStudioProvider
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'artworks';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Artworks';
|
||||
}
|
||||
|
||||
public function icon(): string
|
||||
{
|
||||
return 'fa-solid fa-images';
|
||||
}
|
||||
|
||||
public function createUrl(): string
|
||||
{
|
||||
return '/upload';
|
||||
}
|
||||
|
||||
public function indexUrl(): string
|
||||
{
|
||||
return route('studio.artworks');
|
||||
}
|
||||
|
||||
public function summary(User $user): array
|
||||
{
|
||||
$baseQuery = Artwork::query()->withTrashed()->where('user_id', $user->id);
|
||||
|
||||
$count = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
$draftCount = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->where(function (Builder $query): void {
|
||||
$query->where('is_public', false)
|
||||
->orWhere('artwork_status', 'draft');
|
||||
})
|
||||
->count();
|
||||
|
||||
$publishedCount = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_public', true)
|
||||
->whereNotNull('published_at')
|
||||
->count();
|
||||
|
||||
$recentPublishedCount = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_public', true)
|
||||
->where('published_at', '>=', now()->subDays(30))
|
||||
->count();
|
||||
|
||||
$archivedCount = Artwork::onlyTrashed()
|
||||
->where('user_id', $user->id)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'key' => $this->key(),
|
||||
'label' => $this->label(),
|
||||
'icon' => $this->icon(),
|
||||
'count' => $count,
|
||||
'draft_count' => $draftCount,
|
||||
'published_count' => $publishedCount,
|
||||
'archived_count' => $archivedCount,
|
||||
'trend_value' => $recentPublishedCount,
|
||||
'trend_label' => 'published in 30d',
|
||||
'quick_action_label' => 'Upload artwork',
|
||||
'index_url' => $this->indexUrl(),
|
||||
'create_url' => $this->createUrl(),
|
||||
];
|
||||
}
|
||||
|
||||
public function items(User $user, string $bucket = 'all', int $limit = 200): Collection
|
||||
{
|
||||
$query = Artwork::query()
|
||||
->withTrashed()
|
||||
->where('user_id', $user->id)
|
||||
->with(['stats', 'categories', 'tags'])
|
||||
->orderByDesc('updated_at')
|
||||
->limit($limit);
|
||||
|
||||
if ($bucket === 'drafts') {
|
||||
$query->whereNull('deleted_at')
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->where('is_public', false)
|
||||
->orWhere('artwork_status', 'draft');
|
||||
});
|
||||
} elseif ($bucket === 'scheduled') {
|
||||
$query->whereNull('deleted_at')
|
||||
->where('artwork_status', 'scheduled');
|
||||
} elseif ($bucket === 'archived') {
|
||||
$query->onlyTrashed();
|
||||
} elseif ($bucket === 'published') {
|
||||
$query->whereNull('deleted_at')
|
||||
->where('is_public', true)
|
||||
->whereNotNull('published_at');
|
||||
} else {
|
||||
$query->whereNull('deleted_at');
|
||||
}
|
||||
|
||||
return $query->get()->map(fn (Artwork $artwork): array => $this->mapItem($artwork));
|
||||
}
|
||||
|
||||
public function topItems(User $user, int $limit = 5): Collection
|
||||
{
|
||||
return Artwork::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_public', true)
|
||||
->with(['stats', 'categories', 'tags'])
|
||||
->whereHas('stats')
|
||||
->orderByDesc(
|
||||
ArtworkStats::select('ranking_score')
|
||||
->whereColumn('artwork_stats.artwork_id', 'artworks.id')
|
||||
->limit(1)
|
||||
)
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (Artwork $artwork): array => $this->mapItem($artwork));
|
||||
}
|
||||
|
||||
public function analytics(User $user): array
|
||||
{
|
||||
$totals = DB::table('artwork_stats')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_stats.artwork_id')
|
||||
->where('artworks.user_id', $user->id)
|
||||
->whereNull('artworks.deleted_at')
|
||||
->selectRaw('COALESCE(SUM(artwork_stats.views), 0) as views')
|
||||
->selectRaw('COALESCE(SUM(artwork_stats.favorites), 0) as appreciation')
|
||||
->selectRaw('COALESCE(SUM(artwork_stats.shares_count), 0) as shares')
|
||||
->selectRaw('COALESCE(SUM(artwork_stats.comments_count), 0) as comments')
|
||||
->selectRaw('COALESCE(SUM(artwork_stats.downloads), 0) as saves')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'views' => (int) ($totals->views ?? 0),
|
||||
'appreciation' => (int) ($totals->appreciation ?? 0),
|
||||
'shares' => (int) ($totals->shares ?? 0),
|
||||
'comments' => (int) ($totals->comments ?? 0),
|
||||
'saves' => (int) ($totals->saves ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
public function scheduledItems(User $user, int $limit = 50): Collection
|
||||
{
|
||||
return $this->items($user, 'scheduled', $limit);
|
||||
}
|
||||
|
||||
private function mapItem(Artwork $artwork): array
|
||||
{
|
||||
$stats = $artwork->stats;
|
||||
$status = $artwork->deleted_at
|
||||
? 'archived'
|
||||
: ($artwork->artwork_status === 'scheduled'
|
||||
? 'scheduled'
|
||||
: ((bool) $artwork->is_public ? 'published' : 'draft'));
|
||||
|
||||
$category = $artwork->categories->first();
|
||||
$visibility = $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE);
|
||||
|
||||
return [
|
||||
'id' => sprintf('%s:%d', $this->key(), (int) $artwork->id),
|
||||
'numeric_id' => (int) $artwork->id,
|
||||
'module' => $this->key(),
|
||||
'module_label' => $this->label(),
|
||||
'module_icon' => $this->icon(),
|
||||
'title' => $artwork->title,
|
||||
'subtitle' => $category?->name,
|
||||
'description' => $artwork->description,
|
||||
'status' => $status,
|
||||
'visibility' => $visibility,
|
||||
'image_url' => $artwork->thumbUrl('md'),
|
||||
'preview_url' => $artwork->published_at ? route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]) : route('studio.artworks.edit', ['id' => $artwork->id]),
|
||||
'view_url' => $artwork->published_at ? route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]) : route('studio.artworks.edit', ['id' => $artwork->id]),
|
||||
'edit_url' => route('studio.artworks.edit', ['id' => $artwork->id]),
|
||||
'manage_url' => route('studio.artworks.edit', ['id' => $artwork->id]),
|
||||
'analytics_url' => route('studio.artworks.analytics', ['id' => $artwork->id]),
|
||||
'create_url' => $this->createUrl(),
|
||||
'actions' => $this->actionsFor($artwork, $status),
|
||||
'created_at' => $artwork->created_at?->toIso8601String(),
|
||||
'updated_at' => $artwork->updated_at?->toIso8601String(),
|
||||
'published_at' => $artwork->published_at?->toIso8601String(),
|
||||
'scheduled_at' => $artwork->publish_at?->toIso8601String(),
|
||||
'schedule_timezone' => $artwork->artwork_timezone,
|
||||
'featured' => false,
|
||||
'metrics' => [
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'appreciation' => (int) ($stats?->favorites ?? 0),
|
||||
'shares' => (int) ($stats?->shares_count ?? 0),
|
||||
'comments' => (int) ($stats?->comments_count ?? 0),
|
||||
'saves' => (int) ($stats?->downloads ?? 0),
|
||||
],
|
||||
'engagement_score' => (int) ($stats?->views ?? 0)
|
||||
+ ((int) ($stats?->favorites ?? 0) * 2)
|
||||
+ ((int) ($stats?->comments_count ?? 0) * 3)
|
||||
+ ((int) ($stats?->shares_count ?? 0) * 2),
|
||||
'taxonomies' => [
|
||||
'categories' => $artwork->categories->map(fn ($entry): array => [
|
||||
'id' => (int) $entry->id,
|
||||
'name' => (string) $entry->name,
|
||||
'slug' => (string) $entry->slug,
|
||||
])->values()->all(),
|
||||
'tags' => $artwork->tags->map(fn ($entry): array => [
|
||||
'id' => (int) $entry->id,
|
||||
'name' => (string) $entry->name,
|
||||
'slug' => (string) $entry->slug,
|
||||
])->values()->all(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function actionsFor(Artwork $artwork, string $status): array
|
||||
{
|
||||
$actions = [];
|
||||
|
||||
if ($status === 'draft') {
|
||||
$actions[] = $this->requestAction('publish', 'Publish', 'fa-solid fa-rocket', route('api.studio.artworks.toggle', ['id' => $artwork->id]), ['action' => 'publish']);
|
||||
}
|
||||
|
||||
if ($status === 'scheduled') {
|
||||
$actions[] = $this->requestAction('publish_now', 'Publish now', 'fa-solid fa-bolt', route('api.studio.schedule.publishNow', ['module' => 'artworks', 'id' => $artwork->id]), []);
|
||||
$actions[] = $this->requestAction('unschedule', 'Unschedule', 'fa-solid fa-calendar-xmark', route('api.studio.schedule.unschedule', ['module' => 'artworks', 'id' => $artwork->id]), []);
|
||||
}
|
||||
|
||||
if ($status === 'published') {
|
||||
$actions[] = $this->requestAction('unpublish', 'Unpublish', 'fa-solid fa-eye-slash', route('api.studio.artworks.toggle', ['id' => $artwork->id]), ['action' => 'unpublish']);
|
||||
$actions[] = $this->requestAction('archive', 'Archive', 'fa-solid fa-box-archive', route('api.studio.artworks.toggle', ['id' => $artwork->id]), ['action' => 'archive']);
|
||||
}
|
||||
|
||||
if ($status === 'archived') {
|
||||
$actions[] = $this->requestAction('restore', 'Restore', 'fa-solid fa-rotate-left', route('api.studio.artworks.toggle', ['id' => $artwork->id]), ['action' => 'unarchive']);
|
||||
}
|
||||
|
||||
$actions[] = $this->requestAction(
|
||||
'delete',
|
||||
'Delete',
|
||||
'fa-solid fa-trash',
|
||||
route('api.studio.artworks.bulk'),
|
||||
[
|
||||
'action' => 'delete',
|
||||
'artwork_ids' => [$artwork->id],
|
||||
'confirm' => 'DELETE',
|
||||
],
|
||||
'Delete this artwork permanently?'
|
||||
);
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
private function requestAction(string $key, string $label, string $icon, string $url, array $payload, ?string $confirm = null): array
|
||||
{
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'icon' => $icon,
|
||||
'type' => 'request',
|
||||
'method' => 'post',
|
||||
'url' => $url,
|
||||
'payload' => $payload,
|
||||
'confirm' => $confirm,
|
||||
];
|
||||
}
|
||||
}
|
||||
261
app/Services/Studio/Providers/CardStudioProvider.php
Normal file
261
app/Services/Studio/Providers/CardStudioProvider.php
Normal file
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio\Providers;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\User;
|
||||
use App\Services\Studio\Contracts\CreatorStudioProvider;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class CardStudioProvider implements CreatorStudioProvider
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'cards';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Cards';
|
||||
}
|
||||
|
||||
public function icon(): string
|
||||
{
|
||||
return 'fa-solid fa-id-card';
|
||||
}
|
||||
|
||||
public function createUrl(): string
|
||||
{
|
||||
return route('studio.cards.create');
|
||||
}
|
||||
|
||||
public function indexUrl(): string
|
||||
{
|
||||
return route('studio.cards.index');
|
||||
}
|
||||
|
||||
public function summary(User $user): array
|
||||
{
|
||||
$baseQuery = NovaCard::query()->withTrashed()->where('user_id', $user->id);
|
||||
|
||||
$count = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotIn('status', [NovaCard::STATUS_HIDDEN, NovaCard::STATUS_REJECTED])
|
||||
->count();
|
||||
|
||||
$draftCount = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->where('status', NovaCard::STATUS_DRAFT)
|
||||
->count();
|
||||
|
||||
$publishedCount = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->where('status', NovaCard::STATUS_PUBLISHED)
|
||||
->count();
|
||||
|
||||
$recentPublishedCount = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->where('status', NovaCard::STATUS_PUBLISHED)
|
||||
->where('published_at', '>=', now()->subDays(30))
|
||||
->count();
|
||||
|
||||
$archivedCount = (clone $baseQuery)
|
||||
->where(function (Builder $query): void {
|
||||
$query->whereNotNull('deleted_at')
|
||||
->orWhereIn('status', [NovaCard::STATUS_HIDDEN, NovaCard::STATUS_REJECTED]);
|
||||
})
|
||||
->count();
|
||||
|
||||
return [
|
||||
'key' => $this->key(),
|
||||
'label' => $this->label(),
|
||||
'icon' => $this->icon(),
|
||||
'count' => $count,
|
||||
'draft_count' => $draftCount,
|
||||
'published_count' => $publishedCount,
|
||||
'archived_count' => $archivedCount,
|
||||
'trend_value' => $recentPublishedCount,
|
||||
'trend_label' => 'published in 30d',
|
||||
'quick_action_label' => 'Create card',
|
||||
'index_url' => $this->indexUrl(),
|
||||
'create_url' => $this->createUrl(),
|
||||
];
|
||||
}
|
||||
|
||||
public function items(User $user, string $bucket = 'all', int $limit = 200): Collection
|
||||
{
|
||||
$query = NovaCard::query()
|
||||
->withTrashed()
|
||||
->where('user_id', $user->id)
|
||||
->with(['category', 'tags'])
|
||||
->orderByDesc('updated_at')
|
||||
->limit($limit);
|
||||
|
||||
if ($bucket === 'drafts') {
|
||||
$query->whereNull('deleted_at')->where('status', NovaCard::STATUS_DRAFT);
|
||||
} elseif ($bucket === 'scheduled') {
|
||||
$query->whereNull('deleted_at')->where('status', NovaCard::STATUS_SCHEDULED);
|
||||
} elseif ($bucket === 'archived') {
|
||||
$query->where(function (Builder $builder): void {
|
||||
$builder->whereNotNull('deleted_at')
|
||||
->orWhereIn('status', [NovaCard::STATUS_HIDDEN, NovaCard::STATUS_REJECTED]);
|
||||
});
|
||||
} elseif ($bucket === 'published') {
|
||||
$query->whereNull('deleted_at')->where('status', NovaCard::STATUS_PUBLISHED);
|
||||
} else {
|
||||
$query->whereNull('deleted_at')->whereNotIn('status', [NovaCard::STATUS_HIDDEN, NovaCard::STATUS_REJECTED]);
|
||||
}
|
||||
|
||||
return $query->get()->map(fn (NovaCard $card): array => $this->mapItem($card));
|
||||
}
|
||||
|
||||
public function topItems(User $user, int $limit = 5): Collection
|
||||
{
|
||||
return NovaCard::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at')
|
||||
->where('status', NovaCard::STATUS_PUBLISHED)
|
||||
->orderByDesc('trending_score')
|
||||
->orderByDesc('views_count')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (NovaCard $card): array => $this->mapItem($card));
|
||||
}
|
||||
|
||||
public function analytics(User $user): array
|
||||
{
|
||||
$totals = NovaCard::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw('COALESCE(SUM(views_count), 0) as views')
|
||||
->selectRaw('COALESCE(SUM(likes_count + favorites_count), 0) as appreciation')
|
||||
->selectRaw('COALESCE(SUM(shares_count), 0) as shares')
|
||||
->selectRaw('COALESCE(SUM(comments_count), 0) as comments')
|
||||
->selectRaw('COALESCE(SUM(saves_count), 0) as saves')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'views' => (int) ($totals->views ?? 0),
|
||||
'appreciation' => (int) ($totals->appreciation ?? 0),
|
||||
'shares' => (int) ($totals->shares ?? 0),
|
||||
'comments' => (int) ($totals->comments ?? 0),
|
||||
'saves' => (int) ($totals->saves ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
public function scheduledItems(User $user, int $limit = 50): Collection
|
||||
{
|
||||
return $this->items($user, 'scheduled', $limit);
|
||||
}
|
||||
|
||||
private function mapItem(NovaCard $card): array
|
||||
{
|
||||
$status = $card->deleted_at || in_array($card->status, [NovaCard::STATUS_HIDDEN, NovaCard::STATUS_REJECTED], true)
|
||||
? 'archived'
|
||||
: $card->status;
|
||||
|
||||
return [
|
||||
'id' => sprintf('%s:%d', $this->key(), (int) $card->id),
|
||||
'numeric_id' => (int) $card->id,
|
||||
'module' => $this->key(),
|
||||
'module_label' => $this->label(),
|
||||
'module_icon' => $this->icon(),
|
||||
'title' => $card->title,
|
||||
'subtitle' => $card->category?->name ?: strtoupper((string) $card->format),
|
||||
'description' => $card->description,
|
||||
'status' => $status,
|
||||
'visibility' => $card->visibility,
|
||||
'image_url' => $card->previewUrl(),
|
||||
'preview_url' => route('studio.cards.preview', ['id' => $card->id]),
|
||||
'view_url' => $card->status === NovaCard::STATUS_PUBLISHED ? $card->publicUrl() : route('studio.cards.preview', ['id' => $card->id]),
|
||||
'edit_url' => route('studio.cards.edit', ['id' => $card->id]),
|
||||
'manage_url' => route('studio.cards.edit', ['id' => $card->id]),
|
||||
'analytics_url' => route('studio.cards.analytics', ['id' => $card->id]),
|
||||
'create_url' => $this->createUrl(),
|
||||
'actions' => $this->actionsFor($card, $status),
|
||||
'created_at' => $card->created_at?->toIso8601String(),
|
||||
'updated_at' => $card->updated_at?->toIso8601String(),
|
||||
'published_at' => $card->published_at?->toIso8601String(),
|
||||
'scheduled_at' => $card->scheduled_for?->toIso8601String(),
|
||||
'schedule_timezone' => $card->scheduling_timezone,
|
||||
'featured' => (bool) $card->featured,
|
||||
'metrics' => [
|
||||
'views' => (int) $card->views_count,
|
||||
'appreciation' => (int) ($card->likes_count + $card->favorites_count),
|
||||
'shares' => (int) $card->shares_count,
|
||||
'comments' => (int) $card->comments_count,
|
||||
'saves' => (int) $card->saves_count,
|
||||
],
|
||||
'engagement_score' => (int) $card->views_count
|
||||
+ ((int) $card->likes_count * 2)
|
||||
+ ((int) $card->favorites_count * 2)
|
||||
+ ((int) $card->comments_count * 3)
|
||||
+ ((int) $card->shares_count * 2)
|
||||
+ ((int) $card->saves_count * 2),
|
||||
'taxonomies' => [
|
||||
'categories' => $card->category ? [[
|
||||
'id' => (int) $card->category->id,
|
||||
'name' => (string) $card->category->name,
|
||||
'slug' => (string) $card->category->slug,
|
||||
]] : [],
|
||||
'tags' => $card->tags->map(fn ($entry): array => [
|
||||
'id' => (int) $entry->id,
|
||||
'name' => (string) $entry->name,
|
||||
'slug' => (string) $entry->slug,
|
||||
])->values()->all(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function actionsFor(NovaCard $card, string $status): array
|
||||
{
|
||||
$actions = [
|
||||
[
|
||||
'key' => 'duplicate',
|
||||
'label' => 'Duplicate',
|
||||
'icon' => 'fa-solid fa-id-card',
|
||||
'type' => 'request',
|
||||
'method' => 'post',
|
||||
'url' => route('api.cards.duplicate', ['id' => $card->id]),
|
||||
'redirect_pattern' => route('studio.cards.edit', ['id' => '__ID__']),
|
||||
],
|
||||
];
|
||||
|
||||
if ($status === NovaCard::STATUS_DRAFT) {
|
||||
$actions[] = [
|
||||
'key' => 'delete',
|
||||
'label' => 'Delete draft',
|
||||
'icon' => 'fa-solid fa-trash',
|
||||
'type' => 'request',
|
||||
'method' => 'delete',
|
||||
'url' => route('api.cards.drafts.destroy', ['id' => $card->id]),
|
||||
'confirm' => 'Delete this card draft?',
|
||||
];
|
||||
}
|
||||
|
||||
if ($status === NovaCard::STATUS_SCHEDULED) {
|
||||
$actions[] = [
|
||||
'key' => 'publish_now',
|
||||
'label' => 'Publish now',
|
||||
'icon' => 'fa-solid fa-bolt',
|
||||
'type' => 'request',
|
||||
'method' => 'post',
|
||||
'url' => route('api.studio.schedule.publishNow', ['module' => 'cards', 'id' => $card->id]),
|
||||
];
|
||||
$actions[] = [
|
||||
'key' => 'unschedule',
|
||||
'label' => 'Unschedule',
|
||||
'icon' => 'fa-solid fa-calendar-xmark',
|
||||
'type' => 'request',
|
||||
'method' => 'post',
|
||||
'url' => route('api.studio.schedule.unschedule', ['module' => 'cards', 'id' => $card->id]),
|
||||
];
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
}
|
||||
308
app/Services/Studio/Providers/CollectionStudioProvider.php
Normal file
308
app/Services/Studio/Providers/CollectionStudioProvider.php
Normal file
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio\Providers;
|
||||
|
||||
use App\Models\Collection;
|
||||
use App\Models\User;
|
||||
use App\Services\CollectionService;
|
||||
use App\Services\Studio\Contracts\CreatorStudioProvider;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection as SupportCollection;
|
||||
|
||||
final class CollectionStudioProvider implements CreatorStudioProvider
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CollectionService $collections,
|
||||
) {
|
||||
}
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'collections';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Collections';
|
||||
}
|
||||
|
||||
public function icon(): string
|
||||
{
|
||||
return 'fa-solid fa-layer-group';
|
||||
}
|
||||
|
||||
public function createUrl(): string
|
||||
{
|
||||
return route('settings.collections.create');
|
||||
}
|
||||
|
||||
public function indexUrl(): string
|
||||
{
|
||||
return route('studio.collections');
|
||||
}
|
||||
|
||||
public function summary(User $user): array
|
||||
{
|
||||
$baseQuery = Collection::query()->withTrashed()->where('user_id', $user->id);
|
||||
|
||||
$count = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->where('lifecycle_state', '!=', Collection::LIFECYCLE_ARCHIVED)
|
||||
->count();
|
||||
|
||||
$draftCount = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->where(function (Builder $query): void {
|
||||
$query->where('lifecycle_state', Collection::LIFECYCLE_DRAFT)
|
||||
->orWhere('workflow_state', Collection::WORKFLOW_DRAFT)
|
||||
->orWhere('workflow_state', Collection::WORKFLOW_IN_REVIEW);
|
||||
})
|
||||
->count();
|
||||
|
||||
$publishedCount = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->whereIn('lifecycle_state', [Collection::LIFECYCLE_PUBLISHED, Collection::LIFECYCLE_FEATURED, Collection::LIFECYCLE_SCHEDULED])
|
||||
->count();
|
||||
|
||||
$recentPublishedCount = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->whereIn('lifecycle_state', [Collection::LIFECYCLE_PUBLISHED, Collection::LIFECYCLE_FEATURED, Collection::LIFECYCLE_SCHEDULED])
|
||||
->where('published_at', '>=', now()->subDays(30))
|
||||
->count();
|
||||
|
||||
$archivedCount = (clone $baseQuery)
|
||||
->where(function (Builder $query): void {
|
||||
$query->whereNotNull('deleted_at')
|
||||
->orWhere('lifecycle_state', Collection::LIFECYCLE_ARCHIVED);
|
||||
})
|
||||
->count();
|
||||
|
||||
return [
|
||||
'key' => $this->key(),
|
||||
'label' => $this->label(),
|
||||
'icon' => $this->icon(),
|
||||
'count' => $count,
|
||||
'draft_count' => $draftCount,
|
||||
'published_count' => $publishedCount,
|
||||
'archived_count' => $archivedCount,
|
||||
'trend_value' => $recentPublishedCount,
|
||||
'trend_label' => 'published in 30d',
|
||||
'quick_action_label' => 'Create collection',
|
||||
'index_url' => $this->indexUrl(),
|
||||
'create_url' => $this->createUrl(),
|
||||
];
|
||||
}
|
||||
|
||||
public function items(User $user, string $bucket = 'all', int $limit = 200): SupportCollection
|
||||
{
|
||||
$query = Collection::query()
|
||||
->withTrashed()
|
||||
->where('user_id', $user->id)
|
||||
->with(['user.profile', 'coverArtwork'])
|
||||
->orderByDesc('updated_at')
|
||||
->limit($limit);
|
||||
|
||||
if ($bucket === 'drafts') {
|
||||
$query->whereNull('deleted_at')
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->where('lifecycle_state', Collection::LIFECYCLE_DRAFT)
|
||||
->orWhere('workflow_state', Collection::WORKFLOW_DRAFT)
|
||||
->orWhere('workflow_state', Collection::WORKFLOW_IN_REVIEW);
|
||||
});
|
||||
} elseif ($bucket === 'scheduled') {
|
||||
$query->whereNull('deleted_at')
|
||||
->where('lifecycle_state', Collection::LIFECYCLE_SCHEDULED);
|
||||
} elseif ($bucket === 'archived') {
|
||||
$query->where(function (Builder $builder): void {
|
||||
$builder->whereNotNull('deleted_at')
|
||||
->orWhere('lifecycle_state', Collection::LIFECYCLE_ARCHIVED);
|
||||
});
|
||||
} elseif ($bucket === 'published') {
|
||||
$query->whereNull('deleted_at')
|
||||
->whereIn('lifecycle_state', [Collection::LIFECYCLE_PUBLISHED, Collection::LIFECYCLE_FEATURED, Collection::LIFECYCLE_SCHEDULED]);
|
||||
} else {
|
||||
$query->whereNull('deleted_at')->where('lifecycle_state', '!=', Collection::LIFECYCLE_ARCHIVED);
|
||||
}
|
||||
|
||||
return collect($this->collections->mapCollectionCardPayloads($query->get(), true, $user))
|
||||
->map(fn (array $item): array => $this->mapItem($item));
|
||||
}
|
||||
|
||||
public function topItems(User $user, int $limit = 5): SupportCollection
|
||||
{
|
||||
$collections = Collection::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at')
|
||||
->whereIn('lifecycle_state', [Collection::LIFECYCLE_PUBLISHED, Collection::LIFECYCLE_FEATURED])
|
||||
->with(['user.profile', 'coverArtwork'])
|
||||
->orderByDesc('ranking_score')
|
||||
->orderByDesc('views_count')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
return collect($this->collections->mapCollectionCardPayloads($collections, true, $user))
|
||||
->map(fn (array $item): array => $this->mapItem($item));
|
||||
}
|
||||
|
||||
public function analytics(User $user): array
|
||||
{
|
||||
$totals = Collection::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw('COALESCE(SUM(views_count), 0) as views')
|
||||
->selectRaw('COALESCE(SUM(likes_count + followers_count), 0) as appreciation')
|
||||
->selectRaw('COALESCE(SUM(shares_count), 0) as shares')
|
||||
->selectRaw('COALESCE(SUM(comments_count), 0) as comments')
|
||||
->selectRaw('COALESCE(SUM(saves_count), 0) as saves')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'views' => (int) ($totals->views ?? 0),
|
||||
'appreciation' => (int) ($totals->appreciation ?? 0),
|
||||
'shares' => (int) ($totals->shares ?? 0),
|
||||
'comments' => (int) ($totals->comments ?? 0),
|
||||
'saves' => (int) ($totals->saves ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
public function scheduledItems(User $user, int $limit = 50): SupportCollection
|
||||
{
|
||||
return $this->items($user, 'scheduled', $limit);
|
||||
}
|
||||
|
||||
private function mapItem(array $item): array
|
||||
{
|
||||
$status = $item['lifecycle_state'] ?? 'draft';
|
||||
if ($status === Collection::LIFECYCLE_FEATURED) {
|
||||
$status = 'published';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => sprintf('%s:%d', $this->key(), (int) $item['id']),
|
||||
'numeric_id' => (int) $item['id'],
|
||||
'module' => $this->key(),
|
||||
'module_label' => $this->label(),
|
||||
'module_icon' => $this->icon(),
|
||||
'title' => $item['title'],
|
||||
'subtitle' => $item['subtitle'] ?: ($item['type'] ? ucfirst((string) $item['type']) : null),
|
||||
'description' => $item['summary'] ?: $item['description'],
|
||||
'status' => $status,
|
||||
'visibility' => $item['visibility'],
|
||||
'image_url' => $item['cover_image'],
|
||||
'preview_url' => $item['url'],
|
||||
'view_url' => $item['url'],
|
||||
'edit_url' => $item['edit_url'] ?: $item['manage_url'],
|
||||
'manage_url' => $item['manage_url'],
|
||||
'analytics_url' => route('settings.collections.analytics', ['collection' => $item['id']]),
|
||||
'create_url' => $this->createUrl(),
|
||||
'actions' => $this->actionsFor($item, $status),
|
||||
'created_at' => $item['published_at'] ?? $item['updated_at'],
|
||||
'updated_at' => $item['updated_at'],
|
||||
'published_at' => $item['published_at'] ?? null,
|
||||
'scheduled_at' => $status === Collection::LIFECYCLE_SCHEDULED ? ($item['published_at'] ?? null) : null,
|
||||
'featured' => (bool) ($item['is_featured'] ?? false),
|
||||
'metrics' => [
|
||||
'views' => (int) ($item['views_count'] ?? 0),
|
||||
'appreciation' => (int) (($item['likes_count'] ?? 0) + ($item['followers_count'] ?? 0)),
|
||||
'shares' => (int) ($item['shares_count'] ?? 0),
|
||||
'comments' => (int) ($item['comments_count'] ?? 0),
|
||||
'saves' => (int) ($item['saves_count'] ?? 0),
|
||||
],
|
||||
'engagement_score' => (int) ($item['views_count'] ?? 0)
|
||||
+ ((int) ($item['likes_count'] ?? 0) * 2)
|
||||
+ ((int) ($item['followers_count'] ?? 0) * 2)
|
||||
+ ((int) ($item['comments_count'] ?? 0) * 3)
|
||||
+ ((int) ($item['shares_count'] ?? 0) * 2)
|
||||
+ ((int) ($item['saves_count'] ?? 0) * 2),
|
||||
'taxonomies' => [
|
||||
'categories' => [],
|
||||
'tags' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function actionsFor(array $item, string $status): array
|
||||
{
|
||||
$collectionId = (int) $item['id'];
|
||||
$actions = [];
|
||||
$featured = (bool) ($item['is_featured'] ?? false);
|
||||
|
||||
if ($status === 'draft') {
|
||||
$actions[] = $this->requestAction(
|
||||
'publish',
|
||||
'Publish',
|
||||
'fa-solid fa-rocket',
|
||||
route('settings.collections.lifecycle', ['collection' => $collectionId]),
|
||||
[
|
||||
'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED,
|
||||
'visibility' => Collection::VISIBILITY_PUBLIC,
|
||||
'published_at' => now()->toIso8601String(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if (in_array($status, ['published', 'scheduled'], true)) {
|
||||
$actions[] = $this->requestAction(
|
||||
'archive',
|
||||
'Archive',
|
||||
'fa-solid fa-box-archive',
|
||||
route('settings.collections.lifecycle', ['collection' => $collectionId]),
|
||||
[
|
||||
'lifecycle_state' => Collection::LIFECYCLE_ARCHIVED,
|
||||
'archived_at' => now()->toIso8601String(),
|
||||
]
|
||||
);
|
||||
|
||||
$actions[] = $featured
|
||||
? $this->requestAction('unfeature', 'Remove feature', 'fa-solid fa-star-half-stroke', route('settings.collections.unfeature', ['collection' => $collectionId]), [], null, 'delete')
|
||||
: $this->requestAction('feature', 'Feature', 'fa-solid fa-star', route('settings.collections.feature', ['collection' => $collectionId]), []);
|
||||
|
||||
if ($status === 'scheduled') {
|
||||
$actions[] = $this->requestAction('publish_now', 'Publish now', 'fa-solid fa-bolt', route('api.studio.schedule.publishNow', ['module' => 'collections', 'id' => $collectionId]), []);
|
||||
$actions[] = $this->requestAction('unschedule', 'Unschedule', 'fa-solid fa-calendar-xmark', route('api.studio.schedule.unschedule', ['module' => 'collections', 'id' => $collectionId]), []);
|
||||
}
|
||||
}
|
||||
|
||||
if ($status === 'archived') {
|
||||
$actions[] = $this->requestAction(
|
||||
'restore',
|
||||
'Restore',
|
||||
'fa-solid fa-rotate-left',
|
||||
route('settings.collections.lifecycle', ['collection' => $collectionId]),
|
||||
[
|
||||
'lifecycle_state' => Collection::LIFECYCLE_DRAFT,
|
||||
'visibility' => Collection::VISIBILITY_PRIVATE,
|
||||
'archived_at' => null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$actions[] = $this->requestAction(
|
||||
'delete',
|
||||
'Delete',
|
||||
'fa-solid fa-trash',
|
||||
route('settings.collections.destroy', ['collection' => $collectionId]),
|
||||
[],
|
||||
'Delete this collection permanently?',
|
||||
'delete'
|
||||
);
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
private function requestAction(string $key, string $label, string $icon, string $url, array $payload = [], ?string $confirm = null, string $method = 'post'): array
|
||||
{
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'icon' => $icon,
|
||||
'type' => 'request',
|
||||
'method' => $method,
|
||||
'url' => $url,
|
||||
'payload' => $payload,
|
||||
'confirm' => $confirm,
|
||||
];
|
||||
}
|
||||
}
|
||||
277
app/Services/Studio/Providers/StoryStudioProvider.php
Normal file
277
app/Services/Studio/Providers/StoryStudioProvider.php
Normal file
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio\Providers;
|
||||
|
||||
use App\Models\Story;
|
||||
use App\Models\User;
|
||||
use App\Services\Studio\Contracts\CreatorStudioProvider;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class StoryStudioProvider implements CreatorStudioProvider
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'stories';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Stories';
|
||||
}
|
||||
|
||||
public function icon(): string
|
||||
{
|
||||
return 'fa-solid fa-feather-pointed';
|
||||
}
|
||||
|
||||
public function createUrl(): string
|
||||
{
|
||||
return route('creator.stories.create');
|
||||
}
|
||||
|
||||
public function indexUrl(): string
|
||||
{
|
||||
return route('studio.stories');
|
||||
}
|
||||
|
||||
public function summary(User $user): array
|
||||
{
|
||||
$baseQuery = Story::query()->where('creator_id', $user->id);
|
||||
|
||||
$count = (clone $baseQuery)
|
||||
->whereNotIn('status', ['archived'])
|
||||
->count();
|
||||
|
||||
$draftCount = (clone $baseQuery)
|
||||
->whereIn('status', ['draft', 'pending_review', 'rejected'])
|
||||
->count();
|
||||
|
||||
$publishedCount = (clone $baseQuery)
|
||||
->whereIn('status', ['published', 'scheduled'])
|
||||
->count();
|
||||
|
||||
$recentPublishedCount = (clone $baseQuery)
|
||||
->whereIn('status', ['published', 'scheduled'])
|
||||
->where('published_at', '>=', now()->subDays(30))
|
||||
->count();
|
||||
|
||||
$archivedCount = (clone $baseQuery)
|
||||
->where('status', 'archived')
|
||||
->count();
|
||||
|
||||
return [
|
||||
'key' => $this->key(),
|
||||
'label' => $this->label(),
|
||||
'icon' => $this->icon(),
|
||||
'count' => $count,
|
||||
'draft_count' => $draftCount,
|
||||
'published_count' => $publishedCount,
|
||||
'archived_count' => $archivedCount,
|
||||
'trend_value' => $recentPublishedCount,
|
||||
'trend_label' => 'published in 30d',
|
||||
'quick_action_label' => 'Create story',
|
||||
'index_url' => $this->indexUrl(),
|
||||
'create_url' => $this->createUrl(),
|
||||
];
|
||||
}
|
||||
|
||||
public function items(User $user, string $bucket = 'all', int $limit = 200): Collection
|
||||
{
|
||||
$query = Story::query()
|
||||
->where('creator_id', $user->id)
|
||||
->with(['tags'])
|
||||
->orderByDesc('updated_at')
|
||||
->limit($limit);
|
||||
|
||||
if ($bucket === 'drafts') {
|
||||
$query->whereIn('status', ['draft', 'pending_review', 'rejected']);
|
||||
} elseif ($bucket === 'scheduled') {
|
||||
$query->where('status', 'scheduled');
|
||||
} elseif ($bucket === 'archived') {
|
||||
$query->where('status', 'archived');
|
||||
} elseif ($bucket === 'published') {
|
||||
$query->whereIn('status', ['published', 'scheduled']);
|
||||
} else {
|
||||
$query->where('status', '!=', 'archived');
|
||||
}
|
||||
|
||||
return $query->get()->map(fn (Story $story): array => $this->mapItem($story));
|
||||
}
|
||||
|
||||
public function topItems(User $user, int $limit = 5): Collection
|
||||
{
|
||||
return Story::query()
|
||||
->where('creator_id', $user->id)
|
||||
->whereIn('status', ['published', 'scheduled'])
|
||||
->orderByDesc('views')
|
||||
->orderByDesc('likes_count')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (Story $story): array => $this->mapItem($story));
|
||||
}
|
||||
|
||||
public function analytics(User $user): array
|
||||
{
|
||||
$totals = Story::query()
|
||||
->where('creator_id', $user->id)
|
||||
->where('status', '!=', 'archived')
|
||||
->selectRaw('COALESCE(SUM(views), 0) as views')
|
||||
->selectRaw('COALESCE(SUM(likes_count), 0) as appreciation')
|
||||
->selectRaw('0 as shares')
|
||||
->selectRaw('COALESCE(SUM(comments_count), 0) as comments')
|
||||
->selectRaw('0 as saves')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'views' => (int) ($totals->views ?? 0),
|
||||
'appreciation' => (int) ($totals->appreciation ?? 0),
|
||||
'shares' => 0,
|
||||
'comments' => (int) ($totals->comments ?? 0),
|
||||
'saves' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function scheduledItems(User $user, int $limit = 50): Collection
|
||||
{
|
||||
return $this->items($user, 'scheduled', $limit);
|
||||
}
|
||||
|
||||
private function mapItem(Story $story): array
|
||||
{
|
||||
$subtitle = $story->story_type ? ucfirst(str_replace('_', ' ', (string) $story->story_type)) : null;
|
||||
$viewUrl = in_array($story->status, ['published', 'scheduled'], true)
|
||||
? route('stories.show', ['slug' => $story->slug])
|
||||
: route('creator.stories.preview', ['story' => $story->id]);
|
||||
|
||||
return [
|
||||
'id' => sprintf('%s:%d', $this->key(), (int) $story->id),
|
||||
'numeric_id' => (int) $story->id,
|
||||
'module' => $this->key(),
|
||||
'module_label' => $this->label(),
|
||||
'module_icon' => $this->icon(),
|
||||
'title' => $story->title,
|
||||
'subtitle' => $subtitle,
|
||||
'description' => $story->excerpt,
|
||||
'status' => $story->status,
|
||||
'visibility' => $story->status === 'published' ? 'public' : 'private',
|
||||
'image_url' => $story->cover_url,
|
||||
'preview_url' => route('creator.stories.preview', ['story' => $story->id]),
|
||||
'view_url' => $viewUrl,
|
||||
'edit_url' => route('creator.stories.edit', ['story' => $story->id]),
|
||||
'manage_url' => route('creator.stories.edit', ['story' => $story->id]),
|
||||
'analytics_url' => route('creator.stories.analytics', ['story' => $story->id]),
|
||||
'create_url' => $this->createUrl(),
|
||||
'actions' => $this->actionsFor($story, $story->status),
|
||||
'created_at' => $story->created_at?->toIso8601String(),
|
||||
'updated_at' => $story->updated_at?->toIso8601String(),
|
||||
'published_at' => $story->published_at?->toIso8601String(),
|
||||
'scheduled_at' => $story->scheduled_for?->toIso8601String(),
|
||||
'featured' => (bool) $story->featured,
|
||||
'activity_state' => in_array($story->status, ['published', 'scheduled'], true)
|
||||
? 'active'
|
||||
: ($story->status === 'archived' ? 'archived' : 'inactive'),
|
||||
'metrics' => [
|
||||
'views' => (int) $story->views,
|
||||
'appreciation' => (int) $story->likes_count,
|
||||
'shares' => 0,
|
||||
'comments' => (int) $story->comments_count,
|
||||
'saves' => 0,
|
||||
],
|
||||
'engagement_score' => (int) $story->views
|
||||
+ ((int) $story->likes_count * 2)
|
||||
+ ((int) $story->comments_count * 3),
|
||||
'taxonomies' => [
|
||||
'categories' => [],
|
||||
'tags' => $story->tags->map(fn ($entry): array => [
|
||||
'id' => (int) $entry->id,
|
||||
'name' => (string) $entry->name,
|
||||
'slug' => (string) $entry->slug,
|
||||
])->values()->all(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function actionsFor(Story $story, string $status): array
|
||||
{
|
||||
$actions = [];
|
||||
|
||||
if (in_array($status, ['draft', 'pending_review', 'rejected'], true)) {
|
||||
$actions[] = $this->requestAction(
|
||||
'publish',
|
||||
'Publish',
|
||||
'fa-solid fa-rocket',
|
||||
route('api.stories.update'),
|
||||
[
|
||||
'story_id' => (int) $story->id,
|
||||
'status' => 'published',
|
||||
],
|
||||
null,
|
||||
'put'
|
||||
);
|
||||
}
|
||||
|
||||
if (in_array($status, ['draft', 'pending_review', 'rejected', 'published', 'scheduled'], true)) {
|
||||
$actions[] = $this->requestAction(
|
||||
'archive',
|
||||
'Archive',
|
||||
'fa-solid fa-box-archive',
|
||||
route('api.stories.update'),
|
||||
[
|
||||
'story_id' => (int) $story->id,
|
||||
'status' => 'archived',
|
||||
],
|
||||
null,
|
||||
'put'
|
||||
);
|
||||
}
|
||||
|
||||
if ($status === 'scheduled') {
|
||||
$actions[] = $this->requestAction('publish_now', 'Publish now', 'fa-solid fa-bolt', route('api.studio.schedule.publishNow', ['module' => 'stories', 'id' => $story->id]), []);
|
||||
$actions[] = $this->requestAction('unschedule', 'Unschedule', 'fa-solid fa-calendar-xmark', route('api.studio.schedule.unschedule', ['module' => 'stories', 'id' => $story->id]), []);
|
||||
}
|
||||
|
||||
if ($status === 'archived') {
|
||||
$actions[] = $this->requestAction(
|
||||
'restore',
|
||||
'Restore',
|
||||
'fa-solid fa-rotate-left',
|
||||
route('api.stories.update'),
|
||||
[
|
||||
'story_id' => (int) $story->id,
|
||||
'status' => 'draft',
|
||||
],
|
||||
null,
|
||||
'put'
|
||||
);
|
||||
}
|
||||
|
||||
$actions[] = $this->requestAction(
|
||||
'delete',
|
||||
'Delete',
|
||||
'fa-solid fa-trash',
|
||||
route('creator.stories.destroy', ['story' => $story->id]),
|
||||
[],
|
||||
'Delete this story permanently?',
|
||||
'delete'
|
||||
);
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
private function requestAction(string $key, string $label, string $icon, string $url, array $payload = [], ?string $confirm = null, string $method = 'post'): array
|
||||
{
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'icon' => $icon,
|
||||
'type' => 'request',
|
||||
'method' => $method,
|
||||
'url' => $url,
|
||||
'payload' => $payload,
|
||||
'confirm' => $confirm,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,18 @@ class ThumbnailService
|
||||
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/');
|
||||
}
|
||||
|
||||
protected static function artworkPrefix(): string
|
||||
{
|
||||
return trim((string) config('uploads.object_storage.prefix', 'artworks'), '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical size keys: xs · sm · md · lg · xl (+ legacy thumb/sq support).
|
||||
*/
|
||||
protected const VALID_SIZES = ['xs', 'sm', 'md', 'lg', 'xl', 'thumb', 'sq'];
|
||||
|
||||
/** Size aliases for backwards compatibility with old callers. */
|
||||
protected const SIZE_ALIAS = [];
|
||||
protected const SIZE_ALIAS = ['thumb' => 'sm'];
|
||||
|
||||
protected const THUMB_SIZES = [
|
||||
'xs' => ['height' => 160, 'quality' => 74, 'dir' => 'xs'],
|
||||
@@ -33,7 +38,7 @@ class ThumbnailService
|
||||
'lg' => ['height' => 1920, 'quality' => 85, 'dir' => 'lg'],
|
||||
'xl' => ['height' => 2560, 'quality' => 90, 'dir' => 'xl'],
|
||||
// Legacy compatibility for older paths still expecting /thumb/.
|
||||
'thumb' => ['height' => 320, 'quality' => 78, 'dir' => 'thumb'],
|
||||
'thumb' => ['height' => 320, 'quality' => 78, 'dir' => 'sm'],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -94,7 +99,7 @@ class ThumbnailService
|
||||
$h = $hash;
|
||||
$h1 = substr($h, 0, 2);
|
||||
$h2 = substr($h, 2, 2);
|
||||
return sprintf('%s/%s/%s/%s/%s.%s', self::cdnHost(), $dir, $h1, $h2, $h, $ext);
|
||||
return sprintf('%s/%s/%s/%s/%s.%s', self::cdnHost(), self::artworkPrefix() . '/' . $dir, $h1, $h2, $h, $ext);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user