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

Freegle / Iznik / 28924

29 Jul 2026 12:16PM UTC coverage: 72.876% (+0.004%) from 72.872%
28924

Pull #1193

circleci

edwh
feat(browse): size feed photos from the screen height so six posts fit

At lg+ a feed card is a horizontal row whose photo is a square, so the
square's side is also the card's height. That side was a fixed 200px, which
meant a short screen showed only two or three posts at a time.

The side now comes from the viewport height, aiming to fit six cards between
the fixed navbar and the sticky bottom ad, and clamped at both ends: never
larger than the 200px it used to be, so a very large screen is unchanged, and
never so small that the text beside the photo stops being readable. Below
about 710px of viewport the clamp holds and we show fewer than six rather than
render an unusable card.

The text column degrades with the row instead of being clipped: padding
shrinks with the photo, the description drops to a single ellipsised line when
only one fits (a fade over the only line just greys it out), and is dropped
entirely when none does. Subject, location, distance and age always stay.
MessageSkeleton tracks the same sizes and tiers so the card doesn't jump when
the real one replaces it.

The "freegled"/"promised" stamp was positioned on a hard-coded 200px square
and is now centred on whatever size the photo is.

Portrait layouts (xs/sm/md grid, mobile landscape) are unchanged - there the
photo's width comes from the grid column, not from the screen height.

The new partial is duplicated into modtools/assets/css/ like every other one
there: ModTools is a Nuxt layer whose assets/css/* resolves to its own copy,
so the shared MessageSummary/MessageSkeleton fail to compile in the ModTools
build without it.

Also fixes file-sync.sh, which never synced assets/ to the container vitest
runs in, so a unit test reading a stylesheet saw the copy baked into the image
rather than the edit on disk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mrih9qw8XXt1va2g6pMqN5
Pull Request #1193: Size browse feed photos from the screen height so six posts fit

13440 of 17659 branches covered (76.11%)

Branch coverage included in aggregate %.

141262 of 194621 relevant lines covered (72.58%)

38.16 hits per line

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

81.12
/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
838✔
169
    {
170
        return $this->hasMany(UserEmail::class, 'userid');
838✔
171
    }
172

173
    /**
174
     * Get user's memberships.
175
     */
176
    public function memberships(): HasMany
321✔
177
    {
178
        return $this->hasMany(Membership::class, 'userid');
321✔
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 the legacy V1 PHP implementation's getEmailPreferred() behavior.
250
     */
251
    public function getEmailPreferredAttribute(): ?string
661✔
252
    {
253
        $emails = $this->emails()
661✔
254
            ->orderByRaw('preferred DESC, validated DESC')
661✔
255
            ->pluck('email');
661✔
256

257
        foreach ($emails as $email) {
661✔
258
            if (!self::isInternalEmail($email)) {
653✔
259
                return $email;
650✔
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 the legacy V1 PHP implementation's Mail::ourDomain() + GROUP_DOMAIN + yahoogroups filtering.
270
     */
271
    public static function isInternalEmail(string $email): bool
750✔
272
    {
273
        $email = strtolower($email);
750✔
274

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

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

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

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

300
        return FALSE;
740✔
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
208✔
310
    {
311
        $name = null;
208✔
312

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

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

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

325
        return NameSanitiser::sanitize($name, $this->isNameExempt());
207✔
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
207✔
333
    {
334
        if (in_array($this->systemrole, ['Moderator', 'Support', 'Admin'], TRUE)) {
207✔
335
            return TRUE;
×
336
        }
337
        return $this->isModerator();
207✔
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
219✔
345
    {
346
        return preg_replace('/^([\s\S]+?)-g[0-9]+$/', '$1', $name);
219✔
347
    }
348

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

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

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

378
    /**
379
     * Get user's first name for personalization.
380
     */
381
    public function getFirstNameAttribute(): ?string
73✔
382
    {
383
        return $this->attributes['firstname'] ?? NULL;
73✔
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
82✔
391
    {
392
        $email = $this->email_preferred;
82✔
393
        return $email && str_ends_with($email, '@user.trashnothing.com');
82✔
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
42✔
401
    {
402
        return ! empty($this->attributes['ljuserid']);
42✔
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 the legacy V1 PHP implementation.
423
     */
424
    public static function canonMail(string $email): string
29✔
425
    {
426
        # Googlemail is Gmail really in US and UK.
427
        $email = str_replace('@googlemail.', '@gmail.', $email);
29✔
428
        $email = str_replace('@googlemail.co.uk', '@gmail.co.uk', $email);
29✔
429

430
        # Canonicalise TN addresses.
431
        if (preg_match('/(.*)\-(.*)(@user.trashnothing.com)/', $email, $matches)) {
29✔
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) {
29✔
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, '@');
29✔
446

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

451
            if (stripos($rhs, '@gmail') !== FALSE || stripos($rhs, '@googlemail') !== FALSE) {
29✔
452
                $lhs = str_replace('.', '', $lhs);
4✔
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);
29✔
458
        }
459

460
        return $email;
29✔
461
    }
462

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

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

469
        if (
470
            stripos($email, '-owner@yahoogroups.co') !== FALSE ||
16✔
471
            stripos($email, "-volunteers@{$groupDomain}") !== FALSE ||
16✔
472
            stripos($email, "-auto@{$groupDomain}") !== FALSE
16✔
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) {
13✔
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);
11✔
485

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

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

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

557
        return $rc;
16✔
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
16✔
588
    {
589
        $email = trim($email);
16✔
590

591
        if (strlen($email)) {
16✔
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)
16✔
595
                ->whereNull('userid')
16✔
596
                ->get();
16✔
597

598
            foreach ($donations as $donation) {
16✔
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
45✔
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())) {
45✔
625
            return FALSE;
2✔
626
        }
627

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

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

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

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

645
        return $result;
43✔
646
    }
647

648
    /**
649
     * Notification type constants matching the legacy V1 PHP implementation.
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 the legacy V1 PHP implementation.
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 the legacy V1 PHP User::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
141✔
821
    {
822
        $location = $this->lastLocation;
141✔
823
        if (!$location) {
141✔
824
            return [NULL, NULL];
101✔
825
        }
826

827
        // Locations have a geometry column with a POINT.
828
        if ($location->lat && $location->lng) {
40✔
829
            return [$location->lat, $location->lng];
40✔
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
93✔
841
    {
842
        [$lat, $lng] = $this->getLatLng();
93✔
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()) {
93✔
848
            return [
92✔
849
                'jobs' => collect(),
92✔
850
                'location' => NULL,
92✔
851
            ];
92✔
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
132✔
893
    {
894
        // Find the user's profile image, preferring the default one.
895
        $profileImage = UserImage::where('userid', $this->id)
132✔
896
            ->orderByDesc('default')
132✔
897
            ->orderBy('id')
132✔
898
            ->first(['id', 'url']);
132✔
899

900
        if (!$profileImage) {
132✔
901
            return NULL;
132✔
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
33✔
928
    {
929
        // Check for existing LOGIN_LINK credential.
930
        $login = UserLogin::where('userid', $this->id)
33✔
931
            ->where('type', self::LOGIN_LINK)
33✔
932
            ->first(['credentials']);
33✔
933

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

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

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

947
        return $key;
33✔
948
    }
949

950
    /**
951
     * Build an auto-login link, mirroring the legacy V1 PHP 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
12✔
961
    {
962
        $userSite = rtrim(config('freegle.sites.user', 'https://www.ilovefreegle.org'), '/');
12✔
963
        $sep = str_contains($url, '?') ? '&' : '?';
12✔
964

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

973
        return $userSite . $url . $sep . $query;
12✔
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
16✔
992
    {
993
        $key = $this->getUserKey();
16✔
994
        $userSite = config('freegle.sites.user', 'https://www.ilovefreegle.org');
16✔
995

996
        return "{$userSite}/one-click-unsubscribe/{$this->id}/{$key}";
16✔
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
18✔
1019
    {
1020
        $settings = $this->settings ?? [];
18✔
1021
        return $settings['canmerge'] ?? TRUE;
18✔
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
4✔
1029
    {
1030
        $role = self::ROLE_NONMEMBER;
4✔
1031

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

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

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

1044
        return $role;
4✔
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
18✔
1052
    {
1053
        $role = self::SYSTEMROLE_USER;
18✔
1054

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

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

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

1067
        return $role;
18✔
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 the legacy V1 PHP User::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
21✔
1087
    {
1088
        Logger::info("Merge {$id2} into {$id1}, {$reason}");
21✔
1089

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

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

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

1101
        if (!$forceMerge && (!$u1->canMerge() || !$u2->canMerge())) {
19✔
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();
18✔
1120

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

1124
            // Conflict memberships (id1 was already a member, so id2's row is merged
1125
            // into id1's and then removed) are collected here and deleted AFTER commit.
1126
            // We keep the in-memory models rather than re-querying by userid post-commit:
1127
            // under the read/write split that re-query hits a possibly-lagging replica,
1128
            // where a just-reparented membership can still show userid=$id2 and would be
1129
            // wrongly deleted, silently dropping a membership that should survive on id1.
1130
            $membershipsToDelete = [];
18✔
1131

1132
            # Merge the top-level memberships
1133
            foreach ($id2Memberships as $id2Memb) {
18✔
1134
                $id1Memb = Membership::where('userid', $id1)
5✔
1135
                    ->where('groupid', $id2Memb->groupid)
5✔
1136
                    ->first();
5✔
1137

1138
                if (!$id1Memb) {
5✔
1139
                    // id1 is not already a member — just reassign the membership.
1140
                    $id2Memb->userid = $id1;
2✔
1141
                    Logger::info("TN-SYNC-TRACE [WRITE] table=memberships op=update where=userid={$id2},groupid={$id2Memb->groupid} set=userid={$id1}");
2✔
1142
                    if (!$dryRun) {
2✔
1143
                        $id2Memb->save();
2✔
1144
                    }
1145
                } else {
1146
                    // Both are members — merge: take highest role, oldest date, non-NULL attributes.
1147
                    $role = self::roleMax($id1Memb->role, $id2Memb->role);
4✔
1148

1149
                    if ($role !== $id1Memb->role) {
4✔
1150
                        $id1Memb->role = $role;
2✔
1151
                        Logger::info("TN-SYNC-TRACE [WRITE] table=memberships op=update where=userid={$id1},groupid={$id2Memb->groupid} set=role={$role}");
2✔
1152
                    }
1153

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

1159
                    // Take non-NULL values from id2 for these attributes.
1160
                    foreach (['configid', 'settings', 'heldby'] as $key) {
4✔
1161
                        if ($id2Memb->$key !== NULL) {
4✔
1162
                            $id1Memb->$key = $id2Memb->$key;
×
1163
                            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));
×
1164
                        }
1165
                    }
1166

1167
                    if (!$dryRun) {
4✔
1168
                        $id1Memb->save();
4✔
1169
                    }
1170

1171
                    // id2's row for this group is now redundant — delete it after commit.
1172
                    $membershipsToDelete[] = $id2Memb;
4✔
1173
                }
1174
            }
1175

1176
            // --- Merge emails ---
1177
            // Both might have a primary (preferred) address; id1 wins.
1178
            $primary = NULL;
18✔
1179
            $foundPrim = FALSE;
18✔
1180

1181
            $id2PrimaryEmail = UserEmail::where('userid', $id2)
18✔
1182
                ->where('preferred', 1)
18✔
1183
                ->first();
18✔
1184

1185
            if ($id2PrimaryEmail) {
18✔
1186
                $primary = $id2PrimaryEmail->id;
18✔
1187
                $foundPrim = TRUE;
18✔
1188
            }
1189

1190
            $id1PrimaryEmail = UserEmail::where('userid', $id1)
18✔
1191
                ->where('preferred', 1)
18✔
1192
                ->first();
18✔
1193

1194
            if ($id1PrimaryEmail) {
18✔
1195
                $primary = $id1PrimaryEmail->id;
18✔
1196
                $foundPrim = TRUE;
18✔
1197
            }
1198

1199
            if (!$foundPrim) {
18✔
1200
                // No primary — use whatever getEmailPreferred would choose for id1.
1201
                $preferredEmail = $u1->email_preferred;
×
1202
                if ($preferredEmail) {
×
1203
                    $emailRow = UserEmail::where('email', $preferredEmail)
×
1204
                        ->first();
×
1205
                    if ($emailRow) {
×
1206
                        $primary = $emailRow->id;
×
1207
                    }
1208
                }
1209
            }
1210

1211
            // Move all id2 emails to id1, clearing preferred.
1212
            Logger::info("TN-SYNC-TRACE [WRITE] table=users_emails op=update where=userid={$id2} set=userid={$id1},preferred=0");
18✔
1213
            UserEmail::where('userid', $id2)->get()->each(function ($emailRow) use ($id1, $dryRun) {
18✔
1214
                $emailRow->userid = $id1;
18✔
1215
                $emailRow->preferred = 0;
18✔
1216
                if (!$dryRun) {
18✔
1217
                    $emailRow->save();
18✔
1218
                }
1219
            });
18✔
1220

1221
            if ($primary) {
18✔
1222
                Logger::info("TN-SYNC-TRACE [WRITE] table=users_emails op=update where=id={$primary} set=preferred=1");
18✔
1223
                $primaryRow = UserEmail::find($primary);
18✔
1224
                if ($primaryRow) {
18✔
1225
                    $primaryRow->preferred = 1;
18✔
1226
                    if (!$dryRun) {
18✔
1227
                        $primaryRow->save();
18✔
1228
                    }
1229
                }
1230
            }
1231

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

1290
            // --- Handle bans ---
1291
            EloquentUtils::reparentRowIgnore(UserBanned::class, 'userid', $id2, $id1, $dryRun);
18✔
1292
            EloquentUtils::reparentRowIgnore(UserBanned::class, 'byuser', $id2, $id1, $dryRun);
18✔
1293

1294
            // Remove memberships for groups the merged user is banned from.
1295
            $bans = UserBanned::where('userid', $id1)->get();
18✔
1296
            foreach ($bans as $ban) {
18✔
1297
                Logger::info("TN-SYNC-TRACE [WRITE] table=memberships op=delete where=userid={$id1},groupid={$ban->groupid}");
×
1298
                $banMembership = Membership::where('userid', $id1)
×
1299
                    ->where('groupid', $ban->groupid)
×
1300
                    ->first();
×
1301
                if (!$dryRun) {
×
1302
                    $banMembership?->delete();
×
1303
                }
1304
            }
1305

1306
            // --- Merge chat rooms ---
1307
            $rooms = ChatRoom::where(function ($q) use ($id2) {
18✔
1308
                $q->where('user1', $id2)->orWhere('user2', $id2);
18✔
1309
            })
18✔
1310
                ->whereIn('chattype', [ChatRoom::TYPE_USER2MOD, ChatRoom::TYPE_USER2USER])
18✔
1311
                ->get();
18✔
1312

1313
            foreach ($rooms as $room) {
18✔
1314
                $existing = NULL;
2✔
1315

1316
                if ($room->chattype === ChatRoom::TYPE_USER2MOD) {
2✔
1317
                    $existing = ChatRoom::where('user1', $id1)
×
1318
                        ->where('groupid', $room->groupid)
×
1319
                        ->first();
×
1320
                } elseif ($room->chattype === ChatRoom::TYPE_USER2USER) {
2✔
1321
                    $other = ($room->user1 == $id2) ? $room->user2 : $room->user1;
2✔
1322
                    $existing = ChatRoom::where(function ($q) use ($id1, $other) {
2✔
1323
                        $q->where(function ($q2) use ($id1, $other) {
2✔
1324
                            $q2->where('user1', $id1)->where('user2', $other);
2✔
1325
                        })->orWhere(function ($q2) use ($id1, $other) {
2✔
1326
                            $q2->where('user2', $id1)->where('user1', $other);
2✔
1327
                        });
2✔
1328
                    })
2✔
1329
                        ->first();
2✔
1330
                }
1331

1332
                if ($existing) {
2✔
1333
                    // Room already exists for id1 — move messages into it.
1334
                    Logger::info("TN-SYNC-TRACE [WRITE] table=chat_messages op=update where=chatid={$room->id} set=chatid={$existing->id}");
1✔
1335
                    ChatMessage::where('chatid', $room->id)->get()->each(function ($chatMessage) use ($existing, $dryRun) {
1✔
1336
                        $chatMessage->chatid = $existing->id;
1✔
1337
                        if (!$dryRun) {
1✔
1338
                            $chatMessage->save();
1✔
1339
                        }
1340
                    });
1✔
1341

1342
                    // Keep the latest message timestamp.
1343
                    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✔
1344
                    if ($room->latestmessage && (!$existing->latestmessage || $room->latestmessage > $existing->latestmessage)) {
1✔
1345
                        $existing->latestmessage = $room->latestmessage;
×
1346
                        if (!$dryRun) {
×
1347
                            $existing->save();
×
1348
                        }
1349
                    }
1350
                } else {
1351
                    // No existing room — just reassign user reference.
1352
                    $col = ($room->user1 == $id2) ? 'user1' : 'user2';
1✔
1353
                    $room->$col = $id1;
1✔
1354
                    Logger::info("TN-SYNC-TRACE [WRITE] table=chat_rooms op=update where=id={$room->id} set={$col}={$id1}");
1✔
1355
                    if (!$dryRun) {
1✔
1356
                        $room->save();
1✔
1357
                    }
1358
                }
1359
            }
1360

1361
            // Move all remaining chat messages from id2.
1362
            EloquentUtils::reparentRow(ChatMessage::class, 'userid', $id2, $id1, $dryRun);
18✔
1363

1364
            // --- Merge user attributes (keep non-NULL from id2 if id1 is NULL) ---
1365
            // Refresh models after membership changes.
1366
            $u1->refresh();
18✔
1367
            $u2->refresh();
18✔
1368

1369
            foreach (['fullname', 'firstname', 'lastname', 'yahooid'] as $att) {
18✔
1370
                $id2Value = $u2->$att;
18✔
1371

1372
                // Skip early continue during dry run to get identical logs as tn_sync.
1373
                if (!$dryRun) {
18✔
1374
                    if ($id2Value === NULL) {
18✔
1375
                        continue;
18✔
1376
                    }
1377
                }
1378

1379
                // Clear id2's attribute first (unique key safety for yahooid).
1380
                $u2->$att = NULL;
18✔
1381
                Logger::info("TN-SYNC-TRACE [WRITE] table=users op=update where=id={$id2} set={$att}=NULL");
18✔
1382
                if (!$dryRun) {
18✔
1383
                    $u2->save();
18✔
1384
                }
1385

1386
                // Don't overwrite a name with FBUser or a -owner address.
1387
                $isDodgyName = $att === 'fullname'
18✔
1388
                    && (stripos($id2Value, 'fbuser') !== FALSE || stripos($id2Value, '-owner') !== FALSE);
18✔
1389

1390
                if ($u1->$att === NULL && !$isDodgyName) {
18✔
1391
                    $u1->$att = $id2Value;
1✔
1392
                    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✔
1393
                    if (!$dryRun) {
1✔
1394
                        $u1->save();
1✔
1395
                    }
1396
                }
1397

1398
                $u1->refresh();
18✔
1399
            }
1400

1401
            // --- Merge logs ---
1402
            EloquentUtils::reparentRow(Log::class, 'user', $id2, $id1, $dryRun);
18✔
1403
            EloquentUtils::reparentRow(Log::class, 'byuser', $id2, $id1, $dryRun);
18✔
1404

1405
            // --- Merge messages ---
1406
            EloquentUtils::reparentRow(Message::class, 'fromuser', $id2, $id1, $dryRun);
18✔
1407

1408
            // --- Merge history ---
1409
            EloquentUtils::reparentRow(MessageHistory::class, 'fromuser', $id2, $id1, $dryRun);
18✔
1410
            EloquentUtils::reparentRow(MembershipHistory::class, 'userid', $id2, $id1, $dryRun);
18✔
1411

1412
            // --- Merge system role (take highest) ---
1413
            $u1->refresh();
18✔
1414
            $u2->refresh();
18✔
1415

1416
            $mergedSystemRole = self::systemRoleMax($u1->systemrole, $u2->systemrole);
18✔
1417
            $u1->systemrole = $mergedSystemRole;
18✔
1418
            Logger::info("TN-SYNC-TRACE [WRITE] table=users op=update where=id={$id1} set=systemrole={$mergedSystemRole}");
18✔
1419
            if (!$dryRun) {
18✔
1420
                $u1->save();
18✔
1421
            }
1422

1423
            // --- Merge added date (keep oldest) ---
1424
            $earlierAdded = ($u1->added < $u2->added) ? $u1->added : $u2->added;
18✔
1425
            $u1->added = $earlierAdded;
18✔
1426
            Logger::info("TN-SYNC-TRACE [WRITE] table=users op=update where=id={$id1} set=added={$earlierAdded}");
18✔
1427
            $u1->lastupdated = now();
18✔
1428
            Logger::info("TN-SYNC-TRACE [WRITE] table=users op=update where=id={$id1} set=lastupdated=NOW()");
18✔
1429
            if (!$dryRun) {
18✔
1430
                $u1->save();
18✔
1431
            }
1432

1433
            // --- Merge TN user ID ---
1434
            $tnId1 = $u1->tnuserid;
18✔
1435
            $tnId2 = $u2->tnuserid;
18✔
1436

1437
            if (!$tnId1 && $tnId2) {
18✔
1438
                $u2->tnuserid = NULL;
1✔
1439
                Logger::info("TN-SYNC-TRACE [WRITE] table=users op=update where=id={$id2} set=tnuserid=NULL");
1✔
1440
                if (!$dryRun) {
1✔
1441
                    $u2->save();
1✔
1442
                }
1443
                $u1->tnuserid = $tnId2;
1✔
1444
                Logger::info("TN-SYNC-TRACE [WRITE] table=users op=update where=id={$id1} set=tnuserid={$tnId2}");
1✔
1445
                if (!$dryRun) {
1✔
1446
                    $u1->save();
1✔
1447
                }
1448
            }
1449

1450
            // --- Merge gift aid (keep most favourable declaration) ---
1451
            $giftAids = GiftAid::whereIn('userid', [$id1, $id2])
18✔
1452
                ->orderBy('id')
18✔
1453
                ->get();
18✔
1454

1455
            if ($giftAids->isNotEmpty()) {
18✔
1456
                $weights = [
×
1457
                    self::GIFTAID_PERIOD_PAST_4_YEARS_AND_FUTURE => 0,
×
1458
                    self::GIFTAID_PERIOD_SINCE => 1,
×
1459
                    self::GIFTAID_PERIOD_FUTURE => 2,
×
1460
                    self::GIFTAID_PERIOD_THIS => 3,
×
1461
                    self::GIFTAID_PERIOD_DECLINED => 4,
×
1462
                ];
×
1463

1464
                $best = NULL;
×
1465
                foreach ($giftAids as $giftAid) {
×
1466
                    $weight = $weights[$giftAid->period] ?? 999;
×
1467
                    $bestWeight = $best ? ($weights[$best->period] ?? 999) : 999;
×
1468

1469
                    if ($best === NULL || $weight < $bestWeight) {
×
1470
                        $best = $giftAid;
×
1471
                    }
1472
                }
1473

1474
                // Delete all except the best.
1475
                foreach ($giftAids as $giftAid) {
×
1476
                    if ($giftAid->id !== $best->id) {
×
1477
                        Logger::info("TN-SYNC-TRACE [WRITE] table=giftaid op=delete where=id={$giftAid->id}");
×
1478
                        if (!$dryRun) {
×
1479
                            $giftAid->delete();
×
1480
                        }
1481
                    }
1482
                }
1483

1484
                // Assign the best to id1.
1485
                Logger::info("TN-SYNC-TRACE [WRITE] table=giftaid op=update where=id={$best->id} set=userid={$id1}");
×
1486
                if ($best->userid !== $id1) {
×
1487
                    $best->userid = $id1;
×
1488
                    if (!$dryRun) {
×
1489
                        $best->save();
×
1490
                    }
1491
                }
1492
            }
1493

1494
            // --- Log the merge (before deleting id2) ---
1495
            $mergeText = "Merged {$id2} into {$id1} ({$reason})";
18✔
1496

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

1509
            $logId1 = new Log();
18✔
1510
            $logId1->timestamp = now();
18✔
1511
            $logId1->type = 'User';
18✔
1512
            $logId1->subtype = 'Merged';
18✔
1513
            $logId1->user = $id1;
18✔
1514
            $logId1->byuser = $byUserId;
18✔
1515
            $logId1->text = $mergeText;
18✔
1516
            Logger::info("TN-SYNC-TRACE [WRITE] table=logs op=insert set=type=User,subtype=Merged,user={$id1},byuser=" . ($byUserId ?? 'NULL') . ",text=len=" . strlen($mergeText));
18✔
1517
            if (!$dryRun) {
18✔
1518
                $logId1->save();
18✔
1519
            }
1520

1521
            DB::commit();
18✔
1522
        } catch (\Exception $e) {
×
1523
            DB::rollBack();
×
1524
            Logger::error("Merge exception: " . $e->getMessage());
×
1525
            return FALSE;
×
1526
        }
1527

1528
        # Finally, delete id2.  We used to this inside the transaction, but the result was that
1529
        # fromuser sometimes got set to NULL on messages owned by id2, despite them having been set to
1530
        # id1 earlier on.  Either we're dumb, or there's a subtle interaction between transactions,
1531
        # foreign keys and Percona clusters.  This is safer and proves to be more reliable.
1532
        #
1533
        # Make sure we don't pick up an old cached version, as we've just changed it quite a bit.
1534
        try {
1535
            Logger::info("Merged {$id1} < {$id2}, {$reason}");
18✔
1536
            // Delete the conflict memberships collected during the merge loop above.
1537
            // These are in-memory models, so $m->delete() removes them by primary key
1538
            // (firing model events for auditing) WITHOUT a fresh SELECT — avoiding the
1539
            // read-split replica-lag hazard described where $membershipsToDelete is built.
1540
            foreach ($membershipsToDelete as $m) {
18✔
1541
                Logger::info("TN-SYNC-TRACE [WRITE] table=memberships op=delete where=userid={$m->userid},groupid={$m->groupid}");
4✔
1542
                if (!$dryRun) {
4✔
1543
                    $m->delete();
4✔
1544
                }
1545
            }
1546
            Logger::info("TN-SYNC-TRACE [WRITE] table=users op=delete where=id={$id2}");
18✔
1547
            if (!$dryRun) {
18✔
1548
                User::find($id2)?->delete();
18✔
1549
            }
1550
        } catch (\Exception $e) {
×
1551
            Logger::error("Failed to delete merged user {$id2}: " . $e->getMessage());
×
1552
            // The merge itself succeeded — the user data is consolidated in id1.
1553
            // A dangling id2 row is less harmful than rolling back the entire merge.
1554
        }
1555

1556
        return TRUE;
18✔
1557
    }
1558

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

1598
        // --- Delete external emails (keep internal Freegle addresses) ---
1599
        foreach ($this->emails()->get() as $email) {
17✔
1600
            if (!self::isInternalEmail($email->email)) {
17✔
1601
                Logger::info("TN-SYNC-TRACE [WRITE] table=users_emails op=delete where=userid={$this->id},email={$email->email}");
17✔
1602
                $this->removeEmail($email->email, $dryRun);
17✔
1603
            }
1604
        }
1605

1606
        // --- Delete all login credentials ---
1607
        Logger::info("TN-SYNC-TRACE [WRITE] table=users_logins op=delete where=userid={$this->id}");
17✔
1608
        if (!$dryRun) {
17✔
1609
            UserLogin::where('userid', $this->id)->get()->each->delete();
17✔
1610
        }
1611

1612
        // --- Clear message content and withdraw messages without an outcome ---
1613
        $msgIds = Message::where('fromuser', $this->id)
17✔
1614
            ->whereIn('type', [Message::TYPE_OFFER, Message::TYPE_WANTED])
17✔
1615
            ->pluck('id');
17✔
1616

1617
        foreach ($msgIds as $msgId) {
17✔
1618
            // Update the field of the message
1619
            $message = Message::find($msgId);
1✔
1620
            $message->fromip = NULL;
1✔
1621
            $message->message = NULL;
1✔
1622
            $message->envelopefrom = NULL;
1✔
1623
            $message->fromname = NULL;
1✔
1624
            $message->fromaddr = NULL;
1✔
1625
            $message->messageid = NULL;
1✔
1626
            $message->textbody = NULL;
1✔
1627
            $message->htmlbody = NULL;
1✔
1628
            $message->deleted = now();
1✔
1629
            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✔
1630
            if (!$dryRun) {
1✔
1631
                $message->save();
1✔
1632
            }
1633

1634
            // Mark the message group as deleted
1635
            $messageGroup = MessageGroup::find($msgId);
1✔
1636
            $messageGroup->deleted = 1;
1✔
1637
            Logger::info("TN-SYNC-TRACE [WRITE] table=messages_groups op=update where=msgid={$msgId} set=deleted=1");
1✔
1638
            if (!$dryRun) {
1✔
1639
                $messageGroup->save();
1✔
1640
            }
1641

1642
            // Clear any outcome comments that might contain personal data.
1643
            foreach ($message->outcomes()->get() as $messageOutcome) {
1✔
1644
                $messageOutcome->comments = NULL;
×
1645
                Logger::info("TN-SYNC-TRACE [WRITE] table=messages_outcomes op=update where=msgid={$msgId} set=comments=NULL");
×
1646
                if (!$dryRun) {
×
1647
                    $messageOutcome->save();
×
1648
                }
1649
            }
1650

1651
            // Withdraw if no outcome has been recorded yet.
1652
            if (!$message->hasOutcome()) {
1✔
1653
                $message->withdraw('Withdrawn on user unsubscribe', NULL, NULL, $dryRun);
1✔
1654
            }
1655
        }
1656

1657
        // --- Clear chat message content ---
1658
        foreach ($this->chatMessages()->get() as $chatMessage) {
17✔
1659
            $chatMessage->message = NULL;
1✔
1660
            Logger::info("TN-SYNC-TRACE [WRITE] table=chat_messages op=update where=id={$chatMessage->id} set=message=NULL");
1✔
1661
            if (!$dryRun) {
1✔
1662
                $chatMessage->save();
1✔
1663
            }
1664
        }
1665

1666
        // --- Delete user-generated content ---
1667
        Logger::info("TN-SYNC-TRACE [WRITE] table=communityevents op=delete where=userid={$this->id}");
17✔
1668
        if (!$dryRun) {
17✔
1669
            CommunityEvent::where('userid', $this->id)->get()->each->delete();
17✔
1670
        }
1671
        Logger::info("TN-SYNC-TRACE [WRITE] table=volunteering op=delete where=userid={$this->id}");
17✔
1672
        if (!$dryRun) {
17✔
1673
            Volunteering::where('userid', $this->id)->get()->each->delete();
17✔
1674
        }
1675
        Logger::info("TN-SYNC-TRACE [WRITE] table=newsfeed op=delete where=userid={$this->id}");
17✔
1676
        if (!$dryRun) {
17✔
1677
            Newsfeed::where('userid', $this->id)->get()->each->delete();
17✔
1678
        }
1679
        Logger::info("TN-SYNC-TRACE [WRITE] table=users_stories op=delete where=userid={$this->id}");
17✔
1680
        if (!$dryRun) {
17✔
1681
            UserStory::where('userid', $this->id)->get()->each->delete();
17✔
1682
        }
1683
        Logger::info("TN-SYNC-TRACE [WRITE] table=users_searches op=delete where=userid={$this->id}");
17✔
1684
        if (!$dryRun) {
17✔
1685
            UserSearch::where('userid', $this->id)->get()->each->delete();
17✔
1686
        }
1687
        Logger::info("TN-SYNC-TRACE [WRITE] table=users_aboutme op=delete where=userid={$this->id}");
17✔
1688
        if (!$dryRun) {
17✔
1689
            UserAboutMe::where('userid', $this->id)->get()->each->delete();
17✔
1690
        }
1691
        Logger::info("TN-SYNC-TRACE [WRITE] table=ratings op=delete where=rater={$this->id}");
17✔
1692
        if (!$dryRun) {
17✔
1693
            Rating::where('rater', $this->id)->get()->each->delete();
17✔
1694
        }
1695
        Logger::info("TN-SYNC-TRACE [WRITE] table=ratings op=delete where=ratee={$this->id}");
17✔
1696
        if (!$dryRun) {
17✔
1697
            Rating::where('ratee', $this->id)->get()->each->delete();
17✔
1698
        }
1699

1700
        // --- Remove all group memberships ---
1701
        $groupIds = collect($this->getMembershipList())->pluck('id');
17✔
1702
        foreach ($groupIds as $groupId) {
17✔
1703
            Logger::info("TN-SYNC-TRACE [WRITE] table=memberships op=delete where=userid={$this->id},groupid={$groupId}");
2✔
1704
            $this->removeMembership($groupId, dryRun: $dryRun);
2✔
1705
        }
1706

1707
        // --- Delete postal addresses and profile images ---
1708
        Logger::info("TN-SYNC-TRACE [WRITE] table=users_addresses op=delete where=userid={$this->id}");
17✔
1709
        if (!$dryRun) {
17✔
1710
            UserAddress::where('userid', $this->id)->get()->each->delete();
17✔
1711
        }
1712
        Logger::info("TN-SYNC-TRACE [WRITE] table=users_images op=delete where=userid={$this->id}");
17✔
1713
        if (!$dryRun) {
17✔
1714
            UserImage::where('userid', $this->id)->get()->each->delete();
17✔
1715
        }
1716

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

1723
        // --- Mark user as forgotten ---
1724
        $this->forgotten = now();
17✔
1725
        $this->tnuserid = NULL;
17✔
1726
        Logger::info("TN-SYNC-TRACE [WRITE] table=users op=update where=id={$this->id} set=forgotten=NOW(),tnuserid=NULL");
17✔
1727
        if (!$dryRun) {
17✔
1728
            $this->save();
17✔
1729
        }
1730

1731
        // --- Delete sessions ---
1732
        Logger::info("TN-SYNC-TRACE [WRITE] table=sessions op=delete where=userid={$this->id}");
17✔
1733
        if (!$dryRun) {
17✔
1734
            UserSession::where('userid', $this->id)->get()->each->delete();
17✔
1735
        }
1736

1737
        // --- Log the deletion ---
1738
        $log = new Log();
17✔
1739
        $log->timestamp = now();
17✔
1740
        $log->type = 'User';
17✔
1741
        $log->subtype = 'Deleted';
17✔
1742
        $log->user = $this->id;
17✔
1743
        $log->text = $reason;
17✔
1744
        Logger::info("TN-SYNC-TRACE [WRITE] table=logs op=insert set=type=User,subtype=Deleted,user={$this->id},text=len=" . strlen($reason));
17✔
1745
        if (!$dryRun) {
17✔
1746
            $log->save();
17✔
1747
        }
1748
    }
1749

1750
    /**
1751
     * Remove a user's membership from a group, optionally banning them.
1752
     *
1753
     * When banning, also inserts into users_banned and withdraws any active
1754
     * Offer/Wanted messages the user has on the group.
1755
     *
1756
     * Ported from the legacy V1 PHP User::removeMembership().
1757
     *
1758
     * @param int $groupId The group to remove the user from
1759
     * @param bool $ban If TRUE, also ban the user from the group and withdraw their messages
1760
     * @param bool $spam If TRUE, log the removal as an automated spammer removal
1761
     * @param int|null $byUserId The user performing the removal (for logging)
1762
     * @param bool $byEmail If TRUE, send a farewell email to the user (also sent to TN users automatically)
1763
     * @return bool TRUE if the membership was deleted (or a ban was recorded)
1764
     */
1765
    public function removeMembership(int $groupId, bool $ban = FALSE, bool $spam = FALSE, ?int $byUserId = NULL, bool $byEmail = FALSE, bool $dryRun = false): bool
2✔
1766
    {
1767
        // Notify TN users or email-triggered removals so they know they can no longer see messages.
1768
        if ($byEmail || $this->isTN()) {
2✔
1769
            Logger::info("TN-SYNC-TRACE [EMAIL] action=send-farewell user={$this->id} groupid={$groupId} to=" . ($this->email_preferred ?? 'NULL'));
×
1770

1771
            if (!$dryRun) {
×
1772
                $group = Group::find($groupId);
×
1773
                $preferredEmail = $this->email_preferred;
×
1774

1775
                if ($group && $preferredEmail) {
×
1776
                    try {
1777
                        \Illuminate\Support\Facades\Mail::raw('Parting is such sweet sorrow.', function ($message) use ($group, $preferredEmail) {
×
1778
                            $message->subject('Farewell from ' . $group->nameshort)
×
1779
                                ->from($group->getAutoEmail())
×
1780
                                ->replyTo($group->getModsEmail())
×
1781
                                ->to($preferredEmail);
×
1782
                        });
×
1783
                    } catch (\Exception $e) {
×
1784
                        Logger::warning("Failed to send farewell email for user {$this->id} on group {$groupId}: " . $e->getMessage());
×
1785
                    }
1786
                }
1787
            }
1788
        }
1789

1790
        if ($ban) {
2✔
1791
            // Record the ban.
1792
            $userBanned = new UserBanned();
×
1793
            $userBanned->userid = $this->id;
×
1794
            $userBanned->groupid = $groupId;
×
1795
            $userBanned->byuser = $byUserId;
×
1796
            Logger::info("TN-SYNC-TRACE [WRITE] table=users_banned op=insert where=userid={$this->id},groupid={$groupId},byuser=" . ($byUserId ?? 'NULL'));
×
1797
            if (!$dryRun) {
×
1798
                $userBanned->save();
×
1799
            }
1800

1801
            // Withdraw active Offer/Wanted messages on this group that have no outcome yet.
1802
            $msgIds = MessageGroup::join('messages', 'messages_groups.msgid', '=', 'messages.id')
×
1803
                ->where('messages.fromuser', $this->id)
×
1804
                ->where('messages_groups.groupid', $groupId)
×
1805
                ->whereIn('messages.type', [Message::TYPE_OFFER, Message::TYPE_WANTED])
×
1806
                ->pluck('messages_groups.msgid');
×
1807

1808
            foreach ($msgIds as $msgId) {
×
1809
                $m = Message::find($msgId);
×
1810

1811
                if ($m && !$m->hasOutcome()) {
×
1812
                    $m->withdraw('Marked as withdrawn by ban', NULL, $byUserId, $dryRun);
×
1813
                }
1814
            }
1815
        }
1816

1817
        // Remove the membership.
1818
        Logger::info("TN-SYNC-TRACE [WRITE] table=memberships op=delete where=userid={$this->id},groupid={$groupId}");
2✔
1819
        $membership = Membership::where('userid', $this->id)
2✔
1820
            ->where('groupid', $groupId)
2✔
1821
            ->first();
2✔
1822
        $deleted = $membership ? 1 : 0;
2✔
1823
        if (!$dryRun) {
2✔
1824
            $membership?->delete();
2✔
1825
        }
1826

1827
        if ($deleted || $ban) {
2✔
1828
            $log = new Log();
2✔
1829
            $log->timestamp = now();
2✔
1830
            $log->type = 'Group';
2✔
1831
            $log->subtype = 'Left';
2✔
1832
            $log->user = $this->id;
2✔
1833
            $log->byuser = $byUserId;
2✔
1834
            $log->groupid = $groupId;
2✔
1835
            $log->text = $spam ? 'Autoremoved spammer' : ($ban ? 'via ban' : NULL);
2✔
1836
            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✔
1837
            if (!$dryRun) {
2✔
1838
                $log->save();
2✔
1839
            }
1840
        }
1841

1842
        return $deleted > 0 || $ban;
2✔
1843
    }
1844

1845
    /**
1846
     * Return the group IDs where this user is a Moderator or Owner.
1847
     *
1848
     * Ported from the legacy V1 PHP User::getModeratorships().
1849
     *
1850
     * @param bool $activeOnly When TRUE, only include groups where the user is actively modding
1851
     *                         (i.e. their membership settings have active=1 or showmessages=1).
1852
     * @return array<int> Array of group IDs
1853
     */
1854
    public function getModeratorships(bool $activeOnly = false): array
×
1855
    {
1856
        $ret = [];
×
1857

1858
        foreach ($this->memberships()->get() as $membership) {
×
1859
            if ($membership->role === self::ROLE_OWNER || $membership->role === self::ROLE_MODERATOR) {
×
1860
                if (!$activeOnly || $this->activeModForGroup($membership->groupid)) {
×
1861
                    $ret[] = $membership->groupid;
×
1862
                }
1863
            }
1864
        }
1865

1866
        return $ret;
×
1867
    }
1868

1869
    /**
1870
     * Check whether this user is actively modding a given group.
1871
     *
1872
     * Uses the 'active' flag in membership settings if present; falls back to the legacy
1873
     * 'showmessages' flag; defaults to TRUE (active) if neither is set.
1874
     *
1875
     * Ported from the legacy V1 PHP User::activeModForGroup().
1876
     *
1877
     * @param int $groupId
1878
     * @return bool
1879
     */
1880
    public function activeModForGroup(int $groupId): bool
×
1881
    {
1882
        $settings = $this->getGroupSettings($groupId);
×
1883

1884
        if (array_key_exists('active', $settings)) {
×
1885
            return (bool) $settings['active'];
×
1886
        }
1887

1888
        // Legacy fallback: showmessages=0 means inactive; absent or 1 means active.
1889
        return !array_key_exists('showmessages', $settings) || (bool) $settings['showmessages'];
×
1890
    }
1891

1892
    /**
1893
     * Check whether this user participates in wider chat review.
1894
     *
1895
     * Returns TRUE if the user is an active moderator on at least one group that has
1896
     * the 'widerchatreview' group setting enabled.
1897
     *
1898
     * Ported from the legacy V1 PHP User::widerReview().
1899
     *
1900
     * @return bool
1901
     */
1902
    public function widerReview(): bool
×
1903
    {
1904
        foreach ($this->getModeratorships() as $groupId) {
×
1905
            if ($this->activeModForGroup($groupId)) {
×
1906
                $group = Group::find($groupId);
×
1907

1908
                if ($group && $group->getSetting('widerchatreview', false)) {
×
1909
                    return true;
×
1910
                }
1911
            }
1912
        }
1913

1914
        return false;
×
1915
    }
1916

1917
    /**
1918
     * Get the user's per-group membership settings.
1919
     *
1920
     * Ported from the legacy V1 PHP User::getGroupSettings().
1921
     *
1922
     * @param int $groupId
1923
     * @param int|null $configId Optional mod config ID (used for mod config lookup)
1924
     * @return array
1925
     */
1926
    public function getGroupSettings(int $groupId, ?int $configId = NULL): array
2✔
1927
    {
1928
        $defaults = [
2✔
1929
            'active' => 1,
2✔
1930
            'showchat' => 1,
2✔
1931
            'pushnotify' => 1,
2✔
1932
            'eventsallowed' => 1,
2✔
1933
            'volunteeringallowed' => 1,
2✔
1934
        ];
2✔
1935

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

1938
        if (!$membership) {
2✔
1939
            return $defaults;
×
1940
        }
1941

1942
        $settings = $membership->settings ?? [];
2✔
1943

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

1948
        // Base active setting on legacy showmessages setting if not present.
1949
        $settings['active'] = array_key_exists('active', $settings)
2✔
1950
            ? $settings['active']
×
1951
            : (!array_key_exists('showmessages', $settings) || $settings['showmessages']);
2✔
1952
        $settings['active'] = $settings['active'] ? 1 : 0;
2✔
1953

1954
        // Merge defaults for missing keys.
1955
        foreach ($defaults as $key => $val) {
2✔
1956
            if (!array_key_exists($key, $settings)) {
2✔
1957
                $settings[$key] = $val;
2✔
1958
            }
1959
        }
1960

1961
        $settings['emailfrequency'] = $membership->emailfrequency;
2✔
1962
        $settings['eventsallowed'] = $membership->eventsallowed;
2✔
1963
        $settings['volunteeringallowed'] = $membership->volunteeringallowed ?? 1;
2✔
1964

1965
        return $settings;
2✔
1966
    }
1967

1968
    /**
1969
     * Get this user's group memberships with group details.
1970
     *
1971
     * Returns an array of group data enriched with membership info (role, collection,
1972
     * configid, mysettings). This is distinct from the memberships() Eloquent relationship
1973
     * which returns Membership models.
1974
     *
1975
     * Ported from the legacy V1 PHP User::getMemberships().
1976
     *
1977
     * @param bool $modOnly Only return groups where user is Moderator or Owner
1978
     * @param string|null $groupType Filter by group type (e.g. Group::TYPE_FREEGLE)
1979
     * @param bool $getWork Include work counts for moderator groups
1980
     * @param bool $isModTools Whether this is a ModTools context (affects publish filtering)
1981
     * @return array Array of group data with membership details
1982
     */
1983
    public function getMembershipList(bool $modOnly = FALSE, ?string $groupType = NULL, bool $getWork = FALSE, bool $isModTools = FALSE): array
17✔
1984
    {
1985
        $query = DB::table('memberships')
17✔
1986
            ->join('groups', 'groups.id', '=', 'memberships.groupid')
17✔
1987
            ->where('memberships.userid', $this->id);
17✔
1988

1989
        if ($modOnly) {
17✔
1990
            $query->whereIn('memberships.role', [self::ROLE_MODERATOR, self::ROLE_OWNER]);
×
1991
        }
1992

1993
        if ($groupType) {
17✔
1994
            $query->where('groups.type', $groupType);
×
1995
        }
1996

1997
        if (!$isModTools) {
17✔
1998
            $query->where('groups.publish', 1);
17✔
1999
        }
2000

2001
        $rows = $query->select([
17✔
2002
            'groups.type',
17✔
2003
            'memberships.heldby',
17✔
2004
            'memberships.settings AS membership_settings',
17✔
2005
            'memberships.collection',
17✔
2006
            'memberships.emailfrequency',
17✔
2007
            'memberships.eventsallowed',
17✔
2008
            'memberships.volunteeringallowed',
17✔
2009
            'memberships.groupid',
17✔
2010
            'memberships.role',
17✔
2011
            'memberships.configid',
17✔
2012
            'memberships.ourPostingStatus',
17✔
2013
            DB::raw("CASE WHEN groups.namefull IS NOT NULL THEN groups.namefull ELSE groups.nameshort END AS namedisplay"),
17✔
2014
        ])
17✔
2015
            ->orderByRaw('LOWER(namedisplay) ASC')
17✔
2016
            ->get();
17✔
2017

2018
        $ret = [];
17✔
2019
        $getWorkIds = [];
17✔
2020
        $groupSettings = [];
17✔
2021

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

2026
        foreach ($rows as $row) {
17✔
2027
            $group = $groups->get($row->groupid);
2✔
2028

2029
            if (!$group) {
2✔
2030
                continue;
×
2031
            }
2032

2033
            $one = $group->getPublic();
2✔
2034

2035
            $one['role'] = $row->role;
2✔
2036
            $one['collection'] = $row->collection;
2✔
2037
            $amod = ($one['role'] === self::ROLE_MODERATOR || $one['role'] === self::ROLE_OWNER);
2✔
2038
            $one['configid'] = $row->configid;
2✔
2039

2040
            if ($amod && !$one['configid']) {
2✔
2041
                # Get a config using defaults.
2042
                $one['configid'] = ModConfig::getForGroup($this->id, $row->groupid);
×
2043
            }
2044

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

2047
            $one['mysettings']['emailfrequency'] = ($row->type === Group::TYPE_FREEGLE && $this->sendOurMails(false, false))
2✔
2048
                ? ($one['mysettings']['emailfrequency'] ?? 24)
2✔
2049
                : 0;
×
2050

2051
            $groupSettings[$row->groupid] = $one['mysettings'];
2✔
2052

2053
            if ($getWork && $amod) {
2✔
2054
                $getWorkIds[] = $row->groupid;
×
2055
            }
2056

2057
            $ret[] = $one;
2✔
2058
        }
2059

2060
        if ($getWork && !empty($getWorkIds)) {
17✔
2061
            $workcounts = Group::getWorkCounts($this, $groupSettings, $getWorkIds);
×
2062
            foreach ($ret as &$one) {
×
2063
                $gid = $one['id'];
×
2064
                if (isset($workcounts[$gid])) {
×
2065
                    $one = array_merge($one, $workcounts[$gid]);
×
2066
                }
2067
            }
2068
            unset($one);
×
2069
        }
2070

2071
        return $ret;
17✔
2072
    }
2073
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc