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

Freegle / Iznik / 23197

24 Jun 2026 11:15AM UTC coverage: 71.179% (-1.5%) from 72.669%
23197

push

circleci

edwh
fix(jobs): update JobOne/JobMosaicTile specs for the link field in log()

Commit bfb31b29a added `link: job.value.url` to the jobStore.log() payload
in JobOne.vue and JobMosaicTile.vue (to fix dropped job-click attribution)
but did not update the two specs, which still asserted log() was called with
just `{ id }`. That broke master vitest (2 failed). Align the assertions with
the now-richer `{ id, link }` payload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

11179 of 14862 branches covered (75.22%)

Branch coverage included in aggregate %.

122582 of 173060 relevant lines covered (70.83%)

37.22 hits per line

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

81.1
/iznik-batch/app/Models/User.php
1
<?php
2

3
namespace App\Models;
4

5
use App\Support\EloquentUtils;
6
use App\Support\NameSanitiser;
7
use Illuminate\Database\Eloquent\Builder;
8
use Illuminate\Database\Eloquent\Model;
9
use Illuminate\Database\Eloquent\Relations\BelongsTo;
10
use Illuminate\Database\Eloquent\Relations\HasMany;
11
use Illuminate\Database\Eloquent\Relations\HasOne;
12
use Illuminate\Database\QueryException;
13
use Illuminate\Support\Facades\DB;
14
use Illuminate\Support\Facades\Log as Logger;
15
use OwenIt\Auditing\Contracts\Auditable;
16

17
/**
18
 * @see ../../database/migrations/2025_12_10_094529_create_users_table.php
19
 * @property int $id
20
 * @property string|null $yahooUserId Unique ID of user on Yahoo if known
21
 * @property string|null $firstname
22
 * @property string|null $lastname
23
 * @property string|null $fullname
24
 * @property string $systemrole System-wide roles
25
 * @property \Illuminate\Support\Carbon $added
26
 * @property \Illuminate\Support\Carbon $lastaccess
27
 * @property array<array-key, mixed>|null $settings JSON-encoded settings
28
 * @property int $gotrealemail Until migrated, whether polled FD/TN to get real email
29
 * @property string|null $yahooid Any known YahooID for this user
30
 * @property int $licenses Any licenses not added to groups
31
 * @property int $newslettersallowed Central mails
32
 * @property int $relevantallowed
33
 * @property string|null $onholidaytill
34
 * @property int $marketingconsent Whether we have PECR consent
35
 * @property int $publishconsent Can we republish posts to non-members
36
 * @property int|null $lastlocation
37
 * @property string|null $lastrelevantcheck
38
 * @property string|null $lastidlechaseup
39
 * @property int $bouncing Whether preferred email has been determined to be bouncing
40
 * @property string|null $permissions
41
 * @property int|null $invitesleft
42
 * @property string|null $source
43
 * @property string $chatmodstatus
44
 * @property \Illuminate\Support\Carbon|null $deleted
45
 * @property int $inventedname
46
 * @property string $newsfeedmodstatus
47
 * @property int $replyambit
48
 * @property string|null $engagement
49
 * @property string|null $trustlevel
50
 * @property string|null $lastupdated
51
 * @property int|null $tnuserid
52
 * @property int|null $ljuserid
53
 * @property \Illuminate\Support\Carbon|null $forgotten
54
 * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ChatMessage> $chatMessages
55
 * @property-read int|null $chat_messages_count
56
 * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ChatRoom> $chatRoomsAsUser1
57
 * @property-read int|null $chat_rooms_as_user1_count
58
 * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ChatRoom> $chatRoomsAsUser2
59
 * @property-read int|null $chat_rooms_as_user2_count
60
 * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\UserDonation> $donations
61
 * @property-read int|null $donations_count
62
 * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\EmailTracking> $emailTracking
63
 * @property-read int|null $email_tracking_count
64
 * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\UserEmail> $emails
65
 * @property-read int|null $emails_count
66
 * @property-read string $display_name
67
 * @property-read string|null $email_preferred
68
 * @property-read string|null $first_name
69
 * @property-read \App\Models\GiftAid|null $giftAid
70
 * @property-read \App\Models\Location|null $lastLocation
71
 * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Membership> $memberships
72
 * @property-read int|null $memberships_count
73
 * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Message> $messages
74
 * @property-read int|null $messages_count
75
 * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Notification> $notifications
76
 * @property-read int|null $notifications_count
77
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User newModelQuery()
78
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User newQuery()
79
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User query()
80
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereAdded($value)
81
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereBouncing($value)
82
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereChatmodstatus($value)
83
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereDeleted($value)
84
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereEngagement($value)
85
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereFirstname($value)
86
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereForgotten($value)
87
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereFullname($value)
88
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereGotrealemail($value)
89
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereId($value)
90
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereInventedname($value)
91
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereInvitesleft($value)
92
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereLastaccess($value)
93
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereLastidlechaseup($value)
94
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereLastlocation($value)
95
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereLastname($value)
96
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereLastrelevantcheck($value)
97
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereLastupdated($value)
98
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereLicenses($value)
99
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereLjuserid($value)
100
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereMarketingconsent($value)
101
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereNewsfeedmodstatus($value)
102
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereNewslettersallowed($value)
103
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereOnholidaytill($value)
104
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User wherePermissions($value)
105
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User wherePublishconsent($value)
106
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereRelevantallowed($value)
107
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereReplyambit($value)
108
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereSettings($value)
109
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereSource($value)
110
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereSystemrole($value)
111
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereTnuserid($value)
112
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereTrustlevel($value)
113
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereYahooUserId($value)
114
 * @method static \Illuminate\Database\Eloquent\Builder<static>|User whereYahooid($value)
115
 * @mixin \Eloquent
116
 */
117
class User extends Model implements Auditable
118
{
119
    use \OwenIt\Auditing\Auditable;
120

121
    protected $table = 'users';
122
    protected $guarded = ['id'];
123
    public $timestamps = FALSE;
124

125
    // Group membership roles.
126
    public const ROLE_NONMEMBER = 'Non-member';
127
    public const ROLE_MEMBER = 'Member';
128
    public const ROLE_MODERATOR = 'Moderator';
129
    public const ROLE_OWNER = 'Owner';
130

131
    // System-wide roles.
132
    public const SYSTEMROLE_USER = 'User';
133
    public const SYSTEMROLE_MODERATOR = 'Moderator';
134
    public const SYSTEMROLE_SUPPORT = 'Support';
135
    public const SYSTEMROLE_ADMIN = 'Admin';
136

137
    // Login types.
138
    public const LOGIN_NATIVE = 'Native';
139

140
    /**
141
     * Skip users who haven't accessed in this many days when sending bulk
142
     * notification emails. Matches V1's Engage::USER_INACTIVE
143
     * (365*24*60*60/2 ≈ 182.5 days). Filtering on this protects deliverability:
144
     * a sustained send to a large dormant-mailbox population trips spam
145
     * filters and damages the sending domain reputation.
146
     */
147
    public const USER_INACTIVE_DAYS = 182;
148
    public const USER_INACTIVE = self::USER_INACTIVE_DAYS * 86400;
149

150
    // Gift aid period weights for merge comparison (lower = more favourable).
151
    public const GIFTAID_PERIOD_PAST_4_YEARS_AND_FUTURE = 'Past4YearsAndFuture';
152
    public const GIFTAID_PERIOD_SINCE = 'Since';
153
    public const GIFTAID_PERIOD_FUTURE = 'Future';
154
    public const GIFTAID_PERIOD_THIS = 'This';
155
    public const GIFTAID_PERIOD_DECLINED = 'Declined';
156

157
    protected $casts = [
158
        'added' => 'datetime',
159
        'lastaccess' => 'datetime',
160
        'deleted' => 'datetime',
161
        'forgotten' => 'datetime',
162
        'settings' => 'array',
163
    ];
164

165
    /**
166
     * Get user's email addresses.
167
     */
168
    public function emails(): HasMany
748✔
169
    {
170
        return $this->hasMany(UserEmail::class, 'userid');
748✔
171
    }
172

173
    /**
174
     * Get user's memberships.
175
     */
176
    public function memberships(): HasMany
282✔
177
    {
178
        return $this->hasMany(Membership::class, 'userid');
282✔
179
    }
180

181
    /**
182
     * Get chat rooms where user is user1.
183
     */
184
    public function chatRoomsAsUser1(): HasMany
1✔
185
    {
186
        return $this->hasMany(ChatRoom::class, 'user1');
1✔
187
    }
188

189
    /**
190
     * Get chat rooms where user is user2.
191
     */
192
    public function chatRoomsAsUser2(): HasMany
1✔
193
    {
194
        return $this->hasMany(ChatRoom::class, 'user2');
1✔
195
    }
196

197
    /**
198
     * Get user's donations.
199
     */
200
    public function donations(): HasMany
1✔
201
    {
202
        return $this->hasMany(UserDonation::class, 'userid');
1✔
203
    }
204

205
    /**
206
     * Get user's gift aid declaration.
207
     */
208
    public function giftAid(): HasOne
1✔
209
    {
210
        return $this->hasOne(GiftAid::class, 'userid');
1✔
211
    }
212

213
    /**
214
     * Get user's messages.
215
     */
216
    public function messages(): HasMany
1✔
217
    {
218
        return $this->hasMany(Message::class, 'fromuser');
1✔
219
    }
220

221
    /**
222
     * Get user's notifications.
223
     */
224
    public function notifications(): HasMany
1✔
225
    {
226
        return $this->hasMany(Notification::class, 'touser');
1✔
227
    }
228

229
    /**
230
     * Get user's chat messages.
231
     */
232
    public function chatMessages(): HasMany
18✔
233
    {
234
        return $this->hasMany(ChatMessage::class, 'userid');
18✔
235
    }
236

237
    /**
238
     * Get user's email tracking records.
239
     */
240
    public function emailTracking(): HasMany
1✔
241
    {
242
        return $this->hasMany(EmailTracking::class, 'userid');
1✔
243
    }
244

245
    /**
246
     * Get the user's preferred email address.
247
     *
248
     * Excludes internal Freegle domains (users.ilovefreegle.org, groups.ilovefreegle.org, etc.)
249
     * and Yahoo Groups addresses, matching iznik-server's getEmailPreferred() behavior.
250
     */
251
    public function getEmailPreferredAttribute(): ?string
574✔
252
    {
253
        $emails = $this->emails()
574✔
254
            ->orderByRaw('preferred DESC, validated DESC')
574✔
255
            ->pluck('email');
574✔
256

257
        foreach ($emails as $email) {
574✔
258
            if (!self::isInternalEmail($email)) {
566✔
259
                return $email;
563✔
260
            }
261
        }
262

263
        return NULL;
14✔
264
    }
265

266
    /**
267
     * Check if an email address is an internal Freegle domain that shouldn't receive external mail.
268
     *
269
     * Matches iznik-server's Mail::ourDomain() + GROUP_DOMAIN + yahoogroups filtering.
270
     */
271
    public static function isInternalEmail(string $email): bool
664✔
272
    {
273
        $email = strtolower($email);
664✔
274

275
        // Check against internal domains (users.ilovefreegle.org, groups.ilovefreegle.org, etc.)
276
        $internalDomains = config('freegle.mail.internal_domains', [
664✔
277
            'users.ilovefreegle.org',
664✔
278
            'groups.ilovefreegle.org',
664✔
279
            'direct.ilovefreegle.org',
664✔
280
            'republisher.freegle.in',
664✔
281
        ]);
664✔
282

283
        foreach ($internalDomains as $domain) {
664✔
284
            if (str_contains($email, '@' . strtolower($domain))) {
664✔
285
                return TRUE;
15✔
286
            }
287
        }
288

289
        // Check against excluded domain patterns (e.g., @yahoogroups.)
290
        $excludedPatterns = config('freegle.mail.excluded_domain_patterns', [
656✔
291
            '@yahoogroups.',
656✔
292
        ]);
656✔
293

294
        foreach ($excludedPatterns as $pattern) {
656✔
295
            if (str_contains($email, strtolower($pattern))) {
656✔
296
                return TRUE;
3✔
297
            }
298
        }
299

300
        return FALSE;
654✔
301
    }
302

303
    /**
304
     * Get full name or display name.
305
     * Strips the "-gXXX" suffix from TrashNothing user names.
306
     * Rewrites misleading brand/authority names for non-mods on display
307
     * (Discourse #9587) — storage is untouched.
308
     */
309
    public function getDisplayNameAttribute(): string
176✔
310
    {
311
        $name = null;
176✔
312

313
        if ($this->fullname) {
176✔
314
            $name = $this->fullname;
174✔
315
        } elseif ($this->firstname || $this->lastname) {
2✔
316
            $name = trim("{$this->firstname} {$this->lastname}");
1✔
317
        }
318

319
        if (!$name) {
176✔
320
            return 'Freegle User';
1✔
321
        }
322

323
        $name = self::removeTNGroup($name);
175✔
324

325
        return NameSanitiser::sanitize($name, $this->isNameExempt());
175✔
326
    }
327

328
    /**
329
     * A user is exempt from the display-name sanitiser when they are a
330
     * platform mod/support/admin or Owner/Moderator on any group.
331
     */
332
    public function isNameExempt(): bool
175✔
333
    {
334
        if (in_array($this->systemrole, ['Moderator', 'Support', 'Admin'], TRUE)) {
175✔
335
            return TRUE;
×
336
        }
337
        return $this->isModerator();
175✔
338
    }
339

340
    /**
341
     * Remove TrashNothing group suffix from a name.
342
     * TN users often have names like "Alice-g298" - we hide the "-gXXX" part.
343
     */
344
    public static function removeTNGroup(string $name): string
187✔
345
    {
346
        return preg_replace('/^([\s\S]+?)-g[0-9]+$/', '$1', $name);
187✔
347
    }
348

349
    /**
350
     * Check if user is a moderator of any group.
351
     */
352
    public function isModerator(): bool
189✔
353
    {
354
        return $this->memberships()
189✔
355
            ->whereIn('role', ['Moderator', 'Owner'])
189✔
356
            ->exists();
189✔
357
    }
358

359
    /**
360
     * Check if user is a moderator of a specific group.
361
     */
362
    public function isModeratorOf(int $groupId): bool
33✔
363
    {
364
        return $this->memberships()
33✔
365
            ->where('groupid', $groupId)
33✔
366
            ->whereIn('role', ['Moderator', 'Owner'])
33✔
367
            ->exists();
33✔
368
    }
369

370
    /**
371
     * Get user's last known location.
372
     */
373
    public function lastLocation(): BelongsTo
128✔
374
    {
375
        return $this->belongsTo(Location::class, 'lastlocation');
128✔
376
    }
377

378
    /**
379
     * Get user's first name for personalization.
380
     */
381
    public function getFirstNameAttribute(): ?string
39✔
382
    {
383
        return $this->attributes['firstname'] ?? NULL;
39✔
384
    }
385

386
    /**
387
     * Check if this user is from TrashNothing.
388
     * TN users have email addresses ending in @user.trashnothing.com
389
     */
390
    public function isTN(): bool
73✔
391
    {
392
        $email = $this->email_preferred;
73✔
393
        return $email && str_ends_with($email, '@user.trashnothing.com');
73✔
394
    }
395

396
    /**
397
     * Check if this user is a LoveJunk proxy account.
398
     * LJ users have the ljuserid column set.
399
     */
400
    public function isLJ(): bool
33✔
401
    {
402
        return ! empty($this->attributes['ljuserid']);
33✔
403
    }
404

405
    /**
406
     * Remove an email address from this user.
407
     */
408
    public function removeEmail(string $email, bool $dryRun = false): void
21✔
409
    {
410
        Logger::info("TN-SYNC-TRACE [WRITE] table=users_emails op=delete where=userid={$this->id},email={$email}");
21✔
411

412
        $row = UserEmail::where('userid', $this->id)
21✔
413
            ->where('email', $email)
21✔
414
            ->first();
21✔
415
        if (!$dryRun) {
21✔
416
            $row?->delete();
21✔
417
        }
418
    }
419

420
    /**
421
     * Canonical form of an email address for duplicate detection.
422
     * Mirrors User::canonMail() in iznik-server.
423
     */
424
    public static function canonMail(string $email): string
19✔
425
    {
426
        # Googlemail is Gmail really in US and UK.
427
        $email = str_replace('@googlemail.', '@gmail.', $email);
19✔
428
        $email = str_replace('@googlemail.co.uk', '@gmail.co.uk', $email);
19✔
429

430
        # Canonicalise TN addresses.
431
        if (preg_match('/(.*)\-(.*)(@user.trashnothing.com)/', $email, $matches)) {
19✔
432
            $email = $matches[1] . $matches[3];
3✔
433
        }
434

435
        # Remove plus addressing, which is sometimes used by spammers as a trick, except for Facebook where it
436
        # appears to be genuinely used for routing to distinct users.
437
        #
438
        # O2 puts a + at the start of an email address.  That would lead to us canonicalising all emails the same.
439
        if (substr($email, 0, 1) !== '+' && preg_match('/(.*)\+(.*)(@.*)/', $email, $matches) && strpos($email, '@proxymail.facebook.com') === FALSE) {
19✔
440
            $email = $matches[1] . $matches[3];
1✔
441
        }
442

443
        # Remove dots in LHS, which are ignored by gmail and can therefore be used to give the appearance of separate
444
        # emails.
445
        $p = strpos($email, '@');
19✔
446

447
        if ($p !== FALSE) {
19✔
448
            $lhs = substr($email, 0, $p);
19✔
449
            $rhs = substr($email, $p);
19✔
450

451
            if (stripos($rhs, '@gmail') !== FALSE || stripos($rhs, '@googlemail') !== FALSE) {
19✔
452
                $lhs = str_replace('.', '', $lhs);
3✔
453
            }
454

455
            # Remove dots from the RHS - saves a little space and is the format we have historically used.
456
            # Very unlikely to introduce ambiguity.
457
            $email = $lhs . str_replace('.', '', $rhs);
19✔
458
        }
459

460
        return $email;
19✔
461
    }
462

463
    public function addEmail(string $email, int $primary = 1, bool $changeprimary = TRUE, bool $dryRun = false): ?int
13✔
464
    {
465
        $email = trim($email);
13✔
466

467
        $groupDomain = config('freegle.group_domain');
13✔
468

469
        if (
470
            stripos($email, '-owner@yahoogroups.co') !== FALSE ||
13✔
471
            stripos($email, "-volunteers@{$groupDomain}") !== FALSE ||
13✔
472
            stripos($email, "-auto@{$groupDomain}") !== FALSE
13✔
473
        ) {
474
            # We don't allow people to add Yahoo owner addresses as the address of an individual user, or
475
            # the volunteer addresses.
476
            $rc = NULL;
3✔
477
        } else if (stripos($email, 'replyto-') !== FALSE || stripos($email, 'notify-') !== FALSE) {
10✔
478
            # This can happen because of dodgy email clients replying to the wrong place.  We don't want to end up
479
            # with this getting added to the user.
480
            $rc = NULL;
2✔
481
        } else {
482
            # If the email already exists in the table, then that's fine.  But we don't want to use INSERT IGNORE as
483
            # that scales badly for clusters.
484
            $canon = self::canonMail($email);
8✔
485

486
            $emails = UserEmail::select('id', 'preferred')
8✔
487
                ->where('userid', $this->id)
8✔
488
                ->where('email', $email)
8✔
489
                ->get();
8✔
490

491
            if ($emails->isEmpty()) {
8✔
492
                Logger::info("TN-SYNC-TRACE [WRITE] table=users_emails op=insert where=userid={$this->id}");
8✔
493
                if (!$dryRun) {
8✔
494
                    $newEmail = UserEmail::create([
8✔
495
                        'userid' => $this->id,
8✔
496
                        'email' => $email,
8✔
497
                        'preferred' => $primary,
8✔
498
                        'canon' => $canon,
8✔
499
                        'backwards' => strrev(strtolower($email)),
8✔
500
                    ]);
8✔
501
                    $rc = $newEmail->id;
8✔
502
                } else {
503
                    $rc = true;
×
504
                }
505

506
                if ($rc && $primary) {
8✔
507
                    Logger::info("TN-SYNC-TRACE [WRITE] table=users_emails op=update where=userid={$this->id},id!={$rc} set=preferred=0");
7✔
508
                    # Make sure no other email is flagged as primary
509
                    UserEmail::where('userid', $this->id)
7✔
510
                        ->where('id', '!=', $rc)
7✔
511
                        ->where('preferred', '!=', 0)
7✔
512
                        ->get()
7✔
513
                        ->each(function ($other) use ($dryRun) {
7✔
514
                            $other->preferred = 0;
7✔
515
                            if (!$dryRun) {
7✔
516
                                $other->save();
7✔
517
                            }
518
                        });
7✔
519
                }
520
            } else {
521
                $rc = $emails[0]->id;
1✔
522

523
                if ($changeprimary && $primary != $emails[0]->preferred) {
1✔
524
                    Logger::info("TN-SYNC-TRACE [WRITE] table=users_emails op=update where=id={$rc} set=preferred={$primary}");
×
525
                    # Change in status.
526
                    $existing = UserEmail::find($rc);
×
527
                    if ($existing) {
×
528
                        $existing->preferred = $primary;
×
529
                        if (!$dryRun) {
×
530
                            $existing->save();
×
531
                        }
532
                    }
533
                }
534

535
                if ($primary) {
1✔
536
                    Logger::info("TN-SYNC-TRACE [WRITE] table=users_emails op=update where=userid={$this->id},id!={$rc} set=preferred=0");
1✔
537
                    # Make sure no other email is flagged as primary
538
                    UserEmail::where('userid', $this->id)
1✔
539
                        ->where('id', '!=', $rc)
1✔
540
                        ->where('preferred', '!=', 0)
1✔
541
                        ->get()
1✔
542
                        ->each(function ($other) use ($dryRun) {
1✔
543
                            $other->preferred = 0;
×
544
                            if (!$dryRun) {
×
545
                                $other->save();
×
546
                            }
547
                        });
1✔
548

549
                    # If we've set an email we might no longer be bouncing.
550
                    $this->unbounce($rc, $dryRun);
1✔
551
                }
552
            }
553
        }
554

555
        $this->assignUserToToDonation($email, $this->id, $dryRun);
13✔
556

557
        return $rc;
13✔
558
    }
559

560
    /**
561
     * Haven't ported over logging behavior, add that if needed later.
562
     */
563
    public function unbounce(int $emailid, bool $dryRun = false): void
1✔
564
    {
565
        if ($emailid) {
1✔
566
            Logger::info("TN-SYNC-TRACE [WRITE] table=bounces_emails op=update where=emailid={$emailid} set=reset=1");
1✔
567
            BounceEmail::where('emailid', $emailid)
1✔
568
                ->where('reset', '!=', 1)
1✔
569
                ->get()
1✔
570
                ->each(function ($bounce) use ($dryRun) {
1✔
571
                    $bounce->reset = 1;
×
572
                    if (!$dryRun) {
×
573
                        $bounce->save();
×
574
                    }
575
                });
1✔
576
        }
577

578
        Logger::info("TN-SYNC-TRACE [WRITE] table=users op=update where=id={$this->id} set=bouncing=0");
1✔
579
        if ($this->bouncing != 0) {
1✔
580
            $this->bouncing = 0;
×
581
            if (!$dryRun) {
×
582
                $this->save();
×
583
            }
584
        }
585
    }
586

587
    public function assignUserToToDonation(string $email, int $userid, bool $dryRun = false): void
13✔
588
    {
589
        $email = trim($email);
13✔
590

591
        if (strlen($email)) {
13✔
592
            # We might have donations made via PayPal using this email address which we can now link to this user.  Do
593
            # SELECT first to avoid this having to replicate in the cluster.
594
            $donations = UserDonation::where('Payer', $email)
13✔
595
                ->whereNull('userid')
13✔
596
                ->get();
13✔
597

598
            foreach ($donations as $donation) {
13✔
599
                // Check if user exists before updating to avoid foreign key constraint violations
600
                $userExists = User::where('id', $userid)->exists();
×
601
                if ($userExists) {
×
602
                    Logger::info("TN-SYNC-TRACE [WRITE] table=users_donations op=update where=id={$donation->id} set=userid={$userid}");
×
603
                    $donation->userid = $userid;
×
604
                    if (!$dryRun) {
×
605
                        $donation->save();
×
606
                    }
607
                }
608
            }
609
        }
610
    }
611

612
    /**
613
     * Check if a notification type is enabled for this user.
614
     *
615
     * @param string $type The notification type (email, emailmine, push)
616
     * @param int|null $groupId Optional group ID for mod-specific checks
617
     */
618
    public function notifsOn(string $type, ?int $groupId = NULL): bool
44✔
619
    {
620
        // emailmine is never honoured for TN or LJ proxy users. Their "real"
621
        // inbox is the partner's proxy address, so a self-copy is delivered
622
        // back to themselves and looks like someone else is sending their
623
        // own words at them.
624
        if ($type === self::NOTIFS_EMAIL_MINE && ($this->isTN() || $this->isLJ())) {
44✔
625
            return FALSE;
2✔
626
        }
627

628
        // Default values for notification types.
629
        $defaults = [
42✔
630
            'email' => TRUE,
42✔
631
            'emailmine' => FALSE,
42✔
632
            'push' => TRUE,
42✔
633
        ];
42✔
634

635
        $settings = $this->settings ?? [];
42✔
636
        $notifs = $settings['notifications'] ?? [];
42✔
637

638
        $result = isset($notifs[$type]) ? (bool) $notifs[$type] : ($defaults[$type] ?? TRUE);
42✔
639

640
        // For group-specific checks, verify user is an active mod.
641
        if ($result && $groupId) {
42✔
642
            $result = $this->isModeratorOf($groupId);
12✔
643
        }
644

645
        return $result;
42✔
646
    }
647

648
    /**
649
     * Notification type constants matching iznik-server.
650
     */
651
    public const NOTIFS_EMAIL = 'email';
652
    public const NOTIFS_EMAIL_MINE = 'emailmine';
653
    public const NOTIFS_PUSH = 'push';
654

655
    /**
656
     * Simple mail setting constants matching iznik-server.
657
     *
658
     * SIMPLE_MAIL_NONE: Completely disables all emails.
659
     * SIMPLE_MAIL_BASIC: Daily digest, chat replies only.
660
     * SIMPLE_MAIL_FULL: Immediate notifications, all email types.
661
     */
662
    public const SIMPLE_MAIL_NONE = 'None';
663
    public const SIMPLE_MAIL_BASIC = 'Basic';
664
    public const SIMPLE_MAIL_FULL = 'Full';
665

666
    /**
667
     * Get the user's simple mail setting.
668
     *
669
     * @return string|null One of SIMPLE_MAIL_* constants or null if not set
670
     */
671
    public function getSimpleMail(): ?string
33✔
672
    {
673
        $settings = $this->settings ?? [];
33✔
674
        return $settings['simplemail'] ?? null;
33✔
675
    }
676

677
    /**
678
     * Apply the same filters V1's User::sendOurMails() applies, as SQL clauses
679
     * suitable for joining onto bulk-mail queries (events digest, volunteering
680
     * digest, etc). Restricts to:
681
     *
682
     *   - not soft-deleted
683
     *   - lastaccess within USER_INACTIVE_DAYS (default 182)
684
     *   - simplemail (in users.settings JSON) != 'None'
685
     *   - $checkHoliday: onholidaytill is null or in the past
686
     *   - $checkBouncing: bouncing = 0
687
     *
688
     * Use as a `whereHas` predicate or chained after a `join('users', …)`.
689
     * The simplemail check uses JSON_UNQUOTE/JSON_EXTRACT so it filters at the
690
     * database without materialising every user row in PHP.
691
     *
692
     * IMPORTANT: requires the join to alias the users table as `users`
693
     * (or use the qualified column names). For ambiguous joins, scope each
694
     * filter explicitly with `users.<column>`.
695
     */
696
    public function scopeReceivingOurMails(
37✔
697
        Builder $query,
698
        bool $checkHoliday = TRUE,
699
        bool $checkBouncing = TRUE,
700
    ): Builder {
701
        $query
37✔
702
            ->whereNull('users.deleted')
37✔
703
            ->where('users.lastaccess', '>=', now()->subDays(self::USER_INACTIVE_DAYS))
37✔
704
            ->where(function (Builder $q) {
37✔
705
                // simplemail in settings JSON is either absent (default = TRUE)
706
                // or any value other than 'None'.
707
                $q->whereRaw("JSON_EXTRACT(users.settings, '$.simplemail') IS NULL")
37✔
708
                    ->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(users.settings, '$.simplemail')) != ?", [
37✔
709
                        self::SIMPLE_MAIL_NONE,
37✔
710
                    ]);
37✔
711
            });
37✔
712

713
        if ($checkHoliday) {
37✔
714
            $query->where(function (Builder $q) {
37✔
715
                $q->whereNull('users.onholidaytill')
37✔
716
                    ->orWhere('users.onholidaytill', '<', now());
37✔
717
            });
37✔
718
        }
719

720
        if ($checkBouncing) {
37✔
721
            $query->where('users.bouncing', 0);
37✔
722
        }
723

724
        return $query;
37✔
725
    }
726

727
    /**
728
     * Determine if user wants digest emails based on their simplemail setting.
729
     * Falls back to checking per-group emailfrequency if simplemail is not set.
730
     *
731
     * @return bool True if user should receive digest emails
732
     */
733
    public function wantsDigestEmails(): bool
×
734
    {
735
        $simpleMail = $this->getSimpleMail();
×
736

737
        if ($simpleMail !== null) {
×
738
            // User has a simplemail preference.
739
            return $simpleMail !== self::SIMPLE_MAIL_NONE;
×
740
        }
741

742
        // Fall back to checking if any membership has a non-zero email frequency.
743
        return $this->memberships()
×
744
            ->where('emailfrequency', '!=', 0)
×
745
            ->exists();
×
746
    }
747

748
    /**
749
     * Whether we should send our mails to this user.
750
     *
751
     * Ported from iznik-server/include/user/User.php::sendOurMails().
752
     *
753
     * @param bool $checkHoliday Whether to check if user is on holiday
754
     * @param bool $checkBouncing Whether to check if user's email is bouncing
755
     * @return bool TRUE if this user should receive emails
756
     */
757
    public function sendOurMails(bool $checkHoliday = TRUE, bool $checkBouncing = TRUE): bool
16✔
758
    {
759
        if ($this->deleted) {
16✔
760
            return FALSE;
×
761
        }
762

763
        // We have two kinds of email settings - the top-level Simple one, and more detailed per group ones.
764
        // Where present the Simple one overrides the group ones, so check that first.
765
        $simpleMail = $this->getSimpleMail();
16✔
766
        if ($simpleMail === self::SIMPLE_MAIL_NONE) {
16✔
767
            return FALSE;
×
768
        }
769

770
        // We don't want to send emails to people who haven't been active for more than six months.  This improves
771
        // our spam reputation, by avoiding honeytraps.
772
        // This time is also present on the client in ModMember, and in Engage.
773
        $sendIt = FALSE;
16✔
774
        $lastaccess = $this->lastaccess;
16✔
775

776
        if ($lastaccess !== NULL && $lastaccess->timestamp >= (time() - self::USER_INACTIVE)) {
16✔
777
            $sendIt = TRUE;
16✔
778

779
            if ($sendIt && $checkHoliday) {
16✔
780
                // We might be on holiday.
781
                $hol = $this->onholidaytill;
14✔
782
                $till = $hol ? strtotime($hol) : 0;
14✔
783

784
                $sendIt = time() > $till;
14✔
785
            }
786

787
            if ($sendIt && $checkBouncing) {
16✔
788
                // And don't send if we're bouncing.
789
                $sendIt = !$this->bouncing;
14✔
790
            }
791
        }
792

793
        return $sendIt;
16✔
794
    }
795

796
    /**
797
     * Determine if user wants immediate notifications based on their simplemail setting.
798
     *
799
     * @return bool True if user should receive immediate notifications
800
     */
801
    public function wantsImmediateNotifications(): bool
×
802
    {
803
        $simpleMail = $this->getSimpleMail();
×
804

805
        if ($simpleMail !== null) {
×
806
            return $simpleMail === self::SIMPLE_MAIL_FULL;
×
807
        }
808

809
        // Fall back to checking if any membership has immediate frequency (-1).
810
        return $this->memberships()
×
811
            ->where('emailfrequency', -1)
×
812
            ->exists();
×
813
    }
814

815
    /**
816
     * Get user's latitude and longitude from their last location.
817
     *
818
     * @return array [lat, lng] or [null, null] if not available
819
     */
820
    public function getLatLng(): array
127✔
821
    {
822
        $location = $this->lastLocation;
127✔
823
        if (!$location) {
127✔
824
            return [NULL, NULL];
85✔
825
        }
826

827
        // Locations have a geometry column with a POINT.
828
        if ($location->lat && $location->lng) {
42✔
829
            return [$location->lat, $location->lng];
42✔
830
        }
831

832
        return [NULL, NULL];
×
833
    }
834

835
    /**
836
     * Get job ads for this user based on their location.
837
     *
838
     * @return array ['jobs' => Collection, 'location' => string|null]
839
     */
840
    public function getJobAds(): array
81✔
841
    {
842
        [$lat, $lng] = $this->getLatLng();
81✔
843

844
        // Suppress job ads for recent donors, matching the website (recentDonor
845
        // in useMe.js / ADFREE_PERIOD in iznik-server-go): ad-free for 31 days
846
        // after a donation, 41 for External (bank-transfer) ones that arrive late.
847
        if (!$lat || !$lng || $this->isAdFree()) {
81✔
848
            return [
80✔
849
                'jobs' => collect(),
80✔
850
                'location' => NULL,
80✔
851
            ];
80✔
852
        }
853

854
        $jobs = Job::nearLocation($lat, $lng, 4);
1✔
855

856
        return [
1✔
857
            'jobs' => $jobs,
1✔
858
            'location' => NULL,  // Not currently used.
1✔
859
        ];
1✔
860
    }
861

862
    /**
863
     * True when the user is within their ad-free period after a donation,
864
     * mirroring iznik-server-go ADFREE_PERIOD (31 days) + ADFREE_GRACE_PERIOD
865
     * (10 extra days for External / bank-transfer donations).
866
     */
867
    public function isAdFree(): bool
3✔
868
    {
869
        $latest = DB::table('users_donations')
3✔
870
            ->where('userid', $this->id)
3✔
871
            ->orderByDesc('timestamp')
3✔
872
            ->first(['timestamp', 'type']);
3✔
873

874
        if (!$latest || !$latest->timestamp) {
3✔
875
            return false;
2✔
876
        }
877

878
        $days = ($latest->type === 'External') ? 41 : 31;
2✔
879

880
        return strtotime($latest->timestamp) > (time() - $days * 86400);
2✔
881
    }
882

883
    /**
884
     * Get the user's profile image URL.
885
     *
886
     * Profile images are stored in users_images table with the image ID used in the URL.
887
     * Format: https://{IMAGE_DOMAIN}/tuimg_{image_id}.jpg (thumbnail)
888
     *
889
     * @param bool $thumbnail Whether to get thumbnail (tuimg_) or full size (uimg_)
890
     * @return string|null The profile image URL or null if no profile image
891
     */
892
    public function getProfileImageUrl(bool $thumbnail = TRUE): ?string
111✔
893
    {
894
        // Find the user's profile image, preferring the default one.
895
        $profileImage = UserImage::where('userid', $this->id)
111✔
896
            ->orderByDesc('default')
111✔
897
            ->orderBy('id')
111✔
898
            ->first(['id', 'url']);
111✔
899

900
        if (!$profileImage) {
111✔
901
            return NULL;
111✔
902
        }
903

904
        // If there's an external URL, use it directly.
905
        if (!empty($profileImage->url)) {
×
906
            return $profileImage->url;
×
907
        }
908

909
        // Build URL from image domain.
910
        $imagesDomain = config('freegle.images.domain', 'https://images.ilovefreegle.org');
×
911
        $prefix = $thumbnail ? 'tuimg_' : 'uimg_';
×
912

913
        return "{$imagesDomain}/{$prefix}{$profileImage->id}.jpg";
×
914
    }
915

916
    /**
917
     * Login type for one-click unsubscribe links.
918
     */
919
    public const LOGIN_LINK = 'Link';
920

921
    /**
922
     * Get user's key for one-click unsubscribe/login links.
923
     * Creates one if it doesn't exist.
924
     *
925
     * @return string The user key
926
     */
927
    public function getUserKey(): string
27✔
928
    {
929
        // Check for existing LOGIN_LINK credential.
930
        $login = UserLogin::where('userid', $this->id)
27✔
931
            ->where('type', self::LOGIN_LINK)
27✔
932
            ->first(['credentials']);
27✔
933

934
        if ($login && $login->credentials) {
27✔
935
            return $login->credentials;
8✔
936
        }
937

938
        // Create a new key.
939
        $key = bin2hex(random_bytes(16));
27✔
940

941
        UserLogin::create([
27✔
942
            'userid' => $this->id,
27✔
943
            'type' => self::LOGIN_LINK,
27✔
944
            'credentials' => $key,
27✔
945
        ]);
27✔
946

947
        return $key;
27✔
948
    }
949

950
    /**
951
     * Build an auto-login link, mirroring iznik-server User::loginLink($auto=TRUE).
952
     *
953
     * Produces `https://{userSite}{url}?u={id}&k={key}&src={src}` using the same
954
     * users_logins (type='Link') 32-char key the Go API validates for ?u=&k= links.
955
     *
956
     * @param  string  $url   Path on the user site (may already contain a query string).
957
     * @param  string|null  $src  Optional source tag (V1 src= param).
958
     * @param  bool  $auto  When true (default) include the login key for passwordless login.
959
     */
960
    public function loginLink(string $url = '/', ?string $src = NULL, bool $auto = TRUE): string
8✔
961
    {
962
        $userSite = rtrim(config('freegle.sites.user', 'https://www.ilovefreegle.org'), '/');
8✔
963
        $sep = str_contains($url, '?') ? '&' : '?';
8✔
964

965
        $query = 'u=' . $this->id;
8✔
966
        if ($auto) {
8✔
967
            $query .= '&k=' . $this->getUserKey();
8✔
968
        }
969
        if ($src) {
8✔
970
            $query .= '&src=' . $src;
8✔
971
        }
972

973
        return $userSite . $url . $sep . $query;
8✔
974
    }
975

976
    /**
977
     * Generate List-Unsubscribe header value for RFC 8058 one-click unsubscribe.
978
     *
979
     * @return string The unsubscribe URL in angle brackets
980
     */
981
    public function listUnsubscribe(): string
12✔
982
    {
983
        return "<{$this->listUnsubscribeUrl()}>";
12✔
984
    }
985

986
    /**
987
     * Generate the one-click unsubscribe URL for use in email links.
988
     *
989
     * @return string The unsubscribe URL
990
     */
991
    public function listUnsubscribeUrl(): string
12✔
992
    {
993
        $key = $this->getUserKey();
12✔
994
        $userSite = config('freegle.sites.user', 'https://www.ilovefreegle.org');
12✔
995

996
        return "{$userSite}/one-click-unsubscribe/{$this->id}/{$key}";
12✔
997
    }
998

999
    /**
1000
     * Generate the marketing opt-out URL for marketing/non-essential admin emails.
1001
     *
1002
     * Uses the same getUserKey() mechanism as unsubscribe for authentication.
1003
     *
1004
     * @return string The marketing opt-out URL
1005
     */
1006
    public function marketingOptOutUrl(): string
3✔
1007
    {
1008
        $key = $this->getUserKey();
3✔
1009
        $userSite = config('freegle.sites.user', 'https://www.ilovefreegle.org');
3✔
1010

1011
        return "{$userSite}/marketing-optout?u={$this->id}&k={$key}";
3✔
1012
    }
1013

1014
    /**
1015
     * Check if this user allows merging.
1016
     * Users can set canmerge=false in their settings to prevent being merged.
1017
     */
1018
    public function canMerge(): bool
17✔
1019
    {
1020
        $settings = $this->settings ?? [];
17✔
1021
        return $settings['canmerge'] ?? TRUE;
17✔
1022
    }
1023

1024
    /**
1025
     * Return the highest privilege group role between two roles.
1026
     * Order: Owner > Moderator > Member > Non-member
1027
     */
1028
    public static function roleMax(string $role1, string $role2): string
3✔
1029
    {
1030
        $role = self::ROLE_NONMEMBER;
3✔
1031

1032
        if ($role1 === self::ROLE_MEMBER || $role2 === self::ROLE_MEMBER) {
3✔
1033
            $role = self::ROLE_MEMBER;
3✔
1034
        }
1035

1036
        if ($role1 === self::ROLE_MODERATOR || $role2 === self::ROLE_MODERATOR) {
3✔
1037
            $role = self::ROLE_MODERATOR;
1✔
1038
        }
1039

1040
        if ($role1 === self::ROLE_OWNER || $role2 === self::ROLE_OWNER) {
3✔
1041
            $role = self::ROLE_OWNER;
×
1042
        }
1043

1044
        return $role;
3✔
1045
    }
1046

1047
    /**
1048
     * Return the highest system role between two roles.
1049
     * Order: Admin > Support > Moderator > User
1050
     */
1051
    public static function systemRoleMax(string $role1, string $role2): string
17✔
1052
    {
1053
        $role = self::SYSTEMROLE_USER;
17✔
1054

1055
        if ($role1 === self::SYSTEMROLE_MODERATOR || $role2 === self::SYSTEMROLE_MODERATOR) {
17✔
1056
            $role = self::SYSTEMROLE_MODERATOR;
×
1057
        }
1058

1059
        if ($role1 === self::SYSTEMROLE_SUPPORT || $role2 === self::SYSTEMROLE_SUPPORT) {
17✔
1060
            $role = self::SYSTEMROLE_SUPPORT;
1✔
1061
        }
1062

1063
        if ($role1 === self::SYSTEMROLE_ADMIN || $role2 === self::SYSTEMROLE_ADMIN) {
17✔
1064
            $role = self::SYSTEMROLE_ADMIN;
×
1065
        }
1066

1067
        return $role;
17✔
1068
    }
1069

1070
    /**
1071
     * Merge two user accounts, consolidating $id2 into $id1.
1072
     *
1073
     * Merges memberships (taking highest role, oldest join date), emails,
1074
     * chat rooms, user attributes, logs, gift aid, and 40+ foreign key tables.
1075
     * The secondary user ($id2) is deleted after a successful merge.
1076
     *
1077
     * Ported from iznik-server/include/user/User.php::merge().
1078
     *
1079
     * @param int $id1 The user ID to keep (merge target)
1080
     * @param int $id2 The user ID to absorb and delete
1081
     * @param string $reason Human-readable reason for the merge
1082
     * @param bool $forceMerge If TRUE, bypass canMerge() checks
1083
     * @param int|null $byUserId The user performing the merge (for logging)
1084
     * @return bool TRUE on success, FALSE on failure or if merge is blocked
1085
     */
1086
    public static function merge(int $id1, int $id2, string $reason, bool $forceMerge = FALSE, ?int $byUserId = NULL, bool $dryRun = false): bool
20✔
1087
    {
1088
        Logger::info("Merge {$id2} into {$id1}, {$reason}");
20✔
1089

1090
        if ($id1 === $id2) {
20✔
1091
            return FALSE;
1✔
1092
        }
1093

1094
        $u1 = self::find($id1);
19✔
1095
        $u2 = self::find($id2);
19✔
1096

1097
        if (!$u1 || !$u2) {
19✔
1098
            return FALSE;
1✔
1099
        }
1100

1101
        if (!$forceMerge && (!$u1->canMerge() || !$u2->canMerge())) {
18✔
1102
            return FALSE;
1✔
1103
        }
1104

1105
        try {
1106

1107
            # We want to merge two users.  At present we just merge the memberships, comments, emails and logs; we don't try to
1108
            # merge any conflicting settings.
1109
            #
1110
            # Both users might have membership of the same group, including at different levels.
1111
            #
1112
            # A useful query to find foreign key references is of this form:
1113
            #
1114
            # USE information_schema; SELECT * FROM KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = 'iznik' AND REFERENCED_TABLE_NAME = 'users';
1115
            #
1116
            # We avoid too much use of quoting in preQuery/preExec because quoted numbers can't use a numeric index and therefore
1117
            # perform slowly.
1118

1119
            DB::beginTransaction();
17✔
1120

1121
            // --- Merge memberships ---
1122
            $id2Memberships = Membership::where('userid', $id2)->get();
17✔
1123

1124
            # Merge the top-level memberships
1125
            foreach ($id2Memberships as $id2Memb) {
17✔
1126
                $id1Memb = Membership::where('userid', $id1)
4✔
1127
                    ->where('groupid', $id2Memb->groupid)
4✔
1128
                    ->first();
4✔
1129

1130
                if (!$id1Memb) {
4✔
1131
                    // id1 is not already a member — just reassign the membership.
1132
                    $id2Memb->userid = $id1;
1✔
1133
                    Logger::info("TN-SYNC-TRACE [WRITE] table=memberships op=update where=userid={$id2},groupid={$id2Memb->groupid} set=userid={$id1}");
1✔
1134
                    if (!$dryRun) {
1✔
1135
                        $id2Memb->save();
1✔
1136
                    }
1137
                } else {
1138
                    // Both are members — merge: take highest role, oldest date, non-NULL attributes.
1139
                    $role = self::roleMax($id1Memb->role, $id2Memb->role);
3✔
1140

1141
                    if ($role !== $id1Memb->role) {
3✔
1142
                        $id1Memb->role = $role;
1✔
1143
                        Logger::info("TN-SYNC-TRACE [WRITE] table=memberships op=update where=userid={$id1},groupid={$id2Memb->groupid} set=role={$role}");
1✔
1144
                    }
1145

1146
                    // Keep the older added date.
1147
                    $date = min(strtotime($id1Memb->added), strtotime($id2Memb->added));
3✔
1148
                    $id1Memb->added = date('Y-m-d H:i:s', $date);
3✔
1149
                    Logger::info("TN-SYNC-TRACE [WRITE] table=memberships op=update where=userid={$id1},groupid={$id2Memb->groupid} set=added={$id1Memb->added}");
3✔
1150

1151
                    // Take non-NULL values from id2 for these attributes.
1152
                    foreach (['configid', 'settings', 'heldby'] as $key) {
3✔
1153
                        if ($id2Memb->$key !== NULL) {
3✔
1154
                            $id1Memb->$key = $id2Memb->$key;
×
1155
                            Logger::info("TN-SYNC-TRACE [WRITE] table=memberships op=update where=userid={$id1},groupid={$id2Memb->groupid} set={$key}=" . (is_string($id2Memb->$key) && strlen($id2Memb->$key) > 50 ? ('len=' . strlen($id2Memb->$key)) : $id2Memb->$key));
×
1156
                        }
1157
                    }
1158

1159
                    if (!$dryRun) {
3✔
1160
                        $id1Memb->save();
3✔
1161
                    }
1162
                }
1163
            }
1164

1165
            // --- Merge emails ---
1166
            // Both might have a primary (preferred) address; id1 wins.
1167
            $primary = NULL;
17✔
1168
            $foundPrim = FALSE;
17✔
1169

1170
            $id2PrimaryEmail = UserEmail::where('userid', $id2)
17✔
1171
                ->where('preferred', 1)
17✔
1172
                ->first();
17✔
1173

1174
            if ($id2PrimaryEmail) {
17✔
1175
                $primary = $id2PrimaryEmail->id;
17✔
1176
                $foundPrim = TRUE;
17✔
1177
            }
1178

1179
            $id1PrimaryEmail = UserEmail::where('userid', $id1)
17✔
1180
                ->where('preferred', 1)
17✔
1181
                ->first();
17✔
1182

1183
            if ($id1PrimaryEmail) {
17✔
1184
                $primary = $id1PrimaryEmail->id;
17✔
1185
                $foundPrim = TRUE;
17✔
1186
            }
1187

1188
            if (!$foundPrim) {
17✔
1189
                // No primary — use whatever getEmailPreferred would choose for id1.
1190
                $preferredEmail = $u1->email_preferred;
×
1191
                if ($preferredEmail) {
×
1192
                    $emailRow = UserEmail::where('email', $preferredEmail)
×
1193
                        ->first();
×
1194
                    if ($emailRow) {
×
1195
                        $primary = $emailRow->id;
×
1196
                    }
1197
                }
1198
            }
1199

1200
            // Move all id2 emails to id1, clearing preferred.
1201
            Logger::info("TN-SYNC-TRACE [WRITE] table=users_emails op=update where=userid={$id2} set=userid={$id1},preferred=0");
17✔
1202
            UserEmail::where('userid', $id2)->get()->each(function ($emailRow) use ($id1, $dryRun) {
17✔
1203
                $emailRow->userid = $id1;
17✔
1204
                $emailRow->preferred = 0;
17✔
1205
                if (!$dryRun) {
17✔
1206
                    $emailRow->save();
17✔
1207
                }
1208
            });
17✔
1209

1210
            if ($primary) {
17✔
1211
                Logger::info("TN-SYNC-TRACE [WRITE] table=users_emails op=update where=id={$primary} set=preferred=1");
17✔
1212
                $primaryRow = UserEmail::find($primary);
17✔
1213
                if ($primaryRow) {
17✔
1214
                    $primaryRow->preferred = 1;
17✔
1215
                    if (!$dryRun) {
17✔
1216
                        $primaryRow->save();
17✔
1217
                    }
1218
                }
1219
            }
1220

1221
            // Merge other foreign keys where success is less important.  For some of these there might already
1222
            // be entries, so we do an IGNORE.
1223
            EloquentUtils::reparentRow(LocationExcluded::class, 'userid', $id2, $id1, $dryRun);
17✔
1224
            EloquentUtils::reparentRowIgnore(ChatRoster::class, 'userid', $id2, $id1, $dryRun);
17✔
1225
            EloquentUtils::reparentRowIgnore(UserSession::class, 'userid', $id2, $id1, $dryRun);
17✔
1226
            EloquentUtils::reparentRowIgnore(SpamUser::class, 'userid', $id2, $id1, $dryRun);
17✔
1227
            EloquentUtils::reparentRowIgnore(SpamUser::class, 'byuserid', $id2, $id1, $dryRun);
17✔
1228
            EloquentUtils::reparentRowIgnore(UserAddress::class, 'userid', $id2, $id1, $dryRun);
17✔
1229
            EloquentUtils::reparentRow(UserComment::class, 'userid', $id2, $id1, $dryRun);
17✔
1230
            EloquentUtils::reparentRow(UserComment::class, 'byuserid', $id2, $id1, $dryRun);
17✔
1231
            EloquentUtils::reparentRowIgnore(UserDonation::class, 'userid', $id2, $id1, $dryRun);
17✔
1232
            EloquentUtils::reparentRowIgnore(UserImage::class, 'userid', $id2, $id1, $dryRun);
17✔
1233
            EloquentUtils::reparentRowIgnore(UserInvitation::class, 'userid', $id2, $id1, $dryRun);
17✔
1234
            EloquentUtils::reparentRow(UserLogin::class, 'userid', $id2, $id1, $dryRun);
17✔
1235
            // Update Native login uid to match new userid.
1236
            Logger::info("TN-SYNC-TRACE [WRITE] table=users_logins op=update where=userid={$id1},type=" . self::LOGIN_NATIVE . " set=uid={$id1}");
17✔
1237
            UserLogin::where('userid', $id1)
17✔
1238
                ->where('type', self::LOGIN_NATIVE)
17✔
1239
                ->where('uid', '!=', (string) $id1)
17✔
1240
                ->get()
17✔
1241
                ->each(function ($nativeLogin) use ($id1, $dryRun) {
17✔
1242
                    try {
1243
                        $nativeLogin->uid = (string) $id1;
×
1244
                        if (!$dryRun) {
×
1245
                            $nativeLogin->save();
×
1246
                        }
1247
                    } catch (QueryException $e) {
×
1248
                        Logger::warning("Native login uid update conflict for {$nativeLogin->getKey()}: " . $e->getMessage());
×
1249
                    }
1250
                });
17✔
1251
            EloquentUtils::reparentRowIgnore(UserNearby::class, 'userid', $id2, $id1, $dryRun);
17✔
1252
            EloquentUtils::reparentRowIgnore(Notification::class, 'fromuser', $id2, $id1, $dryRun);
17✔
1253
            EloquentUtils::reparentRowIgnore(Notification::class, 'touser', $id2, $id1, $dryRun);
17✔
1254
            EloquentUtils::reparentRowIgnore(UserNudge::class, 'fromuser', $id2, $id1, $dryRun);
17✔
1255
            EloquentUtils::reparentRowIgnore(UserNudge::class, 'touser', $id2, $id1, $dryRun);
17✔
1256
            EloquentUtils::reparentRowIgnore(UserPushNotification::class, 'userid', $id2, $id1, $dryRun);
17✔
1257
            EloquentUtils::reparentRowIgnore(UserRequest::class, 'userid', $id2, $id1, $dryRun);
17✔
1258
            EloquentUtils::reparentRowIgnore(UserRequest::class, 'completedby', $id2, $id1, $dryRun);
17✔
1259
            EloquentUtils::reparentRowIgnore(UserSearch::class, 'userid', $id2, $id1, $dryRun);
17✔
1260
            EloquentUtils::reparentRowIgnore(Newsfeed::class, 'userid', $id2, $id1, $dryRun);
17✔
1261
            EloquentUtils::reparentRowIgnore(MessageReneged::class, 'userid', $id2, $id1, $dryRun);
17✔
1262
            EloquentUtils::reparentRowIgnore(UserStory::class, 'userid', $id2, $id1, $dryRun);
17✔
1263
            EloquentUtils::reparentRowIgnore(UserStoryLike::class, 'userid', $id2, $id1, $dryRun);
17✔
1264
            EloquentUtils::reparentRowIgnore(UserStoryRequested::class, 'userid', $id2, $id1, $dryRun);
17✔
1265
            EloquentUtils::reparentRowIgnore(UserThanks::class, 'userid', $id2, $id1, $dryRun);
17✔
1266
            EloquentUtils::reparentRowIgnore(ModNotif::class, 'userid', $id2, $id1, $dryRun);
17✔
1267
            EloquentUtils::reparentRowIgnore(TeamMember::class, 'userid', $id2, $id1, $dryRun);
17✔
1268
            EloquentUtils::reparentRowIgnore(UserAboutMe::class, 'userid', $id2, $id1, $dryRun);
17✔
1269
            EloquentUtils::reparentRowIgnore(Rating::class, 'rater', $id2, $id1, $dryRun);
17✔
1270
            EloquentUtils::reparentRowIgnore(Rating::class, 'ratee', $id2, $id1, $dryRun);
17✔
1271
            EloquentUtils::reparentRowIgnore(UserReplyTime::class, 'userid', $id2, $id1, $dryRun);
17✔
1272
            EloquentUtils::reparentRowIgnore(MessagePromise::class, 'userid', $id2, $id1, $dryRun);
17✔
1273
            EloquentUtils::reparentRowIgnore(MessageBy::class, 'userid', $id2, $id1, $dryRun);
17✔
1274
            EloquentUtils::reparentRowIgnore(Tryst::class, 'user1', $id2, $id1, $dryRun);
17✔
1275
            EloquentUtils::reparentRowIgnore(Tryst::class, 'user2', $id2, $id1, $dryRun);
17✔
1276
            EloquentUtils::reparentRowIgnore(IsochroneUser::class, 'userid', $id2, $id1, $dryRun);
17✔
1277
            EloquentUtils::reparentRowIgnore(Microaction::class, 'userid', $id2, $id1, $dryRun);
17✔
1278

1279
            // --- Handle bans ---
1280
            EloquentUtils::reparentRowIgnore(UserBanned::class, 'userid', $id2, $id1, $dryRun);
17✔
1281
            EloquentUtils::reparentRowIgnore(UserBanned::class, 'byuser', $id2, $id1, $dryRun);
17✔
1282

1283
            // Remove memberships for groups the merged user is banned from.
1284
            $bans = UserBanned::where('userid', $id1)->get();
17✔
1285
            foreach ($bans as $ban) {
17✔
1286
                Logger::info("TN-SYNC-TRACE [WRITE] table=memberships op=delete where=userid={$id1},groupid={$ban->groupid}");
×
1287
                $banMembership = Membership::where('userid', $id1)
×
1288
                    ->where('groupid', $ban->groupid)
×
1289
                    ->first();
×
1290
                if (!$dryRun) {
×
1291
                    $banMembership?->delete();
×
1292
                }
1293
            }
1294

1295
            // --- Merge chat rooms ---
1296
            $rooms = ChatRoom::where(function ($q) use ($id2) {
17✔
1297
                $q->where('user1', $id2)->orWhere('user2', $id2);
17✔
1298
            })
17✔
1299
                ->whereIn('chattype', [ChatRoom::TYPE_USER2MOD, ChatRoom::TYPE_USER2USER])
17✔
1300
                ->get();
17✔
1301

1302
            foreach ($rooms as $room) {
17✔
1303
                $existing = NULL;
2✔
1304

1305
                if ($room->chattype === ChatRoom::TYPE_USER2MOD) {
2✔
1306
                    $existing = ChatRoom::where('user1', $id1)
×
1307
                        ->where('groupid', $room->groupid)
×
1308
                        ->first();
×
1309
                } elseif ($room->chattype === ChatRoom::TYPE_USER2USER) {
2✔
1310
                    $other = ($room->user1 == $id2) ? $room->user2 : $room->user1;
2✔
1311
                    $existing = ChatRoom::where(function ($q) use ($id1, $other) {
2✔
1312
                        $q->where(function ($q2) use ($id1, $other) {
2✔
1313
                            $q2->where('user1', $id1)->where('user2', $other);
2✔
1314
                        })->orWhere(function ($q2) use ($id1, $other) {
2✔
1315
                            $q2->where('user2', $id1)->where('user1', $other);
2✔
1316
                        });
2✔
1317
                    })
2✔
1318
                        ->first();
2✔
1319
                }
1320

1321
                if ($existing) {
2✔
1322
                    // Room already exists for id1 — move messages into it.
1323
                    Logger::info("TN-SYNC-TRACE [WRITE] table=chat_messages op=update where=chatid={$room->id} set=chatid={$existing->id}");
1✔
1324
                    ChatMessage::where('chatid', $room->id)->get()->each(function ($chatMessage) use ($existing, $dryRun) {
1✔
1325
                        $chatMessage->chatid = $existing->id;
1✔
1326
                        if (!$dryRun) {
1✔
1327
                            $chatMessage->save();
1✔
1328
                        }
1329
                    });
1✔
1330

1331
                    // Keep the latest message timestamp.
1332
                    Logger::info("TN-SYNC-TRACE [WRITE] table=chat_rooms op=update where=id={$existing->id} set=latestmessage=" . (is_string($room->latestmessage) && strlen($room->latestmessage) > 50 ? ('len=' . strlen($room->latestmessage)) : $room->latestmessage));
1✔
1333
                    if ($room->latestmessage && (!$existing->latestmessage || $room->latestmessage > $existing->latestmessage)) {
1✔
1334
                        $existing->latestmessage = $room->latestmessage;
×
1335
                        if (!$dryRun) {
×
1336
                            $existing->save();
×
1337
                        }
1338
                    }
1339
                } else {
1340
                    // No existing room — just reassign user reference.
1341
                    $col = ($room->user1 == $id2) ? 'user1' : 'user2';
1✔
1342
                    $room->$col = $id1;
1✔
1343
                    Logger::info("TN-SYNC-TRACE [WRITE] table=chat_rooms op=update where=id={$room->id} set={$col}={$id1}");
1✔
1344
                    if (!$dryRun) {
1✔
1345
                        $room->save();
1✔
1346
                    }
1347
                }
1348
            }
1349

1350
            // Move all remaining chat messages from id2.
1351
            EloquentUtils::reparentRow(ChatMessage::class, 'userid', $id2, $id1, $dryRun);
17✔
1352

1353
            // --- Merge user attributes (keep non-NULL from id2 if id1 is NULL) ---
1354
            // Refresh models after membership changes.
1355
            $u1->refresh();
17✔
1356
            $u2->refresh();
17✔
1357

1358
            foreach (['fullname', 'firstname', 'lastname', 'yahooid'] as $att) {
17✔
1359
                $id2Value = $u2->$att;
17✔
1360

1361
                // Skip early continue during dry run to get identical logs as tn_sync.
1362
                if (!$dryRun) {
17✔
1363
                    if ($id2Value === NULL) {
17✔
1364
                        continue;
17✔
1365
                    }
1366
                }
1367

1368
                // Clear id2's attribute first (unique key safety for yahooid).
1369
                $u2->$att = NULL;
17✔
1370
                Logger::info("TN-SYNC-TRACE [WRITE] table=users op=update where=id={$id2} set={$att}=NULL");
17✔
1371
                if (!$dryRun) {
17✔
1372
                    $u2->save();
17✔
1373
                }
1374

1375
                // Don't overwrite a name with FBUser or a -owner address.
1376
                $isDodgyName = $att === 'fullname'
17✔
1377
                    && (stripos($id2Value, 'fbuser') !== FALSE || stripos($id2Value, '-owner') !== FALSE);
17✔
1378

1379
                if ($u1->$att === NULL && !$isDodgyName) {
17✔
1380
                    $u1->$att = $id2Value;
1✔
1381
                    Logger::info("TN-SYNC-TRACE [WRITE] table=users op=update where=" . ($att !== 'fullname' ? "id={$id1},{$att}=NULL" : "id={$id1}") . " set={$att}=" . (is_string($id2Value) && strlen($id2Value) > 50 ? ('len=' . strlen($id2Value)) : $id2Value));
1✔
1382
                    if (!$dryRun) {
1✔
1383
                        $u1->save();
1✔
1384
                    }
1385
                }
1386

1387
                $u1->refresh();
17✔
1388
            }
1389

1390
            // --- Merge logs ---
1391
            EloquentUtils::reparentRow(Log::class, 'user', $id2, $id1, $dryRun);
17✔
1392
            EloquentUtils::reparentRow(Log::class, 'byuser', $id2, $id1, $dryRun);
17✔
1393

1394
            // --- Merge messages ---
1395
            EloquentUtils::reparentRow(Message::class, 'fromuser', $id2, $id1, $dryRun);
17✔
1396

1397
            // --- Merge history ---
1398
            EloquentUtils::reparentRow(MessageHistory::class, 'fromuser', $id2, $id1, $dryRun);
17✔
1399
            EloquentUtils::reparentRow(MembershipHistory::class, 'userid', $id2, $id1, $dryRun);
17✔
1400

1401
            // --- Merge system role (take highest) ---
1402
            $u1->refresh();
17✔
1403
            $u2->refresh();
17✔
1404

1405
            $mergedSystemRole = self::systemRoleMax($u1->systemrole, $u2->systemrole);
17✔
1406
            $u1->systemrole = $mergedSystemRole;
17✔
1407
            Logger::info("TN-SYNC-TRACE [WRITE] table=users op=update where=id={$id1} set=systemrole={$mergedSystemRole}");
17✔
1408
            if (!$dryRun) {
17✔
1409
                $u1->save();
17✔
1410
            }
1411

1412
            // --- Merge added date (keep oldest) ---
1413
            $earlierAdded = ($u1->added < $u2->added) ? $u1->added : $u2->added;
17✔
1414
            $u1->added = $earlierAdded;
17✔
1415
            Logger::info("TN-SYNC-TRACE [WRITE] table=users op=update where=id={$id1} set=added={$earlierAdded}");
17✔
1416
            $u1->lastupdated = now();
17✔
1417
            Logger::info("TN-SYNC-TRACE [WRITE] table=users op=update where=id={$id1} set=lastupdated=NOW()");
17✔
1418
            if (!$dryRun) {
17✔
1419
                $u1->save();
17✔
1420
            }
1421

1422
            // --- Merge TN user ID ---
1423
            $tnId1 = $u1->tnuserid;
17✔
1424
            $tnId2 = $u2->tnuserid;
17✔
1425

1426
            if (!$tnId1 && $tnId2) {
17✔
1427
                $u2->tnuserid = NULL;
1✔
1428
                Logger::info("TN-SYNC-TRACE [WRITE] table=users op=update where=id={$id2} set=tnuserid=NULL");
1✔
1429
                if (!$dryRun) {
1✔
1430
                    $u2->save();
1✔
1431
                }
1432
                $u1->tnuserid = $tnId2;
1✔
1433
                Logger::info("TN-SYNC-TRACE [WRITE] table=users op=update where=id={$id1} set=tnuserid={$tnId2}");
1✔
1434
                if (!$dryRun) {
1✔
1435
                    $u1->save();
1✔
1436
                }
1437
            }
1438

1439
            // --- Merge gift aid (keep most favourable declaration) ---
1440
            $giftAids = GiftAid::whereIn('userid', [$id1, $id2])
17✔
1441
                ->orderBy('id')
17✔
1442
                ->get();
17✔
1443

1444
            if ($giftAids->isNotEmpty()) {
17✔
1445
                $weights = [
×
1446
                    self::GIFTAID_PERIOD_PAST_4_YEARS_AND_FUTURE => 0,
×
1447
                    self::GIFTAID_PERIOD_SINCE => 1,
×
1448
                    self::GIFTAID_PERIOD_FUTURE => 2,
×
1449
                    self::GIFTAID_PERIOD_THIS => 3,
×
1450
                    self::GIFTAID_PERIOD_DECLINED => 4,
×
1451
                ];
×
1452

1453
                $best = NULL;
×
1454
                foreach ($giftAids as $giftAid) {
×
1455
                    $weight = $weights[$giftAid->period] ?? 999;
×
1456
                    $bestWeight = $best ? ($weights[$best->period] ?? 999) : 999;
×
1457

1458
                    if ($best === NULL || $weight < $bestWeight) {
×
1459
                        $best = $giftAid;
×
1460
                    }
1461
                }
1462

1463
                // Delete all except the best.
1464
                foreach ($giftAids as $giftAid) {
×
1465
                    if ($giftAid->id !== $best->id) {
×
1466
                        Logger::info("TN-SYNC-TRACE [WRITE] table=giftaid op=delete where=id={$giftAid->id}");
×
1467
                        if (!$dryRun) {
×
1468
                            $giftAid->delete();
×
1469
                        }
1470
                    }
1471
                }
1472

1473
                // Assign the best to id1.
1474
                Logger::info("TN-SYNC-TRACE [WRITE] table=giftaid op=update where=id={$best->id} set=userid={$id1}");
×
1475
                if ($best->userid !== $id1) {
×
1476
                    $best->userid = $id1;
×
1477
                    if (!$dryRun) {
×
1478
                        $best->save();
×
1479
                    }
1480
                }
1481
            }
1482

1483
            // --- Log the merge (before deleting id2) ---
1484
            $mergeText = "Merged {$id2} into {$id1} ({$reason})";
17✔
1485

1486
            $logId2 = new Log();
17✔
1487
            $logId2->timestamp = now();
17✔
1488
            $logId2->type = 'User';
17✔
1489
            $logId2->subtype = 'Merged';
17✔
1490
            $logId2->user = $id2;
17✔
1491
            $logId2->byuser = $byUserId;
17✔
1492
            $logId2->text = $mergeText;
17✔
1493
            Logger::info("TN-SYNC-TRACE [WRITE] table=logs op=insert set=type=User,subtype=Merged,user={$id2},byuser=" . ($byUserId ?? 'NULL') . ",text=len=" . strlen($mergeText));
17✔
1494
            if (!$dryRun) {
17✔
1495
                $logId2->save();
17✔
1496
            }
1497

1498
            $logId1 = new Log();
17✔
1499
            $logId1->timestamp = now();
17✔
1500
            $logId1->type = 'User';
17✔
1501
            $logId1->subtype = 'Merged';
17✔
1502
            $logId1->user = $id1;
17✔
1503
            $logId1->byuser = $byUserId;
17✔
1504
            $logId1->text = $mergeText;
17✔
1505
            Logger::info("TN-SYNC-TRACE [WRITE] table=logs op=insert set=type=User,subtype=Merged,user={$id1},byuser=" . ($byUserId ?? 'NULL') . ",text=len=" . strlen($mergeText));
17✔
1506
            if (!$dryRun) {
17✔
1507
                $logId1->save();
17✔
1508
            }
1509

1510
            DB::commit();
17✔
1511
        } catch (\Exception $e) {
×
1512
            DB::rollBack();
×
1513
            Logger::error("Merge exception: " . $e->getMessage());
×
1514
            return FALSE;
×
1515
        }
1516

1517
        # Finally, delete id2.  We used to this inside the transaction, but the result was that
1518
        # fromuser sometimes got set to NULL on messages owned by id2, despite them having been set to
1519
        # id1 earlier on.  Either we're dumb, or there's a subtle interaction between transactions,
1520
        # foreign keys and Percona clusters.  This is safer and proves to be more reliable.
1521
        #
1522
        # Make sure we don't pick up an old cached version, as we've just changed it quite a bit.
1523
        try {
1524
            Logger::info("Merged {$id1} < {$id2}, {$reason}");
17✔
1525
            Membership::where('userid', $id2)->get()->each(function ($m) use ($dryRun) {
17✔
1526
                Logger::info("TN-SYNC-TRACE [WRITE] table=memberships op=delete where=userid={$m->userid},groupid={$m->groupid}");
3✔
1527
                if (!$dryRun) {
3✔
1528
                    $m->delete();
3✔
1529
                }
1530
            });
17✔
1531
            Logger::info("TN-SYNC-TRACE [WRITE] table=users op=delete where=id={$id2}");
17✔
1532
            if (!$dryRun) {
17✔
1533
                User::find($id2)?->delete();
17✔
1534
            }
1535
        } catch (\Exception $e) {
×
1536
            Logger::error("Failed to delete merged user {$id2}: " . $e->getMessage());
×
1537
            // The merge itself succeeded — the user data is consolidated in id1.
1538
            // A dangling id2 row is less harmful than rolling back the entire merge.
1539
        }
1540

1541
        return TRUE;
17✔
1542
    }
1543

1544
    /**
1545
     * Wipe a user of personal data for the GDPR right to be forgotten.
1546
     *
1547
     * The user record itself is retained (marked as forgotten) so that message
1548
     * statistics remain accurate.  All identifiable content is removed:
1549
     * - Name, settings and Yahoo ID cleared
1550
     * - External email addresses deleted (internal Freegle addresses kept)
1551
     * - All login credentials deleted
1552
     * - Message content cleared; messages without an outcome are withdrawn
1553
     * - Chat message content cleared
1554
     * - Community events, volunteering, newsfeed posts, stories, searches,
1555
     *   about-me entries and ratings deleted
1556
     * - All group memberships removed
1557
     * - Postal addresses and profile images deleted
1558
     * - Message promises deleted
1559
     * - Sessions deleted
1560
     * - Deletion logged
1561
     *
1562
     * Ported from iznik-server/include/user/User.php::forget().
1563
     *
1564
     * @param string $reason Human-readable reason for the deletion (e.g. 'GDPR request')
1565
     */
1566
    public function forget(string $reason, bool $dryRun = false): void
17✔
1567
    {
1568
        // --- Clear personal attributes ---
1569
        Logger::info("TN-SYNC-TRACE [WRITE] table=users op=update where=id={$this->id} set=firstname=NULL");
17✔
1570
        $this->firstname = NULL;
17✔
1571
        Logger::info("TN-SYNC-TRACE [WRITE] table=users op=update where=id={$this->id} set=lastname=NULL");
17✔
1572
        $this->lastname = NULL;
17✔
1573
        Logger::info("TN-SYNC-TRACE [WRITE] table=users op=update where=id={$this->id} set=fullname=Deleted User #{$this->id}");
17✔
1574
        $this->fullname = 'Deleted User #' . $this->id;
17✔
1575
        Logger::info("TN-SYNC-TRACE [WRITE] table=users op=update where=id={$this->id} set=settings=NULL");
17✔
1576
        $this->settings = NULL;
17✔
1577
        Logger::info("TN-SYNC-TRACE [WRITE] table=users op=update where=id={$this->id} set=yahooid=NULL");
17✔
1578
        $this->yahooid = NULL;
17✔
1579
        if (!$dryRun) {
17✔
1580
            $this->save();
17✔
1581
        }
1582

1583
        // --- Delete external emails (keep internal Freegle addresses) ---
1584
        foreach ($this->emails()->get() as $email) {
17✔
1585
            if (!self::isInternalEmail($email->email)) {
17✔
1586
                Logger::info("TN-SYNC-TRACE [WRITE] table=users_emails op=delete where=userid={$this->id},email={$email->email}");
17✔
1587
                $this->removeEmail($email->email, $dryRun);
17✔
1588
            }
1589
        }
1590

1591
        // --- Delete all login credentials ---
1592
        Logger::info("TN-SYNC-TRACE [WRITE] table=users_logins op=delete where=userid={$this->id}");
17✔
1593
        if (!$dryRun) {
17✔
1594
            UserLogin::where('userid', $this->id)->get()->each->delete();
17✔
1595
        }
1596

1597
        // --- Clear message content and withdraw messages without an outcome ---
1598
        $msgIds = Message::where('fromuser', $this->id)
17✔
1599
            ->whereIn('type', [Message::TYPE_OFFER, Message::TYPE_WANTED])
17✔
1600
            ->pluck('id');
17✔
1601

1602
        foreach ($msgIds as $msgId) {
17✔
1603
            // Update the field of the message
1604
            $message = Message::find($msgId);
1✔
1605
            $message->fromip = NULL;
1✔
1606
            $message->message = NULL;
1✔
1607
            $message->envelopefrom = NULL;
1✔
1608
            $message->fromname = NULL;
1✔
1609
            $message->fromaddr = NULL;
1✔
1610
            $message->messageid = NULL;
1✔
1611
            $message->textbody = NULL;
1✔
1612
            $message->htmlbody = NULL;
1✔
1613
            $message->deleted = now();
1✔
1614
            Logger::info("TN-SYNC-TRACE [WRITE] table=messages op=update where=id={$msgId} set=fromip=NULL,message=NULL,envelopefrom=NULL,fromname=NULL,fromaddr=NULL,messageid=NULL,textbody=NULL,htmlbody=NULL,deleted=NOW()");
1✔
1615
            if (!$dryRun) {
1✔
1616
                $message->save();
1✔
1617
            }
1618

1619
            // Mark the message group as deleted
1620
            $messageGroup = MessageGroup::find($msgId);
1✔
1621
            $messageGroup->deleted = 1;
1✔
1622
            Logger::info("TN-SYNC-TRACE [WRITE] table=messages_groups op=update where=msgid={$msgId} set=deleted=1");
1✔
1623
            if (!$dryRun) {
1✔
1624
                $messageGroup->save();
1✔
1625
            }
1626

1627
            // Clear any outcome comments that might contain personal data.
1628
            foreach ($message->outcomes()->get() as $messageOutcome) {
1✔
1629
                $messageOutcome->comments = NULL;
×
1630
                Logger::info("TN-SYNC-TRACE [WRITE] table=messages_outcomes op=update where=msgid={$msgId} set=comments=NULL");
×
1631
                if (!$dryRun) {
×
1632
                    $messageOutcome->save();
×
1633
                }
1634
            }
1635

1636
            // Withdraw if no outcome has been recorded yet.
1637
            if (!$message->hasOutcome()) {
1✔
1638
                $message->withdraw('Withdrawn on user unsubscribe', NULL, NULL, $dryRun);
1✔
1639
            }
1640
        }
1641

1642
        // --- Clear chat message content ---
1643
        foreach ($this->chatMessages()->get() as $chatMessage) {
17✔
1644
            $chatMessage->message = NULL;
1✔
1645
            Logger::info("TN-SYNC-TRACE [WRITE] table=chat_messages op=update where=id={$chatMessage->id} set=message=NULL");
1✔
1646
            if (!$dryRun) {
1✔
1647
                $chatMessage->save();
1✔
1648
            }
1649
        }
1650

1651
        // --- Delete user-generated content ---
1652
        Logger::info("TN-SYNC-TRACE [WRITE] table=communityevents op=delete where=userid={$this->id}");
17✔
1653
        if (!$dryRun) {
17✔
1654
            CommunityEvent::where('userid', $this->id)->get()->each->delete();
17✔
1655
        }
1656
        Logger::info("TN-SYNC-TRACE [WRITE] table=volunteering op=delete where=userid={$this->id}");
17✔
1657
        if (!$dryRun) {
17✔
1658
            Volunteering::where('userid', $this->id)->get()->each->delete();
17✔
1659
        }
1660
        Logger::info("TN-SYNC-TRACE [WRITE] table=newsfeed op=delete where=userid={$this->id}");
17✔
1661
        if (!$dryRun) {
17✔
1662
            Newsfeed::where('userid', $this->id)->get()->each->delete();
17✔
1663
        }
1664
        Logger::info("TN-SYNC-TRACE [WRITE] table=users_stories op=delete where=userid={$this->id}");
17✔
1665
        if (!$dryRun) {
17✔
1666
            UserStory::where('userid', $this->id)->get()->each->delete();
17✔
1667
        }
1668
        Logger::info("TN-SYNC-TRACE [WRITE] table=users_searches op=delete where=userid={$this->id}");
17✔
1669
        if (!$dryRun) {
17✔
1670
            UserSearch::where('userid', $this->id)->get()->each->delete();
17✔
1671
        }
1672
        Logger::info("TN-SYNC-TRACE [WRITE] table=users_aboutme op=delete where=userid={$this->id}");
17✔
1673
        if (!$dryRun) {
17✔
1674
            UserAboutMe::where('userid', $this->id)->get()->each->delete();
17✔
1675
        }
1676
        Logger::info("TN-SYNC-TRACE [WRITE] table=ratings op=delete where=rater={$this->id}");
17✔
1677
        if (!$dryRun) {
17✔
1678
            Rating::where('rater', $this->id)->get()->each->delete();
17✔
1679
        }
1680
        Logger::info("TN-SYNC-TRACE [WRITE] table=ratings op=delete where=ratee={$this->id}");
17✔
1681
        if (!$dryRun) {
17✔
1682
            Rating::where('ratee', $this->id)->get()->each->delete();
17✔
1683
        }
1684

1685
        // --- Remove all group memberships ---
1686
        $groupIds = collect($this->getMembershipList())->pluck('id');
17✔
1687
        foreach ($groupIds as $groupId) {
17✔
1688
            Logger::info("TN-SYNC-TRACE [WRITE] table=memberships op=delete where=userid={$this->id},groupid={$groupId}");
2✔
1689
            $this->removeMembership($groupId, dryRun: $dryRun);
2✔
1690
        }
1691

1692
        // --- Delete postal addresses and profile images ---
1693
        Logger::info("TN-SYNC-TRACE [WRITE] table=users_addresses op=delete where=userid={$this->id}");
17✔
1694
        if (!$dryRun) {
17✔
1695
            UserAddress::where('userid', $this->id)->get()->each->delete();
17✔
1696
        }
1697
        Logger::info("TN-SYNC-TRACE [WRITE] table=users_images op=delete where=userid={$this->id}");
17✔
1698
        if (!$dryRun) {
17✔
1699
            UserImage::where('userid', $this->id)->get()->each->delete();
17✔
1700
        }
1701

1702
        // --- Delete message promises ---
1703
        Logger::info("TN-SYNC-TRACE [WRITE] table=messages_promises op=delete where=userid={$this->id}");
17✔
1704
        if (!$dryRun) {
17✔
1705
            MessagePromise::where('userid', $this->id)->get()->each->delete();
17✔
1706
        }
1707

1708
        // --- Mark user as forgotten ---
1709
        $this->forgotten = now();
17✔
1710
        $this->tnuserid = NULL;
17✔
1711
        Logger::info("TN-SYNC-TRACE [WRITE] table=users op=update where=id={$this->id} set=forgotten=NOW(),tnuserid=NULL");
17✔
1712
        if (!$dryRun) {
17✔
1713
            $this->save();
17✔
1714
        }
1715

1716
        // --- Delete sessions ---
1717
        Logger::info("TN-SYNC-TRACE [WRITE] table=sessions op=delete where=userid={$this->id}");
17✔
1718
        if (!$dryRun) {
17✔
1719
            UserSession::where('userid', $this->id)->get()->each->delete();
17✔
1720
        }
1721

1722
        // --- Log the deletion ---
1723
        $log = new Log();
17✔
1724
        $log->timestamp = now();
17✔
1725
        $log->type = 'User';
17✔
1726
        $log->subtype = 'Deleted';
17✔
1727
        $log->user = $this->id;
17✔
1728
        $log->text = $reason;
17✔
1729
        Logger::info("TN-SYNC-TRACE [WRITE] table=logs op=insert set=type=User,subtype=Deleted,user={$this->id},text=len=" . strlen($reason));
17✔
1730
        if (!$dryRun) {
17✔
1731
            $log->save();
17✔
1732
        }
1733
    }
1734

1735
    /**
1736
     * Remove a user's membership from a group, optionally banning them.
1737
     *
1738
     * When banning, also inserts into users_banned and withdraws any active
1739
     * Offer/Wanted messages the user has on the group.
1740
     *
1741
     * Ported from iznik-server/include/user/User.php::removeMembership().
1742
     *
1743
     * @param int $groupId The group to remove the user from
1744
     * @param bool $ban If TRUE, also ban the user from the group and withdraw their messages
1745
     * @param bool $spam If TRUE, log the removal as an automated spammer removal
1746
     * @param int|null $byUserId The user performing the removal (for logging)
1747
     * @param bool $byEmail If TRUE, send a farewell email to the user (also sent to TN users automatically)
1748
     * @return bool TRUE if the membership was deleted (or a ban was recorded)
1749
     */
1750
    public function removeMembership(int $groupId, bool $ban = FALSE, bool $spam = FALSE, ?int $byUserId = NULL, bool $byEmail = FALSE, bool $dryRun = false): bool
2✔
1751
    {
1752
        // Notify TN users or email-triggered removals so they know they can no longer see messages.
1753
        if ($byEmail || $this->isTN()) {
2✔
1754
            Logger::info("TN-SYNC-TRACE [EMAIL] action=send-farewell user={$this->id} groupid={$groupId} to=" . ($this->email_preferred ?? 'NULL'));
×
1755

1756
            if (!$dryRun) {
×
1757
                $group = Group::find($groupId);
×
1758
                $preferredEmail = $this->email_preferred;
×
1759

1760
                if ($group && $preferredEmail) {
×
1761
                    try {
1762
                        \Illuminate\Support\Facades\Mail::raw('Parting is such sweet sorrow.', function ($message) use ($group, $preferredEmail) {
×
1763
                            $message->subject('Farewell from ' . $group->nameshort)
×
1764
                                ->from($group->getAutoEmail())
×
1765
                                ->replyTo($group->getModsEmail())
×
1766
                                ->to($preferredEmail);
×
1767
                        });
×
1768
                    } catch (\Exception $e) {
×
1769
                        Logger::warning("Failed to send farewell email for user {$this->id} on group {$groupId}: " . $e->getMessage());
×
1770
                    }
1771
                }
1772
            }
1773
        }
1774

1775
        if ($ban) {
2✔
1776
            // Record the ban.
1777
            $userBanned = new UserBanned();
×
1778
            $userBanned->userid = $this->id;
×
1779
            $userBanned->groupid = $groupId;
×
1780
            $userBanned->byuser = $byUserId;
×
1781
            Logger::info("TN-SYNC-TRACE [WRITE] table=users_banned op=insert where=userid={$this->id},groupid={$groupId},byuser=" . ($byUserId ?? 'NULL'));
×
1782
            if (!$dryRun) {
×
1783
                $userBanned->save();
×
1784
            }
1785

1786
            // Withdraw active Offer/Wanted messages on this group that have no outcome yet.
1787
            $msgIds = MessageGroup::join('messages', 'messages_groups.msgid', '=', 'messages.id')
×
1788
                ->where('messages.fromuser', $this->id)
×
1789
                ->where('messages_groups.groupid', $groupId)
×
1790
                ->whereIn('messages.type', [Message::TYPE_OFFER, Message::TYPE_WANTED])
×
1791
                ->pluck('messages_groups.msgid');
×
1792

1793
            foreach ($msgIds as $msgId) {
×
1794
                $m = Message::find($msgId);
×
1795

1796
                if ($m && !$m->hasOutcome()) {
×
1797
                    $m->withdraw('Marked as withdrawn by ban', NULL, $byUserId, $dryRun);
×
1798
                }
1799
            }
1800
        }
1801

1802
        // Remove the membership.
1803
        Logger::info("TN-SYNC-TRACE [WRITE] table=memberships op=delete where=userid={$this->id},groupid={$groupId}");
2✔
1804
        $membership = Membership::where('userid', $this->id)
2✔
1805
            ->where('groupid', $groupId)
2✔
1806
            ->first();
2✔
1807
        $deleted = $membership ? 1 : 0;
2✔
1808
        if (!$dryRun) {
2✔
1809
            $membership?->delete();
2✔
1810
        }
1811

1812
        if ($deleted || $ban) {
2✔
1813
            $log = new Log();
2✔
1814
            $log->timestamp = now();
2✔
1815
            $log->type = 'Group';
2✔
1816
            $log->subtype = 'Left';
2✔
1817
            $log->user = $this->id;
2✔
1818
            $log->byuser = $byUserId;
2✔
1819
            $log->groupid = $groupId;
2✔
1820
            $log->text = $spam ? 'Autoremoved spammer' : ($ban ? 'via ban' : NULL);
2✔
1821
            Logger::info("TN-SYNC-TRACE [WRITE] table=logs op=insert set=type=Group,subtype=Left,user={$this->id},byuser=" . ($byUserId ?? 'NULL') . ",groupid={$groupId},text=" . ($spam ? 'Autoremoved spammer' : ($ban ? 'via ban' : 'NULL')));
2✔
1822
            if (!$dryRun) {
2✔
1823
                $log->save();
2✔
1824
            }
1825
        }
1826

1827
        return $deleted > 0 || $ban;
2✔
1828
    }
1829

1830
    /**
1831
     * Return the group IDs where this user is a Moderator or Owner.
1832
     *
1833
     * Ported from iznik-server/include/user/User.php::getModeratorships().
1834
     *
1835
     * @param bool $activeOnly When TRUE, only include groups where the user is actively modding
1836
     *                         (i.e. their membership settings have active=1 or showmessages=1).
1837
     * @return array<int> Array of group IDs
1838
     */
1839
    public function getModeratorships(bool $activeOnly = false): array
×
1840
    {
1841
        $ret = [];
×
1842

1843
        foreach ($this->memberships()->get() as $membership) {
×
1844
            if ($membership->role === self::ROLE_OWNER || $membership->role === self::ROLE_MODERATOR) {
×
1845
                if (!$activeOnly || $this->activeModForGroup($membership->groupid)) {
×
1846
                    $ret[] = $membership->groupid;
×
1847
                }
1848
            }
1849
        }
1850

1851
        return $ret;
×
1852
    }
1853

1854
    /**
1855
     * Check whether this user is actively modding a given group.
1856
     *
1857
     * Uses the 'active' flag in membership settings if present; falls back to the legacy
1858
     * 'showmessages' flag; defaults to TRUE (active) if neither is set.
1859
     *
1860
     * Ported from iznik-server/include/user/User.php::activeModForGroup().
1861
     *
1862
     * @param int $groupId
1863
     * @return bool
1864
     */
1865
    public function activeModForGroup(int $groupId): bool
×
1866
    {
1867
        $settings = $this->getGroupSettings($groupId);
×
1868

1869
        if (array_key_exists('active', $settings)) {
×
1870
            return (bool) $settings['active'];
×
1871
        }
1872

1873
        // Legacy fallback: showmessages=0 means inactive; absent or 1 means active.
1874
        return !array_key_exists('showmessages', $settings) || (bool) $settings['showmessages'];
×
1875
    }
1876

1877
    /**
1878
     * Check whether this user participates in wider chat review.
1879
     *
1880
     * Returns TRUE if the user is an active moderator on at least one group that has
1881
     * the 'widerchatreview' group setting enabled.
1882
     *
1883
     * Ported from iznik-server/include/user/User.php::widerReview().
1884
     *
1885
     * @return bool
1886
     */
1887
    public function widerReview(): bool
×
1888
    {
1889
        foreach ($this->getModeratorships() as $groupId) {
×
1890
            if ($this->activeModForGroup($groupId)) {
×
1891
                $group = Group::find($groupId);
×
1892

1893
                if ($group && $group->getSetting('widerchatreview', false)) {
×
1894
                    return true;
×
1895
                }
1896
            }
1897
        }
1898

1899
        return false;
×
1900
    }
1901

1902
    /**
1903
     * Get the user's per-group membership settings.
1904
     *
1905
     * Ported from iznik-server/include/user/User.php::getGroupSettings().
1906
     *
1907
     * @param int $groupId
1908
     * @param int|null $configId Optional mod config ID (used for mod config lookup)
1909
     * @return array
1910
     */
1911
    public function getGroupSettings(int $groupId, ?int $configId = NULL): array
2✔
1912
    {
1913
        $defaults = [
2✔
1914
            'active' => 1,
2✔
1915
            'showchat' => 1,
2✔
1916
            'pushnotify' => 1,
2✔
1917
            'eventsallowed' => 1,
2✔
1918
            'volunteeringallowed' => 1,
2✔
1919
        ];
2✔
1920

1921
        $membership = $this->memberships()->where('groupid', $groupId)->first();
2✔
1922

1923
        if (!$membership) {
2✔
1924
            return $defaults;
×
1925
        }
1926

1927
        $settings = $membership->settings ?? [];
2✔
1928

1929
        if (!empty($settings) && !$configId && in_array($membership->role, [self::ROLE_OWNER, self::ROLE_MODERATOR])) {
2✔
1930
            $settings['configid'] = $membership->configid ?? ModConfig::getForGroup($this->id, $groupId);
×
1931
        }
1932

1933
        // Base active setting on legacy showmessages setting if not present.
1934
        $settings['active'] = array_key_exists('active', $settings)
2✔
1935
            ? $settings['active']
×
1936
            : (!array_key_exists('showmessages', $settings) || $settings['showmessages']);
2✔
1937
        $settings['active'] = $settings['active'] ? 1 : 0;
2✔
1938

1939
        // Merge defaults for missing keys.
1940
        foreach ($defaults as $key => $val) {
2✔
1941
            if (!array_key_exists($key, $settings)) {
2✔
1942
                $settings[$key] = $val;
2✔
1943
            }
1944
        }
1945

1946
        $settings['emailfrequency'] = $membership->emailfrequency;
2✔
1947
        $settings['eventsallowed'] = $membership->eventsallowed;
2✔
1948
        $settings['volunteeringallowed'] = $membership->volunteeringallowed ?? 1;
2✔
1949

1950
        return $settings;
2✔
1951
    }
1952

1953
    /**
1954
     * Get this user's group memberships with group details.
1955
     *
1956
     * Returns an array of group data enriched with membership info (role, collection,
1957
     * configid, mysettings). This is distinct from the memberships() Eloquent relationship
1958
     * which returns Membership models.
1959
     *
1960
     * Ported from iznik-server/include/user/User.php::getMemberships().
1961
     *
1962
     * @param bool $modOnly Only return groups where user is Moderator or Owner
1963
     * @param string|null $groupType Filter by group type (e.g. Group::TYPE_FREEGLE)
1964
     * @param bool $getWork Include work counts for moderator groups
1965
     * @param bool $isModTools Whether this is a ModTools context (affects publish filtering)
1966
     * @return array Array of group data with membership details
1967
     */
1968
    public function getMembershipList(bool $modOnly = FALSE, ?string $groupType = NULL, bool $getWork = FALSE, bool $isModTools = FALSE): array
17✔
1969
    {
1970
        $query = DB::table('memberships')
17✔
1971
            ->join('groups', 'groups.id', '=', 'memberships.groupid')
17✔
1972
            ->where('memberships.userid', $this->id);
17✔
1973

1974
        if ($modOnly) {
17✔
1975
            $query->whereIn('memberships.role', [self::ROLE_MODERATOR, self::ROLE_OWNER]);
×
1976
        }
1977

1978
        if ($groupType) {
17✔
1979
            $query->where('groups.type', $groupType);
×
1980
        }
1981

1982
        if (!$isModTools) {
17✔
1983
            $query->where('groups.publish', 1);
17✔
1984
        }
1985

1986
        $rows = $query->select([
17✔
1987
            'groups.type',
17✔
1988
            'memberships.heldby',
17✔
1989
            'memberships.settings AS membership_settings',
17✔
1990
            'memberships.collection',
17✔
1991
            'memberships.emailfrequency',
17✔
1992
            'memberships.eventsallowed',
17✔
1993
            'memberships.volunteeringallowed',
17✔
1994
            'memberships.groupid',
17✔
1995
            'memberships.role',
17✔
1996
            'memberships.configid',
17✔
1997
            'memberships.ourPostingStatus',
17✔
1998
            DB::raw("CASE WHEN groups.namefull IS NOT NULL THEN groups.namefull ELSE groups.nameshort END AS namedisplay"),
17✔
1999
        ])
17✔
2000
            ->orderByRaw('LOWER(namedisplay) ASC')
17✔
2001
            ->get();
17✔
2002

2003
        $ret = [];
17✔
2004
        $getWorkIds = [];
17✔
2005
        $groupSettings = [];
17✔
2006

2007
        // Eager-load Group models for all membership group IDs.
2008
        $groupIdList = $rows->pluck('groupid')->filter()->all();
17✔
2009
        $groups = Group::whereIn('id', $groupIdList)->get()->keyBy('id');
17✔
2010

2011
        foreach ($rows as $row) {
17✔
2012
            $group = $groups->get($row->groupid);
2✔
2013

2014
            if (!$group) {
2✔
2015
                continue;
×
2016
            }
2017

2018
            $one = $group->getPublic();
2✔
2019

2020
            $one['role'] = $row->role;
2✔
2021
            $one['collection'] = $row->collection;
2✔
2022
            $amod = ($one['role'] === self::ROLE_MODERATOR || $one['role'] === self::ROLE_OWNER);
2✔
2023
            $one['configid'] = $row->configid;
2✔
2024

2025
            if ($amod && !$one['configid']) {
2✔
2026
                # Get a config using defaults.
2027
                $one['configid'] = ModConfig::getForGroup($this->id, $row->groupid);
×
2028
            }
2029

2030
            $one['mysettings'] = $this->getGroupSettings($row->groupid, $row->configid);
2✔
2031

2032
            $one['mysettings']['emailfrequency'] = ($row->type === Group::TYPE_FREEGLE && $this->sendOurMails(false, false))
2✔
2033
                ? ($one['mysettings']['emailfrequency'] ?? 24)
2✔
2034
                : 0;
×
2035

2036
            $groupSettings[$row->groupid] = $one['mysettings'];
2✔
2037

2038
            if ($getWork && $amod) {
2✔
2039
                $getWorkIds[] = $row->groupid;
×
2040
            }
2041

2042
            $ret[] = $one;
2✔
2043
        }
2044

2045
        if ($getWork && !empty($getWorkIds)) {
17✔
2046
            $workcounts = Group::getWorkCounts($this, $groupSettings, $getWorkIds);
×
2047
            foreach ($ret as &$one) {
×
2048
                $gid = $one['id'];
×
2049
                if (isset($workcounts[$gid])) {
×
2050
                    $one = array_merge($one, $workcounts[$gid]);
×
2051
                }
2052
            }
2053
            unset($one);
×
2054
        }
2055

2056
        return $ret;
17✔
2057
    }
2058
}
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