Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
123 / 123
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
RolePlayProjects
100.00% covered (success)
100.00%
123 / 123
100.00% covered (success)
100.00%
11 / 11
22
100.00% covered (success)
100.00%
1 / 1
 getCompanyProjectNameAttribute
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 companySizeKey
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 companySizeLabel
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 newFactory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 conversations
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 user
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 companyProject
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 scopeUserOwned
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 calculateProgression
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
7
 getRules
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
1 / 1
1
 getProjectAverageScore
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace App\Http\Models;
4
5use App\Http\Models\Auth\User;
6use App\Observers\RolePlayProjectsObserver;
7use Database\Factories\Http\Models\RolePlayProjectsFactory;
8use Illuminate\Database\Eloquent\Attributes\ObservedBy;
9use Illuminate\Database\Eloquent\Factories\HasFactory;
10use Illuminate\Validation\Rule;
11use MongoDB\Laravel\Eloquent\SoftDeletes;
12
13/**
14 * @property string $name
15 * @property string $user_id
16 * @property string $type
17 * @property string|null $description
18 * @property string|null $difficulty_level
19 * @property array|null $key_features
20 * @property array|null $industry
21 * @property array|null $target_job_titles
22 * @property array|null $company_sizes
23 * @property array|null $scorecard_config
24 * @property array|null $objections
25 * @property array|null $removed_default_objection_ids
26 * @property array|null $customer_profiles
27 * @property string|null $customer_profiles_signature
28 * @property array|null $progression
29 * @property float|null $average_score
30 * @property string|null $company_project_id
31 * @property bool|null $is_clone
32 * @property string|null $source
33 * @property int|null $sessions_count Virtual attribute set at runtime
34 * @property \Illuminate\Support\Carbon|null $created_at
35 * @property \Illuminate\Support\Carbon|null $updated_at
36 */
37#[ObservedBy([RolePlayProjectsObserver::class])]
38class RolePlayProjects extends Moloquent
39{
40    use HasFactory, SoftDeletes;
41
42    protected $table = 'role_play_projects';
43
44    public static $LEVEL_LOW = 'Low';
45
46    public static $LEVEL_MODERATE = 'Moderate';
47
48    public static $LEVEL_HIGH = 'High';
49
50    public static $LEVEL_CRITICAL = 'Critical';
51
52    public static $COMPANY_SIZE_SMALL = 'Small (10-99 employees)';
53
54    public static $COMPANY_SIZE_MEDIUM = 'Medium (100-999 employees)';
55
56    public static $COMPANY_SIZE_LARGE = 'Large (1000+ employees)';
57
58    /**
59     * Short keys used for persona.company_sizes targeting and objection filtering.
60     */
61    public static $COMPANY_SIZE_KEY_SMALL = 'small';
62
63    public static $COMPANY_SIZE_KEY_MEDIUM = 'medium';
64
65    public static $COMPANY_SIZE_KEY_LARGE = 'large';
66
67    public static $COMPANY_SIZE_KEY_ALL = 'all';
68
69    public const COMPANY_SIZE_KEYS = ['small', 'medium', 'large'];
70
71    public static $COLD_CALL = 'cold-call';
72
73    public static $DISCOVERY_CALL = 'discovery-call';
74
75    protected $fillable = [
76        'name',
77        'user_id',
78        'type',
79        'description',
80        'difficulty_level',
81        'key_features',
82        'industry',
83        'target_job_titles',
84        'company_sizes',
85        'scorecard_config',
86        'objections',
87        'removed_default_objection_ids',
88        'customer_profiles',
89        'customer_profiles_signature',
90        'progression',
91        'average_score',
92        'company_project_id',
93        'is_clone',
94        'source',
95        'updated_at',
96        'created_at',
97    ];
98
99    /**
100     * Virtual attributes appended to every array/JSON serialization so the
101     * roleplay frontend can render the "Cloned from ..." chip on the persona
102     * list without a second round-trip.
103     *
104     * @var array<int, string>
105     */
106    protected $appends = [
107        'company_project_name',
108    ];
109
110    /**
111     * Memoized resolved name of the parent CompanyRolePlayProject (when the
112     * current row is a clone). Keeps the accessor from re-querying on every
113     * `toArray()` pass, and sidesteps an N+1 on small list sizes (<50/user).
114     *
115     * Public to allow the controller to prefill it via a batch lookup if
116     * list sizes grow — the accessor honors an already-set value.
117     */
118    public ?string $resolvedCompanyProjectName = null;
119
120    /**
121     * Resolve the source corporate persona's name for a clone.
122     *
123     * Returns null for user-authored personas. Cached per instance so
124     * successive `toArray()` / JSON passes don't re-hit Mongo.
125     */
126    public function getCompanyProjectNameAttribute(): ?string
127    {
128        if (empty($this->company_project_id)) {
129            return null;
130        }
131
132        if ($this->resolvedCompanyProjectName !== null) {
133            return $this->resolvedCompanyProjectName;
134        }
135
136        $parent = CompanyRolePlayProject::find($this->company_project_id);
137        $this->resolvedCompanyProjectName = $parent?->name;
138
139        return $this->resolvedCompanyProjectName;
140    }
141
142    /**
143     * Map a freeform ICP company_size label (e.g. "Small (10-99 employees)")
144     * to one of the short keys ("small"|"medium"|"large"|"all").
145     *
146     * Returns "all" if no match is found.
147     *
148     * @param  string|null  $label  The display label (case-insensitive)
149     * @return string One of: small, medium, large, all
150     */
151    public static function companySizeKey(?string $label): string
152    {
153        $label = strtolower((string) $label);
154
155        if ($label === '') {
156            return self::$COMPANY_SIZE_KEY_ALL;
157        }
158
159        // Check the size keys (small/medium/large) FIRST so that labels
160        // like "Small (10-99 employees)" — which happens to contain the
161        // letters "all" — resolve to "small" rather than "all".
162        foreach (self::COMPANY_SIZE_KEYS as $key) {
163            if (str_contains($label, $key)) {
164                return $key;
165            }
166        }
167
168        return self::$COMPANY_SIZE_KEY_ALL;
169    }
170
171    /**
172     * Resolve a short company-size key to its display label using existing constants.
173     *
174     * @param  string  $key  small|medium|large
175     */
176    public static function companySizeLabel(string $key): string
177    {
178        return match (strtolower($key)) {
179            'small' => self::$COMPANY_SIZE_SMALL,
180            'medium' => self::$COMPANY_SIZE_MEDIUM,
181            'large' => self::$COMPANY_SIZE_LARGE,
182            default => $key,
183        };
184    }
185
186    protected static function newFactory()
187    {
188        return RolePlayProjectsFactory::new();
189    }
190
191    public function conversations()
192    {
193        return $this->hasMany(RolePlayConversations::class, 'project_id');
194    }
195
196    public function user()
197    {
198        return $this->belongsTo(User::class, 'user_id');
199    }
200
201    /**
202     * Get the company project this was cloned from, if any.
203     *
204     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
205     */
206    public function companyProject()
207    {
208        return $this->belongsTo(CompanyRolePlayProject::class, 'company_project_id');
209    }
210
211    /**
212     * Scope query to user-created projects only.
213     *
214     * @param  \Illuminate\Database\Eloquent\Builder  $query
215     * @return \Illuminate\Database\Eloquent\Builder
216     */
217    public function scopeUserOwned($query)
218    {
219        return $query->where(function ($q) {
220            $q->whereNull('source')->orWhere('source', 'user');
221        });
222    }
223
224    public function calculateProgression(array $feedback)
225    {
226        $project = $this;
227        $averageScore = $this->getProjectAverageScore($project->id);
228        $project->average_score = $averageScore;
229
230        $progressions = $project->progression ?? [];
231
232        foreach ($feedback['scores'] ?? [] as $score) {
233            if (empty($score['criteria']) || ! is_array($score['criteria'])) {
234                continue;
235            }
236
237            $currentProgress = collect($progressions)->first(function ($prog) use ($score) {
238                return $prog['name'] === $score['name'];
239            });
240
241            if (! $currentProgress) {
242                $progressions[] = [
243                    'name' => $score['name'],
244                    'feedback' => $score['feedback'] ?? '',
245                    'score' => max($score['score'] ?? 0, 0),
246                    'improve' => $score['improve'] ?? '',
247                    'criteria' => $score['criteria'] ?? [],
248                ];
249            } else {
250                $currentProgress['feedback'] = $score['feedback'] ?? $currentProgress['feedback'];
251                $alpha = 0.3;
252                $currentProgress['score'] = max(
253                    $alpha * ($score['score'] ?? 0) + (1 - $alpha) * $currentProgress['score'],
254                    0
255                );
256
257                foreach ($score['criteria'] as $criteria) {
258                    $currentCriteria = collect($currentProgress['criteria'] ?? [])->first(function ($crit) use ($criteria) {
259                        return $crit['name'] === $criteria['name'];
260                    });
261
262                    if (! $currentCriteria) {
263                        $currentProgress['criteria'][] = [
264                            'name' => $criteria['name'],
265                            'feedback' => $criteria['feedback'] ?? '',
266                            'score' => max($criteria['score'] ?? 0, 0),
267                        ];
268                    } else {
269                        $currentCriteria['feedback'] = $criteria['feedback'] ?? $currentCriteria['feedback'];
270                        $currentCriteria['score'] = max(
271                            $alpha * ($criteria['score'] ?? 0) + (1 - $alpha) * $currentCriteria['score'],
272                            0
273                        );
274                    }
275                }
276            }
277        }
278
279        $project->progression = $progressions;
280        $project->save();
281    }
282
283    public static function getRules(): array
284    {
285        return [
286            'name' => 'required|string|max:255',
287            'type' => ['required', 'string', Rule::in([RolePlayProjects::$COLD_CALL, RolePlayProjects::$DISCOVERY_CALL])],
288            'description' => 'nullable|string',
289            'difficulty_level' => 'required|integer|min:1|max:5',
290            'key_features' => 'nullable|array',
291            'key_features.*' => 'string|max:255',
292            'industry' => 'required|array|min:1',
293            'industry.*' => 'string|max:255',
294            'target_job_titles' => 'nullable|array',
295            'target_job_titles.*' => 'string|max:255',
296            'company_sizes' => 'required|array|min:1',
297            'company_sizes.*' => ['required', 'string', Rule::in(self::COMPANY_SIZE_KEYS)],
298            'customer_profiles_signature' => 'nullable|string|max:128',
299            'removed_default_objection_ids' => 'nullable|array',
300            'removed_default_objection_ids.*' => 'string|max:64',
301            'customer_profiles' => 'required|array|min:1',
302            'customer_profiles.*.id' => 'required|integer',
303            'customer_profiles.*.company_name' => 'required|string|max:255',
304            'customer_profiles.*.company_size' => 'required|string|max:255',
305            'customer_profiles.*.budget' => 'required|string',
306            'customer_profiles.*.decision_making' => 'required|string',
307            'customer_profiles.*.urgency_level' => ['required', 'string', Rule::in([
308                RolePlayProjects::$LEVEL_LOW,
309                RolePlayProjects::$LEVEL_MODERATE,
310                RolePlayProjects::$LEVEL_HIGH,
311                RolePlayProjects::$LEVEL_CRITICAL,
312            ])],
313            'customer_profiles.*.openess_to_new_solutions' => ['required', 'string', Rule::in([
314                RolePlayProjects::$LEVEL_LOW,
315                RolePlayProjects::$LEVEL_MODERATE,
316                RolePlayProjects::$LEVEL_HIGH,
317                RolePlayProjects::$LEVEL_CRITICAL,
318            ])],
319            'customer_profiles.*.communication_style' => 'required|string',
320            'customer_profiles.*.pain_points' => 'required|string',
321            'customer_profiles.*.current_solution' => 'required|string',
322            'customer_profiles.*.personality' => 'required',
323            'customer_profiles.*.gender' => 'required|string|in:male,female',
324            'customer_profiles.*.image' => 'required|string',
325            'customer_profiles.*.voice' => 'nullable|string',
326            'customer_profiles.*.name' => 'required|string',
327            'customer_profiles.*.target_job_title' => 'required|string|max:255',
328            'customer_profiles.*.industry' => 'nullable|string|max:255',
329            'objections' => 'required|array|min:1',
330            'objections.*.category' => 'required|string',
331            'objections.*.options' => 'required|array|min:1',
332            'objections.*.options.*' => 'required|array',
333            'objections.*.options.*.text' => 'required|string|max:2000',
334            'objections.*.options.*.company_sizes' => ['required', 'array', 'min:1'],
335            'objections.*.options.*.company_sizes.*' => ['required', 'string', Rule::in(self::COMPANY_SIZE_KEYS)],
336        ];
337    }
338
339    private function getProjectAverageScore(string $projectId): float
340    {
341        return RolePlayConversations::where('project_id', $projectId)
342            ->where('status', 'done')
343            ->avg('score') ?? 0.0;
344    }
345}