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).'…');
}
}