Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.52% covered (success)
97.52%
118 / 121
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
RolePlayCallTypeSettingsService
97.52% covered (success)
97.52%
118 / 121
62.50% covered (warning)
62.50%
5 / 8
31
0.00% covered (danger)
0.00%
0 / 1
 resolve
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
1 / 1
8
 resolveAll
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 listRowsForScope
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
8
 upsert
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
6
 defaultForCallType
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 assertCallType
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 assertScope
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 assertSeconds
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace App\Http\Services\RolePlay;
4
5use App\Http\Models\Auth\User;
6use App\Http\Models\RolePlayCallTypeSettings;
7use InvalidArgumentException;
8
9/**
10 * Reads and writes the per-call-type target duration settings and exposes
11 * the resolver that collapses system / company / user scopes into a single
12 * effective value for the roleplay runtime.
13 *
14 * Defaults when no row exists at any scope:
15 *  - cold-call:      300 seconds (5 minutes)
16 *  - discovery-call: 900 seconds (15 minutes)
17 */
18class RolePlayCallTypeSettingsService
19{
20    /** Hard-coded safety default for cold-call when no row exists anywhere. */
21    public const DEFAULT_COLD_CALL_SECONDS = 300;
22
23    /** Hard-coded safety default for discovery-call when no row exists anywhere. */
24    public const DEFAULT_DISCOVERY_CALL_SECONDS = 900;
25
26    /** Minimum target duration allowed anywhere in the system. */
27    public const MIN_TARGET_SECONDS = 30;
28
29    /** Maximum target duration allowed anywhere in the system. */
30    public const MAX_TARGET_SECONDS = 3600;
31
32    /**
33     * Resolve the effective target duration for a given user and call type.
34     *
35     * Precedence: user → company → system → hard-coded default. When the
36     * company row has `allow_user_override = false`, the user row is skipped
37     * and the response indicates the setting is locked.
38     *
39     * @return array{
40     *     call_type: string,
41     *     target_duration_seconds: int,
42     *     source: 'user'|'company'|'system'|'default',
43     *     can_user_override: bool
44     * }
45     */
46    public function resolve(User $user, string $callType): array
47    {
48        $this->assertCallType($callType);
49
50        $companyId = $user->company_id ? (string) $user->company_id : null;
51        $userId = (string) $user->id;
52
53        $systemRow = RolePlayCallTypeSettings::where('call_type', $callType)
54            ->where('scope', RolePlayCallTypeSettings::SCOPE_SYSTEM)
55            ->first();
56
57        $companyRow = $companyId
58            ? RolePlayCallTypeSettings::where('call_type', $callType)
59                ->where('scope', RolePlayCallTypeSettings::SCOPE_COMPANY)
60                ->where('scope_id', $companyId)
61                ->first()
62            : null;
63
64        $userRow = RolePlayCallTypeSettings::where('call_type', $callType)
65            ->where('scope', RolePlayCallTypeSettings::SCOPE_USER)
66            ->where('scope_id', $userId)
67            ->first();
68
69        $canUserOverride = $companyRow
70            ? (bool) ($companyRow->allow_user_override ?? true)
71            : true;
72
73        if ($userRow && $canUserOverride) {
74            return [
75                'call_type' => $callType,
76                'target_duration_seconds' => (int) $userRow->target_duration_seconds,
77                'source' => RolePlayCallTypeSettings::SCOPE_USER,
78                'can_user_override' => true,
79            ];
80        }
81
82        if ($companyRow) {
83            return [
84                'call_type' => $callType,
85                'target_duration_seconds' => (int) $companyRow->target_duration_seconds,
86                'source' => RolePlayCallTypeSettings::SCOPE_COMPANY,
87                'can_user_override' => $canUserOverride,
88            ];
89        }
90
91        if ($systemRow) {
92            return [
93                'call_type' => $callType,
94                'target_duration_seconds' => (int) $systemRow->target_duration_seconds,
95                'source' => RolePlayCallTypeSettings::SCOPE_SYSTEM,
96                'can_user_override' => true,
97            ];
98        }
99
100        return [
101            'call_type' => $callType,
102            'target_duration_seconds' => $this->defaultForCallType($callType),
103            'source' => 'default',
104            'can_user_override' => true,
105        ];
106    }
107
108    /**
109     * Resolve both supported call types in one call for UI convenience.
110     *
111     * @return array<int, array{
112     *     call_type: string,
113     *     target_duration_seconds: int,
114     *     source: string,
115     *     can_user_override: bool
116     * }>
117     */
118    public function resolveAll(User $user): array
119    {
120        return array_map(
121            fn (string $callType) => $this->resolve($user, $callType),
122            RolePlayCallTypeSettings::SUPPORTED_CALL_TYPES,
123        );
124    }
125
126    /**
127     * Return the raw rows for a given scope + scope_id, or null if none yet.
128     *
129     * @return array<int, array{
130     *     call_type: string,
131     *     target_duration_seconds: int,
132     *     allow_user_override: bool|null
133     * }>
134     */
135    public function listRowsForScope(string $scope, ?string $scopeId = null): array
136    {
137        $this->assertScope($scope);
138
139        $query = RolePlayCallTypeSettings::where('scope', $scope);
140
141        if ($scope === RolePlayCallTypeSettings::SCOPE_SYSTEM) {
142            $query->whereNull('scope_id');
143        } else {
144            if ($scopeId === null) {
145                throw new InvalidArgumentException("scope_id is required for scope '{$scope}'.");
146            }
147            $query->where('scope_id', $scopeId);
148        }
149
150        $rows = $query->get();
151
152        $byCallType = [];
153        foreach ($rows as $row) {
154            $byCallType[$row->call_type] = [
155                'call_type' => (string) $row->call_type,
156                'target_duration_seconds' => (int) $row->target_duration_seconds,
157                'allow_user_override' => $scope === RolePlayCallTypeSettings::SCOPE_COMPANY
158                    ? (bool) ($row->allow_user_override ?? true)
159                    : null,
160            ];
161        }
162
163        // Fill missing call types with defaults so the UI has something to
164        // render for every row rather than a sparse response.
165        $output = [];
166        foreach (RolePlayCallTypeSettings::SUPPORTED_CALL_TYPES as $callType) {
167            if (isset($byCallType[$callType])) {
168                $output[] = $byCallType[$callType];
169
170                continue;
171            }
172
173            $output[] = [
174                'call_type' => $callType,
175                'target_duration_seconds' => $this->defaultForCallType($callType),
176                'allow_user_override' => $scope === RolePlayCallTypeSettings::SCOPE_COMPANY
177                    ? true
178                    : null,
179            ];
180        }
181
182        return $output;
183    }
184
185    /**
186     * Upsert a settings row for the given scope and call type.
187     *
188     * For the user scope, this method enforces `allow_user_override` — when
189     * the resolving company row has it turned off, the write is silently
190     * ignored (callers should check `resolve()` first) and the existing
191     * company/system value is returned unchanged.
192     *
193     * @param  string|null  $scopeId  company_id for company scope, user_id
194     *                                for user scope, null for system scope.
195     */
196    public function upsert(string $scope, ?string $scopeId, string $callType, int $targetSeconds, ?bool $allowUserOverride = null): RolePlayCallTypeSettings
197    {
198        $this->assertScope($scope);
199        $this->assertCallType($callType);
200        $this->assertSeconds($targetSeconds);
201
202        if ($scope === RolePlayCallTypeSettings::SCOPE_SYSTEM) {
203            $scopeId = null;
204        } elseif ($scopeId === null || $scopeId === '') {
205            throw new InvalidArgumentException("scope_id is required for scope '{$scope}'.");
206        }
207
208        $row = RolePlayCallTypeSettings::where('call_type', $callType)
209            ->where('scope', $scope)
210            ->when(
211                $scope === RolePlayCallTypeSettings::SCOPE_SYSTEM,
212                fn ($q) => $q->whereNull('scope_id'),
213                fn ($q) => $q->where('scope_id', $scopeId),
214            )
215            ->first();
216
217        if (! $row) {
218            $row = new RolePlayCallTypeSettings;
219            $row->call_type = $callType;
220            $row->scope = $scope;
221            $row->scope_id = $scopeId;
222        }
223
224        $row->target_duration_seconds = $targetSeconds;
225
226        if ($scope === RolePlayCallTypeSettings::SCOPE_COMPANY) {
227            $row->allow_user_override = $allowUserOverride ?? true;
228        }
229
230        $row->save();
231
232        return $row;
233    }
234
235    /**
236     * Hard-coded safety default for a given call type.
237     */
238    public function defaultForCallType(string $callType): int
239    {
240        return match ($callType) {
241            RolePlayCallTypeSettings::CALL_TYPE_COLD_CALL => self::DEFAULT_COLD_CALL_SECONDS,
242            RolePlayCallTypeSettings::CALL_TYPE_DISCOVERY_CALL => self::DEFAULT_DISCOVERY_CALL_SECONDS,
243            default => self::DEFAULT_COLD_CALL_SECONDS,
244        };
245    }
246
247    private function assertCallType(string $callType): void
248    {
249        if (! in_array($callType, RolePlayCallTypeSettings::SUPPORTED_CALL_TYPES, true)) {
250            throw new InvalidArgumentException("Unsupported call type: {$callType}");
251        }
252    }
253
254    private function assertScope(string $scope): void
255    {
256        $valid = [
257            RolePlayCallTypeSettings::SCOPE_SYSTEM,
258            RolePlayCallTypeSettings::SCOPE_COMPANY,
259            RolePlayCallTypeSettings::SCOPE_USER,
260        ];
261        if (! in_array($scope, $valid, true)) {
262            throw new InvalidArgumentException("Unsupported scope: {$scope}");
263        }
264    }
265
266    private function assertSeconds(int $seconds): void
267    {
268        if ($seconds < self::MIN_TARGET_SECONDS || $seconds > self::MAX_TARGET_SECONDS) {
269            throw new InvalidArgumentException(sprintf(
270                'Target duration must be between %d and %d seconds, got %d.',
271                self::MIN_TARGET_SECONDS,
272                self::MAX_TARGET_SECONDS,
273                $seconds,
274            ));
275        }
276    }
277}