Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.53% covered (success)
97.53%
79 / 81
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
PersonaFieldLockingService
97.53% covered (success)
97.53%
79 / 81
88.89% covered (warning)
88.89%
8 / 9
30
0.00% covered (danger)
0.00%
0 / 1
 hasConversations
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getIcpSessionCounts
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getIcpIdsWithSessions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getChangedLockedFields
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 annotateIcpsWithSessions
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 processIcpUpdates
94.12% covered (success)
94.12%
32 / 34
0.00% covered (danger)
0.00%
0 / 1
11.02
 filterDeletedIcps
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 valuesAreDifferent
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 icpFieldsChanged
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace App\Http\Services\RolePlay;
4
5use App\Http\Models\RolePlayConversations;
6use App\Http\Models\RolePlayProjects;
7use Carbon\Carbon;
8use Illuminate\Support\Collection;
9
10/**
11 * Handles field-locking and ICP soft-delete logic for roleplay personas.
12 *
13 * When a persona has existing sessions (conversations), certain fields
14 * become locked and ICPs with sessions cannot be removed or modified.
15 */
16class PersonaFieldLockingService
17{
18    /**
19     * Fields that become locked once a persona has sessions.
20     *
21     * @var array<string>
22     */
23    public const LOCKED_FIELDS = [
24        'type',
25        'company_sizes',
26        'difficulty_level',
27        'industry',
28    ];
29
30    /**
31     * ICP fields excluded from change-detection comparisons.
32     *
33     * @var array<string>
34     */
35    private const ICP_EXCLUDED_FIELDS = ['has_sessions', 'deleted_at'];
36
37    /**
38     * Check whether the persona has any conversations.
39     *
40     * @param  string  $projectId  The persona/project ID
41     */
42    public function hasConversations(string $projectId): bool
43    {
44        return RolePlayConversations::where('project_id', $projectId)->exists();
45    }
46
47    /**
48     * Get the set of ICP ids that have sessions for a given project.
49     *
50     * Loads all conversation ICP fields for the project once and parses
51     * the JSON to extract ICP ids.
52     *
53     * @param  string  $projectId  The persona/project ID
54     * @return Collection<int, int> Unique ICP ids with sessions
55     */
56    /**
57     * Returns ICP id → session count mapping for a project.
58     *
59     * @param  string  $projectId  The persona/project ID
60     * @return Collection<int, int> Keyed by ICP id, value = session count
61     */
62    public function getIcpSessionCounts(string $projectId): Collection
63    {
64        $conversations = RolePlayConversations::where('project_id', $projectId)
65            ->get(['icp']);
66
67        $counts = collect();
68
69        foreach ($conversations as $conv) {
70            $icpData = is_string($conv->icp) ? json_decode($conv->icp, true) : $conv->icp;
71            if (isset($icpData['id'])) {
72                $id = $icpData['id'];
73                $counts[$id] = ($counts[$id] ?? 0) + 1;
74            }
75        }
76
77        return $counts;
78    }
79
80    /**
81     * Legacy helper — returns unique ICP ids that have at least one session.
82     *
83     * @param  string  $projectId  The persona/project ID
84     * @return Collection<int, int> Unique ICP ids with sessions
85     */
86    public function getIcpIdsWithSessions(string $projectId): Collection
87    {
88        return $this->getIcpSessionCounts($projectId)->keys();
89    }
90
91    /**
92     * Validate that no locked persona-level fields have changed.
93     *
94     * Returns the list of locked fields that the caller tried to change.
95     *
96     * @param  RolePlayProjects  $persona  The existing persona
97     * @param  array<string, mixed>  $newData  The incoming validated data
98     * @return array<string> Changed locked fields (empty = OK)
99     */
100    public function getChangedLockedFields(RolePlayProjects $persona, array $newData): array
101    {
102        $changed = [];
103
104        foreach (self::LOCKED_FIELDS as $field) {
105            if (! array_key_exists($field, $newData)) {
106                continue;
107            }
108
109            $oldValue = $persona->getAttribute($field);
110            $newValue = $newData[$field];
111
112            // Normalize for comparison (arrays, scalars)
113            if ($this->valuesAreDifferent($oldValue, $newValue)) {
114                $changed[] = $field;
115            }
116        }
117
118        return $changed;
119    }
120
121    /**
122     * Annotate each ICP in the customer_profiles array with `has_sessions`.
123     *
124     * @param  array<int, array<string, mixed>>  $customerProfiles
125     * @param  Collection<int, int>  $icpIdsWithSessions
126     * @return array<int, array<string, mixed>>
127     */
128    /**
129     * Annotate each ICP with `has_sessions` (bool) and `sessions_count` (int).
130     *
131     * @param  array<int, array<string, mixed>>  $customerProfiles
132     * @param  Collection<int, int>  $icpSessionCounts  Keyed by ICP id → count
133     * @return array<int, array<string, mixed>>
134     */
135    public function annotateIcpsWithSessions(array $customerProfiles, Collection $icpSessionCounts): array
136    {
137        return array_map(function ($icp) use ($icpSessionCounts) {
138            $id = $icp['id'] ?? null;
139            $count = $icpSessionCounts[$id] ?? 0;
140            $icp['has_sessions'] = $count > 0;
141            $icp['sessions_count'] = $count;
142
143            return $icp;
144        }, $customerProfiles);
145    }
146
147    /**
148     * Process ICP soft-delete and modification validation on update.
149     *
150     * Returns an array with:
151     * - 'customer_profiles': the merged customer_profiles array
152     * - 'error': null or an error message string
153     * - 'status': HTTP status code (only relevant when error is set)
154     *
155     * @param  array<int, array<string, mixed>>  $existingProfiles  Current ICPs
156     * @param  array<int, array<string, mixed>>  $incomingProfiles  Submitted ICPs
157     * @param  Collection<int, int>  $icpIdsWithSessions
158     * @return array{customer_profiles: array, error: string|null, status: int}
159     */
160    public function processIcpUpdates(
161        array $existingProfiles,
162        array $incomingProfiles,
163        Collection $icpIdsWithSessions
164    ): array {
165        // Index existing and incoming by ICP id
166        $existingById = collect($existingProfiles)->keyBy('id');
167        $incomingById = collect($incomingProfiles)->keyBy('id');
168
169        // 1. Validate incoming ICPs that already exist
170        foreach ($incomingProfiles as $incoming) {
171            $icpId = $incoming['id'] ?? null;
172            if ($icpId === null || ! $existingById->has($icpId)) {
173                continue; // New ICP, no restrictions
174            }
175
176            $existing = $existingById->get($icpId);
177
178            // Cannot modify a soft-deleted ICP
179            if (! empty($existing['deleted_at'])) {
180                return [
181                    'customer_profiles' => [],
182                    'error' => 'Cannot modify a deleted customer profile.',
183                    'status' => 422,
184                ];
185            }
186
187            // Cannot modify an ICP that has sessions
188            if ($icpIdsWithSessions->contains($icpId)) {
189                if ($this->icpFieldsChanged($existing, $incoming)) {
190                    return [
191                        'customer_profiles' => [],
192                        'error' => 'Cannot modify a customer profile with existing sessions.',
193                        'status' => 422,
194                    ];
195                }
196            }
197        }
198
199        // 2. Handle removed ICPs (in existing but not in incoming)
200        $mergedProfiles = $incomingProfiles;
201
202        foreach ($existingProfiles as $existing) {
203            $icpId = $existing['id'] ?? null;
204            if ($icpId === null) {
205                continue;
206            }
207
208            if (! $incomingById->has($icpId)) {
209                // ICP was removed from the request
210                if ($icpIdsWithSessions->contains($icpId)) {
211                    // Soft-delete: keep it with deleted_at
212                    $existing['deleted_at'] = Carbon::now()->toIso8601String();
213                    $mergedProfiles[] = $existing;
214                }
215                // else: hard delete — just don't include it
216            }
217        }
218
219        return [
220            'customer_profiles' => $mergedProfiles,
221            'error' => null,
222            'status' => 200,
223        ];
224    }
225
226    /**
227     * Filter out soft-deleted ICPs from customer_profiles.
228     *
229     * @param  array<int, array<string, mixed>>  $customerProfiles
230     * @param  bool  $includeDeleted  Whether to keep deleted entries
231     * @return array<int, array<string, mixed>>
232     */
233    public function filterDeletedIcps(array $customerProfiles, bool $includeDeleted = false): array
234    {
235        if ($includeDeleted) {
236            return $customerProfiles;
237        }
238
239        return array_values(array_filter($customerProfiles, function ($icp) {
240            return empty($icp['deleted_at']);
241        }));
242    }
243
244    /**
245     * Compare two values for equality (handles arrays and scalars).
246     *
247     * @return bool True if values are different
248     */
249    private function valuesAreDifferent(mixed $old, mixed $new): bool
250    {
251        // Normalize to comparable forms
252        if (is_array($old) && is_array($new)) {
253            // Sort arrays for comparison
254            $sortedOld = $old;
255            $sortedNew = $new;
256            sort($sortedOld);
257            sort($sortedNew);
258
259            return $sortedOld !== $sortedNew;
260        }
261
262        // Cast to same type for scalar comparison
263        return (string) $old !== (string) $new;
264    }
265
266    /**
267     * Check if any meaningful ICP fields have changed.
268     *
269     * @param  array<string, mixed>  $existing
270     * @param  array<string, mixed>  $incoming
271     * @return bool True if fields changed
272     */
273    private function icpFieldsChanged(array $existing, array $incoming): bool
274    {
275        // Get all keys from both, excluding meta fields
276        $allKeys = array_unique(array_merge(array_keys($existing), array_keys($incoming)));
277        $allKeys = array_diff($allKeys, self::ICP_EXCLUDED_FIELDS);
278
279        foreach ($allKeys as $key) {
280            $oldVal = $existing[$key] ?? null;
281            $newVal = $incoming[$key] ?? null;
282
283            if ($this->valuesAreDifferent($oldVal, $newVal)) {
284                return true;
285            }
286        }
287
288        return false;
289    }
290}