File: /home/xedaptot/ai.naniguide.com/app/Http/Requests/Refactor/SaveFormBuilderRequest.php
<?php
namespace App\Http\Requests\Refactor;
use App\Http\Requests\Request;
use App\Model\Form;
use App\Services\BuilderJs\BuilderJsonWalker;
use App\Services\Forms\TermsAcceptanceService;
class SaveFormBuilderRequest extends Request
{
/** @var Form|null */
private $form;
public function authorize(): bool
{
$this->form = Form::findByUid($this->route('uid'));
if (!$this->form) {
return false;
}
return \Gate::allows('update', $this->form);
}
public function rules(): array
{
return [
'content' => ['required', 'string'],
'json' => ['required', 'string'],
];
}
/**
* Save-time validator. All tree-walking goes through the shared
* `BuilderJsonWalker` so save-time and submit-time enforcement see the
* same structure (no two-walker drift). The walker is also reused by
* any future compliance widget that needs save-time placement rules
* (captcha, GDPR consent, age-gate …).
*/
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$raw = $this->input('json');
if (!is_string($raw) || $raw === '') {
$validator->errors()->add('json', trans('refactor/forms_builder.validation.invalid_json'));
return;
}
$data = json_decode($raw, true);
if (!is_array($data)) {
$validator->errors()->add('json', trans('refactor/forms_builder.validation.invalid_json'));
return;
}
$walker = app(BuilderJsonWalker::class);
$fieldNodes = $walker->collectByName($data, 'FieldElement');
$buttonNodes = $walker->collect(
$data,
static fn (array $n): bool => in_array($n['name'] ?? null, ['ButtonElement', 'SubmitButtonElement'], true)
);
$termsNodes = $walker->collectByName($data, TermsAcceptanceService::NODE_NAME);
// Rule 1 — at least one submit button (form must be submittable).
if (count($buttonNodes) === 0) {
$validator->errors()->add(
'submit_button',
trans('refactor/forms_builder.validation.missing_submit')
);
}
// Rule 2 — exactly one EMAIL field (subscriber identification).
$emailFields = array_filter(
$fieldNodes,
static fn (array $entry): bool => strtoupper((string) ($entry['node']['fieldName'] ?? '')) === 'EMAIL'
|| strtolower((string) ($entry['node']['fieldType'] ?? '')) === 'email'
);
if (count($emailFields) === 0) {
$validator->errors()->add(
'email_field',
trans('refactor/forms_builder.validation.missing_email')
);
} elseif (count($emailFields) > 1) {
$validator->errors()->add(
'email_field',
trans('refactor/forms_builder.validation.duplicate_email', ['count' => count($emailFields)])
);
}
// Rule 3 — no duplicate fieldName. The reserved Terms input
// name is excluded so it can't collide with FieldElement
// names (Rule 8 — name reservation).
$fieldNameCounts = [];
foreach ($fieldNodes as $entry) {
$name = trim((string) ($entry['node']['fieldName'] ?? ''));
if ($name === '' || strtoupper($name) === TermsAcceptanceService::RESERVED_INPUT_NAME) {
continue;
}
$fieldNameCounts[$name] = ($fieldNameCounts[$name] ?? 0) + 1;
}
foreach (array_filter($fieldNameCounts, static fn (int $count): bool => $count > 1) as $name => $count) {
$validator->errors()->add(
'duplicate_field.'.$name,
trans('refactor/forms_builder.validation.duplicate_field', [
'name' => $name,
'count' => $count,
])
);
}
// Rules 4 + 5 — cross-check against the linked MailList.
if ($this->form !== null) {
$listFields = $this->form->mailList->getFields()->get();
$listFieldsByTag = [];
foreach ($listFields as $listField) {
$listFieldsByTag[strtoupper((string) $listField->tag)] = $listField;
}
// Rule 4 — every required list field must appear in content.
foreach ($listFields as $listField) {
if (!$listField->required) {
continue;
}
$tag = strtoupper((string) $listField->tag);
$found = false;
foreach ($fieldNodes as $entry) {
if (strtoupper((string) ($entry['node']['fieldName'] ?? '')) === $tag) {
$found = true;
break;
}
}
if (!$found) {
$validator->errors()->add(
'missing_required.'.$tag,
trans('refactor/forms_builder.validation.missing_required_field', [
'label' => $listField->label,
'tag' => $tag,
])
);
}
}
// Rule 5 — every field in content must reference an existing
// list field tag. Reserved Terms name is excluded.
foreach ($fieldNodes as $entry) {
$tag = strtoupper(trim((string) ($entry['node']['fieldName'] ?? '')));
if ($tag === '' || $tag === TermsAcceptanceService::RESERVED_INPUT_NAME) {
continue;
}
if (!isset($listFieldsByTag[$tag])) {
$validator->errors()->add(
'orphan_field.'.$tag,
trans('refactor/forms_builder.validation.orphan_field', ['tag' => $tag])
);
}
}
}
// Rule 6 — at most one TermsOfServiceElement (singleton invariant).
if (count($termsNodes) > 1) {
$validator->errors()->add(
'terms_duplicate',
trans('refactor/forms_builder.validation.terms_duplicate')
);
}
// Rule 7 — every TermsOfServiceElement must descend a
// FormContainerCell. Outside the form wrapper its checkbox
// would never POST and the server-side gate would silently
// pass everything. Uses the walker's ancestor stack instead
// of a bespoke recursion.
foreach ($termsNodes as $entry) {
$insideFcc = BuilderJsonWalker::hasAncestorNamed(
$entry['node'],
$entry['ancestors'],
'FormContainerCell'
);
if (!$insideFcc) {
$validator->errors()->add(
'terms_outside_form',
trans('refactor/forms_builder.validation.terms_outside_form')
);
break; // one error is enough — UX guidance is identical
}
}
});
}
}