HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
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
                }
            }
        });
    }
}