@@ -26,6 +26,7 @@ public function approvalSubject(SupportCase $case): string
2626 'profile_update ' => 'Please review — name change ' ,
2727 'account_restore ' => 'Please review — account restore ' ,
2828 'role_add ' => 'Please review — add user role ' ,
29+ 'role_remove ' => 'Please review — remove user role ' ,
2930 'code_change ' => 'Please review — proposed code fix (PR into dev) ' ,
3031 'artisan_command ' => 'Please review — proposed server maintenance command ' ,
3132 'content_update ' => 'Please review — proposed content/copy change ' ,
@@ -282,6 +283,20 @@ private function proposedActionForCase(SupportCase $case): array
282283 }
283284 }
284285
286+ if ($ case ->case_type === 'role_remove ' ) {
287+ $ role = $ this ->roleResolver ->resolve ($ case );
288+ if ($ role ['role ' ] !== null && $ role ['emails ' ] !== []) {
289+ return [
290+ 'action ' => 'user_role_remove ' ,
291+ 'payload ' => [
292+ 'operation ' => $ role ['operation ' ],
293+ 'role ' => $ role ['role ' ],
294+ 'emails ' => $ role ['emails ' ],
295+ ],
296+ ];
297+ }
298+ }
299+
285300 if ($ case ->case_type === 'role_add ' ) {
286301 $ role = $ this ->roleResolver ->resolve ($ case );
287302 if ($ role ['role ' ] !== null && $ role ['emails ' ] !== []) {
@@ -514,8 +529,8 @@ private function dryRunPlannedChangeLines(SupportCase $case, array $proposedActi
514529 return $ this ->dryRunContentLines ($ case );
515530 }
516531
517- if ($ action === 'user_role_add ' ) {
518- return $ this ->dryRunRoleAddLines ($ case , $ payload );
532+ if ($ action === 'user_role_add ' || $ action === ' user_role_remove ' ) {
533+ return $ this ->dryRunRoleChangeLines ($ case , $ payload, $ action );
519534 }
520535
521536 return [
@@ -529,10 +544,13 @@ private function dryRunPlannedChangeLines(SupportCase $case, array $proposedActi
529544 * @param array<string, mixed> $payload
530545 * @return list<string>
531546 */
532- private function dryRunRoleAddLines (SupportCase $ case , array $ payload ): array
547+ private function dryRunRoleChangeLines (SupportCase $ case , array $ payload, string $ action ): array
533548 {
549+ $ actionName = $ action === 'user_role_remove ' ? 'user_role_remove ' : 'user_role_add ' ;
550+ $ isRemove = $ action === 'user_role_remove ' ;
551+
534552 $ output = $ case ->actions ()
535- ->where ('action_name ' , ' user_role_add ' )
553+ ->where ('action_name ' , $ actionName )
536554 ->where ('action_type ' , 'write ' )
537555 ->latest ()
538556 ->first ()?->output_json;
@@ -544,7 +562,9 @@ private function dryRunRoleAddLines(SupportCase $case, array $payload): array
544562
545563 $ lines = [
546564 '' ,
547- 'We will add the role " ' .$ role .'" to the following CodeWeek accounts: ' ,
565+ $ isRemove
566+ ? 'We will remove the role " ' .$ role .'" from the following CodeWeek accounts: '
567+ : 'We will add the role " ' .$ role .'" to the following CodeWeek accounts: ' ,
548568 '' ,
549569 ];
550570
@@ -554,22 +574,26 @@ private function dryRunRoleAddLines(SupportCase $case, array $payload): array
554574 $ lines [] = ' • ' .$ email ;
555575 }
556576 $ lines [] = '' ;
557- $ lines [] = 'We will add the role to each account above that does not already have it. ' ;
577+ $ lines [] = $ isRemove
578+ ? 'We will remove the role from each account above that currently has it. '
579+ : 'We will add the role to each account above that does not already have it. ' ;
558580
559581 return $ lines ;
560582 }
561583
562584 foreach ($ items as $ item ) {
563- $ lines [] = ' • ' .$ this ->roleItemLine ((array ) $ item );
585+ $ lines [] = ' • ' .$ this ->roleItemLine ((array ) $ item, $ isRemove );
564586 }
565587
566588 $ lines [] = '' ;
567- $ lines [] = 'Summary: ' .$ this ->roleSummaryLine ($ summary );
589+ $ lines [] = 'Summary: ' .$ this ->roleSummaryLine ($ summary, $ isRemove );
568590
569591 if (($ summary ['ambiguous ' ] ?? 0 ) > 0 || ($ summary ['user_not_found ' ] ?? 0 ) > 0 ) {
570592 $ lines [] = '' ;
571593 $ lines [] = 'Accounts marked "not found" or "needs manual check" will be skipped. ' ;
572- $ lines [] = 'Approving will still add the role to all accounts that are ready. ' ;
594+ $ lines [] = $ isRemove
595+ ? 'Approving will still remove the role from all accounts that are ready. '
596+ : 'Approving will still add the role to all accounts that are ready. ' ;
573597 }
574598
575599 return $ lines ;
@@ -578,7 +602,7 @@ private function dryRunRoleAddLines(SupportCase $case, array $payload): array
578602 /**
579603 * @param array<string, mixed> $item
580604 */
581- private function roleItemLine (array $ item ): string
605+ private function roleItemLine (array $ item, bool $ isRemove = false ): string
582606 {
583607 $ email = (string ) ($ item ['email ' ] ?? '' );
584608 $ status = (string ) ($ item ['status ' ] ?? '' );
@@ -587,6 +611,9 @@ private function roleItemLine(array $item): string
587611 'would_add ' => 'will be added ' ,
588612 'added ' => 'role added ' ,
589613 'already_has_role ' => 'already has this role (no change) ' ,
614+ 'would_remove ' => 'will be removed ' ,
615+ 'removed ' => 'role removed ' ,
616+ 'does_not_have_role ' => 'does not have this role (no change) ' ,
590617 'user_not_found ' => 'no CodeWeek account found — skipped ' ,
591618 'ambiguous ' => 'multiple accounts match — needs manual check, skipped ' ,
592619 default => $ status ,
@@ -598,12 +625,20 @@ private function roleItemLine(array $item): string
598625 /**
599626 * @param array<string, mixed> $summary
600627 */
601- private function roleSummaryLine (array $ summary ): string
628+ private function roleSummaryLine (array $ summary, bool $ isRemove = false ): string
602629 {
603- $ toAdd = (int ) (($ summary ['would_add ' ] ?? 0 ) + ($ summary ['added ' ] ?? 0 ));
604- $ parts = [$ toAdd .' to add ' ];
605- if (($ summary ['already_has_role ' ] ?? 0 ) > 0 ) {
606- $ parts [] = (int ) $ summary ['already_has_role ' ].' already have it ' ;
630+ if ($ isRemove ) {
631+ $ toChange = (int ) (($ summary ['would_remove ' ] ?? 0 ) + ($ summary ['removed ' ] ?? 0 ));
632+ $ parts = [$ toChange .' to remove ' ];
633+ if (($ summary ['does_not_have_role ' ] ?? 0 ) > 0 ) {
634+ $ parts [] = (int ) $ summary ['does_not_have_role ' ].' do not have it ' ;
635+ }
636+ } else {
637+ $ toChange = (int ) (($ summary ['would_add ' ] ?? 0 ) + ($ summary ['added ' ] ?? 0 ));
638+ $ parts = [$ toChange .' to add ' ];
639+ if (($ summary ['already_has_role ' ] ?? 0 ) > 0 ) {
640+ $ parts [] = (int ) $ summary ['already_has_role ' ].' already have it ' ;
641+ }
607642 }
608643 if (($ summary ['user_not_found ' ] ?? 0 ) > 0 ) {
609644 $ parts [] = (int ) $ summary ['user_not_found ' ].' not found ' ;
@@ -796,6 +831,7 @@ private function completionHeadline(SupportCase $case, string $action, bool $suc
796831 'user_profile_update ' => 'Done — name updated on CodeWeek account ' ,
797832 'user_restore ' => 'Done — CodeWeek account reactivated ' ,
798833 'user_role_add ' => 'Done — user role added ' ,
834+ 'user_role_remove ' => 'Done — user role removed ' ,
799835 'code_change ' => 'Started — AI coding agent is preparing a PR into dev ' ,
800836 'artisan_command ' => 'Done — maintenance command completed on the server ' ,
801837 'content_update ' => 'Done — content updated ' ,
@@ -840,8 +876,8 @@ private function buildCompletionBody(
840876 $ lines = array_merge ($ lines , $ this ->completionFailureLines ($ action , $ result , $ email , $ case ->id ));
841877 }
842878
843- // Role- add is a batch action ; the per-account list is shown above, so skip the single email line .
844- if ($ email !== '' && $ action !== 'user_role_add ' ) {
879+ // 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 ) ) {
845881 $ lines [] = '' ;
846882 $ lines [] = 'Account email: ' .$ email ;
847883 }
@@ -852,10 +888,10 @@ private function buildCompletionBody(
852888
853889 $ lines [] = '' ;
854890 if ($ succeeded ) {
855- $ lines [] = $ action === 'user_role_add '
891+ $ lines [] = in_array ( $ action, [ 'user_role_add ' , ' user_role_remove ' ], true )
856892 ? 'No further action is needed. You do not need to reply to this email. '
857893 : 'No further action is needed. The supporter can sign in with their usual email and password. ' ;
858- if ($ action !== 'user_role_add ' ) {
894+ if (! in_array ( $ action, [ 'user_role_add ' , ' user_role_remove ' ], true ) ) {
859895 $ lines [] = 'You do not need to reply to this email. ' ;
860896 }
861897 } else {
@@ -978,8 +1014,8 @@ private function completionSuccessLines(SupportCase $case, string $action, array
9781014 return $ lines ;
9791015 }
9801016
981- if ($ action === 'user_role_add ' ) {
982- return $ this ->completionRoleAddLines ($ inner );
1017+ if ($ action === 'user_role_add ' || $ action === ' user_role_remove ' ) {
1018+ return $ this ->completionRoleChangeLines ($ inner, $ action );
9831019 }
9841020
9851021 return [
@@ -991,23 +1027,32 @@ private function completionSuccessLines(SupportCase $case, string $action, array
9911027 * @param array<string, mixed> $inner
9921028 * @return list<string>
9931029 */
994- private function completionRoleAddLines (array $ inner ): array
1030+ private function completionRoleChangeLines (array $ inner, string $ action ): array
9951031 {
1032+ $ isRemove = $ action === 'user_role_remove ' ;
9961033 $ role = (string ) ($ inner ['role ' ] ?? 'the requested role ' );
9971034 $ items = is_array ($ inner ['items ' ] ?? null ) ? $ inner ['items ' ] : [];
9981035 $ summary = is_array ($ inner ['summary ' ] ?? null ) ? $ inner ['summary ' ] : [];
999- $ addedCount = (int ) ($ summary ['added ' ] ?? 0 );
1036+ $ changedCount = (int ) ($ isRemove ? ( $ summary ['removed ' ] ?? 0 ) : ( $ summary [ ' added ' ] ?? 0 ) );
10001037
1001- $ lines = [
1002- $ addedCount === 1
1038+ if ($ isRemove ) {
1039+ $ headline = $ changedCount === 1
1040+ ? 'We removed the role " ' .$ role .'" from 1 CodeWeek account. '
1041+ : 'We removed the role " ' .$ role .'" from ' .$ changedCount .' CodeWeek accounts. ' ;
1042+ } else {
1043+ $ headline = $ changedCount === 1
10031044 ? 'We added the role " ' .$ role .'" to 1 CodeWeek account. '
1004- : 'We added the role " ' .$ role .'" to ' .$ addedCount .' CodeWeek accounts. ' ,
1045+ : 'We added the role " ' .$ role .'" to ' .$ changedCount .' CodeWeek accounts. ' ;
1046+ }
1047+
1048+ $ lines = [
1049+ $ headline ,
10051050 '' ,
10061051 'Details: ' ,
10071052 ];
10081053
10091054 foreach ($ items as $ item ) {
1010- $ lines [] = ' • ' .$ this ->roleItemLine ((array ) $ item );
1055+ $ lines [] = ' • ' .$ this ->roleItemLine ((array ) $ item, $ isRemove );
10111056 }
10121057
10131058 $ skipped = (int ) ($ summary ['user_not_found ' ] ?? 0 ) + (int ) ($ summary ['ambiguous ' ] ?? 0 );
@@ -1104,9 +1149,10 @@ private function humanizeError(string $code, string $action, int $caseId): strin
11041149 str_contains ($ code , 'no_effective_change ' ) => 'The content already matched the requested text — no change was needed. ' ,
11051150 str_starts_with ($ code , 'role_not_found ' ) => 'We could not find a matching role. Check the role name (e.g. "leading teacher"). ' ,
11061151 str_starts_with ($ code , 'role_not_allowed ' ) => 'That role is not on the list the copilot is allowed to add. ' ,
1107- str_contains ($ code , 'no_role_specified ' ) => 'The request did not say which role to add . ' ,
1152+ str_contains ($ code , 'no_role_specified ' ) => 'The request did not say which role to change . ' ,
11081153 str_contains ($ code , 'no_target_emails ' ) => 'The request did not include any valid email addresses. ' ,
11091154 str_contains ($ code , 'only_add_operation ' ) => 'Only adding roles is supported right now (not removing). ' ,
1155+ str_contains ($ code , 'only_remove_operation ' ) => 'Only removing roles is supported for this action. ' ,
11101156 $ action === 'user_restore ' && str_contains ($ code , 'verification ' ) => 'The account was changed but we could not confirm it is fully active. Please verify in Nova. ' ,
11111157 default => 'Technical detail: ' .$ code ,
11121158 };
0 commit comments