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

User::signUp()   B

Complexity

Conditions 7
Paths 9

Size

Total Lines 34
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 19
dl 0
loc 34
rs 8.8333
c 0
b 0
f 0
cc 7
nc 9
nop 10

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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