Files
SkinbaseNova/app/Http/Requests/Academy/UpsertAcademyPromptTemplateRequest.php
2026-06-09 13:16:01 +02:00

446 lines
19 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Requests\Academy;
use JsonException;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpsertAcademyPromptTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return (bool) $this->user()?->hasStaffAccess();
}
protected function prepareForValidation(): void
{
$this->merge([
'featured' => $this->boolean('featured'),
'prompt_of_week' => $this->boolean('prompt_of_week'),
'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')),
'filled_examples' => $this->normalizeFilledExamples($this->input('filled_examples')),
'tool_notes' => collect($this->input('tool_notes', []))
->filter(static fn ($note): bool => is_array($note) || is_string($note))
->map(function ($note): array|string {
if (is_string($note)) {
return $note;
}
return [
'display_type' => $note['display_type'] ?? null,
'provider' => $note['provider'] ?? null,
'model_name' => $note['model_name'] ?? null,
'notes' => $note['notes'] ?? null,
'strengths' => $note['strengths'] ?? null,
'weaknesses' => $note['weaknesses'] ?? null,
'best_for' => $note['best_for'] ?? null,
'image_path' => $note['image_path'] ?? null,
'thumb_path' => $note['thumb_path'] ?? null,
'settings' => $note['settings'] ?? null,
'score' => filled($note['score'] ?? null) ? (int) $note['score'] : null,
'active' => filter_var($note['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
];
})
->values()
->all(),
]);
}
public function rules(): array
{
$promptId = $this->route('academyPromptTemplate')?->id;
return [
// Require either an existing category selection or a new category name.
'category_id' => ['nullable', 'integer', 'exists:academy_categories,id', 'required_without:new_category_name'],
'new_category_name' => ['nullable', 'string', 'max:120', 'required_without:category_id'],
'title' => ['required', 'string', 'max:180'],
'slug' => ['required', 'string', 'max:180', Rule::unique('academy_prompt_templates', 'slug')->ignore($promptId)],
'excerpt' => ['nullable', 'string'],
'prompt' => ['required', 'string'],
'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'],
'filled_examples' => ['nullable', 'array', 'max:5'],
'filled_examples.*.title' => ['nullable', 'string', 'max:180'],
'filled_examples.*.description' => ['nullable', 'string'],
'filled_examples.*.placeholder_values' => ['nullable', 'array'],
'filled_examples.*.prompt' => ['required_with:filled_examples', 'string'],
'filled_examples.*.negative_prompt' => ['nullable', 'string'],
'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'],
'tool_notes.*.strengths' => ['nullable', 'string'],
'tool_notes.*.weaknesses' => ['nullable', 'string'],
'tool_notes.*.best_for' => ['nullable', 'string'],
'tool_notes.*.image_path' => ['nullable', 'string', 'max:500'],
'tool_notes.*.thumb_path' => ['nullable', 'string', 'max:500'],
'tool_notes.*.settings' => ['nullable', 'string'],
'tool_notes.*.score' => ['nullable', 'integer', 'min:1', 'max:10'],
'tool_notes.*.active' => ['nullable', 'boolean'],
'preview_image' => ['nullable', 'string', 'max:2048'],
'preview_image_file' => ['nullable', 'file', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
'featured' => ['required', 'boolean'],
'prompt_of_week' => ['required', 'boolean'],
'active' => ['required', 'boolean'],
'published_at' => ['nullable', 'date'],
'seo_title' => ['nullable', 'string', 'max:180'],
'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 normalizeFilledExamples(mixed $value): mixed
{
$value = $this->decodeStructuredInput($value);
if ($value === null) {
return [];
}
if (! is_array($value)) {
return $value;
}
$value = $this->normalizeStructuredObjectList($value, ['title', 'description', 'placeholder_values', 'prompt', 'negative_prompt']);
return collect($value)
->values()
->map(function ($example): mixed {
if (! is_array($example)) {
return $example;
}
return [
'title' => $this->normalizeOptionalString($example['title'] ?? null),
'description' => $this->normalizeOptionalString($example['description'] ?? null),
'placeholder_values' => is_array($example['placeholder_values'] ?? null) ? $example['placeholder_values'] : [],
'prompt' => $this->normalizeOptionalString($example['prompt'] ?? null),
'negative_prompt' => $this->normalizeOptionalString($example['negative_prompt'] ?? null),
];
})
->filter(function ($example): bool {
if (! is_array($example)) {
return true;
}
return collect([
$example['title'] ?? null,
$example['description'] ?? null,
$example['prompt'] ?? null,
$example['negative_prompt'] ?? null,
$example['placeholder_values'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []);
})
->take(5)
->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];
}
}