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/Services/Form/FormInsightService.php
<?php

namespace App\Services\Form;

use App\Model\Form;
use App\Model\MailList;
use Throwable;

/**
 * F4.4 — read-side single source of truth for the 3 Form Coach tools
 * (`form.suggest_fields` / `form.check_gdpr` / `form.suggest_success_msg`).
 * Every method is read / preview-only — no DB mutation. Apply happens in
 * the form builder + parent-list field manager + BuilderJS template editor
 * (DEC-F4.4-1, D79 read-only coach pattern).
 *
 * Pure-PHP heuristics + Eloquent reads against the parent `mail_list` and
 * the form's BuilderJS template HTML. NO nested LLM call (F4.4-S3 invariant).
 */
class FormInsightService
{
    private const SUCCESS_MSG_MAX_CHARS = 280;

    /**
     * Suggest a field set for a form against a chosen signup-intent preset.
     * Returns: present fields (from parent list) + recommendations (recommended
     * + optional - already present) + overcollection warnings.
     *
     * @return array{
     *     form_uid:string, form_name:string,
     *     mail_list_uid:?string, mail_list_name:?string,
     *     purpose:string, preset_label:string, preset_summary:string,
     *     present_fields:list<array{tag:string,label:string,type:string,required:bool}>,
     *     recommendations:list<array{tag:string,label:string,type:string,required:bool,rationale:string,status:string}>,
     *     conversion_warnings:list<string>,
     *     max_visible_fields:int
     * }
     */
    public function suggestFields(Form $form, string $purpose): array
    {
        $preset = FormFieldCatalog::preset($purpose);
        if ($preset === null) {
            // Caller-side validation should have rejected; fall back to general.
            $preset = FormFieldCatalog::preset('general');
        }
        // After fallback, preset is always non-null (general always exists).
        \assert($preset !== null);

        $list = $this->mailList($form);
        $present = $list !== null ? $this->listFields($list) : [];
        $presentTags = array_map(fn (array $f) => $f['tag'], $present);

        $recommendations = [];
        foreach ($preset['recommended_fields'] as $f) {
            $recommendations[] = [
                'tag' => $f['tag'],
                'label' => $f['label'],
                'type' => $f['type'],
                'required' => (bool) $f['required'],
                'rationale' => $f['rationale'],
                'status' => in_array($f['tag'], $presentTags, true) ? 'present' : 'recommended',
            ];
        }
        foreach ($preset['optional_fields'] as $f) {
            $recommendations[] = [
                'tag' => $f['tag'],
                'label' => $f['label'],
                'type' => $f['type'],
                'required' => (bool) $f['required'],
                'rationale' => $f['rationale'],
                'status' => in_array($f['tag'], $presentTags, true) ? 'present' : 'optional',
            ];
        }

        // Flag overcollection: present fields that the preset's avoid_fields list calls out.
        foreach ($present as $f) {
            if (in_array($f['tag'], $preset['avoid_fields'], true)) {
                $recommendations[] = [
                    'tag' => $f['tag'],
                    'label' => $f['label'],
                    'type' => $f['type'],
                    'required' => $f['required'],
                    'rationale' => sprintf(
                        'Not recommended for `%s` forms — drops conversion ~5-10%% per non-essential field. Consider downgrading to optional or removing.',
                        $preset['id'],
                    ),
                    'status' => 'remove_consideration',
                ];
            }
        }

        $warnings = [];
        $visibleCount = count(array_filter($present, fn (array $f) => $f['visible']));
        if ($visibleCount > $preset['max_visible_fields']) {
            $warnings[] = sprintf(
                'Form has %d visible fields; preset `%s` recommends max %d. Each extra field cuts conversion ~5-10%%.',
                $visibleCount,
                $preset['id'],
                $preset['max_visible_fields'],
            );
        }
        $requiredCount = count(array_filter($present, fn (array $f) => $f['required']));
        if ($requiredCount > 3) {
            $warnings[] = sprintf(
                '%d fields are marked required. Beyond 3 required fields, conversion drops materially. Mark non-essential fields as optional.',
                $requiredCount,
            );
        }
        $hasEmail = false;
        foreach ($present as $f) {
            if ($f['type'] === 'email' || $f['tag'] === 'EMAIL') {
                $hasEmail = true;
                break;
            }
        }
        if (! $hasEmail && $present !== []) {
            $warnings[] = 'No email field detected on the parent list. Acelle requires exactly one email field per list.';
        }

        return [
            'form_uid' => (string) $form->uid,
            'form_name' => (string) $form->name,
            'mail_list_uid' => $list !== null ? (string) $list->uid : null,
            'mail_list_name' => $list !== null ? (string) $list->name : null,
            'purpose' => $preset['id'],
            'preset_label' => $preset['label'],
            'preset_summary' => $preset['summary'],
            'present_fields' => $present,
            'recommendations' => $recommendations,
            'conversion_warnings' => array_values(array_unique($warnings)),
            'max_visible_fields' => $preset['max_visible_fields'],
        ];
    }

    /**
     * Audit a form against the 6-rule GDPR checklist. Returns rule-by-rule
     * `pass|warn|fail` + evidence + the canonical fix hint.
     *
     * @return array{
     *     form_uid:string, form_name:string,
     *     mail_list_uid:?string, mail_list_name:?string,
     *     score:string, pass_count:int, warn_count:int, fail_count:int,
     *     findings:list<array{rule_id:string,label:string,status:string,evidence:string,fix_hint:string,rationale:string}>
     * }
     */
    public function checkGdpr(Form $form): array
    {
        $list = $this->mailList($form);
        $template = $this->extractTemplateHtml($form);
        $templateLower = mb_strtolower($template);

        $findings = [];

        // Rule 1 — consent_checkbox: scan for checkbox + consent verb pattern.
        $hasCheckbox = (bool) preg_match('/<input[^>]*type\s*=\s*["\']?checkbox/i', $template);
        $hasConsentLanguage = (bool) preg_match(
            '/\b(i\s+(agree|consent)|consent\s+to|opt[\s\-]?in|sign[\s\-]?up)\b/i',
            $template,
        );
        $consentRule = $this->buildFinding('consent_checkbox', match (true) {
            $hasCheckbox && $hasConsentLanguage => ['pass', 'Consent checkbox + consent language detected in form template.'],
            $hasCheckbox && ! $hasConsentLanguage => ['warn', 'A checkbox is present but no consent language ("I agree", "I consent", "opt-in") detected near it.'],
            ! $hasCheckbox && $hasConsentLanguage => ['warn', 'Consent language is present but no checkbox detected — implicit consent does not satisfy GDPR Article 7.'],
            default => ['fail', 'No consent checkbox detected in form template.'],
        });
        $findings[] = $consentRule;

        // Rule 2 — privacy_policy_link: scan for an anchor with "privacy" / "policy".
        $privacyMatch = preg_match(
            '/<a[^>]+href\s*=\s*["\'][^"\']*(privacy|policy)[^"\']*["\'][^>]*>/i',
            $template,
        );
        $privacyAnchor = preg_match(
            '/<a[^>]*>[^<]*\b(privacy|policy)\b[^<]*<\/a>/i',
            $template,
        );
        $findings[] = $this->buildFinding('privacy_policy_link', match (true) {
            $privacyMatch === 1 || $privacyAnchor === 1 => ['pass', 'Privacy policy link detected in form template.'],
            default => ['fail', 'No anchor pointing to a privacy / policy URL detected.'],
        });

        // Rule 3 — double_opt_in_enabled: parent list `subscribe_confirmation`.
        $subscribeConfirmation = $list !== null
            ? (int) ($list->getAttribute('subscribe_confirmation') ?? 0)
            : 0;
        $findings[] = $this->buildFinding('double_opt_in_enabled', match (true) {
            $list === null => ['warn', 'Parent mail list could not be loaded; cannot verify double opt-in status.'],
            $subscribeConfirmation === 1 => ['pass', 'Parent list has `subscribe_confirmation=1` — every signup goes through email confirmation.'],
            default => ['warn', 'Parent list runs single opt-in. Legal but weaker GDPR posture.'],
        });

        // Rule 4 — consent_language_explicit: look for specific email-type words near consent.
        $hasExplicitTopic = (bool) preg_match(
            '/\b(newsletter|product\s+updates?|marketing|promotional?|news|offers?|tips?|digest|updates?)\b/i',
            $template,
        );
        $findings[] = $this->buildFinding('consent_language_explicit', match (true) {
            $hasConsentLanguage && $hasExplicitTopic => ['pass', 'Consent language names a specific email type (e.g. newsletter / updates / offers).'],
            $hasConsentLanguage && ! $hasExplicitTopic => ['warn', 'Consent language is generic — name what kind of email the user is signing up for.'],
            default => ['fail', 'No specific email-type words detected ("newsletter", "updates", "offers", etc.) — consent text is too vague.'],
        });

        // Rule 5 — data_minimization: required fields on parent list count <= 3.
        $listFields = $list !== null ? $this->listFields($list) : [];
        $requiredCount = count(array_filter($listFields, fn (array $f) => $f['required']));
        $findings[] = $this->buildFinding('data_minimization', match (true) {
            $list === null => ['warn', 'Parent mail list could not be loaded; cannot count required fields.'],
            $requiredCount <= 3 => ['pass', sprintf('Parent list has %d required field%s — within the 3-field minimization ceiling.', $requiredCount, $requiredCount === 1 ? '' : 's')],
            $requiredCount <= 5 => ['warn', sprintf('Parent list has %d required fields. 3 is the soft ceiling for marketing forms; 4-5 is acceptable for B2B lead-gen if every field is documented.', $requiredCount)],
            default => ['fail', sprintf('Parent list has %d required fields — well beyond the data-minimization ceiling. Mark non-essential fields as optional or remove them.', $requiredCount)],
        });

        // Rule 6 — unsubscribe_info: scan template for unsubscribe / opt-out language.
        $unsubscribeMatch = (bool) preg_match(
            '/\b(unsubscribe|opt[\s\-]?out|stop\s+receiving|leave\s+the\s+list)\b/i',
            $templateLower,
        );
        $findings[] = $this->buildFinding('unsubscribe_info', match (true) {
            $unsubscribeMatch => ['pass', 'Unsubscribe / opt-out language present in form template.'],
            default => ['warn', 'No unsubscribe wording in form template. Optional but recommended — boosts trust and reduces complaints.'],
        });

        $passCount = count(array_filter($findings, fn (array $f) => $f['status'] === 'pass'));
        $warnCount = count(array_filter($findings, fn (array $f) => $f['status'] === 'warn'));
        $failCount = count(array_filter($findings, fn (array $f) => $f['status'] === 'fail'));

        $score = match (true) {
            $failCount >= 2 => 'fail',
            $failCount === 1 || $warnCount >= 3 => 'warning',
            default => 'pass',
        };

        return [
            'form_uid' => (string) $form->uid,
            'form_name' => (string) $form->name,
            'mail_list_uid' => $list !== null ? (string) $list->uid : null,
            'mail_list_name' => $list !== null ? (string) $list->name : null,
            'score' => $score,
            'pass_count' => $passCount,
            'warn_count' => $warnCount,
            'fail_count' => $failCount,
            'findings' => $findings,
        ];
    }

    /**
     * Generate 3 success-message proposals for a form (one per angle:
     * confirmation_first / value_prop_first / next_step_first), tone-tuned.
     * Substitutes the parent list's name into the template; `{topic}`
     * placeholder is left for the user to fill in with their content type.
     *
     * @return array{
     *     form_uid:string, form_name:string,
     *     mail_list_uid:?string, mail_list_name:?string,
     *     tone:string, double_opt_in_enabled:bool,
     *     suggestions:list<array{angle:string,text:string,length:int}>,
     *     warnings:list<string>
     * }
     */
    public function suggestSuccessMessages(Form $form, string $tone): array
    {
        if (! in_array($tone, FormSuccessMessageTemplates::TONES, true)) {
            $tone = 'warm';
        }

        $list = $this->mailList($form);
        $listName = $list !== null && $list->name !== null && $list->name !== ''
            ? (string) $list->name
            : 'our list';

        $doubleOptIn = $list !== null && (int) ($list->getAttribute('subscribe_confirmation') ?? 0) === 1;

        $warnings = [];
        if ($list === null) {
            $warnings[] = 'Parent mail list could not be loaded; suggestions use the placeholder name "our list".';
        }
        if (! $doubleOptIn) {
            $warnings[] = 'Parent list uses single opt-in. The "confirmation" angle assumes a confirmation email — if you skip double opt-in, prefer the value_prop_first or next_step_first variant without confirmation language.';
        }

        $suggestions = [];
        foreach (FormSuccessMessageTemplates::templatesForTone($tone) as $template) {
            $text = str_replace('{list_name}', $listName, $template['text']);
            $text = $this->truncate($text, self::SUCCESS_MSG_MAX_CHARS);
            $suggestions[] = [
                'angle' => $template['angle'],
                'text' => $text,
                'length' => mb_strlen($text),
            ];
        }

        return [
            'form_uid' => (string) $form->uid,
            'form_name' => (string) $form->name,
            'mail_list_uid' => $list !== null ? (string) $list->uid : null,
            'mail_list_name' => $list !== null ? (string) $list->name : null,
            'tone' => $tone,
            'double_opt_in_enabled' => $doubleOptIn,
            'suggestions' => $suggestions,
            'warnings' => array_values(array_unique($warnings)),
        ];
    }

    private function mailList(Form $form): ?MailList
    {
        try {
            $list = $form->getRelationValue('mailList');
            return $list instanceof MailList ? $list : null;
        } catch (Throwable) {
            return null;
        }
    }

    /**
     * @return list<array{tag:string,label:string,type:string,required:bool,visible:bool}>
     */
    private function listFields(MailList $list): array
    {
        try {
            $fields = $list->getRelationValue('fields');
            if (! is_iterable($fields)) {
                return [];
            }
            $out = [];
            foreach ($fields as $field) {
                if (! method_exists($field, 'getAttribute')) {
                    continue;
                }
                $out[] = [
                    'tag' => (string) ($field->getAttribute('tag') ?? ''),
                    'label' => (string) ($field->getAttribute('label') ?? ''),
                    'type' => (string) ($field->getAttribute('type') ?? 'text'),
                    'required' => (int) ($field->getAttribute('required') ?? 0) === 1,
                    'visible' => (int) ($field->getAttribute('visible') ?? 1) === 1,
                ];
            }
            return $out;
        } catch (Throwable) {
            return [];
        }
    }

    private function extractTemplateHtml(Form $form): string
    {
        // Read the loaded relation directly — `Form::hasTemplate()` issues an
        // `exists()` query which makes the auditor side-effectful + flaky in
        // unit tests. The form builder + admin views always eager-load the
        // template; we degrade silently when it's not loaded.
        try {
            $template = $form->getRelationValue('template');
            if ($template !== null && method_exists($template, 'getAttribute')) {
                $content = (string) ($template->getAttribute('content') ?? '');
                return $content;
            }
        } catch (Throwable) {
            // Relation missing / DB issue — degrade silently.
        }
        return '';
    }

    /**
     * @param  array{0:string,1:string}  $statusEvidence  ['pass'|'warn'|'fail', evidence text]
     * @return array{rule_id:string,label:string,status:string,evidence:string,fix_hint:string,rationale:string}
     */
    private function buildFinding(string $ruleId, array $statusEvidence): array
    {
        $rule = FormGdprChecklist::rule($ruleId);
        if ($rule === null) {
            // Logic error — rule id not in catalog. Surface a fail rather than throw to keep the audit usable.
            return [
                'rule_id' => $ruleId,
                'label' => $ruleId,
                'status' => 'fail',
                'evidence' => 'Rule definition missing from FormGdprChecklist catalog.',
                'fix_hint' => 'Audit catalog drift between FormGdprChecklist and form.json.',
                'rationale' => '',
            ];
        }

        return [
            'rule_id' => $ruleId,
            'label' => $rule['label'],
            'status' => $statusEvidence[0],
            'evidence' => $statusEvidence[1],
            'fix_hint' => $rule['fix_hint'],
            'rationale' => $rule['rationale'],
        ];
    }

    private function truncate(string $s, int $max): string
    {
        $s = trim($s);
        return mb_strlen($s) <= $max ? $s : (mb_substr($s, 0, $max - 1).'…');
    }
}