Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.52% |
118 / 121 |
|
62.50% |
5 / 8 |
CRAP | |
0.00% |
0 / 1 |
| RolePlayCallTypeSettingsService | |
97.52% |
118 / 121 |
|
62.50% |
5 / 8 |
31 | |
0.00% |
0 / 1 |
| resolve | |
100.00% |
46 / 46 |
|
100.00% |
1 / 1 |
8 | |||
| resolveAll | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| listRowsForScope | |
96.30% |
26 / 27 |
|
0.00% |
0 / 1 |
8 | |||
| upsert | |
96.00% |
24 / 25 |
|
0.00% |
0 / 1 |
6 | |||
| defaultForCallType | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| assertCallType | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| assertScope | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
2.01 | |||
| assertSeconds | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Http\Services\RolePlay; |
| 4 | |
| 5 | use App\Http\Models\Auth\User; |
| 6 | use App\Http\Models\RolePlayCallTypeSettings; |
| 7 | use 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 | */ |
| 18 | class 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 | } |