Passed
Push — master ( 5aa5e3...07d8b3 )
by Darko
09:19
created

InvitationService   A

Complexity

Total Complexity 22

Size/Duplication

Total Lines 247
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 22
eloc 107
dl 0
loc 247
rs 10
c 1
b 0
f 0

11 Methods

Rating   Name   Duplication   Size   Complexity  
A getUserInvitationDetails() 0 41 2
A getUserInvitationStats() 0 7 1
A sendInvitationEmail() 0 3 1
A getUserInvitations() 0 12 1
A cleanupExpiredInvitations() 0 3 1
A getInvitationPreview() 0 16 2
A acceptInvitation() 0 26 3
A createAndSendInvitation() 0 50 5
A cancelInvitation() 0 11 2
A resendInvitation() 0 11 2
A canUserSendInvitation() 0 9 2
1
<?php
2
3
namespace App\Services;
4
5
use App\Mail\InvitationMail;
6
use App\Models\Invitation;
7
use App\Models\User;
8
use Illuminate\Support\Facades\Mail;
9
use Illuminate\Support\Facades\Validator;
10
use Illuminate\Validation\ValidationException;
11
12
class InvitationService
13
{
14
    /**
15
     * Create and send an invitation
16
     */
17
    public function createAndSendInvitation(
18
        string $email,
19
        int $invitedBy,
20
        int $expiryDays = Invitation::DEFAULT_INVITE_EXPIRY_DAYS,
21
        array $metadata = []
22
    ): Invitation {
23
        // Get the user sending the invitation
24
        $user = User::find($invitedBy);
25
        if (! $user) {
26
            throw new \Exception('User not found.');
27
        }
28
29
        // Calculate how many invites are currently "in use" (pending/active)
30
        $activeInvitations = Invitation::where('invited_by', $invitedBy)
31
            ->where('is_active', true)
32
            ->where('used_at', null)
33
            ->where('expires_at', '>', now())
34
            ->count();
35
36
        // Check if user has invites available (total invites - active pending invitations)
37
        $availableInvites = $user->invites - $activeInvitations;
38
        if ($availableInvites <= 0) {
39
            throw new \Exception('You have no invitations available. You have '.$activeInvitations.' pending invitation(s). Contact an administrator if you need more invitations.');
40
        }
41
42
        // Validate email
43
        $validator = Validator::make(['email' => $email], [
44
            'email' => 'required|email|unique:users,email',
45
        ]);
46
47
        if ($validator->fails()) {
48
            throw new ValidationException($validator);
49
        }
50
51
        // Check if there's already a valid invitation for this email
52
        $existingInvitation = Invitation::where('email', strtolower(trim($email)))
53
            ->valid()
54
            ->first();
55
56
        if ($existingInvitation) {
57
            throw new \Exception('A valid invitation already exists for this email address.');
58
        }
59
60
        // Create the invitation (don't decrement invites here - only when used)
61
        $invitation = Invitation::createInvitation($email, $invitedBy, $expiryDays, $metadata);
62
63
        // Send the invitation email
64
        $this->sendInvitationEmail($invitation);
65
66
        return $invitation;
67
    }
68
69
    /**
70
     * Send invitation email
71
     */
72
    public function sendInvitationEmail(Invitation $invitation): void
73
    {
74
        Mail::to($invitation->email)->send(new InvitationMail($invitation));
75
    }
76
77
    /**
78
     * Resend an invitation
79
     */
80
    public function resendInvitation(int $invitationId): Invitation
81
    {
82
        $invitation = Invitation::findOrFail($invitationId);
83
84
        if (! $invitation->isValid()) {
85
            throw new \Exception('Cannot resend an invalid invitation.');
86
        }
87
88
        $this->sendInvitationEmail($invitation);
0 ignored issues
show
Bug introduced by
It seems like $invitation can also be of type Illuminate\Database\Eloq...gHasThroughRelationship; however, parameter $invitation of App\Services\InvitationS...::sendInvitationEmail() does only seem to accept App\Models\Invitation, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

88
        $this->sendInvitationEmail(/** @scrutinizer ignore-type */ $invitation);
Loading history...
89
90
        return $invitation;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $invitation could return the type Illuminate\Database\Eloq...gHasThroughRelationship which is incompatible with the type-hinted return App\Models\Invitation. Consider adding an additional type-check to rule them out.
Loading history...
91
    }
92
93
    /**
94
     * Cancel an invitation
95
     */
96
    public function cancelInvitation(int $invitationId): bool
97
    {
98
        $invitation = Invitation::findOrFail($invitationId);
99
100
        if ($invitation->isUsed()) {
101
            throw new \Exception('Cannot cancel a used invitation.');
102
        }
103
104
        $invitation->is_active = false;
0 ignored issues
show
Bug introduced by
The property is_active does not seem to exist on Illuminate\Database\Eloq...gHasThroughRelationship.
Loading history...
105
106
        return $invitation->save();
107
    }
108
109
    /**
110
     * Accept an invitation (use it for registration)
111
     */
112
    public function acceptInvitation(string $token, array $userData): User
113
    {
114
        $invitation = Invitation::findValidByToken($token);
115
116
        if (! $invitation) {
117
            throw new \Exception('Invalid or expired invitation token.');
118
        }
119
120
        // Get the user who sent the invitation
121
        $inviter = User::find($invitation->invited_by);
122
123
        // Create the user
124
        $user = User::create(array_merge($userData, [
125
            'email' => $invitation->email,
126
            'invited_by' => $invitation->invited_by,
127
        ]));
128
129
        // Mark invitation as used
130
        $invitation->markAsUsed($user->id);
131
132
        // Now decrement the inviter's available invites (only when actually used)
133
        if ($inviter) {
134
            $inviter->decrement('invites');
135
        }
136
137
        return $user;
138
    }
139
140
    /**
141
     * Get invitation statistics for a user
142
     */
143
    public function getUserInvitationStats(int $userId): array
144
    {
145
        return [
146
            'sent' => Invitation::where('invited_by', $userId)->count(),
147
            'pending' => Invitation::where('invited_by', $userId)->valid()->count(),
148
            'used' => Invitation::where('invited_by', $userId)->used()->count(),
149
            'expired' => Invitation::where('invited_by', $userId)->expired()->count(),
150
        ];
151
    }
152
153
    /**
154
     * Get all invitations for a user
155
     */
156
    public function getUserInvitations(int $userId, ?string $status = null)
157
    {
158
        $query = Invitation::where('invited_by', $userId)
159
            ->with(['usedBy'])
160
            ->orderBy('created_at', 'desc');
161
162
        return match ($status) {
163
            'valid' => $query->valid()->paginate(15),
164
            'used' => $query->used()->paginate(15),
165
            'expired' => $query->expired()->paginate(15),
166
            'pending' => $query->unused()->paginate(15),
167
            default => $query->paginate(15),
168
        };
169
    }
170
171
    /**
172
     * Clean up expired invitations
173
     */
174
    public function cleanupExpiredInvitations(): int
175
    {
176
        return Invitation::cleanupExpired();
177
    }
178
179
    /**
180
     * Check if a user can send more invitations
181
     */
182
    public function canUserSendInvitation(int $userId, ?int $maxInvitations = null): bool
183
    {
184
        if ($maxInvitations === null) {
185
            return true; // No limit set
186
        }
187
188
        $sentCount = Invitation::where('invited_by', $userId)->count();
189
190
        return $sentCount < $maxInvitations;
191
    }
192
193
    /**
194
     * Get invitation by token for preview
195
     */
196
    public function getInvitationPreview(string $token): ?array
197
    {
198
        $invitation = Invitation::findByToken($token);
199
200
        if (! $invitation) {
0 ignored issues
show
introduced by
$invitation is of type App\Models\Invitation, thus it always evaluated to true.
Loading history...
201
            return null;
202
        }
203
204
        return [
205
            'email' => $invitation->email,
206
            'invited_by' => $invitation->invitedBy->username ?? 'Unknown',
207
            'expires_at' => $invitation->expires_at,
208
            'is_valid' => $invitation->isValid(),
209
            'is_expired' => $invitation->isExpired(),
210
            'is_used' => $invitation->isUsed(),
211
            'metadata' => $invitation->metadata,
212
        ];
213
    }
214
215
    /**
216
     * Get detailed invitation information for a user (for debugging)
217
     */
218
    public function getUserInvitationDetails(int $userId): array
219
    {
220
        $user = User::find($userId);
221
        if (! $user) {
222
            return [];
223
        }
224
225
        $totalInvites = $user->invites;
226
227
        $activeInvitations = Invitation::where('invited_by', $userId)
228
            ->where('is_active', true)
229
            ->where('used_at', null)
230
            ->where('expires_at', '>', now())
231
            ->count();
232
233
        $usedInvitations = Invitation::where('invited_by', $userId)
234
            ->whereNotNull('used_at')
235
            ->count();
236
237
        $expiredInvitations = Invitation::where('invited_by', $userId)
238
            ->where('expires_at', '<=', now())
239
            ->where('used_at', null)
240
            ->count();
241
242
        $cancelledInvitations = Invitation::where('invited_by', $userId)
243
            ->where('is_active', false)
244
            ->where('used_at', null)
245
            ->count();
246
247
        $availableInvites = $totalInvites - $activeInvitations;
248
249
        return [
250
            'user_id' => $userId,
251
            'username' => $user->username,
252
            'total_invites' => $totalInvites,
253
            'active_pending' => $activeInvitations,
254
            'used_invitations' => $usedInvitations,
255
            'expired_invitations' => $expiredInvitations,
256
            'cancelled_invitations' => $cancelledInvitations,
257
            'calculated_available' => $availableInvites,
258
            'can_send_invite' => $availableInvites > 0,
259
        ];
260
    }
261
}
262