Implement academy analytics, billing, and web stories updates
This commit is contained in:
174
app/Services/WebStories/WorldWebStoryValidationService.php
Normal file
174
app/Services/WebStories/WorldWebStoryValidationService.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\WebStories;
|
||||
|
||||
use App\Models\WorldWebStory;
|
||||
use App\Models\WorldWebStoryPage;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class WorldWebStoryValidationService
|
||||
{
|
||||
/**
|
||||
* @return array{valid: bool, errors: list<string>, warnings: list<string>, page_count: int}
|
||||
*/
|
||||
public function validate(WorldWebStory $story): array
|
||||
{
|
||||
$story->loadMissing('orderedPages');
|
||||
$pages = $story->orderedPages->where('active', true)->values();
|
||||
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
|
||||
if (trim((string) $story->title) === '') {
|
||||
$errors[] = 'Story title is required.';
|
||||
}
|
||||
|
||||
if (trim((string) $story->slug) === '') {
|
||||
$errors[] = 'Story slug is required.';
|
||||
}
|
||||
|
||||
if (trim((string) $story->poster_portrait_path) === '') {
|
||||
$errors[] = 'Poster portrait image is required.';
|
||||
}
|
||||
|
||||
if (trim((string) $story->publisher_logo_path) === '') {
|
||||
$errors[] = 'Publisher logo is required.';
|
||||
}
|
||||
|
||||
if ($pages->count() < 5) {
|
||||
$errors[] = 'A published web story must have at least 5 active pages.';
|
||||
}
|
||||
|
||||
if ($pages->count() > 10) {
|
||||
$errors[] = 'A published web story may not have more than 10 active pages.';
|
||||
}
|
||||
|
||||
foreach ($pages as $page) {
|
||||
$pageNumber = (int) $page->position;
|
||||
$body = trim((string) $page->body);
|
||||
$headline = trim((string) $page->headline);
|
||||
|
||||
if (in_array((string) $page->background_type, [WorldWebStoryPage::BACKGROUND_IMAGE, WorldWebStoryPage::BACKGROUND_VIDEO], true)
|
||||
&& trim((string) ($page->background_mobile_path ?: $page->background_path)) === '') {
|
||||
$errors[] = sprintf('Page %d is missing required background media.', $pageNumber);
|
||||
}
|
||||
|
||||
if (mb_strlen($body) > 180) {
|
||||
$errors[] = sprintf('Page %d body exceeds 180 characters.', $pageNumber);
|
||||
}
|
||||
|
||||
if (trim((string) $page->alt_text) === '') {
|
||||
$errors[] = sprintf('Page %d is missing alt text.', $pageNumber);
|
||||
}
|
||||
|
||||
if ($headline === '' && $body === '') {
|
||||
$warnings[] = sprintf('Page %d has no story text.', $pageNumber);
|
||||
}
|
||||
|
||||
if (filled($page->cta_label) || filled($page->cta_url)) {
|
||||
if (! filled($page->cta_label) || ! filled($page->cta_url)) {
|
||||
$errors[] = sprintf('Page %d CTA requires both label and URL.', $pageNumber);
|
||||
} elseif (! $this->isAllowedCtaUrl((string) $page->cta_url)) {
|
||||
$errors[] = sprintf('Page %d CTA URL is not allowed.', $pageNumber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => $errors === [],
|
||||
'errors' => array_values(array_unique($errors)),
|
||||
'warnings' => array_values(array_unique($warnings)),
|
||||
'page_count' => $pages->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $page
|
||||
*/
|
||||
public function validatePagePayload(array $page): array
|
||||
{
|
||||
$errors = [];
|
||||
$position = (int) ($page['position'] ?? 0);
|
||||
$body = trim((string) ($page['body'] ?? ''));
|
||||
$backgroundType = (string) ($page['background_type'] ?? WorldWebStoryPage::BACKGROUND_IMAGE);
|
||||
$backgroundPath = trim((string) ($page['background_mobile_path'] ?? $page['background_path'] ?? ''));
|
||||
$altText = trim((string) ($page['alt_text'] ?? ''));
|
||||
$ctaUrl = trim((string) ($page['cta_url'] ?? ''));
|
||||
$ctaLabel = trim((string) ($page['cta_label'] ?? ''));
|
||||
|
||||
if ($body !== '' && mb_strlen($body) > 180) {
|
||||
$errors['body'] = sprintf('Page %d body exceeds 180 characters.', max(1, $position));
|
||||
}
|
||||
|
||||
if (in_array($backgroundType, [WorldWebStoryPage::BACKGROUND_IMAGE, WorldWebStoryPage::BACKGROUND_VIDEO], true) && $backgroundPath === '') {
|
||||
$errors['background_path'] = 'Background media is required for image and video pages.';
|
||||
}
|
||||
|
||||
if ($altText === '') {
|
||||
$errors['alt_text'] = 'Alt text is required.';
|
||||
}
|
||||
|
||||
if (($ctaUrl !== '' || $ctaLabel !== '') && ($ctaUrl === '' || $ctaLabel === '')) {
|
||||
$errors['cta'] = 'CTA label and URL must both be present.';
|
||||
}
|
||||
|
||||
if ($ctaUrl !== '' && ! $this->isAllowedCtaUrl($ctaUrl)) {
|
||||
$errors['cta_url'] = 'CTA URL must stay on Skinbase or use a relative path.';
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
public function assertPublishable(WorldWebStory $story): void
|
||||
{
|
||||
$result = $this->validate($story);
|
||||
|
||||
if ($result['valid']) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'story' => $result['errors'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function isAllowedCtaUrl(string $url): bool
|
||||
{
|
||||
$value = trim($url);
|
||||
|
||||
if ($value === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Str::startsWith($value, ['/'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$parts = parse_url($value);
|
||||
$host = strtolower((string) Arr::get($parts, 'host', ''));
|
||||
|
||||
if ($host === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowedHosts = array_filter([
|
||||
strtolower((string) parse_url((string) config('app.url'), PHP_URL_HOST)),
|
||||
'skinbase.org',
|
||||
'www.skinbase.org',
|
||||
'skinbase.top',
|
||||
'www.skinbase.top',
|
||||
]);
|
||||
|
||||
foreach ($allowedHosts as $allowedHost) {
|
||||
if ($host === $allowedHost || Str::endsWith($host, '.' . $allowedHost)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user