• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Freegle / Iznik / 10911

07 May 2026 08:30PM UTC coverage: 68.873% (-3.9%) from 72.775%
10911

push

circleci

invalid-email-address
chore(batch): enable seven migrated V1 schedules; Galera-safe purge:logs

Promotes purge:chats, purge:logs, emails:validate, users:update-kudos,
groups:update-counts, chats:update-counts and users:update-lastaccess from
the DISABLED section to the active schedule. Daily jobs spread across
02:00–04:30 to avoid bunching. Each has a `// V1: cron/<name>.php` pointer
to the original cron.

Hardens two PurgeService methods that purge:logs depends on:

- purgeOldLikes(): the previous DELETE … WHERE timestamp < ? did a full
  scan on an unindexed column and gap-locked the rows it touched, which
  deadlocks against concurrent inserts on Galera. Switched to the v1
  pattern: read distinct old msgids first (read-only full scan, no gap
  locks), then delete per msgid using the (msgid, type) index.

- purgeOrphanedMessageLogs(): was loading every orphan ID into memory
  before deleting, which is unbounded across the 30–60-day window.
  Now fetches in chunkSize-bounded chunks. Dry-run path switched to
  SELECT COUNT(*) instead of materialising the rows just to count them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

9125 of 10552 branches covered (86.48%)

Branch coverage included in aggregate %.

15 of 22 new or added lines in 1 file covered. (68.18%)

11964 existing lines in 143 files now uncovered.

98596 of 145854 relevant lines covered (67.6%)

19.88 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.82
/iznik-batch/app/Services/UserManagementService.php
1
<?php
2

3
namespace App\Services;
4

5
use App\Helpers\MailHelper;
6
use App\Models\Message;
7
use App\Models\User;
8
use App\Models\UserEmail;
9
use App\Traits\ChunkedProcessing;
10
use Illuminate\Support\Facades\DB;
11
use Illuminate\Support\Facades\Log;
12

13
class UserManagementService
14
{
15
    use ChunkedProcessing;
16

17
    /**
18
     * Chunk size for batch operations.
19
     */
20
    protected int $chunkSize = 1000;
21

22
    private LokiService $lokiService;
23

24
    public function __construct(LokiService $lokiService)
47✔
25
    {
26
        $this->lokiService = $lokiService;
47✔
27
    }
28

29
    /**
30
     * Merge duplicate user accounts.
31
     * Users with the same email should be merged.
32
     */
33
    public function mergeDuplicates(bool $dryRun = false): array
2✔
34
    {
35
        $stats = [
2✔
36
            'duplicates_found' => 0,
2✔
37
            'users_merged' => 0,
2✔
38
            'errors' => 0,
2✔
39
        ];
2✔
40

41
        // Find email addresses linked to multiple users.
42
        $duplicates = UserEmail::select('email')
2✔
43
            ->groupBy('email')
2✔
44
            ->havingRaw('COUNT(DISTINCT userid) > 1')
2✔
45
            ->get();
2✔
46

47
        $stats['duplicates_found'] = $duplicates->count();
2✔
48

49
        foreach ($duplicates as $duplicate) {
2✔
50
            try {
51
                if (!$dryRun) {
×
UNCOV
52
                    $this->mergeUsersForEmail($duplicate->email);
×
53
                }
54
                $stats['users_merged']++;
×
55
            } catch (\Exception $e) {
×
56
                Log::error("Error merging users for email {$duplicate->email}: ".$e->getMessage());
×
UNCOV
57
                $stats['errors']++;
×
58
            }
59
        }
60

61
        return $stats;
2✔
62
    }
63

64
    /**
65
     * Merge all users associated with an email into the oldest account.
66
     */
67
    protected function mergeUsersForEmail(string $email): void
1✔
68
    {
69
        $userIds = UserEmail::where('email', $email)
1✔
70
            ->whereNotNull('userid')
1✔
71
            ->orderBy('userid')
1✔
72
            ->pluck('userid')
1✔
73
            ->unique();
1✔
74

75
        if ($userIds->count() < 2) {
1✔
76
            return;
1✔
77
        }
78

79
        // Keep the oldest (lowest ID) user.
80
        $keepUserId = $userIds->first();
×
UNCOV
81
        $mergeUserIds = $userIds->slice(1);
×
82

83
        foreach ($mergeUserIds as $mergeUserId) {
×
UNCOV
84
            $this->mergeUser($keepUserId, $mergeUserId);
×
85
        }
86
    }
87

88
    /**
89
     * Merge one user into another.
90
     */
91
    protected function mergeUser(int $keepUserId, int $mergeUserId): void
1✔
92
    {
93
        DB::transaction(function () use ($keepUserId, $mergeUserId) {
1✔
94
            // Update foreign keys pointing to merged user.
95
            $tables = [
1✔
96
                'memberships' => 'userid',
1✔
97
                'chat_rooms' => 'user1',
1✔
98
                'chat_rooms' => 'user2',
1✔
99
                'chat_messages' => 'userid',
1✔
100
                'messages' => 'fromuser',
1✔
101
                'users_donations' => 'userid',
1✔
102
                'users_emails' => 'userid',
1✔
103
            ];
1✔
104

105
            foreach ($tables as $table => $column) {
1✔
106
                try {
107
                    DB::table($table)
1✔
108
                        ->where($column, $mergeUserId)
1✔
109
                        ->update([$column => $keepUserId]);
1✔
UNCOV
110
                } catch (\Exception $e) {
×
111
                    // May fail on unique constraints, which is fine.
UNCOV
112
                    Log::debug("Could not update {$table}.{$column}: ".$e->getMessage());
×
113
                }
114
            }
115

116
            // Soft delete the merged user.
117
            User::where('id', $mergeUserId)
1✔
118
                ->update(['deleted' => now()]);
1✔
119

120
            Log::info("Merged user {$mergeUserId} into {$keepUserId}");
1✔
121
        });
1✔
122
    }
123

124
    /**
125
     * Check and update user email validity via bounce tracking.
126
     * Emails that have bounced (bounced timestamp is set) and were validated
127
     * are marked as invalid (validated set to NULL).
128
     */
129
    public function processBouncedEmails(bool $dryRun = false): array
6✔
130
    {
131
        $stats = [
6✔
132
            'processed' => 0,
6✔
133
            'marked_invalid' => 0,
6✔
134
        ];
6✔
135

136
        // Get validated emails that have bounced.
137
        $bouncedEmails = DB::table('users_emails')
6✔
138
            ->whereNotNull('bounced')
6✔
139
            ->whereNotNull('validated')
6✔
140
            ->limit($this->chunkSize)
6✔
141
            ->get();
6✔
142

143
        foreach ($bouncedEmails as $email) {
6✔
144
            if (!$dryRun) {
1✔
145
                UserEmail::where('id', $email->id)
1✔
146
                    ->update(['validated' => null]);
1✔
147

148
                $this->lokiService->logBounceEvent(
1✔
149
                    $email->email ?? '',
1✔
150
                    $email->userid ?? 0,
1✔
151
                    true,
1✔
152
                    'Bounced email marked invalid',
1✔
153
                );
1✔
154
            }
155

156
            $stats['marked_invalid']++;
1✔
157
            $stats['processed']++;
1✔
158
        }
159

160
        return $stats;
6✔
161
    }
162

163
    /**
164
     * Update user kudos based on their activity.
165
     *
166
     * Selects users with lastaccess > 2 days ago, calculates kudos per user,
167
     * and writes to users_kudos table via REPLACE INTO.
168
     */
169
    public function updateKudos(bool $dryRun = FALSE): int
5✔
170
    {
171
        $updated = 0;
5✔
172

173
        $mysqltime = now()->subDays(2)->startOfDay()->toDateString();
5✔
174

175
        $users = DB::table('users')
5✔
176
            ->select('id')
5✔
177
            ->where('lastaccess', '>', $mysqltime)
5✔
178
            ->get();
5✔
179

180
        $total = $users->count();
5✔
181

182
        foreach ($users as $userData) {
5✔
183
            $this->updateKudosForUser($userData->id, $dryRun);
5✔
184
            $updated++;
5✔
185

186
            if ($updated % 10 === 0) {
5✔
187
                Log::info("Kudos update progress: {$updated} / {$total}");
5✔
188
            }
189
        }
190

191
        return $updated;
5✔
192
    }
193

194
    /**
195
     * Update kudos for a single user.
196
     *
197
     * - No existing kudos record, OR
198
     * - Existing record is more than 24 hours old.
199
     *
200
     * Writes to users_kudos table with columns: userid, kudos, posts, chats,
201
     * newsfeed, events, vols, facebook, platform.
202
     */
203
    public function updateKudosForUser(int $userId, bool $dryRun = FALSE): void
5✔
204
    {
205
        // Check throttle: only update if no existing record or record is older than 24h.
206
        $current = DB::table('users_kudos')->where('userid', $userId)->first();
5✔
207

208
        if ($current && $current->timestamp) {
5✔
209
            $age = now()->diffInSeconds(\Carbon\Carbon::parse($current->timestamp));
×
210
            if ($age <= 24 * 60 * 60) {
×
UNCOV
211
                return;
×
212
            }
213
        }
214

215
        $kudosData = $this->calculateKudos($userId);
5✔
216

217
        $kudos = $kudosData['posts'] + $kudosData['chats'] + $kudosData['newsfeed']
5✔
218
            + $kudosData['events'] + $kudosData['vols'];
5✔
219

220
        if ($kudos > 0) {
5✔
221
            // No sense in creating entries which are blank or the same.
222
            $currentKudos = $current ? $current->kudos : 0;
5✔
223

224
            if ($currentKudos != $kudos) {
5✔
225
                if (!$dryRun) {
5✔
226
                    DB::statement(
5✔
227
                        'REPLACE INTO users_kudos (userid, kudos, posts, chats, newsfeed, events, vols, facebook, platform) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
5✔
228
                        [
5✔
229
                            $userId,
5✔
230
                            $kudos,
5✔
231
                            $kudosData['posts'],
5✔
232
                            $kudosData['chats'],
5✔
233
                            $kudosData['newsfeed'],
5✔
234
                            $kudosData['events'],
5✔
235
                            $kudosData['vols'],
5✔
236
                            $kudosData['facebook'] ? 1 : 0,
5✔
237
                            $kudosData['platform'] ? 1 : 0,
5✔
238
                        ]
5✔
239
                    );
5✔
240
                }
241
            }
242
        }
243
    }
244

245
    /**
246
     * Calculate kudos components for a user.
247
     *
248
     * - Distinct months with posts (messages table, last 365 days)
249
     * - Distinct months with chats (chat_messages table, last 365 days)
250
     * - Distinct months with newsfeed posts (newsfeed table, last 365 days)
251
     * - Community events count (last 365 days)
252
     * - Volunteering count (last 365 days)
253
     * - Facebook login (boolean)
254
     * - Platform posting (boolean, sourceheader = 'Platform', last 365 days)
255
     */
256
    protected function calculateKudos(int $userId): array
7✔
257
    {
258
        $start = now()->subDays(365)->toDateString();
7✔
259

260
        // Distinct months with posts.
261
        $posts = (int) DB::table('messages')
7✔
262
            ->where('fromuser', $userId)
7✔
263
            ->where('date', '>=', $start)
7✔
264
            ->selectRaw("COUNT(DISTINCT CONCAT(YEAR(date), '-', MONTH(date))) AS count")
7✔
265
            ->value('count');
7✔
266

267
        // Distinct months with chat messages.
268
        $chats = (int) DB::table('chat_messages')
7✔
269
            ->where('userid', $userId)
7✔
270
            ->where('date', '>=', $start)
7✔
271
            ->selectRaw("COUNT(DISTINCT CONCAT(YEAR(date), '-', MONTH(date))) AS count")
7✔
272
            ->value('count');
7✔
273

274
        // Distinct months with newsfeed posts.
275
        $newsfeed = (int) DB::table('newsfeed')
7✔
276
            ->where('userid', $userId)
7✔
277
            ->where('added', '>=', $start)
7✔
278
            ->selectRaw("COUNT(DISTINCT CONCAT(YEAR(timestamp), '-', MONTH(timestamp))) AS count")
7✔
279
            ->value('count');
7✔
280

281
        // Community events count.
282
        $events = (int) DB::table('communityevents')
7✔
283
            ->where('userid', $userId)
7✔
284
            ->where('added', '>=', $start)
7✔
285
            ->count();
7✔
286

287
        // Volunteering count.
288
        $vols = (int) DB::table('volunteering')
7✔
289
            ->where('userid', $userId)
7✔
290
            ->where('added', '>=', $start)
7✔
291
            ->count();
7✔
292

293
        // Facebook login.
294
        $facebook = DB::table('users_logins')
7✔
295
            ->where('userid', $userId)
7✔
296
            ->where('type', 'Facebook')
7✔
297
            ->count() > 0;
7✔
298

299
        // Posted from the platform (vs email/TN).
300
        $platform = DB::table('messages')
7✔
301
            ->where('fromuser', $userId)
7✔
302
            ->where('arrival', '>=', $start)
7✔
303
            ->where('sourceheader', 'Platform')
7✔
304
            ->count() > 0;
7✔
305

306
        return [
7✔
307
            'posts' => $posts,
7✔
308
            'chats' => $chats,
7✔
309
            'newsfeed' => $newsfeed,
7✔
310
            'events' => $events,
7✔
311
            'vols' => $vols,
7✔
312
            'facebook' => $facebook,
7✔
313
            'platform' => $platform,
7✔
314
        ];
7✔
315
    }
316

317
    /**
318
     * Clean up inactive and deleted users.
319
     *
320
     * Steps:
321
     *   1. Delete legacy Yahoo Groups users
322
     *   2. Forget inactive users (no memberships, no activity in 6 months, no logs in 90 days)
323
     *   3. Process GDPR forgets (users deleted > 14 days ago)
324
     *   4. Hard-delete fully forgotten users with no remaining messages
325
     *
326
     * @param  bool  $dryRun  If true, count what would be affected but don't modify data.
327
     */
328
    public function cleanupUsers(bool $dryRun = FALSE): array
5✔
329
    {
330
        $stats = [
5✔
331
            'yahoo_users_deleted' => 0,
5✔
332
            'inactive_users_forgotten' => 0,
5✔
333
            'gdpr_forgets_processed' => 0,
5✔
334
            'forgotten_users_deleted' => 0,
5✔
335
        ];
5✔
336

337
        $stats['yahoo_users_deleted'] = $this->deleteYahooGroupsUsers($dryRun);
5✔
338
        $stats['inactive_users_forgotten'] = $this->forgetInactiveUsers($dryRun);
5✔
339
        $stats['gdpr_forgets_processed'] = $this->processForgets($dryRun);
5✔
340
        $stats['forgotten_users_deleted'] = $this->deleteFullyForgottenUsers($dryRun);
5✔
341

342
        Log::info('User cleanup completed', $stats);
5✔
343

344
        return $stats;
5✔
345
    }
346

347
    /**
348
     * Delete users with @yahoogroups.com emails.
349
     *
350
     * These are legacy Yahoo Groups users that no longer serve a purpose.
351
     */
352
    public function deleteYahooGroupsUsers(bool $dryRun = FALSE): int
6✔
353
    {
354
        $yahooUsers = DB::table('users')
6✔
355
            ->join('users_emails', 'users.id', '=', 'users_emails.userid')
6✔
356
            ->where('users_emails.email', 'LIKE', '%@yahoogroups.com')
6✔
357
            ->whereNull('users.deleted')
6✔
358
            ->distinct()
6✔
359
            ->pluck('users.id');
6✔
360

361
        $count = $yahooUsers->count();
6✔
362

363
        if (!$dryRun) {
6✔
364
            foreach ($yahooUsers as $userId) {
4✔
365
                Log::info("Deleting Yahoo Groups user #{$userId}");
1✔
366

367
                // Remove memberships first (matches V1 User::delete()).
368
                DB::table('memberships')->where('userid', $userId)->delete();
1✔
369

370
                // Hard delete the user.
371
                DB::table('users')->where('id', $userId)->delete();
1✔
372
            }
373
        }
374

375
        return $count;
6✔
376
    }
377

378
    /**
379
     * Forget inactive users who meet all criteria:
380
     * - No group memberships
381
     * - Last access > 6 months ago
382
     * - Not a spammer
383
     * - No moderator notes (users_comments)
384
     * - No meaningful logs in 90 days (excluding User/Created and User/Deleted log entries)
385
     * - systemrole = 'User'
386
     * - Not already deleted
387
     *
388
     */
389
    public function forgetInactiveUsers(bool $dryRun = FALSE): int
8✔
390
    {
391
        $sixMonthsAgo = now()->subMonths(6)->format('Y-m-d');
8✔
392

393
        // Find candidates: no memberships, no spammer record, no mod notes,
394
        // last access > 6 months, systemrole = User, not deleted.
395
        $candidates = DB::select("
8✔
396
            SELECT users.id
397
            FROM users
398
            LEFT JOIN memberships ON users.id = memberships.userid
399
            LEFT JOIN spam_users ON users.id = spam_users.userid
400
            LEFT JOIN users_comments ON users.id = users_comments.userid
401
            WHERE memberships.userid IS NULL
402
              AND spam_users.userid IS NULL
403
              AND users_comments.userid IS NULL
404
              AND users.lastaccess < ?
405
              AND users.systemrole = ?
406
              AND users.deleted IS NULL
407
              AND users.forgotten IS NULL
408
        ", [$sixMonthsAgo, 'User']);
8✔
409

410
        $count = 0;
8✔
411

412
        foreach ($candidates as $candidate) {
8✔
413
            // Check for recent meaningful logs (excluding User/Created and User/Deleted).
414
            $logs = DB::select("
2✔
415
                SELECT DATEDIFF(NOW(), timestamp) AS logsago
416
                FROM logs
417
                WHERE user = ?
418
                  AND (type != 'User' OR (subtype != 'Created' AND subtype != 'Deleted'))
419
                ORDER BY id DESC
420
                LIMIT 1
421
            ", [$candidate->id]);
2✔
422

423
            // Forget if no logs at all, or most recent meaningful log is > 90 days old.
424
            if (count($logs) === 0 || $logs[0]->logsago > 90) {
2✔
425
                if (!$dryRun) {
2✔
426
                    Log::info("Forgetting inactive user #{$candidate->id}");
1✔
427
                    $this->forgetUser($candidate->id, 'Inactive');
1✔
428
                }
429
                $count++;
2✔
430
            }
431
        }
432

433
        return $count;
8✔
434
    }
435

436
    /**
437
     * Process GDPR forgets: users with deleted timestamp > 14 days ago
438
     * who haven't been forgotten yet.
439
     *
440
     */
441
    public function processForgets(bool $dryRun = FALSE): int
7✔
442
    {
443
        $users = DB::select("
7✔
444
            SELECT id
445
            FROM users
446
            WHERE deleted IS NOT NULL
447
              AND DATEDIFF(NOW(), deleted) > 14
448
              AND forgotten IS NULL
449
        ");
7✔
450

451
        $count = count($users);
7✔
452

453
        if (!$dryRun) {
7✔
454
            foreach ($users as $user) {
5✔
455
                Log::info("GDPR forget for user #{$user->id} (grace period expired)");
1✔
456
                $this->forgetUser($user->id, 'Grace period');
1✔
457
            }
458
        }
459

460
        return $count;
7✔
461
    }
462

463
    /**
464
     * Wipe a user's personal data for GDPR right to be forgotten.
465
     *
466
     * deletes non-internal emails, logins, community events, volunteering,
467
     * newsfeed, stories, searches, about me, ratings, addresses, images,
468
     * promises, sessions; nullifies message content; removes group memberships;
469
     * marks user as forgotten.
470
     */
471
    public function forgetUser(int $userId, string $reason): void
4✔
472
    {
473
        // Clear personal fields.
474
        DB::table('users')->where('id', $userId)->update([
4✔
475
            'firstname' => NULL,
4✔
476
            'lastname' => NULL,
4✔
477
            'fullname' => "Deleted User #{$userId}",
4✔
478
            'settings' => NULL,
4✔
479
            'yahooid' => NULL,
4✔
480
        ]);
4✔
481

482
        // Delete non-internal-domain emails (keep our platform emails).
483
        $emails = DB::table('users_emails')->where('userid', $userId)->get();
4✔
484
        foreach ($emails as $email) {
4✔
485
            if (!MailHelper::isOurDomain($email->email)) {
2✔
486
                DB::table('users_emails')->where('id', $email->id)->delete();
2✔
487
            }
488
        }
489

490
        // Delete all logins.
491
        DB::table('users_logins')->where('userid', $userId)->delete();
4✔
492

493
        // Wipe message content for Offer/Wanted messages from this user.
494
        $messageIds = DB::table('messages')
4✔
495
            ->where('fromuser', $userId)
4✔
496
            ->whereIn('type', ['Offer', 'Wanted'])
4✔
497
            ->pluck('id');
4✔
498

499
        foreach ($messageIds as $msgId) {
4✔
500
            DB::table('messages')->where('id', $msgId)->update([
1✔
501
                'fromip' => NULL,
1✔
502
                'message' => NULL,
1✔
503
                'envelopefrom' => NULL,
1✔
504
                'fromname' => NULL,
1✔
505
                'fromaddr' => NULL,
1✔
506
                'messageid' => NULL,
1✔
507
                'textbody' => NULL,
1✔
508
                'htmlbody' => NULL,
1✔
509
                'deleted' => now(),
1✔
510
            ]);
1✔
511

512
            DB::table('messages_groups')->where('msgid', $msgId)->update([
1✔
513
                'deleted' => 1,
1✔
514
            ]);
1✔
515

516
            // Delete outcome comments (may contain personal data).
517
            DB::table('messages_outcomes')->where('msgid', $msgId)->update([
1✔
518
                'comments' => NULL,
1✔
519
            ]);
1✔
520
        }
521

522
        // Remove content of all chat messages sent by this user.
523
        DB::table('chat_messages')->where('userid', $userId)->update([
4✔
524
            'message' => NULL,
4✔
525
        ]);
4✔
526

527
        // Delete community events, volunteering, newsfeed, stories, searches, about me.
528
        DB::table('communityevents')->where('userid', $userId)->delete();
4✔
529
        DB::table('volunteering')->where('userid', $userId)->delete();
4✔
530
        DB::table('newsfeed')->where('userid', $userId)->delete();
4✔
531
        DB::table('users_stories')->where('userid', $userId)->delete();
4✔
532
        DB::table('users_searches')->where('userid', $userId)->delete();
4✔
533
        DB::table('users_aboutme')->where('userid', $userId)->delete();
4✔
534

535
        // Delete ratings by and about this user.
536
        DB::table('ratings')->where('rater', $userId)->delete();
4✔
537
        DB::table('ratings')->where('ratee', $userId)->delete();
4✔
538

539
        // Remove from all groups.
540
        DB::table('memberships')->where('userid', $userId)->delete();
4✔
541

542
        // Remove from Related Members — deleted users should not appear as related to anyone.
543
        DB::table('users_related')
4✔
544
            ->where('user1', $userId)
4✔
545
            ->orWhere('user2', $userId)
4✔
546
            ->delete();
4✔
547

548
        // Delete postal addresses.
549
        DB::table('users_addresses')->where('userid', $userId)->delete();
4✔
550

551
        // Delete profile images.
552
        DB::table('users_images')->where('userid', $userId)->delete();
4✔
553

554
        // Remove promises.
555
        DB::table('messages_promises')->where('userid', $userId)->delete();
4✔
556

557
        // Mark as forgotten and clear TN user ID.
558
        DB::table('users')->where('id', $userId)->update([
4✔
559
            'forgotten' => now(),
4✔
560
            'tnuserid' => NULL,
4✔
561
        ]);
4✔
562

563
        // Delete sessions.
564
        DB::table('sessions')->where('userid', $userId)->delete();
4✔
565

566
        // Log the forget action.
567
        DB::table('logs')->insert([
4✔
568
            'type' => 'User',
4✔
569
            'subtype' => 'Deleted',
4✔
570
            'user' => $userId,
4✔
571
            'text' => $reason,
4✔
572
            'timestamp' => now(),
4✔
573
        ]);
4✔
574
    }
575

576
    /**
577
     * Delete fully forgotten users who have no remaining messages.
578
     *
579
     * These users have been forgotten (personal data wiped) and have no messages
580
     * left as a placeholder — they can be safely hard-deleted.
581
     *
582
     */
583
    public function deleteFullyForgottenUsers(bool $dryRun = FALSE): int
7✔
584
    {
585
        $sixMonthsAgo = now()->subMonths(6)->format('Y-m-d');
7✔
586

587
        $users = DB::select("
7✔
588
            SELECT users.id
589
            FROM users
590
            LEFT JOIN messages ON messages.fromuser = users.id
591
            WHERE users.forgotten IS NOT NULL
592
              AND users.lastaccess < ?
593
              AND messages.id IS NULL
594
            LIMIT 100000
595
        ", [$sixMonthsAgo]);
7✔
596

597
        $count = count($users);
7✔
598

599
        if (!$dryRun) {
7✔
600
            $processed = 0;
5✔
601
            foreach ($users as $user) {
5✔
602
                // Remove memberships first (matches V1 User::delete()).
603
                DB::table('memberships')->where('userid', $user->id)->delete();
1✔
604

605
                // Hard delete the user.
606
                DB::table('users')->where('id', $user->id)->delete();
1✔
607

608
                $processed++;
1✔
609
                if ($processed % 1000 === 0) {
1✔
UNCOV
610
                    Log::info("Deleted {$processed} / {$count} fully forgotten users");
×
611
                }
612
            }
613
        }
614

615
        return $count;
7✔
616
    }
617

618
    /**
619
     * Fallback update of user lastaccess timestamps.
620
     *
621
     * Finds users whose lastaccess is more than 10 minutes behind their latest
622
     * chat message or membership join, and updates accordingly.
623
     *
624
     */
625
    public function updateLastAccess(bool $dryRun = false): array
3✔
626
    {
627
        $stats = [
3✔
628
            'candidates' => 0,
3✔
629
            'updated' => 0,
3✔
630
        ];
3✔
631

632
        // Find users whose lastaccess is > 600 seconds behind their latest chat message or membership join.
633
        $users = DB::select("
3✔
634
            SELECT DISTINCT(userid) FROM (
635
                SELECT DISTINCT(userid) FROM users
636
                INNER JOIN chat_messages ON chat_messages.userid = users.id
637
                WHERE users.lastaccess < chat_messages.date
638
                    AND TIMESTAMPDIFF(SECOND, users.lastaccess, chat_messages.date) > 600
639
                UNION
640
                SELECT DISTINCT(userid) FROM memberships
641
                INNER JOIN users ON users.id = memberships.userid
642
                WHERE TIMESTAMPDIFF(SECOND, users.lastaccess, memberships.added) > 600
643
            ) t
644
        ");
3✔
645

646
        $stats['candidates'] = count($users);
3✔
647
        $processed = 0;
3✔
648

649
        foreach ($users as $user) {
3✔
650
            // Find the latest activity timestamp from chat messages or memberships.
651
            $result = DB::selectOne("
2✔
652
                SELECT GREATEST(
653
                    COALESCE((SELECT MAX(date) FROM chat_messages WHERE userid = ?), '1970-01-01'),
654
                    COALESCE((SELECT MAX(added) FROM memberships WHERE userid = ?), '1970-01-01')
655
                ) AS max
656
            ", [$user->userid, $user->userid]);
2✔
657

658
            if ($result && $result->max && $result->max !== '1970-01-01') {
2✔
659
                $currentAccess = DB::table('users')
2✔
660
                    ->where('id', $user->userid)
2✔
661
                    ->value('lastaccess');
2✔
662

663
                $diff = strtotime($result->max) - strtotime($currentAccess);
2✔
664

665
                if ($diff > 600) {
2✔
666
                    if (!$dryRun) {
2✔
667
                        DB::table('users')
2✔
668
                            ->where('id', $user->userid)
2✔
669
                            ->update(['lastaccess' => $result->max]);
2✔
670
                    }
671

672
                    $stats['updated']++;
2✔
673
                }
674
            }
675

676
            $processed++;
2✔
677

678
            if ($processed % 1000 === 0) {
2✔
UNCOV
679
                Log::info("Processed {$processed} / {$stats['candidates']} lastaccess candidates");
×
680
            }
681
        }
682

683
        return $stats;
3✔
684
    }
685

686
    /**
687
     * Update support tools access based on team membership.
688
     *
689
     * Grants SYSTEMROLE_SUPPORT to users who are members of teams with supporttools=1.
690
     * Removes the role from users who no longer qualify (downgrading to Moderator).
691
     * Never touches Admin users.
692
     *
693
     */
694
    public function updateSupportRoles(bool $dryRun = false): array
4✔
695
    {
696
        $stats = [
4✔
697
            'granted' => 0,
4✔
698
            'removed' => 0,
4✔
699
        ];
4✔
700

701
        // Users who currently have Support or Admin role.
702
        $currentSupport = DB::table('users')
4✔
703
            ->whereIn('systemrole', ['Support', 'Admin'])
4✔
704
            ->pluck('id')
4✔
705
            ->all();
4✔
706

707
        // Users who should have support tools access (in teams with supporttools=1).
708
        $needSupport = DB::table('teams_members')
4✔
709
            ->join('teams', 'teams.id', '=', 'teams_members.teamid')
4✔
710
            ->where('teams.supporttools', 1)
4✔
711
            ->distinct()
4✔
712
            ->pluck('teams_members.userid')
4✔
713
            ->all();
4✔
714

715
        // Grant support role to users who need it but don't have it.
716
        foreach ($needSupport as $userId) {
4✔
717
            if (!in_array($userId, $currentSupport)) {
1✔
718
                if (!$dryRun) {
1✔
719
                    DB::table('users')
1✔
720
                        ->where('id', $userId)
1✔
721
                        ->update(['systemrole' => 'Support']);
1✔
722

723
                    Log::info("Granted support role to user #{$userId}");
1✔
724
                }
725

726
                $stats['granted']++;
1✔
727
            }
728
        }
729

730
        // Remove support role from users who have it but shouldn't.
731
        // Don't touch Admin users - only downgrade Support to Moderator.
732
        $removeFrom = array_diff($currentSupport, $needSupport);
4✔
733

734
        foreach ($removeFrom as $userId) {
4✔
735
            $currentRole = DB::table('users')
2✔
736
                ->where('id', $userId)
2✔
737
                ->value('systemrole');
2✔
738

739
            // Only downgrade Support, never Admin.
740
            if ($currentRole === 'Support') {
2✔
741
                if (!$dryRun) {
1✔
742
                    DB::table('users')
1✔
743
                        ->where('id', $userId)
1✔
744
                        ->update(['systemrole' => 'Moderator']);
1✔
745

746
                    Log::info("Removed support role from user #{$userId}");
1✔
747
                }
748

749
                $stats['removed']++;
1✔
750
            }
751
        }
752

753
        return $stats;
4✔
754
    }
755

756
    /**
757
     * Validate recently-added non-bouncing emails and delete invalid ones.
758
     *
759
     * Uses Message::EMAIL_REGEXP. Scoped to the last 30 days because the regex
760
     * is purely a function of the address — once a row passes it can never
761
     * become invalid retroactively, so a full-table sweep would be wasted work.
762
     *
763
     */
764
    public function validateEmails(bool $dryRun = false): array
4✔
765
    {
766
        $stats = [
4✔
767
            'total' => 0,
4✔
768
            'invalid' => 0,
4✔
769
        ];
4✔
770

771
        $since = now()->subDays(30);
4✔
772

773
        DB::table('users_emails')
4✔
774
            ->join('users', 'users.id', '=', 'users_emails.userid')
4✔
775
            ->where('users.bouncing', 0)
4✔
776
            ->where('users_emails.added', '>=', $since)
4✔
777
            ->select('users_emails.id', 'users_emails.email', 'users_emails.userid')
4✔
778
            ->orderBy('users_emails.id')
4✔
779
            ->chunkById(5000, function ($emails) use (&$stats, $dryRun) {
4✔
780
                foreach ($emails as $email) {
4✔
781
                    $stats['total']++;
4✔
782

783
                    if (!preg_match(Message::EMAIL_REGEXP, $email->email)) {
4✔
784
                        if (!$dryRun) {
2✔
785
                            DB::table('users_emails')->where('id', $email->id)->delete();
2✔
786
                            Log::info("Deleted invalid email: {$email->email} for user #{$email->userid}");
2✔
787
                        }
788
                        $stats['invalid']++;
2✔
789
                    }
790

791
                    if ($stats['total'] % 1000 === 0) {
4✔
UNCOV
792
                        Log::info("Validated {$stats['total']} emails so far, {$stats['invalid']} invalid");
×
793
                    }
794
                }
795
            }, 'users_emails.id', 'id');
4✔
796

797
        return $stats;
4✔
798
    }
799

800
    /**
801
     * Update rating visibility based on chat interactions.
802
     *
803
     * A rating is visible if the rater and ratee have had meaningful chat interaction:
804
     * - At least one message from each in the same chat room, OR
805
     * - The ratee replied to a post (refmsgid is set).
806
     *
807
     * This prevents frivolous ratings from users who haven't actually interacted.
808
     *
809
     */
810
    public function updateRatingVisibility(string $since = '1 hour ago', bool $dryRun = false): array
4✔
811
    {
812
        $stats = [
4✔
813
            'processed' => 0,
4✔
814
            'made_visible' => 0,
4✔
815
            'made_hidden' => 0,
4✔
816
        ];
4✔
817

818
        $cutoff = date('Y-m-d', strtotime($since));
4✔
819

820
        $ratings = DB::table('ratings')
4✔
821
            ->where('timestamp', '>=', $cutoff)
4✔
822
            ->get();
4✔
823

824
        foreach ($ratings as $rating) {
4✔
825
            $visible = false;
3✔
826

827
            $chats = DB::table('chat_rooms')
3✔
828
                ->where(function ($q) use ($rating) {
3✔
829
                    $q->where('user1', $rating->rater)->where('user2', $rating->ratee);
3✔
830
                })
3✔
831
                ->orWhere(function ($q) use ($rating) {
3✔
832
                    $q->where('user2', $rating->rater)->where('user1', $rating->ratee);
3✔
833
                })
3✔
834
                ->pluck('id');
3✔
835

836
            foreach ($chats as $chatId) {
3✔
837
                // Check if both users have sent messages (excluding system/refmsg-only).
838
                $distinctUsers = DB::table('chat_messages')
2✔
839
                    ->where('chatid', $chatId)
2✔
840
                    ->whereNull('refmsgid')
2✔
841
                    ->whereNotNull('message')
2✔
842
                    ->distinct()
2✔
843
                    ->count('userid');
2✔
844

845
                if ($distinctUsers >= 2) {
2✔
846
                    $visible = true;
1✔
847
                    break;
1✔
848
                }
849

850
                // Check if ratee replied to a post.
851
                $replies = DB::table('chat_messages')
1✔
852
                    ->where('chatid', $chatId)
1✔
853
                    ->where('userid', $rating->ratee)
1✔
854
                    ->whereNotNull('refmsgid')
1✔
855
                    ->whereNotNull('message')
1✔
856
                    ->count();
1✔
857

858
                if ($replies > 0) {
1✔
859
                    $visible = true;
1✔
860
                    break;
1✔
861
                }
862
            }
863

864
            $oldVisible = (bool) $rating->visible;
3✔
865

866
            if ($visible !== $oldVisible) {
3✔
867
                if (!$dryRun) {
3✔
868
                    DB::table('ratings')
3✔
869
                        ->where('id', $rating->id)
3✔
870
                        ->update([
3✔
871
                            'visible' => $visible,
3✔
872
                            'timestamp' => now(),
3✔
873
                        ]);
3✔
874
                }
875

876
                if ($visible) {
3✔
877
                    $stats['made_visible']++;
2✔
878
                } else {
879
                    $stats['made_hidden']++;
1✔
880
                }
881
            }
882

883
            $stats['processed']++;
3✔
884
        }
885

886
        return $stats;
4✔
887
    }
888

889
    /**
890
     * Clean up inactive user data for GDPR compliance.
891
     */
892
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc