Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 194
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
EmployeeInvitationManager
0.00% covered (danger)
0.00%
0 / 194
0.00% covered (danger)
0.00%
0 / 11
870
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 createInvitation
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
30
 validateToken
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
20
 markUsed
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getPendingInvitations
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 getPendingInvitationsForEmployee
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 revokeInvitation
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 resendInvitation
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 cleanupExpired
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 generateToken
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 sendInvitationEmail
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
56
1<?php
2
3namespace BuyerKiosk\UserEmployee;
4
5use PDO;
6use Exception;
7use DateTime;
8
9/**
10 * Manages employee invitations for self-registration
11 *
12 * This class handles creating, validating, and managing invitations that allow
13 * employees to set up their own uf_user accounts.
14 */
15class EmployeeInvitationManager
16{
17    private PDO $centralDb;
18
19    /**
20     * Constructor
21     *
22     * @param PDO $centralDb Connection to kiosk_users database
23     */
24    public function __construct(PDO $centralDb)
25    {
26        $this->centralDb = $centralDb;
27    }
28
29    /**
30     * Create an invitation for an employee to set up their account
31     *
32     * @param string $typeNum Store identifier
33     * @param int $employeeId Employee ID from employees table
34     * @param string $email Email address to send invitation to
35     * @param int $invitedBy User ID who is creating the invitation
36     * @param array $defaultGroups Array of group IDs to assign to new user
37     * @param int $expiresInHours Number of hours until invitation expires (default 72)
38     * @return array Array with 'invitation' (EmployeeInvitation) and 'url' (string) keys
39     * @throws Exception If invitation creation fails
40     */
41    public function createInvitation(
42        string $typeNum,
43        int $employeeId,
44        string $email,
45        int $invitedBy,
46        array $defaultGroups = [],
47        int $expiresInHours = 72
48    ): array {
49        // Check if employee already has a linked user account
50        $linkManager = new UserEmployeeLinkManager($this->centralDb);
51        if ($linkManager->hasLinkedUser($typeNum, $employeeId)) {
52            throw new Exception("Employee already has a linked user account");
53        }
54
55        // Check if there's already a pending invitation for this employee
56        $existing = $this->getPendingInvitationsForEmployee($typeNum, $employeeId);
57        if (!empty($existing)) {
58            throw new Exception("Employee already has a pending invitation");
59        }
60
61        // Get employee data
62        $storeDb = dbConnectByName('kiosk_' . $typeNum);
63        $empStmt = $storeDb->prepare(
64            "SELECT * FROM employees WHERE employeeID = :employeeId"
65        );
66        $empStmt->bindValue(':employeeId', $employeeId, PDO::PARAM_INT);
67        $empStmt->execute();
68        $employee = $empStmt->fetch(PDO::FETCH_ASSOC);
69
70        if (!$employee) {
71            throw new Exception("Employee not found");
72        }
73
74        // Generate secure token
75        $token = $this->generateToken();
76
77        // Calculate expiration
78        $expiresAt = new DateTime();
79        $expiresAt->modify("+{$expiresInHours} hours");
80
81        // Create invitation record
82        $stmt = $this->centralDb->prepare(
83            "INSERT INTO employee_invitations
84             (typeNum, employeeId, email, token, invitedBy, defaultGroups, expiresAt)
85             VALUES (:typeNum, :employeeId, :email, :token, :invitedBy, :defaultGroups, :expiresAt)"
86        );
87        $stmt->bindValue(':typeNum', $typeNum, PDO::PARAM_STR);
88        $stmt->bindValue(':employeeId', $employeeId, PDO::PARAM_INT);
89        $stmt->bindValue(':email', $email, PDO::PARAM_STR);
90        $stmt->bindValue(':token', $token, PDO::PARAM_STR);
91        $stmt->bindValue(':invitedBy', $invitedBy, PDO::PARAM_INT);
92        $stmt->bindValue(':defaultGroups', json_encode($defaultGroups), PDO::PARAM_STR);
93        $stmt->bindValue(':expiresAt', $expiresAt->format('Y-m-d H:i:s'), PDO::PARAM_STR);
94        $stmt->execute();
95
96        $invitationId = (int) $this->centralDb->lastInsertId();
97
98        // Get the created invitation
99        $getStmt = $this->centralDb->prepare(
100            "SELECT * FROM employee_invitations WHERE id = :id"
101        );
102        $getStmt->bindValue(':id', $invitationId, PDO::PARAM_INT);
103        $getStmt->execute();
104        $invitationData = $getStmt->fetch(PDO::FETCH_ASSOC);
105
106        $invitation = EmployeeInvitation::fromRow($invitationData);
107
108        // Generate invitation URL
109        $baseUrl = isset($_ENV['BASE_URL']) ? $_ENV['BASE_URL'] : 'https://buyerkiosk.com';
110        $url = $baseUrl . '/invite/' . $token;
111
112        // Send invitation email
113        $this->sendInvitationEmail($invitation, $employee, $typeNum);
114
115        return [
116            'invitation' => $invitation,
117            'url' => $url
118        ];
119    }
120
121    /**
122     * Validate an invitation token
123     *
124     * @param string $token Invitation token
125     * @return array|null Invitation data with employee info, or null if invalid
126     */
127    public function validateToken(string $token): ?array
128    {
129        $stmt = $this->centralDb->prepare(
130            "SELECT * FROM employee_invitations WHERE token = :token"
131        );
132        $stmt->bindValue(':token', $token, PDO::PARAM_STR);
133        $stmt->execute();
134        $invitationData = $stmt->fetch(PDO::FETCH_ASSOC);
135
136        if (!$invitationData) {
137            return null;
138        }
139
140        $invitation = EmployeeInvitation::fromRow($invitationData);
141
142        // Check if valid
143        if (!$invitation->isValid()) {
144            return null;
145        }
146
147        // Get employee data
148        $storeDb = dbConnectByName('kiosk_' . $invitation->getTypeNum());
149        $empStmt = $storeDb->prepare(
150            "SELECT * FROM employees WHERE employeeID = :employeeId"
151        );
152        $empStmt->bindValue(':employeeId', $invitation->getEmployeeId(), PDO::PARAM_INT);
153        $empStmt->execute();
154        $employee = $empStmt->fetch(PDO::FETCH_ASSOC);
155
156        if (!$employee) {
157            return null;
158        }
159
160        // Get store name
161        $storeStmt = $this->centralDb->prepare(
162            "SELECT * FROM stores WHERE typeNum = :typeNum"
163        );
164        $storeStmt->bindValue(':typeNum', $invitation->getTypeNum(), PDO::PARAM_STR);
165        $storeStmt->execute();
166        $store = $storeStmt->fetch(PDO::FETCH_ASSOC);
167
168        return [
169            'invitation' => $invitation->toArray(),
170            'employee' => $employee,
171            'store' => $store
172        ];
173    }
174
175    /**
176     * Mark invitation as used
177     *
178     * @param string $token Invitation token
179     * @return bool True if marked successfully
180     */
181    public function markUsed(string $token): bool
182    {
183        $stmt = $this->centralDb->prepare(
184            "UPDATE employee_invitations SET usedAt = NOW() WHERE token = :token"
185        );
186        $stmt->bindValue(':token', $token, PDO::PARAM_STR);
187        return $stmt->execute();
188    }
189
190    /**
191     * Get pending invitations for a store
192     *
193     * @param string $typeNum Store identifier
194     * @return array Array of EmployeeInvitation objects
195     */
196    public function getPendingInvitations(string $typeNum): array
197    {
198        $stmt = $this->centralDb->prepare(
199            "SELECT * FROM employee_invitations
200             WHERE typeNum = :typeNum
201             AND usedAt IS NULL
202             AND expiresAt > NOW()
203             ORDER BY createdAt DESC"
204        );
205        $stmt->bindValue(':typeNum', $typeNum, PDO::PARAM_STR);
206        $stmt->execute();
207        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
208
209        $invitations = [];
210        foreach ($rows as $row) {
211            $invitations[] = EmployeeInvitation::fromRow($row);
212        }
213
214        return $invitations;
215    }
216
217    /**
218     * Get pending invitations for a specific employee
219     *
220     * @param string $typeNum Store identifier
221     * @param int $employeeId Employee ID
222     * @return array Array of EmployeeInvitation objects
223     */
224    public function getPendingInvitationsForEmployee(string $typeNum, int $employeeId): array
225    {
226        $stmt = $this->centralDb->prepare(
227            "SELECT * FROM employee_invitations
228             WHERE typeNum = :typeNum
229             AND employeeId = :employeeId
230             AND usedAt IS NULL
231             AND expiresAt > NOW()
232             ORDER BY createdAt DESC"
233        );
234        $stmt->bindValue(':typeNum', $typeNum, PDO::PARAM_STR);
235        $stmt->bindValue(':employeeId', $employeeId, PDO::PARAM_INT);
236        $stmt->execute();
237        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
238
239        $invitations = [];
240        foreach ($rows as $row) {
241            $invitations[] = EmployeeInvitation::fromRow($row);
242        }
243
244        return $invitations;
245    }
246
247    /**
248     * Revoke/cancel an invitation
249     *
250     * @param int $invitationId Invitation ID
251     * @return bool True if revoked successfully
252     */
253    public function revokeInvitation(int $invitationId): bool
254    {
255        // Mark as expired by setting expiresAt to now
256        $stmt = $this->centralDb->prepare(
257            "UPDATE employee_invitations SET expiresAt = NOW() WHERE id = :id"
258        );
259        $stmt->bindValue(':id', $invitationId, PDO::PARAM_INT);
260        return $stmt->execute();
261    }
262
263    /**
264     * Resend invitation email
265     *
266     * @param int $invitationId Invitation ID
267     * @return bool True if resent successfully
268     * @throws Exception If invitation not found or invalid
269     */
270    public function resendInvitation(int $invitationId): bool
271    {
272        // Get invitation
273        $stmt = $this->centralDb->prepare(
274            "SELECT * FROM employee_invitations WHERE id = :id"
275        );
276        $stmt->bindValue(':id', $invitationId, PDO::PARAM_INT);
277        $stmt->execute();
278        $invitationData = $stmt->fetch(PDO::FETCH_ASSOC);
279
280        if (!$invitationData) {
281            throw new Exception("Invitation not found");
282        }
283
284        $invitation = EmployeeInvitation::fromRow($invitationData);
285
286        if (!$invitation->isValid()) {
287            throw new Exception("Invitation is no longer valid");
288        }
289
290        // Get employee data
291        $storeDb = dbConnectByName('kiosk_' . $invitation->getTypeNum());
292        $empStmt = $storeDb->prepare(
293            "SELECT * FROM employees WHERE employeeID = :employeeId"
294        );
295        $empStmt->bindValue(':employeeId', $invitation->getEmployeeId(), PDO::PARAM_INT);
296        $empStmt->execute();
297        $employee = $empStmt->fetch(PDO::FETCH_ASSOC);
298
299        if (!$employee) {
300            throw new Exception("Employee not found");
301        }
302
303        // Resend email
304        return $this->sendInvitationEmail($invitation, $employee, $invitation->getTypeNum());
305    }
306
307    /**
308     * Clean up expired invitations
309     *
310     * Deletes invitations that expired more than 30 days ago.
311     *
312     * @return int Number of invitations deleted
313     */
314    public function cleanupExpired(): int
315    {
316        $stmt = $this->centralDb->prepare(
317            "DELETE FROM employee_invitations WHERE expiresAt < DATE_SUB(NOW(), INTERVAL 30 DAY)"
318        );
319        $stmt->execute();
320        return $stmt->rowCount();
321    }
322
323    /**
324     * Generate secure random token
325     *
326     * @return string 64-character hexadecimal token
327     */
328    private function generateToken(): string
329    {
330        return bin2hex(random_bytes(32));
331    }
332
333    /**
334     * Send invitation email
335     *
336     * @param EmployeeInvitation $invitation Invitation object
337     * @param array $employee Employee data
338     * @param string $typeNum Store identifier
339     * @return bool True if email sent successfully
340     */
341    private function sendInvitationEmail(EmployeeInvitation $invitation, array $employee, string $typeNum): bool
342    {
343        try {
344            // Get store info
345            $storeStmt = $this->centralDb->prepare(
346                "SELECT * FROM stores WHERE typeNum = :typeNum"
347            );
348            $storeStmt->bindValue(':typeNum', $typeNum, PDO::PARAM_STR);
349            $storeStmt->execute();
350            $store = $storeStmt->fetch(PDO::FETCH_ASSOC);
351
352            // Get inviter info
353            $inviterStmt = $this->centralDb->prepare(
354                "SELECT display_name FROM uf_user WHERE id = :id"
355            );
356            $inviterStmt->bindValue(':id', $invitation->getInvitedBy(), PDO::PARAM_INT);
357            $inviterStmt->execute();
358            $inviter = $inviterStmt->fetch(PDO::FETCH_ASSOC);
359
360            $storeName = $store ? $store['storeType'] . ' #' . $store['storeNum'] : $typeNum;
361            $inviterName = $inviter ? $inviter['display_name'] : 'Your manager';
362
363            // Generate invitation URL
364            $baseUrl = isset($_ENV['BASE_URL']) ? $_ENV['BASE_URL'] : 'https://buyerkiosk.com';
365            $inviteUrl = $baseUrl . '/invite/' . $invitation->getToken();
366
367            // Load Twig template
368            $templatePath = __DIR__ . '/../../../templates/mail/employee-invitation.html';
369            if (!file_exists($templatePath)) {
370                error_log("Invitation email template not found: $templatePath");
371                return false;
372            }
373
374            $loader = new \Twig_Loader_Filesystem(__DIR__ . '/../../../templates/mail');
375            $twig = new \Twig_Environment($loader);
376
377            $emailHtml = $twig->render('employee-invitation.html', [
378                'employee' => [
379                    'firstName' => $employee['employeeFirstName'] ?? '',
380                    'lastName' => $employee['employeeLastName'] ?? '',
381                ],
382                'storeName' => $storeName,
383                'inviterName' => $inviterName,
384                'inviteUrl' => $inviteUrl,
385                'expiresAt' => date('F j, Y g:i A', strtotime($invitation->getExpiresAt())),
386                'personalMessage' => null // Can be extended later
387            ]);
388
389            // Send email using existing mail infrastructure
390            $mail = new \PHPMailer\PHPMailer\PHPMailer(true);
391
392            // Configure SMTP if available
393            if (isset($_ENV['SMTP_HOST'])) {
394                $mail->isSMTP();
395                $mail->Host = $_ENV['SMTP_HOST'];
396                $mail->SMTPAuth = true;
397                $mail->Username = $_ENV['SMTP_USER'] ?? '';
398                $mail->Password = $_ENV['SMTP_PASS'] ?? '';
399                $mail->SMTPSecure = $_ENV['SMTP_SECURE'] ?? 'tls';
400                $mail->Port = $_ENV['SMTP_PORT'] ?? 587;
401            }
402
403            $mail->setFrom($_ENV['MAIL_FROM'] ?? 'noreply@buyerkiosk.com', 'BuyerKiosk');
404            $mail->addAddress($invitation->getEmail());
405            $mail->Subject = "You're invited to BuyerKiosk - $storeName";
406            $mail->isHTML(true);
407            $mail->Body = $emailHtml;
408
409            // Plain text alternative
410            $mail->AltBody = "Hi {$employee['employeeFirstName']},\n\n" .
411                            "You've been invited to create an account for $storeName on BuyerKiosk.\n\n" .
412                            "Click the link below to set up your account:\n" .
413                            "$inviteUrl\n\n" .
414                            "This invitation expires on " . date('F j, Y g:i A', strtotime($invitation->getExpiresAt())) . ".\n\n" .
415                            "BuyerKiosk - Employee Management System";
416
417            return $mail->send();
418
419        } catch (Exception $e) {
420            error_log("Failed to send invitation email: " . $e->getMessage());
421            return false;
422        }
423    }
424}