Passed
Push — master ( 6f4b10...8c675f )
by Darko
13:02
created

User   F

Complexity

Total Complexity 124

Size/Duplication

Total Lines 983
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 124
eloc 447
c 1
b 0
f 0
dl 0
loc 983
rs 2

How to fix   Complexity   

Complex Class

Complex classes like User often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use User, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace App\Models;
4
5
use App\Jobs\SendAccountExpiredEmail;
6
use App\Jobs\SendAccountWillExpireEmail;
7
use App\Rules\ValidEmailDomain;
8
use App\Services\InvitationService;
9
use Carbon\CarbonImmutable;
10
use Illuminate\Database\Eloquent\Builder;
11
use Illuminate\Database\Eloquent\Collection;
12
use Illuminate\Database\Eloquent\Model;
13
use Illuminate\Database\Eloquent\ModelNotFoundException;
14
use Illuminate\Database\Eloquent\Relations\BelongsTo;
15
use Illuminate\Database\Eloquent\Relations\HasMany;
16
use Illuminate\Database\Eloquent\Relations\HasOne;
17
use Illuminate\Database\Eloquent\SoftDeletes;
18
use Illuminate\Foundation\Auth\User as Authenticatable;
19
use Illuminate\Http\Request;
20
use Illuminate\Notifications\Notifiable;
21
use Illuminate\Support\Arr;
22
use Illuminate\Support\Carbon;
23
use Illuminate\Support\Facades\Hash;
24
use Illuminate\Support\Facades\Log;
25
use Illuminate\Support\Facades\Password;
26
use Illuminate\Support\Facades\Validator;
27
use Illuminate\Support\Str;
28
use Jrean\UserVerification\Traits\UserVerification;
29
use Spatie\Permission\Models\Role;
30
use Spatie\Permission\Traits\HasRoles;
31
32
/**
33
 * App\Models\User.
34
 *
35
 * App\Models\User.
36
 *
37
 * @property int $id
38
 * @property string $username
39
 * @property string|null $firstname
40
 * @property string|null $lastname
41
 * @property string $email
42
 * @property string $password
43
 * @property int $user_roles_id FK to roles.id
44
 * @property string|null $host
45
 * @property int $grabs
46
 * @property string $rsstoken
47
 * @property \Carbon\Carbon|null $created_at
48
 * @property \Carbon\Carbon|null $updated_at
49
 * @property string|null $resetguid
50
 * @property string|null $lastlogin
51
 * @property string|null $apiaccess
52
 * @property int $invites
53
 * @property int|null $invitedby
54
 * @property int $movieview
55
 * @property int $xxxview
56
 * @property int $musicview
57
 * @property int $consoleview
58
 * @property int $bookview
59
 * @property int $gameview
60
 * @property string|null $saburl
61
 * @property string|null $sabapikey
62
 * @property bool|null $sabapikeytype
63
 * @property bool|null $sabpriority
64
 * @property string|null $nzbgeturl
65
 * @property string|null $nzbgetusername
66
 * @property string|null $nzbgetpassword
67
 * @property string|null $nzbvortex_api_key
68
 * @property string|null $nzbvortex_server_url
69
 * @property string $notes
70
 * @property string|null $cp_url
71
 * @property string|null $cp_api
72
 * @property string|null $style
73
 * @property string|null $rolechangedate When does the role expire
74
 * @property string|null $pending_role_start_date When the pending role change takes effect
75
 * @property int|null $pending_roles_id The role that will be applied after current role expires
76
 * @property string|null $remember_token
77
 * @property-read Collection|\App\Models\ReleaseComment[] $comment
78
 * @property-read Collection|\App\Models\UserDownload[] $download
79
 * @property-read Collection|\App\Models\DnzbFailure[] $failedRelease
80
 * @property-read Collection|\App\Models\Invitation[] $invitation
81
 * @property-read \Illuminate\Notifications\DatabaseNotificationCollection|\Illuminate\NotififupdateUsertcations\DatabaseNotification[] $notifications
82
 * @property-read Collection|\App\Models\UsersRelease[] $release
83
 * @property-read Collection|\App\Models\UserRequest[] $request
84
 * @property-read Collection|\App\Models\UserSerie[] $series
85
 *
86
 * @method static Builder|\App\Models\User whereApiaccess($value)
87
 * @method static Builder|\App\Models\User whereBookview($value)
88
 * @method static Builder|\App\Models\User whereConsoleview($value)
89
 * @method static Builder|\App\Models\User whereCpApi($value)
90
 * @method static Builder|\App\Models\User whereCpUrl($value)
91
 * @method static Builder|\App\Models\User whereCreatedAt($value)
92
 * @method static Builder|\App\Models\User whereEmail($value)
93
 * @method static Builder|\App\Models\User whereFirstname($value)
94
 * @method static Builder|\App\Models\User whereGameview($value)
95
 * @method static Builder|\App\Models\User whereGrabs($value)
96
 * @method static Builder|\App\Models\User whereHost($value)
97
 * @method static Builder|\App\Models\User whereId($value)
98
 * @method static Builder|\App\Models\User whereInvitedby($value)
99
 * @method static Builder|\App\Models\User whereInvites($value)
100
 * @method static Builder|\App\Models\User whereLastlogin($value)
101
 * @method static Builder|\App\Models\User whereLastname($value)
102
 * @method static Builder|\App\Models\User whereMovieview($value)
103
 * @method static Builder|\App\Models\User whereMusicview($value)
104
 * @method static Builder|\App\Models\User whereNotes($value)
105
 * @method static Builder|\App\Models\User whereNzbgetpassword($value)
106
 * @method static Builder|\App\Models\User whereNzbgeturl($value)
107
 * @method static Builder|\App\Models\User whereNzbgetusername($value)
108
 * @method static Builder|\App\Models\User whereNzbvortexApiKey($value)
109
 * @method static Builder|\App\Models\User whereNzbvortexServerUrl($value)
110
 * @method static Builder|\App\Models\User wherePassword($value)
111
 * @method static Builder|\App\Models\User whereRememberToken($value)
112
 * @method static Builder|\App\Models\User whereResetguid($value)
113
 * @method static Builder|\App\Models\User whereRolechangedate($value)
114
 * @method static Builder|\App\Models\User whereRsstoken($value)
115
 * @method static Builder|\App\Models\User whereSabapikey($value)
116
 * @method static Builder|\App\Models\User whereSabapikeytype($value)
117
 * @method static Builder|\App\Models\User whereSabpriority($value)
118
 * @method static Builder|\App\Models\User whereSaburl($value)
119
 * @method static Builder|\App\Models\User whereStyle($value)
120
 * @method static Builder|\App\Models\User whereUpdatedAt($value)
121
 * @method static Builder|\App\Models\User whereUserRolesId($value)
122
 * @method static Builder|\App\Models\User whereUsername($value)
123
 * @method static Builder|\App\Models\User whereXxxview($value)
124
 * @method static Builder|\App\Models\User whereVerified($value)
125
 * @method static Builder|\App\Models\User whereApiToken($value)
126
 *
127
 * @mixin \Eloquent
128
 *
129
 * @property int $roles_id FK to roles.id
130
 * @property string $api_token
131
 * @property int $rate_limit
132
 * @property string|null $email_verified_at
133
 * @property int $verified
134
 * @property string|null $verification_token
135
 * @property-read Collection|\Junaidnasir\Larainvite\Models\LaraInviteModel[] $invitationPending
136
 * @property-read Collection|\Junaidnasir\Larainvite\Models\LaraInviteModel[] $invitationSuccess
137
 * @property-read Collection|\Junaidnasir\Larainvite\Models\LaraInviteModel[] $invitations
138
 * @property-read Collection|\Spatie\Permission\Models\Permission[] $permissions
139
 * @property-read Role $role
140
 * @property-read Collection|\Spatie\Permission\Models\Role[] $roles
141
 *
142
 * @method static Builder|\App\Models\User newModelQuery()
143
 * @method static Builder|\App\Models\User newQuery()
144
 * @method static Builder|\App\Models\User permission($permissions)
145
 * @method static Builder|\App\Models\User query()
146
 * @method static Builder|\App\Models\User whereEmailVerifiedAt($value)
147
 * @method static Builder|\App\Models\User whereRateLimit($value)
148
 * @method static Builder|\App\Models\User whereRolesId($value)
149
 * @method static Builder|\App\Models\User whereVerificationToken($value)
150
 */
151
class User extends Authenticatable
152
{
153
    use HasRoles, Notifiable, SoftDeletes, UserVerification;
154
155
    public const ERR_SIGNUP_BADUNAME = -1;
156
157
    public const ERR_SIGNUP_BADPASS = -2;
158
159
    public const ERR_SIGNUP_BADEMAIL = -3;
160
161
    public const ERR_SIGNUP_UNAMEINUSE = -4;
162
163
    public const ERR_SIGNUP_EMAILINUSE = -5;
164
165
    public const ERR_SIGNUP_BADINVITECODE = -6;
166
167
    public const SUCCESS = 1;
168
169
    public const ROLE_USER = 1;
170
171
    public const ROLE_ADMIN = 2;
172
173
    public const ROLE_DISABLED = 3;
174
175
    public const ROLE_MODERATOR = 4;
176
177
    /**
178
     * Users SELECT queue type.
179
     */
180
    public const QUEUE_NONE = 0;
181
182
    public const QUEUE_SABNZBD = 1;
183
184
    public const QUEUE_NZBGET = 2;
185
186
    /**
187
     * @var string
188
     */
189
190
    /**
191
     * @var bool
192
     */
193
    protected $dateFormat = false;
194
195
    /**
196
     * @var array
197
     */
198
    protected $hidden = ['remember_token', 'password'];
199
200
    /**
201
     * @var array
202
     */
203
    protected $guarded = [];
204
205
    protected function getDefaultGuardName(): string
206
    {
207
        return 'web';
208
    }
209
210
    public function role(): BelongsTo
211
    {
212
        return $this->belongsTo(Role::class, 'roles_id');
213
    }
214
215
    public function request(): HasMany
216
    {
217
        return $this->hasMany(UserRequest::class, 'users_id');
218
    }
219
220
    public function download(): HasMany
221
    {
222
        return $this->hasMany(UserDownload::class, 'users_id');
223
    }
224
225
    public function release(): HasMany
226
    {
227
        return $this->hasMany(UsersRelease::class, 'users_id');
228
    }
229
230
    public function series(): HasMany
231
    {
232
        return $this->hasMany(UserSerie::class, 'users_id');
233
    }
234
235
    public function invitation(): HasMany
236
    {
237
        return $this->hasMany(Invitation::class, 'invited_by');
238
    }
239
240
    public function failedRelease(): HasMany
241
    {
242
        return $this->hasMany(DnzbFailure::class, 'users_id');
243
    }
244
245
    public function comment(): HasMany
246
    {
247
        return $this->hasMany(ReleaseComment::class, 'users_id');
248
    }
249
250
    public function promotionStats(): HasMany
251
    {
252
        return $this->hasMany(RolePromotionStat::class);
253
    }
254
255
    public function roleHistory(): HasMany
256
    {
257
        return $this->hasMany(UserRoleHistory::class);
258
    }
259
260
    /**
261
     * Check if user has a pending stacked role
262
     */
263
    public function hasPendingRole(): bool
264
    {
265
        return $this->pending_roles_id !== null && $this->pending_role_start_date !== null;
266
    }
267
268
    /**
269
     * Get the pending role details
270
     */
271
    public function getPendingRole(): ?Role
272
    {
273
        if (!$this->hasPendingRole()) {
274
            return null;
275
        }
276
277
        return Role::find($this->pending_roles_id);
278
    }
279
280
    /**
281
     * Cancel a pending stacked role
282
     */
283
    public function cancelPendingRole(): bool
284
    {
285
        if (!$this->hasPendingRole()) {
286
            return false;
287
        }
288
289
        return $this->update([
290
            'pending_roles_id' => null,
291
            'pending_role_start_date' => null,
292
        ]);
293
    }
294
295
    /**
296
     * Get role expiry information including pending roles
297
     */
298
    public function getRoleExpiryInfo(): array
299
    {
300
        $currentRole = $this->role;
301
        $currentExpiry = $this->rolechangedate ? Carbon::parse($this->rolechangedate) : null;
302
        $pendingRole = $this->getPendingRole();
303
        $pendingStart = $this->pending_role_start_date ? Carbon::parse($this->pending_role_start_date) : null;
304
305
        return [
306
            'current_role' => $currentRole,
307
            'current_expiry' => $currentExpiry,
308
            'has_expiry' => $currentExpiry !== null,
309
            'is_expired' => $currentExpiry ? $currentExpiry->isPast() : false,
310
            'days_until_expiry' => $currentExpiry ? Carbon::now()->diffInDays($currentExpiry, false) : null,
311
            'pending_role' => $pendingRole,
312
            'pending_start' => $pendingStart,
313
            'has_pending_role' => $this->hasPendingRole(),
314
        ];
315
    }
316
317
    /**
318
     * Get the user's timezone or default to UTC
319
     */
320
    public function getTimezone(): string
321
    {
322
        return $this->timezone ?? 'UTC';
323
    }
324
325
    /**
326
     * @throws \Exception
327
     */
328
    public static function deleteUser($id): void
329
    {
330
        self::find($id)->delete();
331
    }
332
333
    public static function getCount(?string $role = null, ?string $username = '', ?string $host = '', ?string $email = '', ?string $createdFrom = '', ?string $createdTo = ''): int
334
    {
335
        $res = self::query()->withTrashed()->where('email', '<>', '[email protected]');
336
337
        if (! empty($role)) {
338
            $res->where('roles_id', $role);
339
        }
340
341
        if ($username !== '') {
342
            $res->where('username', 'like', '%'.$username.'%');
343
        }
344
345
        if ($host !== '') {
346
            $res->where('host', 'like', '%'.$host.'%');
347
        }
348
349
        if ($email !== '') {
350
            $res->where('email', 'like', '%'.$email.'%');
351
        }
352
353
        if ($createdFrom !== '') {
354
            $res->where('created_at', '>=', $createdFrom.' 00:00:00');
355
        }
356
357
        if ($createdTo !== '') {
358
            $res->where('created_at', '<=', $createdTo.' 23:59:59');
359
        }
360
361
        return $res->count(['id']);
362
    }
363
364
    public static function updateUser(int $id, string $userName, ?string $email, int $grabs, int $role, ?string $notes, int $invites, int $movieview, int $musicview, int $gameview, int $xxxview, int $consoleview, int $bookview, string $style = 'None'): int
365
    {
366
        $userName = trim($userName);
367
368
        $rateLimit = Role::query()->where('id', $role)->first();
369
370
        $sql = [
371
            'username' => $userName,
372
            'grabs' => $grabs,
373
            'roles_id' => $role,
374
            'notes' => substr($notes, 0, 255),
375
            'invites' => $invites,
376
            'movieview' => $movieview,
377
            'musicview' => $musicview,
378
            'gameview' => $gameview,
379
            'xxxview' => $xxxview,
380
            'consoleview' => $consoleview,
381
            'bookview' => $bookview,
382
            'style' => $style,
383
            'rate_limit' => $rateLimit ? $rateLimit['rate_limit'] : 60,
384
        ];
385
386
        if (! empty($email)) {
387
            $email = trim($email);
388
            $sql += ['email' => $email];
389
        }
390
391
        $user = self::find($id);
392
        $user->update($sql);
393
        $user->syncRoles([$rateLimit['name']]);
394
395
        return self::SUCCESS;
396
    }
397
398
    /**
399
     * @return User|Builder|Model|object|null
400
     */
401
    public static function getByUsername(string $userName)
402
    {
403
        return self::whereUsername($userName)->first();
404
    }
405
406
    /**
407
     * @param  string  $email
408
     * @return Model|static
409
     *
410
     */
411
    public static function getByEmail(string $email): Model|static
0 ignored issues
show
Bug introduced by
A parse error occurred: Syntax error, unexpected T_STATIC on line 411 at column 60
Loading history...
412
    {
413
        return self::whereEmail($email)->first();
414
    }
415
416
    public static function updateUserRole(int $uid, int|string $role, bool $applyPromotions = true, bool $stackRole = true, ?int $changedBy = null, ?string $originalExpiryBeforeEdits = null): bool
417
    {
418
        // Handle role parameter - can be int, numeric string, or role name
419
        if (is_numeric($role)) {
420
            $roleQuery = Role::query()->where('id', (int) $role)->first();
421
        } else {
422
            $roleQuery = Role::query()->where('name', $role)->first();
423
        }
424
425
        if (!$roleQuery) {
426
            Log::error('Role not found', ['role' => $role, 'role_type' => gettype($role)]);
427
            return false;
428
        }
429
430
        $roleName = $roleQuery->name;
431
        $user = self::find($uid);
432
433
        if (!$user) {
434
            Log::error('User not found', ['uid' => $uid]);
435
            return false;
436
        }
437
438
        $currentRoleId = $user->roles_id;
439
        // Use the original expiry from before any edits if provided, otherwise use current
440
        $oldExpiryDate = $originalExpiryBeforeEdits
441
            ? Carbon::parse($originalExpiryBeforeEdits)
442
            : ($user->rolechangedate ? Carbon::parse($user->rolechangedate) : null);
443
444
        // The current expiry (after any updates in controller) is what we use for stacking logic
445
        $currentExpiryDate = $user->rolechangedate ? Carbon::parse($user->rolechangedate) : null;
446
447
        Log::info('User current state', [
448
            'currentRoleId' => $currentRoleId,
449
            'oldExpiryDate' => $oldExpiryDate?->toDateTimeString(),
450
            'currentExpiryDate' => $currentExpiryDate?->toDateTimeString(),
451
            'oldExpiryIsFuture' => $oldExpiryDate?->isFuture(),
452
            'currentExpiryIsFuture' => $currentExpiryDate?->isFuture()
453
        ]);
454
455
        // Check if role is actually changing
456
        if ($currentRoleId === $roleQuery->id) {
457
            Log::info('Role not changing, but checking for promotions', [
458
                'applyPromotions' => $applyPromotions,
459
                'currentRoleId' => $currentRoleId
460
            ]);
461
462
            // Even if role isn't changing, apply promotions if requested
463
            if ($applyPromotions) {
464
                $promotionDays = RolePromotion::calculateAdditionalDays($roleQuery->id);
465
466
                if ($promotionDays > 0) {
467
                    Log::info('Applying promotion days to existing role', [
468
                        'promotionDays' => $promotionDays,
469
                        'currentExpiryDate' => $currentExpiryDate?->toDateTimeString()
470
                    ]);
471
472
                    // Calculate new expiry date by adding promotion days
473
                    $newExpiryDate = null;
474
                    if ($currentExpiryDate) {
475
                        // Extend from the current expiry date
476
                        $newExpiryDate = $currentExpiryDate->copy()->addDays($promotionDays);
477
                    } else {
478
                        // No expiry date, create one from now
479
                        $newExpiryDate = Carbon::now()->addDays($promotionDays);
480
                    }
481
482
                    // Update the user's expiry date
483
                    $updated = $user->update([
484
                        'rolechangedate' => $newExpiryDate,
485
                    ]);
486
487
                    Log::info('Updated expiry date with promotions', [
488
                        'updated' => $updated,
489
                        'new_expiry_date' => $newExpiryDate?->toDateTimeString()
490
                    ]);
491
492
                    // Track promotion statistics
493
                    if ($updated) {
494
                        $promotions = RolePromotion::getActivePromotions($roleQuery->id);
495
                        foreach ($promotions as $promotion) {
496
                            $promotion->trackApplication(
497
                                $user->id,
498
                                $roleQuery->id,
499
                                $currentExpiryDate,
500
                                $newExpiryDate
501
                            );
502
                        }
503
504
                        Log::info('Tracked promotion application for existing role', [
505
                            'promotions_count' => $promotions->count()
506
                        ]);
507
                    }
508
509
                    return $updated;
510
                }
511
            }
512
513
            Log::info('No promotions to apply, returning');
514
            return true; // No change needed
515
        }
516
517
        // Determine if we should stack this role change (based on CURRENT expiry, not old)
518
        $shouldStack = $stackRole && $currentExpiryDate && $currentExpiryDate->isFuture();
519
520
        Log::info('Stack decision', [
521
            'shouldStack' => $shouldStack,
522
            'stackRole' => $stackRole,
523
            'hasCurrentExpiry' => $currentExpiryDate !== null,
524
            'isFuture' => $currentExpiryDate?->isFuture()
525
        ]);
526
527
        if ($shouldStack) {
528
            Log::info('Stacking role change', [
529
                'oldExpiryDate' => $oldExpiryDate->toDateTimeString(),
530
                'currentExpiryDate' => $currentExpiryDate->toDateTimeString(),
531
                'pendingRoleStartDate' => $oldExpiryDate->toDateTimeString()
532
            ]);
533
534
            // Calculate a new expiry date for the pending role
535
            // Start with the role's base duration (addyears field converted to days)
536
            $baseDays = $roleQuery->addyears * 365;
537
            $promotionDays = 0;
538
539
            if ($applyPromotions) {
540
                $promotionDays = RolePromotion::calculateAdditionalDays($roleQuery->id);
541
            }
542
543
            $totalDays = $baseDays + $promotionDays;
544
545
            // New role will start at oldExpiryDate (original expiry date before any admin edits)
546
            // Then add the total days (base + promotion) to calculate when it will expire
547
            $newExpiryDate = $oldExpiryDate->copy()->addDays($totalDays);
548
549
            Log::info('Calculated new expiry for pending role', [
550
                'pendingRoleStartDate' => $oldExpiryDate->toDateTimeString(),
551
                'baseDays' => $baseDays,
552
                'promotionDays' => $promotionDays,
553
                'totalDays' => $totalDays,
554
                'newExpiryDate' => $newExpiryDate->toDateTimeString()
555
            ]);
556
557
            // Stack the role change - set it as pending
558
            // Use oldExpiryDate (original expiry) for when the role will start
559
            $user->update([
560
                'pending_roles_id' => $roleQuery->id,
561
                'pending_role_start_date' => $oldExpiryDate,
562
            ]);
563
564
            Log::info('Pending role updated', [
565
                'pending_roles_id' => $roleQuery->id,
566
                'pending_role_start_date' => $oldExpiryDate->toDateTimeString(),
567
                'pending_role_start_date_formatted' => $oldExpiryDate->format('Y-m-d H:i:s')
568
            ]);
569
570
            // Record in history as a stacked change
571
            // effective_date should match old_expiry_date (when the stacked role will start)
572
            try {
573
                $history = UserRoleHistory::recordRoleChange(
574
                    userId: $user->id,
575
                    oldRoleId: $currentRoleId,
576
                    newRoleId: $roleQuery->id,
577
                    oldExpiryDate: $oldExpiryDate, // When current role expires
578
                    newExpiryDate: $newExpiryDate, // When the NEW role will expire (calculated from oldExpiryDate)
579
                    effectiveDate: $oldExpiryDate, // Same as old_expiry_date - when the new role starts
580
                    isStacked: true,
581
                    changeReason: 'stacked_role_change',
582
                    changedBy: $changedBy
583
                );
584
585
                Log::info('Role history recorded for stacked change', [
586
                    'history_id' => $history->id,
587
                    'effective_date' => $history->effective_date->toDateTimeString(),
588
                    'old_expiry_date' => $history->old_expiry_date?->toDateTimeString(),
589
                    'new_expiry_date' => $history->new_expiry_date?->toDateTimeString(),
590
                    'note' => 'effective_date equals old_expiry_date (when stacked role starts)'
591
                ]);
592
            } catch (\Exception $e) {
593
                Log::error('Failed to record role history', [
594
                    'error' => $e->getMessage(),
595
                    'trace' => $e->getTraceAsString()
596
                ]);
597
            }
598
599
            return true;
600
        }
601
602
        // Apply the role change immediately
603
        // Calculate base days from role's addyears field
604
        $baseDays = $roleQuery->addyears * 365;
605
        $promotionDays = 0;
606
607
        if ($applyPromotions) {
608
            $promotionDays = RolePromotion::calculateAdditionalDays($roleQuery->id);
609
        }
610
611
        $totalDays = $baseDays + $promotionDays;
612
613
        Log::info('Applying immediate role change', [
614
            'baseDays' => $baseDays,
615
            'promotionDays' => $promotionDays,
616
            'totalDays' => $totalDays,
617
            'applyPromotions' => $applyPromotions
618
        ]);
619
620
        // Calculate new expiry date
621
        $newExpiryDate = null;
622
        if ($totalDays > 0) {
623
            // For immediate changes, start from now (not from old expiry)
624
            $baseDate = Carbon::now();
625
            $newExpiryDate = $baseDate->copy()->addDays($totalDays);
626
627
            Log::info('Calculated new expiry date', [
628
                'baseDate' => $baseDate->toDateTimeString(),
629
                'totalDays' => $totalDays,
630
                'newExpiryDate' => $newExpiryDate->toDateTimeString()
631
            ]);
632
        }
633
634
        // Update the user's role
635
        $updated = $user->update([
636
            'roles_id' => $roleQuery->id,
637
            'rolechangedate' => $newExpiryDate,
638
        ]);
639
640
        Log::info('User role updated', [
641
            'updated' => $updated,
642
            'new_roles_id' => $roleQuery->id,
643
            'new_rolechangedate' => $newExpiryDate?->toDateTimeString()
644
        ]);
645
646
        // Sync Spatie roles
647
        $user->syncRoles([$roleName]);
648
649
        // Record in history
650
        if ($updated) {
651
            try {
652
                $history = UserRoleHistory::recordRoleChange(
653
                    userId: $user->id,
654
                    oldRoleId: $currentRoleId,
655
                    newRoleId: $roleQuery->id,
656
                    oldExpiryDate: $oldExpiryDate,
657
                    newExpiryDate: $newExpiryDate,
658
                    effectiveDate: Carbon::now(),
659
                    isStacked: false,
660
                    changeReason: 'immediate_role_change',
661
                    changedBy: $changedBy
662
                );
663
664
                \Log::info('Role history recorded for immediate change', [
665
                    'history_id' => $history->id,
666
                    'effective_date' => $history->effective_date->toDateTimeString(),
667
                    'old_expiry_date' => $history->old_expiry_date?->toDateTimeString(),
668
                    'new_expiry_date' => $history->new_expiry_date?->toDateTimeString()
669
                ]);
670
            } catch (\Exception $e) {
671
                Log::error('Failed to record role history for immediate change', [
672
                    'error' => $e->getMessage(),
673
                    'trace' => $e->getTraceAsString()
674
                ]);
675
            }
676
677
            // Track promotion statistics if applicable
678
            if ($applyPromotions && $promotionDays > 0) {
679
                $promotions = RolePromotion::getActivePromotions($roleQuery->id);
680
                foreach ($promotions as $promotion) {
681
                    $promotion->trackApplication(
682
                        $user->id,
683
                        $roleQuery->id,
684
                        $oldExpiryDate,
685
                        $newExpiryDate
686
                    );
687
                }
688
            }
689
        }
690
691
        return $updated;
692
    }
693
    public static function updateExpiredRoles(): void
694
    {
695
        $now = CarbonImmutable::now();
696
        $period = [
697
            'day' => $now->addDay(),
698
            'week' => $now->addWeek(),
699
            'month' => $now->addMonth(),
700
        ];
701
702
        foreach ($period as $value) {
703
            $users = self::query()->whereDate('rolechangedate', '=', $value)->get();
704
            $days = $now->diffInDays($value, true);
705
            foreach ($users as $user) {
706
                SendAccountWillExpireEmail::dispatch($user, $days)->onQueue('emails');
707
            }
708
        }
709
710
        // Process expired roles
711
        foreach (self::query()->whereDate('rolechangedate', '<', $now)->get() as $expired) {
712
            $oldRoleId = $expired->roles_id;
713
            $oldExpiryDate = $expired->rolechangedate ? Carbon::parse($expired->rolechangedate) : null;
714
715
            // Check if there's a pending stacked role
716
            if ($expired->pending_roles_id && $expired->pending_role_start_date) {
717
                $pendingStartDate = Carbon::parse($expired->pending_role_start_date);
718
719
                // If the pending role should start now or earlier
720
                if ($pendingStartDate->lte($now)) {
721
                    // Apply the pending role
722
                    $newRoleId = $expired->pending_roles_id;
723
                    $roleQuery = Role::query()->where('id', $newRoleId)->first();
724
725
                    if ($roleQuery) {
726
                        // Calculate base days from role's addyears field
727
                        $baseDays = $roleQuery->addyears * 365;
728
                        $promotionDays = RolePromotion::calculateAdditionalDays($newRoleId);
729
                        $totalDays = $baseDays + $promotionDays;
730
731
                        // Calculate new expiry date from now (when the role is being activated)
732
                        $newExpiryDate = $totalDays > 0 ? $now->addDays($totalDays) : null;
733
734
                        // Update user with pending role
735
                        $expired->update([
736
                            'roles_id' => $newRoleId,
737
                            'rolechangedate' => $newExpiryDate,
738
                            'pending_roles_id' => null,
739
                            'pending_role_start_date' => null,
740
                        ]);
741
                        $expired->syncRoles([$roleQuery->name]);
742
743
                        // Record in history
744
                        UserRoleHistory::recordRoleChange(
745
                            userId: $expired->id,
746
                            oldRoleId: $oldRoleId,
747
                            newRoleId: $newRoleId,
748
                            oldExpiryDate: $oldExpiryDate,
749
                            newExpiryDate: $newExpiryDate,
750
                            effectiveDate: Carbon::instance($now),
751
                            isStacked: true,
752
                            changeReason: 'stacked_role_activated',
753
                            changedBy: null
754
                        );
755
756
                        continue; // Skip the default expiry handling
757
                    }
758
                }
759
            }
760
761
            // Default behavior: downgrade to User role
762
            $expired->update([
763
                'roles_id' => self::ROLE_USER,
764
                'rolechangedate' => null,
765
                'pending_roles_id' => null,
766
                'pending_role_start_date' => null,
767
            ]);
768
            $expired->syncRoles(['User']);
769
770
            // Record in history
771
            UserRoleHistory::recordRoleChange(
772
                userId: $expired->id,
773
                oldRoleId: $oldRoleId,
774
                newRoleId: self::ROLE_USER,
775
                oldExpiryDate: $oldExpiryDate,
776
                newExpiryDate: null,
777
                effectiveDate: Carbon::instance($now),
778
                isStacked: false,
779
                changeReason: 'role_expired',
780
                changedBy: null
781
            );
782
783
            SendAccountExpiredEmail::dispatch($expired)->onQueue('emails');
784
        }
785
    }
786
787
    /**
788
     * @throws \Throwable
789
     */
790
    public static function getRange($start, $offset, $orderBy, ?string $userName = '', ?string $email = '', ?string $host = '', ?string $role = '', bool $apiRequests = false, ?string $createdFrom = '', ?string $createdTo = ''): Collection
791
    {
792
        if ($apiRequests) {
793
            UserRequest::clearApiRequests(false);
794
            $query = "
795
				SELECT users.*, roles.name AS rolename, COUNT(user_requests.id) AS apirequests
796
				FROM users
797
				INNER JOIN roles ON roles.id = users.roles_id
798
				LEFT JOIN user_requests ON user_requests.users_id = users.id
799
				WHERE users.id != 0 %s %s %s %s %s %s
800
				AND email != '[email protected]'
801
				GROUP BY users.id
802
				ORDER BY %s %s %s ";
803
        } else {
804
            $query = '
805
				SELECT users.*, roles.name AS rolename
806
				FROM users
807
				INNER JOIN roles ON roles.id = users.roles_id
808
				WHERE 1=1 %s %s %s %s %s %s
809
				ORDER BY %s %s %s';
810
        }
811
        $order = self::getBrowseOrder($orderBy);
812
813
        return self::fromQuery(
814
            sprintf(
815
                $query,
816
                ! empty($userName) ? 'AND users.username '.'LIKE '.escapeString('%'.$userName.'%') : '',
817
                ! empty($email) ? 'AND users.email '.'LIKE '.escapeString('%'.$email.'%') : '',
818
                ! empty($host) ? 'AND users.host '.'LIKE '.escapeString('%'.$host.'%') : '',
819
                (! empty($role) ? ('AND users.roles_id = '.$role) : ''),
820
                ! empty($createdFrom) ? 'AND users.created_at >= '.escapeString($createdFrom.' 00:00:00') : '',
821
                ! empty($createdTo) ? 'AND users.created_at <= '.escapeString($createdTo.' 23:59:59') : '',
822
                $order[0],
823
                $order[1],
824
                ($start === false ? '' : ('LIMIT '.$offset.' OFFSET '.$start))
825
            )
826
        );
827
    }
828
829
    /**
830
     * Get sort types for sorting users on the web page user list.
831
     *
832
     * @return string[]
833
     */
834
    public static function getBrowseOrder($orderBy): array
835
    {
836
        $order = (empty($orderBy) ? 'username_desc' : $orderBy);
837
        $orderArr = explode('_', $order);
838
        $orderField = match ($orderArr[0]) {
839
            'email' => 'email',
840
            'host' => 'host',
841
            'createdat' => 'created_at',
842
            'lastlogin' => 'lastlogin',
843
            'apiaccess' => 'apiaccess',
844
            'grabs' => 'grabs',
845
            'role' => 'rolename',
846
            'rolechangedate' => 'rolechangedate',
847
            'verification' => 'verified',
848
            default => 'username',
849
        };
850
        $orderSort = (isset($orderArr[1]) && preg_match('/^asc|desc$/i', $orderArr[1])) ? $orderArr[1] : 'desc';
851
852
        return [$orderField, $orderSort];
853
    }
854
855
    /**
856
     * Verify a password against a hash.
857
     *
858
     * Automatically update the hash if it needs to be.
859
     *
860
     * @param  string  $password  Password to check against hash.
861
     * @param  bool|string  $hash  Hash to check against password.
862
     * @param  int  $userID  ID of the user.
863
     */
864
    public static function checkPassword(string $password, bool|string $hash, int $userID = -1): bool
865
    {
866
        if (Hash::check($password, $hash) === false) {
867
            return false;
868
        }
869
870
        // Update the hash if it needs to be.
871
        if (is_numeric($userID) && $userID > 0 && Hash::needsRehash($hash)) {
872
            $hash = self::hashPassword($password);
873
874
            if ($hash !== false) {
875
                self::find($userID)->update(['password' => $hash]);
876
            }
877
        }
878
879
        return true;
880
    }
881
882
    public static function updateRssKey($uid): int
883
    {
884
        self::find($uid)->update(['api_token' => md5(Password::getRepository()->createNewToken())]);
885
886
        return self::SUCCESS;
887
    }
888
889
    public static function updatePassResetGuid($id, $guid): int
890
    {
891
        self::find($id)->update(['resetguid' => $guid]);
892
893
        return self::SUCCESS;
894
    }
895
896
    public static function updatePassword(int $id, string $password): int
897
    {
898
        self::find($id)->update(['password' => self::hashPassword($password)]);
899
900
        return self::SUCCESS;
901
    }
902
903
    public static function updateUserRoleChangeDate(int $id, string $roleChangeDate): int
904
    {
905
        self::find($id)->update(['rolechangedate' => $roleChangeDate]);
906
907
        return self::SUCCESS;
908
    }
909
910
    public static function hashPassword($password): string
911
    {
912
        return Hash::make($password);
913
    }
914
915
    /**
916
     * @return Model|static
917
     *
918
     * @throws ModelNotFoundException
919
     */
920
    public static function getByPassResetGuid(string $guid)
921
    {
922
        return self::whereResetguid($guid)->first();
923
    }
924
925
    public static function incrementGrabs(int $id, int $num = 1): void
926
    {
927
        self::find($id)->increment('grabs', $num);
928
    }
929
930
    /**
931
     * @return Model|null|static
932
     */
933
    public static function getByRssToken(string $rssToken)
934
    {
935
        return self::whereApiToken($rssToken)->first();
936
    }
937
938
    public static function isValidUrl($url): bool
939
    {
940
        return (! preg_match('/^(http|https|ftp):\/\/([A-Z0-9][A-Z0-9_-]*(?:\.[A-Z0-9][A-Z0-9_-]*)+):?(\d+)?\/?/i', $url)) ? false : true;
941
    }
942
943
    /**
944
     * @throws \Exception
945
     */
946
    public static function generatePassword(int $length = 15): string
947
    {
948
        return Str::password($length);
949
    }
950
951
    /**
952
     * @throws \Exception
953
     */
954
    public static function signUp($userName, $password, $email, $host, $notes, int $invites = Invitation::DEFAULT_INVITES, string $inviteCode = '', bool $forceInviteMode = false, int $role = self::ROLE_USER, bool $validate = true): bool|int|string
955
    {
956
        $user = [
957
            'username' => trim($userName),
958
            'password' => trim($password),
959
            'email' => trim($email),
960
        ];
961
962
        if ($validate) {
963
            $validator = Validator::make($user, [
964
                'username' => ['required', 'string', 'min:5', 'max:255', 'unique:users'],
965
                'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new ValidEmailDomain()],
966
                'password' => ['required', 'string', 'min:8', 'confirmed', 'regex:/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$/'],
967
            ]);
968
969
            if ($validator->fails()) {
970
                return implode('', Arr::collapse($validator->errors()->toArray()));
971
            }
972
        }
973
974
        // Make sure this is the last check, as if a further validation check failed, the invite would still have been used up.
975
        $invitedBy = 0;
976
        if (! $forceInviteMode && (int) Settings::settingValue('registerstatus') === Settings::REGISTER_STATUS_INVITE) {
977
            if ($inviteCode === '') {
978
                return self::ERR_SIGNUP_BADINVITECODE;
979
            }
980
981
            $invitedBy = self::checkAndUseInvite($inviteCode);
982
            if ($invitedBy < 0) {
983
                return self::ERR_SIGNUP_BADINVITECODE;
984
            }
985
        }
986
987
        return self::add($user['username'], $user['password'], $user['email'], $role, $notes, $host, $invites, $invitedBy);
988
    }
989
990
    /**
991
     * If a invite is used, decrement the person who invited's invite count.
992
     */
993
    public static function checkAndUseInvite(string $inviteCode): int
994
    {
995
        $invite = Invitation::findValidByToken($inviteCode);
996
        if (! $invite) {
997
            return -1;
998
        }
999
1000
        self::query()->where('id', $invite->invited_by)->decrement('invites');
1001
        $invite->markAsUsed(0); // Will be updated with actual user ID later
1002
1003
        return $invite->invited_by;
1004
    }
1005
1006
    /**
1007
     * @return false|int|mixed
1008
     */
1009
    public static function add(string $userName, string $password, string $email, int $role, ?string $notes = '', string $host = '', int $invites = Invitation::DEFAULT_INVITES, int $invitedBy = 0)
1010
    {
1011
        $password = self::hashPassword($password);
1012
        if (! $password) {
1013
            return false;
1014
        }
1015
1016
        $storeips = config('nntmux:settings.store_user_ip') === true ? $host : '';
1017
1018
        $user = self::create(
1019
            [
1020
                'username' => $userName,
1021
                'password' => $password,
1022
                'email' => $email,
1023
                'host' => $storeips,
1024
                'roles_id' => $role,
1025
                'invites' => $invites,
1026
                'invitedby' => (int) $invitedBy === 0 ? null : $invitedBy,
1027
                'notes' => $notes,
1028
            ]
1029
        );
1030
1031
        return $user->id;
1032
    }
1033
1034
    /**
1035
     * Get the list of categories the user has excluded.
1036
     *
1037
     * @param  int  $userID  ID of the user.
1038
     *
1039
     * @throws \Exception
1040
     */
1041
    public static function getCategoryExclusionById(int $userID): array
1042
    {
1043
        $ret = [];
1044
1045
        $user = self::find($userID);
1046
1047
        $userAllowed = $user->getDirectPermissions()->pluck('name')->toArray();
1048
        $roleAllowed = $user->getAllPermissions()->pluck('name')->toArray();
1049
1050
        $allowed = array_intersect($roleAllowed, $userAllowed);
1051
1052
        $cats = ['view console', 'view movies', 'view audio', 'view tv', 'view pc', 'view adult', 'view books', 'view other'];
1053
1054
        if (! empty($allowed)) {
1055
            foreach ($cats as $cat) {
1056
                if (! \in_array($cat, $allowed, false)) {
1057
                    $ret[] = match ($cat) {
1058
                        'view console' => 1000,
1059
                        'view movies' => 2000,
1060
                        'view audio' => 3000,
1061
                        'view pc' => 4000,
1062
                        'view tv' => 5000,
1063
                        'view adult' => 6000,
1064
                        'view books' => 7000,
1065
                        'view other' => 1,
1066
                    };
1067
                }
1068
            }
1069
        }
1070
1071
        return Category::query()->whereIn('root_categories_id', $ret)->pluck('id')->toArray();
1072
    }
1073
1074
    /**
1075
     * @throws \Exception
1076
     */
1077
    public static function getCategoryExclusionForApi(Request $request): array
1078
    {
1079
        $apiToken = $request->has('api_token') ? $request->input('api_token') : $request->input('apikey');
1080
        $user = self::getByRssToken($apiToken);
1081
1082
        return self::getCategoryExclusionById($user->id);
1083
    }
1084
1085
    /**
1086
     * @throws \Exception
1087
     */
1088
    public static function sendInvite($serverUrl, $uid, $emailTo): string
1089
    {
1090
        $user = self::find($uid);
1091
1092
        // Create invitation using our custom system
1093
        $invitation = Invitation::createInvitation($emailTo, $user->id);
1094
        $url = $serverUrl.'/register?token='.$invitation->token;
1095
1096
        // Send invitation email
1097
        $invitationService = app(InvitationService::class);
1098
        $invitationService->sendInvitationEmail($invitation);
1099
1100
        return $url;
1101
    }
1102
1103
    /**
1104
     * Deletes users that have not verified their accounts for 3 or more days.
1105
     */
1106
    public static function deleteUnVerified(): void
1107
    {
1108
        static::whereVerified(0)->where('created_at', '<', now()->subDays(3))->delete();
1109
    }
1110
1111
    public function passwordSecurity(): HasOne
1112
    {
1113
        return $this->hasOne(PasswordSecurity::class);
1114
    }
1115
1116
    public static function canPost($user_id): bool
1117
    {
1118
        // return true if can_post column is true and false if can_post column is false
1119
        return self::where('id', $user_id)->value('can_post');
1120
    }
1121
}
1122