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

Freegle / Iznik / 20959

13 Jun 2026 02:43PM UTC coverage: 71.021% (+1.5%) from 69.555%
20959

push

circleci

edwh
feat(web): redirect /councils to /partnerships

The councils content now lives at /partnerships; add a route rule so the old
/councils URL (which 404'd) redirects there.

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

10705 of 14260 branches covered (75.07%)

Branch coverage included in aggregate %.

116833 of 165317 relevant lines covered (70.67%)

35.88 hits per line

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

88.31
/iznik-batch/app/Services/ChatProcessService.php
1
<?php
2

3
namespace App\Services;
4

5
use App\Models\BackgroundTask;
6
use App\Models\ChatMessage;
7
use App\Models\ChatRoom;
8
use App\Models\ChatRoster;
9
use App\Services\ContentCheckService;
10
use Illuminate\Support\Facades\DB;
11
use Illuminate\Support\Facades\Log;
12

13
/**
14
 * Processes chat messages that arrive via incoming email (processingrequired = 1).
15
 *
16
 * Mirrors V1 cron/chat_process.php + ChatMessage::process().
17
 *
18
 * Key responsibilities:
19
 * - Spam/ban checks for User2User chats
20
 * - Setting processingrequired = 0, processingsuccessful = 1 (or 0 on failure)
21
 * - Updating chat_roster for the sender
22
 * - Reopening closed chats after new activity
23
 */
24
class ChatProcessService
25
{
26
    // V1: ChatMessage::REVIEW_SPAM, REVIEW_FORCE, etc.
27
    private const REVIEW_SPAM = 'Spam';
28
    private const REVIEW_LAST = 'Last';
29

30
    /**
31
     * Map a ContentCheckService check identifier to the specific chat_messages
32
     * `reportreason` enum value, so the modtools review UI can tell the moderator
33
     * WHY a message was held instead of the unhelpful "failed spam checks, but we
34
     * don't have any more information about why". Every value here is a member of
35
     * the reportreason enum and is already rendered with friendly text by
36
     * ModChatReview.vue. Anything unmapped falls back to the generic 'Spam'.
37
     */
38
    private const CHECK_TO_REPORTREASON = [
39
        ContentCheckService::CHECK_CONCERN_KEYWORD => 'WorryWord',
40
        ContentCheckService::CHECK_PER_GROUP_WORRY => 'WorryWord',
41
        ContentCheckService::CHECK_MONEY           => 'Money',
42
        ContentCheckService::CHECK_URL             => 'Link',
43
        ContentCheckService::CHECK_MESSAGING_LINK  => 'Link',
44
        ContentCheckService::CHECK_SPAMHAUS_DBL    => 'URL on DBL',
45
        ContentCheckService::CHECK_EMAIL_ADDRESS   => 'Email',
46
        ContentCheckService::CHECK_LANGUAGE        => 'Language',
47
        ContentCheckService::CHECK_KNOWN_SPAMMER   => 'Referenced known spammer',
48
        ContentCheckService::CHECK_GREETING_SPAM   => 'Greetings spam',
49
    ];
50

51
    /**
52
     * Resolve the specific reportreason for a checkChatMessage() result.
53
     * Returns the generic 'Spam' for a null result or an unmapped check.
54
     */
55
    private function reportReasonForCheck(?array $result): string
2✔
56
    {
57
        $check = $result['check'] ?? null;
2✔
58
        return self::CHECK_TO_REPORTREASON[$check] ?? self::REVIEW_SPAM;
2✔
59
    }
60

61
    private ContentCheckService $contentCheck;
62

63
    public function __construct(?ContentCheckService $contentCheck = null)
22✔
64
    {
65
        // Resolve from the container when not injected (keeps `new ChatProcessService()` working).
66
        $this->contentCheck = $contentCheck ?? app(ContentCheckService::class);
22✔
67
    }
68

69
    /**
70
     * Process all pending chat messages (processingrequired = 1).
71
     *
72
     * @return int Number of messages processed.
73
     */
74
    public function processIncoming(): int
21✔
75
    {
76
        $messages = DB::table('chat_messages')
21✔
77
            ->join('chat_rooms', 'chat_messages.chatid', '=', 'chat_rooms.id')
21✔
78
            ->where('chat_messages.processingrequired', 1)
21✔
79
            ->orderBy('chat_messages.id', 'asc')
21✔
80
            ->select('chat_messages.*', 'chat_rooms.chattype', 'chat_rooms.user1', 'chat_rooms.user2')
21✔
81
            ->get();
21✔
82

83
        $count = 0;
21✔
84

85
        foreach ($messages as $message) {
21✔
86
            if ($this->processMessage($message)) {
20✔
87
                $count++;
20✔
88
            }
89
        }
90

91
        Log::info("ChatProcess: processed {$count} messages");
21✔
92

93
        return $count;
21✔
94
    }
95

96
    /**
97
     * Process a single pending message.
98
     *
99
     * @return bool True if the message was processed (success or failure), false if skipped.
100
     */
101
    private function processMessage(object $message): bool
20✔
102
    {
103
        $id = $message->id;
20✔
104
        $chatid = $message->chatid;
20✔
105
        $userid = $message->userid;
20✔
106
        $chattype = $message->chattype;
20✔
107
        $platform = (bool) $message->platform;
20✔
108

109
        // --- Ban check for messages with a refmsgid ---
110
        if (!empty($message->refmsgid)) {
20✔
111
            $banned = DB::table('messages_groups')
×
112
                ->join('users_banned', function ($join) use ($userid) {
×
113
                    $join->on('messages_groups.groupid', '=', 'users_banned.groupid')
×
114
                        ->where('users_banned.userid', '=', $userid);
×
115
                })
×
116
                ->where('messages_groups.msgid', $message->refmsgid)
×
117
                ->exists();
×
118

119
            if ($banned) {
×
120
                $this->processFailed($id);
×
121
                return true;
×
122
            }
123
        }
124

125
        // --- User2User spam and review checks ---
126
        $review = 0;
20✔
127
        $reviewreason = null;
20✔
128
        $spam = 0;
20✔
129

130
        if ($chattype === ChatRoom::TYPE_USER2USER) {
20✔
131
            // Check if sender is a confirmed or pending spammer.
132
            $isSpammer = DB::table('spam_users')
20✔
133
                ->where('userid', $userid)
20✔
134
                ->whereIn('collection', ['Spammer', 'PendingAdd'])
20✔
135
                ->exists();
20✔
136

137
            if ($isSpammer) {
20✔
138
                $this->processFailed($id);
3✔
139
                return true;
3✔
140
            }
141

142
            // Check if sender is banned on all common groups with the other user.
143
            $otherId = $message->user1 == $userid ? $message->user2 : $message->user1;
17✔
144

145
            $bannedInCommon = $this->isBannedInCommonGroups($userid, $otherId);
17✔
146

147
            if ($bannedInCommon) {
17✔
148
                $this->processFailed($id);
×
149
                return true;
×
150
            }
151

152
            // Check if sender's messages should be held for review.
153
            $user = DB::table('users')->where('id', $userid)->first();
17✔
154
            $chatmodstatus = $user?->chatmodstatus ?? 'Moderated';
17✔
155

156
            if ($chatmodstatus === 'Fully') {
17✔
157
                // Fully moderated: every message goes to review (shadow ban).
158
                $review = 1;
2✔
159
                $reviewreason = self::REVIEW_SPAM;
2✔
160
            } elseif ($chatmodstatus === 'Moderated' && $this->isContentCheckable($message->type)) {
15✔
161
                // V1 parity: ChatMessage::process() ran Spam::checkReview() on
162
                // Moderated members' user-text messages and held any that matched
163
                // a concern keyword / link / phone number etc. That scan was lost
164
                // when chat_process.php was migrated to this service, so restore it.
165
                // Map the specific check that fired to its reportreason enum
166
                // value, so the review UI tells the moderator WHY (e.g. "It looks
167
                // like it refers to money.") rather than "...no more information
168
                // about why". Unmapped checks fall back to the generic 'Spam'.
169
                $checkResult = $this->contentCheck->checkChatMessage((string) ($message->message ?? ''));
13✔
170
                if ($checkResult !== null) {
13✔
171
                    $review = 1;
2✔
172
                    $reviewreason = $this->reportReasonForCheck($checkResult);
2✔
173
                }
174
            }
175

176
            // If the PREVIOUS message in this chat is held for review, hold this
177
            // one too. Use id < $id, not id != $id: V1's chat_process.php was a
178
            // continuous daemon that processed each message as it arrived, so the
179
            // newest other row WAS the previous one. This service processes in
180
            // batches (processIncoming orders by id asc), so when a burst of
181
            // messages is pending, "newest other row" is a LATER, not-yet-processed
182
            // message (reviewrequired defaults to 0) and the hold chain silently
183
            // breaks — subsequent messages from a member already under review get
184
            // delivered (Discourse #9656). Looking strictly backwards at the
185
            // immediately preceding (already-processed) message restores the chain.
186
            if (!$review) {
17✔
187
                $lastReview = DB::table('chat_messages')
13✔
188
                    ->where('chatid', $chatid)
13✔
189
                    ->where('id', '<', $id)
13✔
190
                    ->orderByDesc('id')
13✔
191
                    ->value('reviewrequired');
13✔
192

193
                if ($lastReview) {
13✔
194
                    $review = 1;
2✔
195
                    $reviewreason = self::REVIEW_LAST;
2✔
196
                }
197
            }
198
        }
199

200
        // Mark the message as processed.
201
        DB::table('chat_messages')
17✔
202
            ->where('id', $id)
17✔
203
            ->update([
17✔
204
                'reviewrequired' => $review,
17✔
205
                'reportreason' => $reviewreason,
17✔
206
                'reviewrejected' => $spam,
17✔
207
                'processingrequired' => 0,
17✔
208
                'processingsuccessful' => 1,
17✔
209
            ]);
17✔
210

211
        // Update the sender's roster position.
212
        $this->updateSenderRoster($id, $chatid, $userid, $platform);
17✔
213

214
        // Reopen any CLOSED roster entries for this chat (not BLOCKED).
215
        DB::table('chat_roster')
17✔
216
            ->where('chatid', $chatid)
17✔
217
            ->where('status', ChatRoster::STATUS_CLOSED)
17✔
218
            ->update(['status' => ChatRoster::STATUS_OFFLINE]);
17✔
219

220
        // V1 parity: ChatMessage::process() called notifyMembers() here when the
221
        // message wasn't held/banned (the spam/ban paths above early-return).
222
        // Hand off to a background task so we don't block this cron on FCM round-trips.
223
        if (!$review) {
17✔
224
            BackgroundTask::create([
11✔
225
                'task_type' => BackgroundTask::TASK_PUSH_NOTIFY_CHAT_MESSAGE,
11✔
226
                'data' => ['message_id' => $id],
11✔
227
                'created_at' => now(),
11✔
228
                'attempts' => 0,
11✔
229
            ]);
11✔
230
        }
231

232
        return true;
17✔
233
    }
234

235
    /**
236
     * Whether a chat message type carries member-entered text that should be
237
     * content checked. Mirrors the V1 ChatMessage::process() type filter; system
238
     * and templated messages (System, Promised, Nudge, etc.) are excluded.
239
     */
240
    private function isContentCheckable(?string $type): bool
14✔
241
    {
242
        return in_array($type, [
14✔
243
            ChatMessage::TYPE_DEFAULT,
14✔
244
            ChatMessage::TYPE_INTERESTED,
14✔
245
            ChatMessage::TYPE_REPORTEDUSER,
14✔
246
            ChatMessage::TYPE_ADDRESS,
14✔
247
        ], true);
14✔
248
    }
249

250
    /**
251
     * Mark a message as failed processing.
252
     */
253
    private function processFailed(int $messageId): void
3✔
254
    {
255
        DB::table('chat_messages')
3✔
256
            ->where('id', $messageId)
3✔
257
            ->update([
3✔
258
                'processingrequired' => 0,
3✔
259
                'processingsuccessful' => 0,
3✔
260
            ]);
3✔
261
    }
262

263
    /**
264
     * Check if $userId is banned on all groups they have in common with $otherId.
265
     */
266
    private function isBannedInCommonGroups(int $userId, int $otherId): bool
17✔
267
    {
268
        // Get groups both users are members of.
269
        $commonGroups = DB::table('memberships as m1')
17✔
270
            ->join('memberships as m2', function ($join) use ($otherId) {
17✔
271
                $join->on('m1.groupid', '=', 'm2.groupid')
17✔
272
                    ->where('m2.userid', '=', $otherId);
17✔
273
            })
17✔
274
            ->where('m1.userid', $userId)
17✔
275
            ->pluck('m1.groupid');
17✔
276

277
        if ($commonGroups->isEmpty()) {
17✔
278
            return false;
17✔
279
        }
280

281
        // Check if $userId is banned on ALL common groups.
282
        $bannedCount = DB::table('users_banned')
×
283
            ->where('userid', $userId)
×
284
            ->whereIn('groupid', $commonGroups)
×
285
            ->count();
×
286

287
        return $bannedCount >= $commonGroups->count();
×
288
    }
289

290
    /**
291
     * Update the sender's roster entry after they sent a message.
292
     *
293
     * V1: If the message came by email (!platform), mark it seen/emailed (since the sender
294
     * wrote it, they've "seen" it). For platform messages, same unless they have email-mine on.
295
     */
296
    private function updateSenderRoster(int $messageId, int $chatid, int $userid, bool $platform): void
17✔
297
    {
298
        if (!$platform) {
17✔
299
            // Incoming email reply: only update if there are no unseen messages from the other user.
300
            $hasUnseen = DB::table('chat_messages as cm')
1✔
301
                ->leftJoin('chat_roster as cr', function ($join) use ($userid) {
1✔
302
                    $join->on('cr.chatid', '=', 'cm.chatid')
1✔
303
                        ->where('cr.userid', '=', $userid);
1✔
304
                })
1✔
305
                ->where('cm.chatid', $chatid)
1✔
306
                ->where('cm.userid', '!=', $userid)
1✔
307
                ->where('cm.seenbyall', 0)
1✔
308
                ->where('cm.mailedtoall', 0)
1✔
309
                ->where(function ($q) {
1✔
310
                    $q->whereNull('cr.lastmsgseen')
1✔
311
                        ->orWhereColumn('cr.lastmsgseen', '<', 'cm.id');
1✔
312
                })
1✔
313
                ->where(function ($q) {
1✔
314
                    $q->whereNull('cr.lastmsgemailed')
1✔
315
                        ->orWhereColumn('cr.lastmsgemailed', '<', 'cm.id');
1✔
316
                })
1✔
317
                ->exists();
1✔
318

319
            if ($hasUnseen) {
1✔
320
                return;
×
321
            }
322
        }
323

324
        DB::table('chat_roster')
17✔
325
            ->where('chatid', $chatid)
17✔
326
            ->where('userid', $userid)
17✔
327
            ->where(function ($q) use ($messageId) {
17✔
328
                $q->whereNull('lastmsgseen')
17✔
329
                    ->orWhere('lastmsgseen', '<', $messageId);
17✔
330
            })
17✔
331
            ->update([
17✔
332
                'lastmsgseen' => $messageId,
17✔
333
                'lastmsgemailed' => $messageId,
17✔
334
            ]);
17✔
335
    }
336
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc