Skip to content
Merged

Dev #3554

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions app/Services/Support/Gmail/GoogleGmailConnector.php
Original file line number Diff line number Diff line change
Expand Up @@ -247,12 +247,11 @@ public function sendPlainTextMessage(
$message = new GoogleMessage();
$message->setRaw($encoded);

$params = [];
if ($threadId) {
$params['threadId'] = $threadId;
$message->setThreadId($threadId);
}

$sent = $this->gmail->users_messages->send($mailbox, $message, $params);
$sent = $this->gmail->users_messages->send($mailbox, $message);

return [
'id' => (string) $sent->getId(),
Expand Down
201 changes: 174 additions & 27 deletions app/Services/Support/SupportApprovalEmailService.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ public function approvalSubject(SupportCase $case): string
return sprintf('%s #%d] Support copilot - dry run review', $prefix, $case->id);
}

public function completionSubject(SupportCase $case, bool $succeeded): string
public function completionSubject(SupportCase $case, bool $succeeded, string $action = ''): string
{
$prefix = (string) config('support_gmail.approval_subject_prefix', '[CW-SUPPORT');
$label = $succeeded ? 'action completed' : 'action failed';
$headline = $this->completionHeadline($case, $action, $succeeded);

return sprintf('%s #%d] Support copilot - %s', $prefix, $case->id, $label);
return sprintf('%s #%d] %s', $prefix, $case->id, $headline);
}

/**
Expand All @@ -54,8 +54,8 @@ public function sendActionCompletion(
return SupportJson::fail('support_completion_email', ['case_id' => $case->id], 'no_recipient_email');
}

$body = $this->buildCompletionBody($case, $action, $result, $succeeded, (string) ($approval->approved_by ?? ''));
$subject = $this->completionSubject($case, $succeeded);
$body = $this->buildCompletionBody($case, $approval, $action, $result, $succeeded);
$subject = $this->completionSubject($case, $succeeded, $action);
$sentTo = [];

foreach ($recipients as $to) {
Expand Down Expand Up @@ -333,50 +333,197 @@ private function buildDryRunBody(SupportCase $case, array $proposedAction): stri
return implode("\n", $lines);
}

private function completionHeadline(SupportCase $case, string $action, bool $succeeded): string
{
if (!$succeeded) {
return 'Could not complete your request';
}

return match ($action) {
'user_profile_update' => 'Done — name updated on CodeWeek account',
'user_restore' => 'Done — CodeWeek account reactivated',
default => 'Done — your approved request was completed',
};
}

/**
* @param array<string, mixed> $result
*/
private function buildCompletionBody(
SupportCase $case,
SupportApproval $approval,
string $action,
array $result,
bool $succeeded,
string $approvedBy,
): string {
$email = (string) ($case->target_email ?: ($approval->payload_json['email'] ?? ''));
$approvedBy = (string) ($approval->approved_by ?? '');
$approvedAt = $approval->approved_at?->timezone('UTC')->format('j M Y, H:i').' UTC';

$lines = [
'CodeWeek Support Copilot - action result',
'',
'Case #'.$case->id,
'Status: '.($succeeded ? 'COMPLETED' : 'FAILED'),
'Action: '.$action,
'Approved by: '.($approvedBy !== '' ? $approvedBy : '(unknown)'),
'Case status: '.($case->status ?? 'unknown'),
$succeeded
? 'CodeWeek Support — your request has been completed'
: 'CodeWeek Support — we could not complete your request',
'',
'Reference: Case #'.$case->id,
];

$inner = is_array($result['result'] ?? null) ? $result['result'] : [];
if (isset($inner['before'], $inner['after']) && is_array($inner['before']) && is_array($inner['after'])) {
$lines[] = 'Before: '.json_encode($inner['before'], JSON_UNESCAPED_SLASHES);
$lines[] = 'After: '.json_encode($inner['after'], JSON_UNESCAPED_SLASHES);
$lines[] = '';
if ($case->subject) {
$lines[] = 'Original email subject: '.$case->subject;
}

$errors = array_values(array_filter((array) ($result['errors'] ?? [])));
if ($errors !== []) {
$lines[] = 'Errors:';
foreach ($errors as $error) {
$lines[] = '- '.$error;
}
$lines[] = '';
$lines[] = 'What we did';
$lines[] = str_repeat('─', 12);
$lines[] = '';

if ($succeeded) {
$lines = array_merge($lines, $this->completionSuccessLines($case, $action, $result, $email));
} else {
$lines = array_merge($lines, $this->completionFailureLines($action, $result, $email, $case->id));
}

if ($email !== '') {
$lines[] = '';
$lines[] = 'Account email: '.$email;
}

if ($approvedBy !== '') {
$lines[] = 'Approved by: '.$approvedBy.($approvedAt ? ' on '.$approvedAt : '');
}

$lines[] = '';
if ($succeeded) {
$lines[] = 'The approved change has been applied. No further reply is required.';
$lines[] = 'No further action is needed. The supporter can sign in with their usual email and password.';
$lines[] = 'You do not need to reply to this email.';
} else {
$lines[] = 'The change was not applied. Review the case in Nova or contact the technical team.';
$lines[] = 'Include case #'.$case->id.' when escalating.';
$lines[] = 'The change was not applied automatically. Please review this case in Nova or ask the technical team for help.';
$lines[] = 'When escalating, include reference Case #'.$case->id.'.';
}

$lines[] = '';
$lines[] = '— CodeWeek Support Copilot';

return implode("\n", $lines);
}

/**
* @param array<string, mixed> $result
* @return list<string>
*/
private function completionSuccessLines(SupportCase $case, string $action, array $result, string $email): array
{
$inner = is_array($result['result'] ?? null) ? $result['result'] : [];
$note = is_string($inner['note'] ?? null) ? $inner['note'] : '';

if ($action === 'user_profile_update') {
$lines = [
'We updated 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);
}
}

if ($note === 'profile_already_matches_requested_values') {
$lines[] = '';
$lines[] = 'The account already had the requested name — no change was required.';
}

return $lines;
}

if ($action === 'user_restore') {
if ($note === 'user_already_active') {
return [
'The CodeWeek account'.($email !== '' ? ' for '.$email : '').' was already active.',
'No restore was needed.',
];
}

return [
'We reactivated the CodeWeek account'.($email !== '' ? ' for '.$email : '').'.',
'The person can sign in again with their usual email and password.',
];
}

return [
'The approved request for case #'.$case->id.' was completed successfully.',
];
}

/**
* @param array<string, mixed> $before
* @param array<string, mixed> $after
* @return list<string>
*/
private function formatProfileNameChanges(array $before, array $after): array
{
$lines = [];

foreach (['firstname' => 'First name', 'lastname' => 'Last name'] as $field => $label) {
$old = $before[$field] ?? null;
$new = $after[$field] ?? null;
if ($old === $new) {
continue;
}
$lines[] = ' • '.$label.': '.$this->displayNameValue($old).' → '.$this->displayNameValue($new);
}

return $lines;
}

private function displayNameValue(mixed $value): string
{
if ($value === null || $value === '') {
return '(empty)';
}

return (string) $value;
}

/**
* @param array<string, mixed> $result
* @return list<string>
*/
private function completionFailureLines(string $action, array $result, string $email, int $caseId): array
{
$errors = array_values(array_filter((array) ($result['errors'] ?? [])));
$lines = [
'We were not able to apply the approved change'.($email !== '' ? ' for '.$email : '').'.',
'',
'Reason:',
];

if ($errors === []) {
$lines[] = ' • An unexpected error occurred. Please check the case in Nova.';
} else {
foreach ($errors as $error) {
$lines[] = ' • '.$this->humanizeError((string) $error, $action, $caseId);
}
}

return $lines;
}

private function humanizeError(string $code, string $action, int $caseId): string
{
$code = strtolower(trim($code));

return match (true) {
str_contains($code, 'no_matching_user') => 'We could not find a CodeWeek account with that email address.',
str_contains($code, 'ambiguous_user') => 'More than one account matched this email. A team member must review Case #'.$caseId.' manually.',
str_contains($code, 'invalid_email') => 'The email address on the request was not valid.',
str_contains($code, 'no_profile_fields') => 'The request did not include a first or last name to update.',
str_contains($code, 'dry_run_mode') => 'The system is in preview-only mode and could not apply live changes.',
str_contains($code, 'unsupported_approved_action') => 'This type of request cannot be run automatically yet.',
str_contains($code, 'approval_required') => 'This action still requires a separate approval step in the system.',
$action === 'user_restore' && str_contains($code, 'verification') => 'The account was changed but we could not confirm it is fully active. Please verify in Nova.',
default => 'Technical detail: '.$code,
};
}
}
6 changes: 3 additions & 3 deletions tests/Unit/Support/SupportApprovalCompletionEmailTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ public function test_completion_subject_reflects_success_or_failure(): void
$case = new SupportCase(['id' => 10]);
$svc = app(SupportApprovalEmailService::class);

$this->assertStringContainsString('action completed', $svc->completionSubject($case, true));
$this->assertStringContainsString('action failed', $svc->completionSubject($case, false));
$this->assertStringContainsString('#10', $svc->completionSubject($case, true));
$this->assertStringContainsString('Done — name updated', $svc->completionSubject($case, true, 'user_profile_update'));
$this->assertStringContainsString('Could not complete', $svc->completionSubject($case, false, 'user_profile_update'));
$this->assertStringContainsString('#10', $svc->completionSubject($case, true, 'user_profile_update'));
}

public function test_send_action_completion_calls_gmail(): void
Expand Down
88 changes: 88 additions & 0 deletions tests/Unit/Support/SupportCompletionEmailCopyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

namespace Tests\Unit\Support;

use App\Models\Support\SupportApproval;
use App\Models\Support\SupportCase;
use App\Services\Support\Gmail\GmailOutboundService;
use App\Services\Support\SupportApprovalEmailService;
use App\Services\Support\SupportProfileRequestParser;
use App\Services\Support\SupportSenderAllowlist;
use Tests\TestCase;

final class SupportCompletionEmailCopyTest extends TestCase
{
public function test_completion_body_uses_plain_language_for_profile_update(): void
{
$case = SupportCase::create([
'source_channel' => 'manual',
'processing_mode' => 'manual',
'subject' => 'codeweek-support — fix my name',
'raw_message' => 'test',
'target_email' => 'teacher@school.eu',
'status' => 'verified',
'risk_level' => 'low',
'correlation_id' => 'cid',
]);

$approval = SupportApproval::create([
'support_case_id' => $case->id,
'requested_action' => 'user_profile_update',
'payload_json' => ['email' => 'teacher@school.eu', 'firstname' => 'Anna', 'lastname' => 'Smith'],
'risk_level' => 'low',
'status' => 'approved',
'approved_by' => 'admin@matrixinternet.ie',
'approved_at' => now(),
]);

config([
'support_gmail.send_completion_email' => true,
'support_gmail.allowed_sender_domains' => ['matrixinternet.ie'],
'support_gmail.notify_email' => 'notify@matrixinternet.ie',
]);

$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'];
});

$svc = new SupportApprovalEmailService(
$gmail,
app(SupportSenderAllowlist::class),
app(SupportProfileRequestParser::class),
);

$svc->sendActionCompletion(
$case,
$approval,
'user_profile_update',
[
'ok' => true,
'result' => [
'before' => ['firstname' => 'Ann', 'lastname' => ''],
'after' => ['firstname' => 'Anna', 'lastname' => 'Smith'],
],
],
true,
);

$this->assertNotNull($capturedBody);
$this->assertStringContainsString('your request has been completed', $capturedBody);
$this->assertStringContainsString('What we did', $capturedBody);
$this->assertStringContainsString('First name: Ann → Anna', $capturedBody);
$this->assertStringContainsString('Last name: (empty) → Smith', $capturedBody);
$this->assertStringNotContainsString('user_profile_update', $capturedBody);
$this->assertStringNotContainsString('COMPLETED', $capturedBody);
}

public function test_completion_subject_for_account_restore(): void
{
$case = new SupportCase(['id' => 5]);
$svc = app(SupportApprovalEmailService::class);

$this->assertStringContainsString('account reactivated', $svc->completionSubject($case, true, 'user_restore'));
}
}
Loading