Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
RolePlayConversationsObserver
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
4 / 4
16
100.00% covered (success)
100.00%
1 / 1
 creating
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
9
 created
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 updated
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 dispatch
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace App\Observers;
4
5use App\Http\Models\RolePlayConversations;
6use App\Jobs\ProcessRolePlayUsageAsyncJob;
7use Carbon\Carbon;
8
9/**
10 * Tracks roleplay session lifecycle events and feeds the daily usage
11 * bucket + UserInfo roleplay counters once a session reaches status=done.
12 *
13 * The created/updated hooks are the consistency point: whenever a
14 * conversation is stored with (or transitioned to) status=done, the daily
15 * metrics are recomputed from source so session count, time practiced
16 * and daily average score stay in sync.
17 *
18 * Soft-deletes never trigger a recompute here — daily historical metrics
19 * are preserved because UsageTrait::getUsage() counts roleplay via
20 * withTrashed(). Hard deletes shouldn't happen for conversations.
21 */
22class RolePlayConversationsObserver
23{
24    /**
25     * Stamp `company_id` at write-time for direct-call corporate persona sessions.
26     *
27     * Direct-call sessions (where a user runs a call against a corporate
28     * persona without first cloning it) carry `company_project_id` but no
29     * `project_id`. Admin-side analytics filter on `company_id`, so we snap
30     * it in from the authenticated caller's company so the row can be
31     * aggregated alongside conventional sessions.
32     */
33    public function creating(RolePlayConversations $conversation): void
34    {
35        if (! empty($conversation->company_id)) {
36            return;
37        }
38
39        if (! empty($conversation->project_id)) {
40            return;
41        }
42
43        if (empty($conversation->company_project_id)) {
44            return;
45        }
46
47        // Prefer the authenticated caller (any guard); fall back to the
48        // conversation's user_id so async/test contexts still stamp correctly.
49        $user = auth('api')->user() ?? auth()->user();
50        if ((! $user || empty($user->company_id)) && ! empty($conversation->user_id)) {
51            $user = \App\Http\Models\Auth\User::find($conversation->user_id);
52        }
53
54        if ($user && ! empty($user->company_id)) {
55            $conversation->company_id = (string) $user->company_id;
56        }
57    }
58
59    public function created(RolePlayConversations $conversation): void
60    {
61        if ($conversation->status === 'done') {
62            $this->dispatch($conversation);
63        }
64    }
65
66    public function updated(RolePlayConversations $conversation): void
67    {
68        if (! $conversation->wasChanged('status')) {
69            return;
70        }
71
72        if ($conversation->status !== 'done') {
73            return;
74        }
75
76        $this->dispatch($conversation);
77    }
78
79    private function dispatch(RolePlayConversations $conversation): void
80    {
81        if (empty($conversation->user_id)) {
82            return;
83        }
84
85        ProcessRolePlayUsageAsyncJob::dispatch(
86            (string) $conversation->user_id,
87            Carbon::parse($conversation->created_at ?? now()),
88        );
89    }
90}