diff --git a/app/Services/Support/SupportApprovalEmailService.php b/app/Services/Support/SupportApprovalEmailService.php index 647e25645..a91571874 100644 --- a/app/Services/Support/SupportApprovalEmailService.php +++ b/app/Services/Support/SupportApprovalEmailService.php @@ -21,8 +21,13 @@ public function __construct( public function approvalSubject(SupportCase $case): string { $prefix = (string) config('support_gmail.approval_subject_prefix', '[CW-SUPPORT'); + $headline = match ($case->case_type) { + 'profile_update' => 'Please review — name change', + 'account_restore' => 'Please review — account restore', + default => 'Please review before we make changes', + }; - return sprintf('%s #%d] Support copilot - dry run review', $prefix, $case->id); + return sprintf('%s #%d] %s', $prefix, $case->id, $headline); } public function completionSubject(SupportCase $case, bool $succeeded, string $action = ''): string @@ -280,59 +285,176 @@ private function proposedActionForCase(SupportCase $case): array */ private function buildDryRunBody(SupportCase $case, array $proposedAction): string { + $email = (string) ($case->target_email ?? ''); + $action = $proposedAction['action'] ?? 'none'; + $lines = [ - 'CodeWeek Support Copilot - dry run summary', - '', - 'Case #'.$case->id, - 'Subject: '.$case->subject, - 'Type: '.($case->case_type ?? 'unknown'), - 'Risk: '.($case->risk_level ?? 'low'), - 'Target: '.($case->target_email ?? '(unknown)'), + 'CodeWeek Support — please review before we make changes', '', + 'Reference: Case #'.$case->id, + 'Account: '.($email !== '' ? $email : '(email not found in request)'), ]; - $diagnostics = $case->actions()->where('action_name', 'diagnostics')->latest()->first()?->output_json; - if (is_array($diagnostics)) { - $lines[] = 'Diagnostics findings: '.implode(', ', (array) ($diagnostics['findings'] ?? [])); - $lines[] = ''; + if ($case->subject) { + $lines[] = 'Your request: '.$case->subject; } - foreach (['user_restore', 'user_profile_update'] as $writeAction) { - $dryRun = $case->actions()->where('action_name', $writeAction)->where('action_type', 'write')->latest()->first()?->output_json; - if (!is_array($dryRun)) { - continue; - } - $result = $dryRun['result'] ?? $dryRun; - if (is_array($result) && isset($result['before'], $result['after'])) { - $lines[] = 'Planned profile/account changes (dry run):'; - $lines[] = 'Before: '.json_encode($result['before'], JSON_UNESCAPED_SLASHES); - $lines[] = 'After: '.json_encode($result['after'], JSON_UNESCAPED_SLASHES); - $lines[] = ''; - } elseif (is_array($result)) { - $lines[] = 'Planned changes (dry run):'; - $lines[] = json_encode($result['changes_planned'] ?? $result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $lines[] = ''; - } + $warnings = $this->dryRunWarningLines($case); + if ($warnings !== []) { + $lines[] = ''; + $lines[] = 'Please note'; + $lines[] = str_repeat('─', 12); + $lines = array_merge($lines, $warnings); } - $action = $proposedAction['action'] ?? 'none'; + $lines[] = ''; + $lines[] = 'What will change if you approve'; + $lines[] = str_repeat('─', 28); + $lines = array_merge($lines, $this->dryRunPlannedChangeLines($case, $proposedAction)); + if ($action !== 'none') { - $lines[] = 'Proposed action: '.$action; - $lines[] = 'Payload: '.json_encode($proposedAction['payload'] ?? [], JSON_UNESCAPED_SLASHES); $lines[] = ''; - $lines[] = 'To execute this change, reply to this email with a single line:'; - $lines[] = 'APPROVE'; + $lines[] = 'How to approve'; + $lines[] = str_repeat('─', 13); + $lines[] = 'Reply to this email with exactly one word on the first line:'; $lines[] = ''; - $lines[] = 'You will receive a follow-up email when the action has run (completed or failed).'; + $lines[] = 'APPROVE'; $lines[] = ''; - $lines[] = '(Only @matrixinternet.ie and @codeweek.eu senders are accepted.)'; + $lines[] = 'We will then apply the change and send you a short confirmation email.'; + $lines[] = 'Only messages from @matrixinternet.ie and @codeweek.eu can approve.'; } else { - $lines[] = 'No automated write action proposed. Review the case in Nova if needed.'; + $lines[] = ''; + $lines[] = 'No automatic change is available for this request.'; + $lines[] = 'Please review the case in Nova or handle it manually.'; } + $lines[] = ''; + $lines[] = '— CodeWeek Support Copilot'; + return implode("\n", $lines); } + /** + * @return list + */ + private function dryRunWarningLines(SupportCase $case): array + { + $diagnostics = $case->actions()->where('action_name', 'diagnostics')->latest()->first()?->output_json; + $findings = is_array($diagnostics) ? (array) ($diagnostics['findings'] ?? []) : []; + $lines = []; + + foreach ($findings as $finding) { + $line = $this->humanizeDiagnosticFinding((string) $finding, $case->id); + if ($line !== null) { + $lines[] = '• '.$line; + } + } + + return $lines; + } + + private function humanizeDiagnosticFinding(string $finding, int $caseId): ?string + { + $finding = strtolower(trim($finding)); + + return match (true) { + str_contains($finding, 'duplicate_risk') => 'More than one CodeWeek account uses this email. We will update the account that still needs this name change.', + str_contains($finding, 'user_audit_completed') => null, + str_contains($finding, 'ambiguous') => 'We could not tell which account to use. Case #'.$caseId.' may need manual review in Nova.', + default => null, + }; + } + + /** + * @param array{action: string, payload: array} $proposedAction + * @return list + */ + private function dryRunPlannedChangeLines(SupportCase $case, array $proposedAction): array + { + $action = $proposedAction['action'] ?? 'none'; + $payload = $proposedAction['payload'] ?? []; + $email = (string) ($case->target_email ?? $payload['email'] ?? ''); + + if ($action === 'user_profile_update') { + return $this->dryRunProfileChangeLines($case, $payload, $email); + } + + if ($action === 'user_restore') { + return [ + '', + 'We will reactivate the CodeWeek account'.($email !== '' ? ' for '.$email : '').'.', + 'The person can sign in again with their usual email and password.', + ]; + } + + return [ + '', + 'We could not determine an automatic change from this email.', + 'Check that the message includes lines like "Requested first name:" and "Requested last name:".', + ]; + } + + /** + * @param array $payload + * @return list + */ + private function dryRunProfileChangeLines(SupportCase $case, array $payload, string $email): array + { + $dryRun = $case->actions() + ->where('action_name', 'user_profile_update') + ->where('action_type', 'write') + ->latest() + ->first()?->output_json; + + $result = is_array($dryRun) ? ($dryRun['result'] ?? $dryRun) : []; + $inner = is_array($result) ? $result : []; + + $lines = [ + '', + 'We will update the name shown on the CodeWeek account'.($email !== '' ? ' for '.$email : '').'.', + ]; + + if (isset($inner['before'], $inner['after']) && is_array($inner['before']) && is_array($inner['after'])) { + $changeLines = $this->formatProfileNameChanges($inner['before'], $inner['after']); + if ($changeLines !== []) { + $lines[] = ''; + $lines = array_merge($lines, $changeLines); + } elseif (($inner['note'] ?? '') === 'profile_already_matches_requested_values') { + $lines[] = ''; + $lines[] = 'The account already has the requested name — approving will make no change.'; + } + } else { + $lines = array_merge($lines, $this->dryRunProfileChangeLinesFromPayload($payload)); + } + + $lines[] = ''; + $lines[] = 'The login email address will not change.'; + + return $lines; + } + + /** + * @param array $payload + * @return list + */ + private function dryRunProfileChangeLinesFromPayload(array $payload): array + { + $lines = ['', 'Requested updates:']; + + if (!empty($payload['firstname'])) { + $lines[] = ' • First name → '.$payload['firstname']; + } + if (!empty($payload['lastname'])) { + $lines[] = ' • Last name → '.$payload['lastname']; + } + + if (count($lines) === 2) { + $lines[] = ' • (could not read name fields from the email — check the request text)'; + } + + return $lines; + } + private function completionHeadline(SupportCase $case, string $action, bool $succeeded): string { if (!$succeeded) { diff --git a/app/Services/Support/SupportProfileRequestParser.php b/app/Services/Support/SupportProfileRequestParser.php index ff1d756a5..46d65a634 100644 --- a/app/Services/Support/SupportProfileRequestParser.php +++ b/app/Services/Support/SupportProfileRequestParser.php @@ -29,24 +29,10 @@ public function parse(string $text): array { $normalized = Str::of($text)->replace("\r\n", "\n")->toString(); - $email = $this->extractFirstEmail($normalized); - $firstname = $this->extractLabelledValue($normalized, [ - 'requested first name', - 'new first name', - 'first name', - 'firstname', - ]); - $lastname = $this->extractLabelledValue($normalized, [ - 'requested last name', - 'new last name', - 'last name', - 'lastname', - ]); - return [ - 'email' => $email, - 'firstname' => $this->sanitizeName($firstname), - 'lastname' => $this->sanitizeName($lastname), + 'email' => $this->extractFirstEmail($normalized), + 'firstname' => $this->sanitizeName($this->extractRequestedName($normalized, 'first')), + 'lastname' => $this->sanitizeName($this->extractRequestedName($normalized, 'last')), ]; } @@ -58,31 +44,42 @@ private function extractFirstEmail(string $text): ?string return $emails[0] ?? null; } - /** - * @param list $labels - */ - private function extractLabelledValue(string $text, array $labels): ?string + private function extractRequestedName(string $text, string $which): ?string { - foreach ($labels as $label) { - $pattern = '/'.preg_quote($label, '/').'\s*[\*:]?\s*(.+?)(?:\n|$)/iu'; + $field = $which === 'first' ? 'first\\s*name' : 'last\\s*name'; + + foreach (['requested', 'new'] as $prefix) { + $pattern = '/(?:^|\n)\s*'.preg_quote($prefix, '/').'\s+'.$field.'\s*[:*]?\s*([^\n\r]+)/iu'; if (preg_match($pattern, $text, $m)) { - $value = trim($m[1]); - if ($value !== '') { - return $value; - } + return $this->cleanCapturedName($m[1]); } } return null; } + private function cleanCapturedName(string $value): string + { + $value = trim(preg_replace('/\s+/', ' ', $value) ?? ''); + + if (preg_match('/^(.+?)(?:\s+the email\b|\s+email address\b)/iu', $value, $m)) { + $value = trim($m[1]); + } + + if (str_contains($value, "\n")) { + $value = trim(explode("\n", $value)[0]); + } + + return $value; + } + private function sanitizeName(?string $value): ?string { if ($value === null) { return null; } - $value = trim(preg_replace('/\s+/', ' ', $value) ?? ''); + $value = trim($value); if ($value === '') { return null; } diff --git a/tests/Unit/Support/SupportDryRunEmailCopyTest.php b/tests/Unit/Support/SupportDryRunEmailCopyTest.php new file mode 100644 index 000000000..75e4f1751 --- /dev/null +++ b/tests/Unit/Support/SupportDryRunEmailCopyTest.php @@ -0,0 +1,91 @@ + 'gmail', + 'processing_mode' => 'automated', + 'subject' => 'codeweek-support — update last name Hanna to Hannaa', + 'raw_message' => 'Requested last name: Hannaa', + 'normalized_message' => 'Requested last name: Hannaa', + 'target_email' => 'teacher@school.eu', + 'case_type' => 'profile_update', + 'status' => 'diagnosed', + 'risk_level' => 'low', + 'correlation_id' => 'cid', + ]); + + $case->actions()->create([ + 'action_name' => 'diagnostics', + 'action_type' => 'read', + 'input_json' => [], + 'output_json' => ['findings' => ['user_audit_completed', 'duplicate_risk_high']], + 'succeeded' => true, + 'executed_by' => 'system', + ]); + + $case->actions()->create([ + 'action_name' => 'user_profile_update', + 'action_type' => 'write', + 'input_json' => [], + 'output_json' => [ + 'ok' => true, + 'result' => [ + 'before' => ['firstname' => 'Bernard', 'lastname' => 'Hanna'], + 'after' => ['firstname' => 'Bernard', 'lastname' => 'Hannaa'], + ], + ], + 'succeeded' => true, + 'executed_by' => 'system', + ]); + + $capturedBody = null; + $gmail = $this->createMock(GmailOutboundService::class); + $gmail->method('sendPlainText')->willReturnCallback(function ($to, $subject, $body) use (&$capturedBody) { + $capturedBody = $body; + + return ['id' => 'msg-1', 'thread_id' => 't1']; + }); + + config([ + 'support_gmail.notify_email' => 'notify@matrixinternet.ie', + 'support_gmail.allowed_sender_domains' => ['matrixinternet.ie'], + ]); + + $svc = new SupportApprovalEmailService( + $gmail, + app(SupportSenderAllowlist::class), + app(SupportProfileRequestParser::class), + ); + + $svc->sendDryRunReview($case, 'admin@matrixinternet.ie'); + + $this->assertNotNull($capturedBody); + $this->assertStringContainsString('please review before we make changes', $capturedBody); + $this->assertStringContainsString('What will change if you approve', $capturedBody); + $this->assertStringContainsString('Last name: Hanna → Hannaa', $capturedBody); + $this->assertStringContainsString('More than one CodeWeek account uses this email', $capturedBody); + $this->assertStringNotContainsString('duplicate_risk_high', $capturedBody); + $this->assertStringNotContainsString('user_profile_update', $capturedBody); + $this->assertStringNotContainsString('Payload:', $capturedBody); + } + + public function test_dry_run_subject_for_profile_update(): void + { + $case = new SupportCase(['id' => 164, 'case_type' => 'profile_update']); + $svc = app(SupportApprovalEmailService::class); + + $this->assertStringContainsString('Please review — name change', $svc->approvalSubject($case)); + } +} diff --git a/tests/Unit/Support/SupportProfileRequestParserTest.php b/tests/Unit/Support/SupportProfileRequestParserTest.php index fec7cd6c2..ff201cff8 100644 --- a/tests/Unit/Support/SupportProfileRequestParserTest.php +++ b/tests/Unit/Support/SupportProfileRequestParserTest.php @@ -26,11 +26,41 @@ public function test_parses_labelled_profile_fields(): void public function test_rejects_placeholder_last_name(): void { - $text = "Email: u@example.com\nLast name: Last Name\nFirst name: Jane"; + $text = "Email: u@example.com\nCurrent last name: Last Name\nRequested first name: Jane"; $parsed = (new SupportProfileRequestParser())->parse($text); $this->assertSame('Jane', $parsed['firstname']); $this->assertNull($parsed['lastname']); } + + public function test_parses_hanna_to_hannaa_request_without_bleeding_lines(): void + { + $text = <<<'TEXT' + Email: bernard@matrixinternet.ie + + Current first name: Bernard + Current last name: Hanna + + Requested first name: Bernard + Requested last name: Hannaa + + The email address must stay the same. + TEXT; + + $parsed = (new SupportProfileRequestParser())->parse($text); + + $this->assertSame('bernard@matrixinternet.ie', $parsed['email']); + $this->assertSame('Bernard', $parsed['firstname']); + $this->assertSame('Hannaa', $parsed['lastname']); + } + + public function test_strips_trailing_the_email_phrase_on_same_line(): void + { + $text = "Email: u@example.com\nRequested last name: Hannaa The email address must stay the same."; + + $parsed = (new SupportProfileRequestParser())->parse($text); + + $this->assertSame('Hannaa', $parsed['lastname']); + } }