Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.18% covered (success)
95.18%
79 / 83
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
ObjectionNormalizer
95.18% covered (success)
95.18%
79 / 83
33.33% covered (danger)
33.33%
1 / 3
26
0.00% covered (danger)
0.00%
0 / 1
 normalize
92.11% covered (success)
92.11%
35 / 38
0.00% covered (danger)
0.00%
0 / 1
10.05
 filterByCompanySize
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 normalizeOption
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
10
1<?php
2
3namespace App\Http\Services\RolePlay;
4
5use App\Http\Models\RolePlayProjects;
6
7/**
8 * Single source of truth for persona-level objection shape handling.
9 *
10 * The stored format used to be:
11 *   [{ category: string, options: string[] }, ...]
12 *
13 * The new format supports per-option company size targeting:
14 *   [{
15 *     category: string,
16 *     options: [{ text: string, company_sizes: ('small'|'medium'|'large')[] }]
17 *   }, ...]
18 *
19 * This normalizer accepts EITHER shape (plus mixtures) and always returns
20 * the new shape, collapsing duplicate categories, deduping option texts,
21 * and defaulting legacy string options to all three company sizes.
22 */
23class ObjectionNormalizer
24{
25    /**
26     * Normalize + merge an objections array to the canonical shape.
27     *
28     * @param  mixed  $objections
29     * @return array<int, array{category: string, options: array<int, array{text: string, company_sizes: array<int, string>}>}>
30     */
31    public static function normalize($objections): array
32    {
33        if (! is_array($objections)) {
34            return [];
35        }
36
37        $byCategory = [];
38
39        foreach ($objections as $objection) {
40            if (! is_array($objection)) {
41                continue;
42            }
43
44            $category = trim((string) ($objection['category'] ?? ''));
45            if ($category === '') {
46                continue;
47            }
48
49            $rawOptions = $objection['options'] ?? [];
50            if (! is_array($rawOptions)) {
51                continue;
52            }
53
54            if (! isset($byCategory[$category])) {
55                $byCategory[$category] = [
56                    'category' => $category,
57                    'options' => [],
58                    '_index' => [],
59                ];
60            }
61
62            foreach ($rawOptions as $opt) {
63                $normalized = self::normalizeOption($opt);
64                if ($normalized === null) {
65                    continue;
66                }
67
68                $key = $normalized['text'];
69                if (isset($byCategory[$category]['_index'][$key])) {
70                    // Same text seen again â€” union the allowed company sizes.
71                    $existingIdx = $byCategory[$category]['_index'][$key];
72                    $merged = array_values(array_unique(array_merge(
73                        $byCategory[$category]['options'][$existingIdx]['company_sizes'],
74                        $normalized['company_sizes'],
75                    )));
76                    sort($merged);
77                    $byCategory[$category]['options'][$existingIdx]['company_sizes'] = $merged;
78
79                    continue;
80                }
81
82                $byCategory[$category]['options'][] = $normalized;
83                $byCategory[$category]['_index'][$key] = count($byCategory[$category]['options']) - 1;
84            }
85        }
86
87        // Drop the helper index before returning.
88        return array_values(array_map(function ($category) {
89            unset($category['_index']);
90
91            return $category;
92        }, $byCategory));
93    }
94
95    /**
96     * Filter a normalized objections array so that only options whose
97     * `company_sizes` include the given ICP size key are kept. Categories
98     * left with no options after filtering are dropped.
99     *
100     * @param  array  $objections  A normalized objections array (see normalize()).
101     * @param  string|null  $sizeKey  'small'|'medium'|'large' â€” or null to keep everything
102     */
103    public static function filterByCompanySize(array $objections, ?string $sizeKey): array
104    {
105        if ($sizeKey === null || $sizeKey === '') {
106            return $objections;
107        }
108
109        $result = [];
110        foreach ($objections as $objection) {
111            $options = $objection['options'] ?? [];
112            $kept = array_values(array_filter($options, function ($opt) use ($sizeKey) {
113                $sizes = $opt['company_sizes'] ?? [];
114
115                return is_array($sizes) && in_array($sizeKey, $sizes, true);
116            }));
117
118            if (empty($kept)) {
119                continue;
120            }
121
122            $result[] = [
123                'category' => $objection['category'],
124                'options' => $kept,
125            ];
126        }
127
128        return $result;
129    }
130
131    /**
132     * Coerce a single option into {text, company_sizes}. Accepts strings
133     * (legacy) and arrays/objects. Returns null if no usable text exists.
134     *
135     * @return array{text: string, company_sizes: array<int, string>}|null
136     */
137    private static function normalizeOption($option): ?array
138    {
139        if (is_string($option)) {
140            $text = trim($option);
141            if ($text === '') {
142                return null;
143            }
144
145            $allSizes = RolePlayProjects::COMPANY_SIZE_KEYS;
146            sort($allSizes);
147
148            return [
149                'text' => $text,
150                'company_sizes' => $allSizes,
151            ];
152        }
153
154        if (! is_array($option)) {
155            return null;
156        }
157
158        $text = trim((string) ($option['text'] ?? ''));
159        if ($text === '') {
160            return null;
161        }
162
163        $sizes = $option['company_sizes'] ?? null;
164        $cleanSizes = [];
165        if (is_array($sizes)) {
166            foreach ($sizes as $size) {
167                $lc = strtolower((string) $size);
168                if (in_array($lc, RolePlayProjects::COMPANY_SIZE_KEYS, true) && ! in_array($lc, $cleanSizes, true)) {
169                    $cleanSizes[] = $lc;
170                }
171            }
172        }
173        if (empty($cleanSizes)) {
174            $cleanSizes = RolePlayProjects::COMPANY_SIZE_KEYS;
175        }
176        sort($cleanSizes);
177
178        return [
179            'text' => $text,
180            'company_sizes' => $cleanSizes,
181        ];
182    }
183}