Implement academy analytics, billing, and web stories updates

This commit is contained in:
2026-05-26 07:27:29 +02:00
parent 456c3d6bb0
commit 0b33a1b074
177 changed files with 27360 additions and 2685 deletions

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\Academy;
use JsonException;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -22,6 +23,10 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
'active' => $this->boolean('active', true),
'new_category_name' => trim((string) $this->input('new_category_name', '')),
'tags' => array_values(array_filter((array) $this->input('tags', []))),
'documentation' => $this->normalizeDocumentation($this->input('documentation')),
'placeholders' => $this->normalizePlaceholders($this->input('placeholders')),
'helper_prompts' => $this->normalizeHelperPrompts($this->input('helper_prompts')),
'prompt_variants' => $this->normalizePromptVariants($this->input('prompt_variants')),
'tool_notes' => collect($this->input('tool_notes', []))
->filter(static fn ($note): bool => is_array($note) || is_string($note))
->map(function ($note): array|string {
@@ -30,6 +35,7 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
}
return [
'display_type' => $note['display_type'] ?? null,
'provider' => $note['provider'] ?? null,
'model_name' => $note['model_name'] ?? null,
'notes' => $note['notes'] ?? null,
@@ -62,12 +68,57 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
'negative_prompt' => ['nullable', 'string'],
'usage_notes' => ['nullable', 'string'],
'workflow_notes' => ['nullable', 'string'],
'documentation' => ['nullable', 'array'],
'documentation.summary' => ['nullable', 'string'],
'documentation.best_for' => ['nullable', 'array'],
'documentation.best_for.*' => ['nullable', 'string'],
'documentation.how_to_use' => ['nullable', 'array'],
'documentation.how_to_use.*' => ['nullable', 'string'],
'documentation.required_inputs' => ['nullable', 'array'],
'documentation.required_inputs.*' => ['nullable', 'string'],
'documentation.workflow' => ['nullable', 'array'],
'documentation.workflow.*' => ['nullable', 'string'],
'documentation.tips' => ['nullable', 'array'],
'documentation.tips.*' => ['nullable', 'string'],
'documentation.common_mistakes' => ['nullable', 'array'],
'documentation.common_mistakes.*' => ['nullable', 'string'],
'documentation.data_accuracy_notes' => ['nullable', 'array'],
'documentation.data_accuracy_notes.*' => ['nullable', 'string'],
'documentation.display_notes' => ['nullable', 'string'],
'placeholders' => ['nullable', 'array'],
'placeholders.*.key' => ['nullable', 'string', 'max:120'],
'placeholders.*.label' => ['nullable', 'string', 'max:180'],
'placeholders.*.description' => ['nullable', 'string'],
'placeholders.*.required' => ['nullable', 'boolean'],
'placeholders.*.example' => ['nullable'],
'placeholders.*.default' => ['nullable'],
'placeholders.*.type' => ['nullable', 'string', 'max:120'],
'helper_prompts' => ['nullable', 'array'],
'helper_prompts.*.title' => ['required_with:helper_prompts', 'string', 'max:180'],
'helper_prompts.*.type' => ['nullable', 'string', Rule::in(['data_collection', 'prompt_preparation', 'refinement', 'validation', 'variation', 'translation', 'seo', 'other'])],
'helper_prompts.*.description' => ['nullable', 'string'],
'helper_prompts.*.prompt' => ['required_with:helper_prompts', 'string'],
'helper_prompts.*.expected_output' => ['nullable', 'string', Rule::in(['json', 'text', 'markdown', 'image_prompt'])],
'helper_prompts.*.active' => ['nullable', 'boolean'],
'prompt_variants' => ['nullable', 'array'],
'prompt_variants.*.title' => ['required_with:prompt_variants', 'string', 'max:180'],
'prompt_variants.*.slug' => ['nullable', 'string', 'max:180'],
'prompt_variants.*.description' => ['nullable', 'string'],
'prompt_variants.*.prompt' => ['required_with:prompt_variants', 'string'],
'prompt_variants.*.negative_prompt' => ['nullable', 'string'],
'prompt_variants.*.recommended' => ['nullable', 'boolean'],
'prompt_variants.*.recommended_for' => ['nullable', 'array'],
'prompt_variants.*.recommended_for.*' => ['nullable', 'string'],
'prompt_variants.*.risk_notes' => ['nullable', 'array'],
'prompt_variants.*.risk_notes.*' => ['nullable', 'string'],
'prompt_variants.*.active' => ['nullable', 'boolean'],
'difficulty' => ['required', 'string', Rule::in((array) config('academy.difficulty_levels', []))],
'access_level' => ['required', 'string', Rule::in(['free', 'creator', 'pro'])],
'aspect_ratio' => ['nullable', 'string', 'max:20'],
'tags' => ['nullable', 'array'],
'tags.*' => ['string', 'max:60'],
'tool_notes' => ['nullable', 'array'],
'tool_notes.*.display_type' => ['nullable', 'string', 'max:50'],
'tool_notes.*.provider' => ['nullable', 'string', 'max:100'],
'tool_notes.*.model_name' => ['nullable', 'string', 'max:150'],
'tool_notes.*.notes' => ['nullable', 'string'],
@@ -89,4 +140,251 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
'seo_description' => ['nullable', 'string', 'max:255'],
];
}
private function decodeStructuredInput(mixed $value): mixed
{
if (! is_string($value)) {
return $value;
}
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
try {
return json_decode($trimmed, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return $value;
}
}
private function normalizeDocumentation(mixed $value): mixed
{
$value = $this->decodeStructuredInput($value);
if ($value === null) {
return null;
}
if (! is_array($value)) {
return $value;
}
$listFields = ['best_for', 'how_to_use', 'required_inputs', 'workflow', 'tips', 'common_mistakes', 'data_accuracy_notes'];
$documentation = [
'summary' => $this->normalizeOptionalString($value['summary'] ?? null),
'display_notes' => $this->normalizeOptionalString($value['display_notes'] ?? null),
];
foreach ($listFields as $field) {
$documentation[$field] = $this->normalizeStringList($value[$field] ?? []);
}
$hasContent = $documentation['summary'] !== null
|| $documentation['display_notes'] !== null
|| collect($listFields)->contains(fn (string $field): bool => $documentation[$field] !== []);
return $hasContent ? $documentation : null;
}
private function normalizePlaceholders(mixed $value): mixed
{
$value = $this->decodeStructuredInput($value);
if ($value === null) {
return [];
}
if (! is_array($value)) {
return $value;
}
$value = $this->normalizeStructuredObjectList($value, ['key', 'label', 'description', 'required', 'example', 'default', 'type']);
return collect($value)
->values()
->map(function ($placeholder): mixed {
if (! is_array($placeholder)) {
return $placeholder;
}
return [
'key' => $this->normalizeOptionalString($placeholder['key'] ?? null),
'label' => $this->normalizeOptionalString($placeholder['label'] ?? null),
'description' => $this->normalizeOptionalString($placeholder['description'] ?? null),
'required' => filter_var($placeholder['required'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false,
'example' => $this->normalizeJsonScalar($placeholder['example'] ?? null),
'default' => $this->normalizeJsonScalar($placeholder['default'] ?? null),
'type' => $this->normalizeOptionalString($placeholder['type'] ?? null),
];
})
->filter(function ($placeholder): bool {
if (! is_array($placeholder)) {
return true;
}
return collect([
$placeholder['key'] ?? null,
$placeholder['label'] ?? null,
$placeholder['description'] ?? null,
$placeholder['example'] ?? null,
$placeholder['default'] ?? null,
$placeholder['type'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []);
})
->values()
->all();
}
private function normalizeHelperPrompts(mixed $value): mixed
{
$value = $this->decodeStructuredInput($value);
if ($value === null) {
return [];
}
if (! is_array($value)) {
return $value;
}
$value = $this->normalizeStructuredObjectList($value, ['title', 'type', 'description', 'prompt', 'expected_output', 'active']);
return collect($value)
->values()
->map(function ($helperPrompt): mixed {
if (! is_array($helperPrompt)) {
return $helperPrompt;
}
return [
'title' => $this->normalizeOptionalString($helperPrompt['title'] ?? null),
'type' => $this->normalizeOptionalString($helperPrompt['type'] ?? null) ?? 'other',
'description' => $this->normalizeOptionalString($helperPrompt['description'] ?? null),
'prompt' => $this->normalizeOptionalString($helperPrompt['prompt'] ?? null),
'expected_output' => $this->normalizeOptionalString($helperPrompt['expected_output'] ?? null) ?? 'text',
'active' => filter_var($helperPrompt['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
];
})
->filter(function ($helperPrompt): bool {
if (! is_array($helperPrompt)) {
return true;
}
return collect([
$helperPrompt['title'] ?? null,
$helperPrompt['description'] ?? null,
$helperPrompt['prompt'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '');
})
->values()
->all();
}
private function normalizePromptVariants(mixed $value): mixed
{
$value = $this->decodeStructuredInput($value);
if ($value === null) {
return [];
}
if (! is_array($value)) {
return $value;
}
$value = $this->normalizeStructuredObjectList($value, ['title', 'slug', 'description', 'prompt', 'negative_prompt', 'recommended', 'recommended_for', 'risk_notes', 'active']);
return collect($value)
->values()
->map(function ($variant): mixed {
if (! is_array($variant)) {
return $variant;
}
return [
'title' => $this->normalizeOptionalString($variant['title'] ?? null),
'slug' => $this->normalizeOptionalString($variant['slug'] ?? null),
'description' => $this->normalizeOptionalString($variant['description'] ?? null),
'prompt' => $this->normalizeOptionalString($variant['prompt'] ?? null),
'negative_prompt' => $this->normalizeOptionalString($variant['negative_prompt'] ?? null),
'recommended' => filter_var($variant['recommended'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false,
'recommended_for' => $this->normalizeStringList($variant['recommended_for'] ?? []),
'risk_notes' => $this->normalizeStringList($variant['risk_notes'] ?? []),
'active' => filter_var($variant['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
];
})
->filter(function ($variant): bool {
if (! is_array($variant)) {
return true;
}
return collect([
$variant['title'] ?? null,
$variant['description'] ?? null,
$variant['prompt'] ?? null,
$variant['negative_prompt'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '');
})
->values()
->all();
}
private function normalizeStringList(mixed $value): array
{
if (! is_array($value)) {
$value = $value === null ? [] : [$value];
}
return collect($value)
->map(fn ($item): string => trim((string) $item))
->filter(static fn (string $item): bool => $item !== '')
->values()
->all();
}
private function normalizeOptionalString(mixed $value): ?string
{
if ($value === null) {
return null;
}
$normalized = trim((string) $value);
return $normalized !== '' ? $normalized : null;
}
private function normalizeJsonScalar(mixed $value): mixed
{
if (! is_string($value)) {
return $value;
}
$trimmed = trim($value);
return $trimmed !== '' ? $trimmed : null;
}
/**
* @param array<int|string, mixed> $value
* @param array<int, string> $expectedKeys
* @return array<int|string, mixed>
*/
private function normalizeStructuredObjectList(array $value, array $expectedKeys): array
{
if (array_is_list($value)) {
return $value;
}
$keys = array_keys($value);
$normalizedKeys = array_map(static fn ($key): string => (string) $key, $keys);
if ($normalizedKeys === [] || array_intersect($normalizedKeys, $expectedKeys) === []) {
return $value;
}
return [$value];
}
}