From ea49ecf3d2858683082584428ba73bceb8b32f81 Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Tue, 19 May 2026 17:00:21 +0100 Subject: [PATCH 1/2] fix(support): accept APPROVED as approval reply keyword Users often reply "approved" instead of APPROVE. Scan the first few non-empty lines of the reply body so Gmail signatures do not block approval. Co-authored-by: Cursor --- .../Support/SupportApprovalEmailService.php | 20 ++++++++++++------- config/support_gmail.php | 4 ++-- docs/support-copilot-stakeholder-guide.md | 2 +- .../SupportApprovalEmailServiceTest.php | 4 +++- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/app/Services/Support/SupportApprovalEmailService.php b/app/Services/Support/SupportApprovalEmailService.php index 1ac552679..b2e5c9dbb 100644 --- a/app/Services/Support/SupportApprovalEmailService.php +++ b/app/Services/Support/SupportApprovalEmailService.php @@ -195,16 +195,22 @@ private function extractCaseIdFromSubject(?string $subject): ?int public function isApprovalReply(string $body): bool { - $keywords = config('support_gmail.approval_keywords', ['approve', 'yes', 'proceed']); - $firstLine = strtolower(trim(Str::before(str_replace("\r\n", "\n", $body), "\n"))); + $keywords = config('support_gmail.approval_keywords', ['approve', 'approved', 'yes', 'proceed']); + $lines = explode("\n", str_replace("\r\n", "\n", $body)); - foreach ($keywords as $keyword) { - $keyword = strtolower(trim((string) $keyword)); - if ($keyword === '') { + foreach (array_slice($lines, 0, 8) as $line) { + $line = strtolower(trim($line)); + if ($line === '') { continue; } - if ($firstLine === $keyword || str_starts_with($firstLine, $keyword.' ')) { - return true; + foreach ($keywords as $keyword) { + $keyword = strtolower(trim((string) $keyword)); + if ($keyword === '') { + continue; + } + if ($line === $keyword || str_starts_with($line, $keyword.' ')) { + return true; + } } } diff --git a/config/support_gmail.php b/config/support_gmail.php index c3340125b..062bf44f5 100644 --- a/config/support_gmail.php +++ b/config/support_gmail.php @@ -62,8 +62,8 @@ // Default recipient for support:gmail:test and dry-run summaries when requester unknown. 'notify_email' => env('SUPPORT_GMAIL_NOTIFY_EMAIL', 'codeweek@matrixinternet.ie'), - // First line of a reply must match one of these (case-insensitive) to approve. - 'approval_keywords' => ['approve', 'yes', 'proceed'], + // First non-empty line of a reply must match one of these (case-insensitive) to approve. + 'approval_keywords' => ['approve', 'approved', 'yes', 'proceed'], // Subject prefix for approval threads: "[CW-SUPPORT #123] ..." 'approval_subject_prefix' => '[CW-SUPPORT', diff --git a/docs/support-copilot-stakeholder-guide.md b/docs/support-copilot-stakeholder-guide.md index 9d181b866..0c79e4e3f 100644 --- a/docs/support-copilot-stakeholder-guide.md +++ b/docs/support-copilot-stakeholder-guide.md @@ -360,7 +360,7 @@ Read the summary email carefully. APPROVE ``` - (`YES` or `PROCEED` also work.) + (`APPROVED`, `YES`, or `PROCEED` also work. Must be on its own line near the top of the reply.) 3. Send from **`@matrixinternet.ie`** or **`@codeweek.eu`** 4. Wait up to ~1 minute for the system to process your reply 5. You will receive a **follow-up email** in the same thread: diff --git a/tests/Unit/Support/SupportApprovalEmailServiceTest.php b/tests/Unit/Support/SupportApprovalEmailServiceTest.php index de19ce919..ec424912b 100644 --- a/tests/Unit/Support/SupportApprovalEmailServiceTest.php +++ b/tests/Unit/Support/SupportApprovalEmailServiceTest.php @@ -9,11 +9,13 @@ final class SupportApprovalEmailServiceTest extends TestCase { public function test_detects_approval_reply_keywords(): void { - config()->set('support_gmail.approval_keywords', ['approve', 'yes', 'proceed']); + config()->set('support_gmail.approval_keywords', ['approve', 'approved', 'yes', 'proceed']); $svc = app(SupportApprovalEmailService::class); $this->assertTrue($svc->isApprovalReply("APPROVE\n\nSome quoted text")); + $this->assertTrue($svc->isApprovalReply("approved")); + $this->assertTrue($svc->isApprovalReply("\n\napproved\n\nOn Tue wrote:")); $this->assertTrue($svc->isApprovalReply("yes")); $this->assertFalse($svc->isApprovalReply("maybe approve later")); } From 84f4ee87c5a0c73f3070ab8de3cb04a9a26e3e2a Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Tue, 19 May 2026 17:03:21 +0100 Subject: [PATCH 2/2] fix(support): resolve duplicate email for profile updates When multiple users share an email, update the account that still needs the requested first/last name instead of failing ambiguous match. Co-authored-by: Cursor --- .../Support/UserProfileUpdateService.php | 56 ++++++++++++++++--- .../Support/UserProfileUpdateServiceTest.php | 36 ++++++++++++ 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/app/Services/Support/UserProfileUpdateService.php b/app/Services/Support/UserProfileUpdateService.php index 0166434a1..ef676079e 100644 --- a/app/Services/Support/UserProfileUpdateService.php +++ b/app/Services/Support/UserProfileUpdateService.php @@ -4,6 +4,7 @@ use App\Models\Support\SupportCase; use App\User; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -66,12 +67,17 @@ public function updateProfile( return SupportJson::fail($tool, $input, 'no_matching_user_found'); } - if ($matches->count() !== 1) { - return SupportJson::fail($tool, $input, 'ambiguous_user_match'); + $user = $this->resolveUserForProfileUpdate($matches, $firstname, $lastname); + if ($user === null) { + return SupportJson::fail($tool, $input, [ + 'ambiguous_user_match', + 'matched_user_ids: '.$matches->pluck('id')->implode(','), + ]); } - /** @var User $user */ - $user = $matches->first(); + $warnings = $matches->count() > 1 + ? ['resolved_duplicate_email_to_user_id:'.$user->id] + : []; $before = [ 'user_id' => $user->id, @@ -95,7 +101,7 @@ public function updateProfile( 'before' => $before, 'after' => $before, 'note' => 'profile_already_matches_requested_values', - ]); + ], $warnings); } $planned = [ @@ -114,7 +120,7 @@ public function updateProfile( 'changes_applied' => [], 'before' => $before, 'after' => $after, - ]); + ], $warnings); } if (config('support_gmail.dry_run') && !$viaEmailApproval) { @@ -145,7 +151,43 @@ public function updateProfile( 'changes_applied' => [$planned], 'before' => $before, 'after' => $after, - ]); + ], $warnings); + } + + /** + * When multiple users share an email, pick the one that still needs the requested name change. + * + * @param Collection $matches + */ + private function resolveUserForProfileUpdate(Collection $matches, ?string $firstname, ?string $lastname): ?User + { + if ($matches->count() === 1) { + return $matches->first(); + } + + $active = $matches->filter(fn (User $user) => $user->deleted_at === null)->values(); + if ($active->count() === 1) { + return $active->first(); + } + + $candidates = $active->isNotEmpty() ? $active : $matches->values(); + + $needingUpdate = $candidates->filter(function (User $user) use ($firstname, $lastname) { + if ($firstname !== null && $firstname !== $user->firstname) { + return true; + } + if ($lastname !== null && $lastname !== $user->lastname) { + return true; + } + + return false; + }); + + if ($needingUpdate->count() === 1) { + return $needingUpdate->first(); + } + + return null; } private function isValidEmail(string $email): bool diff --git a/tests/Unit/Support/UserProfileUpdateServiceTest.php b/tests/Unit/Support/UserProfileUpdateServiceTest.php index 35cd6fef9..284225c95 100644 --- a/tests/Unit/Support/UserProfileUpdateServiceTest.php +++ b/tests/Unit/Support/UserProfileUpdateServiceTest.php @@ -43,6 +43,42 @@ public function test_profile_update_dry_run_plans_change(): void $this->assertSame('Bernard Hanna', $u->firstname); } + public function test_profile_update_resolves_duplicate_email_to_user_needing_change(): void + { + User::factory()->create([ + 'email' => 'dup@example.com', + 'firstname' => 'Bernard', + 'lastname' => 'Hanna', + ]); + User::factory()->create([ + 'email' => 'dup@example.com', + 'firstname' => 'Bernard Hanna', + 'lastname' => '', + ]); + + $case = SupportCase::create([ + 'source_channel' => 'manual', + 'processing_mode' => 'manual', + 'subject' => 'test', + 'raw_message' => 'test', + 'status' => 'investigating', + 'risk_level' => 'low', + 'correlation_id' => 'cid', + ]); + + $payload = app(UserProfileUpdateService::class)->updateProfile( + $case, + 'dup@example.com', + 'Bernard', + 'Hanna', + true, + ); + + $this->assertTrue($payload['ok']); + $this->assertSame('Bernard Hanna', $payload['result']['before']['firstname']); + $this->assertStringContainsString('resolved_duplicate_email_to_user_id:', (string) ($payload['warnings'][0] ?? '')); + } + public function test_profile_update_execute_changes_user(): void { config(['support_gmail.dry_run' => true]);