Skip to content

Commit bbf94b5

Browse files
authored
Merge pull request #3599 from codeeu/feature/support-email-update
Support copilot: add user_email_update
2 parents 2b57888 + a5b29e9 commit bbf94b5

14 files changed

Lines changed: 576 additions & 5 deletions

app/Jobs/Support/ExecuteApprovedSupportActionJob.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use App\Services\Support\SupportApprovalEmailService;
1212
use App\Services\Support\UserProfileUpdateService;
1313
use App\Services\Support\UserRestoreService;
14+
use App\Services\Support\UserEmailUpdateService;
1415
use App\Services\Support\UserRoleAddService;
1516
use App\Services\Support\UserRoleRemoveService;
1617
use Illuminate\Bus\Queueable;
@@ -37,6 +38,7 @@ public function handle(
3738
ContentUpdateService $contentUpdate,
3839
UserRoleAddService $userRoleAdd,
3940
UserRoleRemoveService $userRoleRemove,
41+
UserEmailUpdateService $userEmailUpdate,
4042
): void
4143
{
4244
$approval = SupportApproval::findOrFail($this->supportApprovalId);
@@ -96,6 +98,8 @@ public function handle(
9698
$result = $userRoleAdd->addFromCase($case, dryRun: false, viaEmailApproval: true);
9799
} elseif ($action === 'user_role_remove') {
98100
$result = $userRoleRemove->removeFromCase($case, dryRun: false, viaEmailApproval: true);
101+
} elseif ($action === 'user_email_update') {
102+
$result = $userEmailUpdate->updateFromCase($case, dryRun: false, viaEmailApproval: true);
99103
} elseif ($action === 'code_change') {
100104
$result = $cursorAgent->launchCodeAgent(
101105
prompt: (string) ($payload['cursor_prompt'] ?? ''),

app/Jobs/Support/ProcessSupportCaseDiagnosticsJob.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use App\Services\Support\UserProfileUpdateService;
1313
use App\Services\Support\UserRestoreService;
1414
use App\Services\Support\UserRoleAddService;
15+
use App\Services\Support\UserEmailUpdateService;
1516
use App\Services\Support\UserRoleRemoveService;
1617
use Illuminate\Bus\Queueable;
1718
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -34,6 +35,7 @@ public function handle(
3435
UserProfileUpdateService $userProfileUpdate,
3536
UserRoleAddService $userRoleAdd,
3637
UserRoleRemoveService $userRoleRemove,
38+
UserEmailUpdateService $userEmailUpdate,
3739
ArtisanCommandRunner $artisanRunner,
3840
ContentUpdateService $contentUpdate,
3941
): void {
@@ -81,6 +83,20 @@ public function handle(
8183
);
8284
}
8385

86+
if ($case->case_type === 'email_change') {
87+
$dryRunResult = $userEmailUpdate->updateFromCase($case, dryRun: true);
88+
$logger->log(
89+
case: $case,
90+
actionName: 'user_email_update',
91+
actionType: 'write',
92+
input: ['dry_run' => true],
93+
output: $dryRunResult,
94+
succeeded: (bool) ($dryRunResult['ok'] ?? false),
95+
executedBy: 'agent',
96+
correlationId: $case->correlation_id,
97+
);
98+
}
99+
84100
if ($case->case_type === 'role_remove') {
85101
$dryRunResult = $userRoleRemove->removeFromCase($case, dryRun: true);
86102
$logger->log(

app/Services/Support/Agents/CursorCliTriageProvider.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class CursorCliTriageProvider implements TriageProvider
2929
'role_issue',
3030
'role_add',
3131
'role_remove',
32+
'email_change',
3233
'code_change',
3334
'artisan_command',
3435
'content_update',
@@ -142,6 +143,8 @@ private function buildPrompt(SupportCase $case, string $rawText): string
142143
Use "role_remove" when the request is to remove/revoke/delete a role (e.g. "ambassador") from one
143144
or more users. Use the same target_email/secondary_emails and role_name fields, and set
144145
"role_operation" to "remove".
146+
Use "email_change" when the request is to change/update a user's login email address. Put the
147+
current account email in target_email/current_email and the requested new email in new_email.
145148
{$artisanBlock}{$contentBlock}
146149
JSON schema to return:
147150
{
@@ -157,6 +160,8 @@ private function buildPrompt(SupportCase $case, string $rawText): string
157160
"profile_lastname": "<requested last name or null>",
158161
"role_name": "<for role_add/role_remove: the role affected, else null>",
159162
"role_operation": "<for role_add: add; for role_remove: remove; else null>",
163+
"current_email": "<for email_change: the account email today, else null>",
164+
"new_email": "<for email_change: the requested new email, else null>",
160165
"change_summary": "<for code_change: one sentence describing the fix, else null>",
161166
"change_area": "<for code_change: e.g. frontend/blade/css/js, else null>",
162167
"cursor_prompt": "<for code_change: a precise instruction for a coding agent to implement the fix and open a PR, else null>",
@@ -301,6 +306,7 @@ private function normalize(array $data): array
301306
'account_restore' => 'user_restore',
302307
'role_add' => 'user_role_add',
303308
'role_remove' => 'user_role_remove',
309+
'email_change' => 'user_email_update',
304310
'code_change' => 'code_change',
305311
'artisan_command' => 'artisan_command',
306312
'content_update' => 'content_update',
@@ -332,6 +338,8 @@ private function normalize(array $data): array
332338
'profile_lastname' => $this->stringOrNull($data['profile_lastname'] ?? null),
333339
'role_name' => $this->stringOrNull($data['role_name'] ?? null),
334340
'role_operation' => $this->normalizeRoleOperation($data['role_operation'] ?? null),
341+
'current_email' => $this->stringOrNull($data['current_email'] ?? null),
342+
'new_email' => $this->stringOrNull($data['new_email'] ?? null),
335343
'risk_level' => $risk,
336344
'recommended_runbook' => $this->stringOrNull($data['recommended_runbook'] ?? null) ?? $caseType,
337345
'needs_human_review' => (bool) ($data['needs_human_review'] ?? false),

app/Services/Support/Agents/TriageAgentService.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace App\Services\Support\Agents;
44

55
use App\Models\Support\SupportCase;
6+
use App\Services\Support\SupportEmailChangeRequestParser;
67
use App\Services\Support\SupportProfileRequestParser;
78
use App\Services\Support\SupportRoleRequestParser;
89
use Illuminate\Support\Str;
@@ -11,6 +12,7 @@ class TriageAgentService
1112
{
1213
public function __construct(
1314
private readonly SupportProfileRequestParser $profileParser,
15+
private readonly SupportEmailChangeRequestParser $emailChangeParser,
1416
private readonly CursorCliTriageProvider $aiProvider,
1517
private readonly SupportRoleRequestParser $roleParser,
1618
) {
@@ -58,7 +60,9 @@ private function heuristicTriage(SupportCase $case): array
5860
$rawText = (string) ($case->normalized_message ?? $case->raw_message ?? '');
5961
$text = Str::lower($rawText);
6062
$profile = $this->profileParser->parse($rawText);
63+
$emailChange = $this->emailChangeParser->parse($rawText);
6164
$roleRequest = $this->roleParser->parse($rawText);
65+
$hasEmailChangeRequest = $emailChange['from_email'] !== null && $emailChange['to_email'] !== null;
6266
$hasRoleAddRequest = $roleRequest['role'] !== null
6367
&& $roleRequest['operation'] === 'add'
6468
&& $roleRequest['emails'] !== [];
@@ -76,6 +80,9 @@ private function heuristicTriage(SupportCase $case): array
7680
} elseif ($hasRoleAddRequest) {
7781
$caseType = 'role_add';
7882
$runbook = 'add_user_role';
83+
} elseif ($hasEmailChangeRequest) {
84+
$caseType = 'email_change';
85+
$runbook = 'update_user_email';
7986
} elseif (Str::contains($text, ['soft-deleted', 'deleted', 'restore account', 'account missing'])) {
8087
$caseType = 'account_restore';
8188
$runbook = 'restore_deleted_account';
@@ -107,6 +114,9 @@ private function heuristicTriage(SupportCase $case): array
107114
if ($hasRoleRequest) {
108115
$targetEmail = $roleRequest['emails'][0];
109116
$secondary = array_values(array_slice($roleRequest['emails'], 1));
117+
} elseif ($hasEmailChangeRequest) {
118+
$targetEmail = $emailChange['from_email'];
119+
$secondary = [$emailChange['to_email']];
110120
} else {
111121
$targetEmail = $profile['email'] ?? $this->extractFirstEmail($text);
112122
$secondary = $this->extractAllEmails($text);
@@ -117,6 +127,9 @@ private function heuristicTriage(SupportCase $case): array
117127
if ($caseType === 'profile_update') {
118128
$risk = 'low';
119129
}
130+
if ($caseType === 'email_change') {
131+
$risk = 'medium';
132+
}
120133
if ($hasRoleRequest && $this->roleLooksPrivileged((string) $roleRequest['role'])) {
121134
$risk = 'high';
122135
}
@@ -128,6 +141,7 @@ private function heuristicTriage(SupportCase $case): array
128141
'profile_update' => 'user_profile_update',
129142
'role_add' => 'user_role_add',
130143
'role_remove' => 'user_role_remove',
144+
'email_change' => 'user_email_update',
131145
default => null,
132146
};
133147

@@ -142,6 +156,8 @@ private function heuristicTriage(SupportCase $case): array
142156
'profile_lastname' => $profile['lastname'],
143157
'role_name' => $hasRoleRequest ? $roleRequest['role'] : null,
144158
'role_operation' => $hasRoleRequest ? $roleRequest['operation'] : null,
159+
'current_email' => $hasEmailChangeRequest ? $emailChange['from_email'] : null,
160+
'new_email' => $hasEmailChangeRequest ? $emailChange['to_email'] : null,
145161
'risk_level' => $risk,
146162
'recommended_runbook' => $runbook,
147163
'needs_human_review' => $needsHuman,

app/Services/Support/SupportApprovalEmailService.php

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public function __construct(
1616
private readonly SupportSenderAllowlist $allowlist,
1717
private readonly SupportProfileRequestParser $profileParser,
1818
private readonly SupportRoleRequestResolver $roleResolver,
19+
private readonly SupportEmailChangeRequestResolver $emailChangeResolver,
1920
) {
2021
}
2122

@@ -24,6 +25,7 @@ public function approvalSubject(SupportCase $case): string
2425
$prefix = (string) config('support_gmail.approval_subject_prefix', '[CW-SUPPORT');
2526
$headline = match ($case->case_type) {
2627
'profile_update' => 'Please review — name change',
28+
'email_change' => 'Please review — account email change',
2729
'account_restore' => 'Please review — account restore',
2830
'role_add' => 'Please review — add user role',
2931
'role_remove' => 'Please review — remove user role',
@@ -269,6 +271,19 @@ private function proposedActionForCase(SupportCase $case): array
269271
];
270272
}
271273

274+
if ($case->case_type === 'email_change') {
275+
$change = $this->emailChangeResolver->resolve($case);
276+
if ($change['from_email'] !== null && $change['to_email'] !== null) {
277+
return [
278+
'action' => 'user_email_update',
279+
'payload' => [
280+
'from' => $change['from_email'],
281+
'to' => $change['to_email'],
282+
],
283+
];
284+
}
285+
}
286+
272287
if ($case->case_type === 'profile_update' && $case->target_email) {
273288
$profile = $this->profileParser->parse((string) ($case->normalized_message ?? $case->raw_message ?? ''));
274289
if ($profile['firstname'] !== null || $profile['lastname'] !== null) {
@@ -503,7 +518,11 @@ private function dryRunPlannedChangeLines(SupportCase $case, array $proposedActi
503518
{
504519
$action = $proposedAction['action'] ?? 'none';
505520
$payload = $proposedAction['payload'] ?? [];
506-
$email = (string) ($case->target_email ?? $payload['email'] ?? '');
521+
$email = (string) ($case->target_email ?? $payload['email'] ?? $payload['from'] ?? '');
522+
523+
if ($action === 'user_email_update') {
524+
return $this->dryRunEmailChangeLines($case, $payload);
525+
}
507526

508527
if ($action === 'user_profile_update') {
509528
return $this->dryRunProfileChangeLines($case, $payload, $email);
@@ -540,6 +559,48 @@ private function dryRunPlannedChangeLines(SupportCase $case, array $proposedActi
540559
];
541560
}
542561

562+
/**
563+
* @param array<string, mixed> $payload
564+
* @return list<string>
565+
*/
566+
private function dryRunEmailChangeLines(SupportCase $case, array $payload): array
567+
{
568+
$output = $case->actions()
569+
->where('action_name', 'user_email_update')
570+
->where('action_type', 'write')
571+
->latest()
572+
->first()?->output_json;
573+
574+
$result = is_array($output) ? ($output['result'] ?? []) : [];
575+
$from = (string) ($result['before']['email'] ?? $payload['from'] ?? $case->target_email ?? '');
576+
$to = (string) ($result['after']['email'] ?? $payload['to'] ?? '');
577+
$note = (string) ($result['note'] ?? '');
578+
579+
if ($note === 'email_already_matches_requested_value') {
580+
return [
581+
'',
582+
'The account already uses '.$to.'.',
583+
'Approving will make no change.',
584+
];
585+
}
586+
587+
$lines = [
588+
'',
589+
'We will update the login email on this CodeWeek account:',
590+
' • From: '.($from !== '' ? $from : '(not found)'),
591+
' • To: '.($to !== '' ? $to : '(not found)'),
592+
];
593+
594+
if (($result['would_update_email_display'] ?? false) === true) {
595+
$lines[] = ' • The public display email will be updated to match.';
596+
}
597+
598+
$lines[] = '';
599+
$lines[] = 'The person will need to verify the new email address before it is fully active.';
600+
601+
return $lines;
602+
}
603+
543604
/**
544605
* @param array<string, mixed> $payload
545606
* @return list<string>
@@ -829,6 +890,7 @@ private function completionHeadline(SupportCase $case, string $action, bool $suc
829890

830891
return match ($action) {
831892
'user_profile_update' => 'Done — name updated on CodeWeek account',
893+
'user_email_update' => 'Done — account email updated',
832894
'user_restore' => 'Done — CodeWeek account reactivated',
833895
'user_role_add' => 'Done — user role added',
834896
'user_role_remove' => 'Done — user role removed',
@@ -877,7 +939,7 @@ private function buildCompletionBody(
877939
}
878940

879941
// Role add/remove are batch actions; the per-account list is shown above.
880-
if ($email !== '' && ! in_array($action, ['user_role_add', 'user_role_remove'], true)) {
942+
if ($email !== '' && ! in_array($action, ['user_role_add', 'user_role_remove', 'user_email_update'], true)) {
881943
$lines[] = '';
882944
$lines[] = 'Account email: '.$email;
883945
}
@@ -888,10 +950,10 @@ private function buildCompletionBody(
888950

889951
$lines[] = '';
890952
if ($succeeded) {
891-
$lines[] = in_array($action, ['user_role_add', 'user_role_remove'], true)
953+
$lines[] = in_array($action, ['user_role_add', 'user_role_remove', 'user_email_update'], true)
892954
? 'No further action is needed. You do not need to reply to this email.'
893955
: 'No further action is needed. The supporter can sign in with their usual email and password.';
894-
if (! in_array($action, ['user_role_add', 'user_role_remove'], true)) {
956+
if (! in_array($action, ['user_role_add', 'user_role_remove', 'user_email_update'], true)) {
895957
$lines[] = 'You do not need to reply to this email.';
896958
}
897959
} else {
@@ -914,6 +976,33 @@ private function completionSuccessLines(SupportCase $case, string $action, array
914976
$inner = is_array($result['result'] ?? null) ? $result['result'] : [];
915977
$note = is_string($inner['note'] ?? null) ? $inner['note'] : '';
916978

979+
if ($action === 'user_email_update') {
980+
$before = (array) ($inner['before'] ?? []);
981+
$after = (array) ($inner['after'] ?? []);
982+
983+
if ($note === 'email_already_matches_requested_value') {
984+
return [
985+
'The account already uses the requested email address — no change was required.',
986+
];
987+
}
988+
989+
$lines = [
990+
'We updated the login email on this CodeWeek account.',
991+
'',
992+
' • From: '.(string) ($before['email'] ?? '(unknown)'),
993+
' • To: '.(string) ($after['email'] ?? '(unknown)'),
994+
];
995+
996+
if (($inner['would_update_email_display'] ?? false) === true) {
997+
$lines[] = ' • The public display email was updated to match.';
998+
}
999+
1000+
$lines[] = '';
1001+
$lines[] = 'The person should sign in with the new email address and complete email verification if prompted.';
1002+
1003+
return $lines;
1004+
}
1005+
9171006
if ($action === 'user_profile_update') {
9181007
$lines = [
9191008
'We updated the name shown on the CodeWeek account'.($email !== '' ? ' for '.$email : '').'.',
@@ -1128,7 +1217,11 @@ private function humanizeError(string $code, string $action, int $caseId): strin
11281217
return match (true) {
11291218
str_contains($code, 'no_matching_user') => 'We could not find a CodeWeek account with that email address.',
11301219
str_contains($code, 'ambiguous_user') => 'More than one account matched this email. A team member must review Case #'.$caseId.' manually.',
1131-
str_contains($code, 'invalid_email') => 'The email address on the request was not valid.',
1220+
str_contains($code, 'invalid_from_email') => 'The current email address on the request was not valid.',
1221+
str_contains($code, 'invalid_to_email') => 'The new email address on the request was not valid.',
1222+
str_contains($code, 'from_and_to_emails_identical') => 'The current and new email addresses are the same.',
1223+
str_contains($code, 'to_email_already_in_use') => 'Another CodeWeek account already uses the new email address.',
1224+
str_contains($code, 'email_already_matches') => 'The account already uses the requested email address.',
11321225
str_contains($code, 'no_profile_fields') => 'The request did not include a first or last name to update.',
11331226
str_contains($code, 'dry_run_mode') => 'The system is in preview-only mode and could not apply live changes.',
11341227
str_contains($code, 'unsupported_approved_action') => 'This type of request cannot be run automatically yet.',

0 commit comments

Comments
 (0)