Implement creator studio and upload updates

This commit is contained in:
2026-04-04 10:12:02 +02:00
parent 1da7d3bf88
commit 0b216b7ecd
15107 changed files with 31206 additions and 626514 deletions

View File

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

View File

@@ -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));

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

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

View 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;
}
}

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

View 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;
}
}

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

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

View File

@@ -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(),
]);
}
}

View 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;
}
}

View File

@@ -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'];
}
}

View 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;
}
}

View 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;
}
}

View 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];
}
}

View 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();
}
}

View 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;
}
}

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

View 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)]);
}
}
}

View 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(),
]);
}
}

View 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;
}
}

View 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,
];
}
}

View 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();
}
}

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

View File

@@ -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',
};
}
}

View File

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

View 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;
}
}

View 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' => [],
]];
}
}

View 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;
}
}

View 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,
]];
}
}

View 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;
}
}

View 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' => [],
]];
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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'],
];
}
}

View 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,
],
];
}
}

View File

@@ -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;
}
}

View File

@@ -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')) {

View 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,
];
}
}

View File

@@ -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,

View File

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

View File

@@ -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 (0100); 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: 10100 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);
}
}

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

View File

@@ -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();
}
}

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

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

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

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

View File

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

View 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;
}
}

View File

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

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

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

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

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

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

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

View 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,
) {
}
}

View 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;
}
}

View 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;
}

View 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());
}
}

View 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;
}

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

View 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,
) {}
}

View 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,
) {}
}

View 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(),
)];
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

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

View 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;
}
}

View 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;
}
}

View 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,
) {
}
}

View 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 = [],
) {}
}

View 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),
);
}
}

View 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;
}
}

View 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)),
]);
}
}

View 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;
}

View 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;
}
}

View 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();
}
}

View 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(),
};
}
}

View 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();
}
}

View 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();
}
}

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

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

View 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(),
]);
}
}

View 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'],
],
];
}
}

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

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

View 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;
}
}

View 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',
];
}
}

View 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;
}
}

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

View 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,
];
}
}

View 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;
}
}

View 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,
];
}
}

View 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,
];
}
}

View File

@@ -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