diff --git a/app/Console/Commands/Support/UserUpdateProfileCommand.php b/app/Console/Commands/Support/UserUpdateProfileCommand.php new file mode 100644 index 000000000..e34755835 --- /dev/null +++ b/app/Console/Commands/Support/UserUpdateProfileCommand.php @@ -0,0 +1,56 @@ +argument('email'); + $firstname = $this->option('firstname'); + $lastname = $this->option('lastname'); + $dryRun = (bool) $this->option('dry-run'); + + $firstname = is_string($firstname) && trim($firstname) !== '' ? trim($firstname) : null; + $lastname = is_string($lastname) && trim($lastname) !== '' ? trim($lastname) : null; + + $case = SupportCase::create([ + 'source_channel' => 'manual', + 'processing_mode' => 'manual', + 'subject' => 'CLI: support:user-update-profile', + 'raw_message' => json_encode(['email' => $email, 'firstname' => $firstname, 'lastname' => $lastname]), + 'status' => 'investigating', + 'risk_level' => 'low', + 'target_email' => $email, + 'correlation_id' => SupportJson::correlationId(), + ]); + + $payload = $service->updateProfile( + case: $case, + email: $email, + firstname: $firstname, + lastname: $lastname, + dryRun: $dryRun, + viaEmailApproval: !$dryRun, + ); + + $json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $this->output->writeln($json); + + return ($payload['ok'] ?? false) ? self::SUCCESS : self::FAILURE; + } +} diff --git a/app/Http/Controllers/Internal/Support/ToolController.php b/app/Http/Controllers/Internal/Support/ToolController.php index fb0499734..0d5a16aa0 100644 --- a/app/Http/Controllers/Internal/Support/ToolController.php +++ b/app/Http/Controllers/Internal/Support/ToolController.php @@ -7,6 +7,7 @@ use App\Services\Support\SupportActionLogger; use App\Services\Support\SupportJson; use App\Services\Support\UserAuditService; +use App\Services\Support\UserProfileUpdateService; use App\Services\Support\UserRestoreService; use Illuminate\Http\Request; @@ -16,6 +17,7 @@ public function __construct( private readonly SupportActionLogger $logger, private readonly UserAuditService $userAudit, private readonly UserRestoreService $userRestore, + private readonly UserProfileUpdateService $userProfileUpdate, private readonly EventAuditService $eventAudit, ) { } @@ -78,6 +80,40 @@ public function userRestore(Request $request) return SupportJson::json($payload, ($payload['ok'] ?? false) ? 200 : 422); } + public function userProfileUpdate(Request $request) + { + $data = $request->validate([ + 'support_case_id' => ['required', 'integer'], + 'email' => ['required', 'string'], + 'firstname' => ['nullable', 'string', 'max:255'], + 'lastname' => ['nullable', 'string', 'max:255'], + 'dry_run' => ['required', 'boolean'], + ]); + + $case = SupportCase::findOrFail((int) $data['support_case_id']); + $payload = $this->userProfileUpdate->updateProfile( + case: $case, + email: $data['email'], + firstname: $data['firstname'] ?? null, + lastname: $data['lastname'] ?? null, + dryRun: (bool) $data['dry_run'], + ); + + $this->logger->log( + case: $case, + actionName: 'user_profile_update', + actionType: 'write', + input: $data, + output: $payload, + succeeded: (bool) ($payload['ok'] ?? false), + executedBy: 'system', + correlationId: $case->correlation_id, + errorMessage: ($payload['ok'] ?? false) ? null : implode(';', (array) ($payload['errors'] ?? [])), + ); + + return SupportJson::json($payload, ($payload['ok'] ?? false) ? 200 : 422); + } + public function eventAudit(Request $request) { $data = $request->validate([ diff --git a/app/Jobs/Support/ExecuteApprovedSupportActionJob.php b/app/Jobs/Support/ExecuteApprovedSupportActionJob.php index ec0ce990b..a0a240a23 100644 --- a/app/Jobs/Support/ExecuteApprovedSupportActionJob.php +++ b/app/Jobs/Support/ExecuteApprovedSupportActionJob.php @@ -5,6 +5,7 @@ use App\Models\Support\SupportApproval; use App\Models\Support\SupportCase; use App\Services\Support\SupportActionLogger; +use App\Services\Support\UserProfileUpdateService; use App\Services\Support\UserRestoreService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -20,7 +21,11 @@ public function __construct(public int $supportApprovalId) { } - public function handle(UserRestoreService $userRestore, SupportActionLogger $logger): void + public function handle( + UserRestoreService $userRestore, + UserProfileUpdateService $userProfileUpdate, + SupportActionLogger $logger, + ): void { $approval = SupportApproval::findOrFail($this->supportApprovalId); $case = SupportCase::findOrFail($approval->support_case_id); @@ -67,6 +72,16 @@ public function handle(UserRestoreService $userRestore, SupportActionLogger $log email: (string) ($payload['email'] ?? ''), dryRun: false, confidence: isset($payload['confidence']) ? (float) $payload['confidence'] : null, + viaEmailApproval: true, + ); + } elseif ($action === 'user_profile_update') { + $result = $userProfileUpdate->updateProfile( + case: $case, + email: (string) ($payload['email'] ?? ''), + firstname: isset($payload['firstname']) ? (string) $payload['firstname'] : null, + lastname: isset($payload['lastname']) ? (string) $payload['lastname'] : null, + dryRun: false, + viaEmailApproval: true, ); } else { $result = [ diff --git a/app/Jobs/Support/ProcessSupportCaseDiagnosticsJob.php b/app/Jobs/Support/ProcessSupportCaseDiagnosticsJob.php index 7b89c60e9..c8c546d14 100644 --- a/app/Jobs/Support/ProcessSupportCaseDiagnosticsJob.php +++ b/app/Jobs/Support/ProcessSupportCaseDiagnosticsJob.php @@ -6,6 +6,7 @@ use App\Models\Support\SupportCaseMessage; use App\Services\Support\Agents\DiagnosticsAgentService; use App\Services\Support\SupportActionLogger; +use App\Services\Support\UserProfileUpdateService; use App\Services\Support\UserRestoreService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -25,6 +26,7 @@ public function handle( DiagnosticsAgentService $diagnostics, SupportActionLogger $logger, UserRestoreService $userRestore, + UserProfileUpdateService $userProfileUpdate, ): void { $case = SupportCase::findOrFail($this->supportCaseId); $case->update(['status' => 'investigating']); @@ -56,6 +58,20 @@ public function handle( ); } + if ($case->case_type === 'profile_update' && $case->target_email) { + $dryRunResult = $userProfileUpdate->updateFromCase($case, dryRun: true); + $logger->log( + case: $case, + actionName: 'user_profile_update', + actionType: 'write', + input: ['email' => $case->target_email, 'dry_run' => true], + output: $dryRunResult, + succeeded: (bool) ($dryRunResult['ok'] ?? false), + executedBy: 'agent', + correlationId: $case->correlation_id, + ); + } + // Persist diagnostics snapshot as a message for UI/debugging (stable storage for later external orchestrator). SupportCaseMessage::create([ 'support_case_id' => $case->id, diff --git a/app/Services/Support/Agents/TriageAgentService.php b/app/Services/Support/Agents/TriageAgentService.php index e9e1afa8e..349957db0 100644 --- a/app/Services/Support/Agents/TriageAgentService.php +++ b/app/Services/Support/Agents/TriageAgentService.php @@ -3,13 +3,21 @@ namespace App\Services\Support\Agents; use App\Models\Support\SupportCase; +use App\Services\Support\SupportProfileRequestParser; use Illuminate\Support\Str; class TriageAgentService { + public function __construct( + private readonly SupportProfileRequestParser $profileParser, + ) { + } + public function triage(SupportCase $case): array { - $text = Str::lower((string) ($case->normalized_message ?? $case->raw_message ?? '')); + $rawText = (string) ($case->normalized_message ?? $case->raw_message ?? ''); + $text = Str::lower($rawText); + $profile = $this->profileParser->parse($rawText); // V1 heuristic placeholder (replace with LLM later, keep output schema stable). $caseType = 'unknown'; @@ -17,6 +25,17 @@ public function triage(SupportCase $case): array if (Str::contains($text, ['soft-deleted', 'deleted', 'restore account', 'account missing'])) { $caseType = 'account_restore'; $runbook = 'restore_deleted_account'; + } elseif (Str::contains($text, [ + 'update profile', + 'profile name', + 'your details', + 'first name', + 'last name', + 'change name', + 'rename profile', + ]) || ($profile['firstname'] !== null || $profile['lastname'] !== null)) { + $caseType = 'profile_update'; + $runbook = 'update_user_profile'; } elseif (Str::contains($text, ['duplicate', 'two accounts', 'split across'])) { $caseType = 'duplicate_account'; $runbook = 'duplicate_account_investigation'; @@ -31,11 +50,17 @@ public function triage(SupportCase $case): array $runbook = 'role_problem'; } - $targetEmail = $this->extractFirstEmail($text); + $targetEmail = $profile['email'] ?? $this->extractFirstEmail($text); $secondary = $this->extractAllEmails($text); $secondary = array_values(array_filter($secondary, fn ($e) => $targetEmail ? $e !== $targetEmail : true)); $risk = Str::contains($text, ['password reset', 'merge', 'ownership', 'privileged']) ? 'high' : 'low'; + if ($caseType === 'profile_update') { + $risk = 'low'; + } + + $needsHuman = $targetEmail === null + || ($caseType === 'profile_update' && $profile['firstname'] === null && $profile['lastname'] === null); return [ 'case_type' => $caseType, @@ -43,10 +68,12 @@ public function triage(SupportCase $case): array 'target_email' => $targetEmail, 'secondary_emails' => $secondary, 'target_user_id' => null, - 'requested_action' => null, + 'requested_action' => $caseType === 'profile_update' ? 'user_profile_update' : null, + 'profile_firstname' => $profile['firstname'], + 'profile_lastname' => $profile['lastname'], 'risk_level' => $risk, 'recommended_runbook' => $runbook, - 'needs_human_review' => $targetEmail === null, + 'needs_human_review' => $needsHuman, 'reasoning_summary' => 'V1 heuristic triage (LLM integration pending).', ]; } diff --git a/app/Services/Support/SupportApprovalEmailService.php b/app/Services/Support/SupportApprovalEmailService.php index 453a412a4..881c4f5bc 100644 --- a/app/Services/Support/SupportApprovalEmailService.php +++ b/app/Services/Support/SupportApprovalEmailService.php @@ -14,6 +14,7 @@ class SupportApprovalEmailService public function __construct( private readonly GmailOutboundService $gmail, private readonly SupportSenderAllowlist $allowlist, + private readonly SupportProfileRequestParser $profileParser, ) { } @@ -175,6 +176,20 @@ private function proposedActionForCase(SupportCase $case): array ]; } + if ($case->case_type === 'profile_update' && $case->target_email) { + $profile = $this->profileParser->parse((string) ($case->normalized_message ?? $case->raw_message ?? '')); + if ($profile['firstname'] !== null || $profile['lastname'] !== null) { + return [ + 'action' => 'user_profile_update', + 'payload' => [ + 'email' => $case->target_email, + 'firstname' => $profile['firstname'], + 'lastname' => $profile['lastname'], + ], + ]; + } + } + return ['action' => 'none', 'payload' => []]; } @@ -200,11 +215,22 @@ private function buildDryRunBody(SupportCase $case, array $proposedAction): stri $lines[] = ''; } - $dryRun = $case->actions()->where('action_name', 'user_restore')->where('action_type', 'write')->latest()->first()?->output_json; - if (is_array($dryRun)) { - $lines[] = 'Planned changes (dry run):'; - $lines[] = json_encode($dryRun['changes_planned'] ?? $dryRun, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $lines[] = ''; + 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[] = ''; + } } $action = $proposedAction['action'] ?? 'none'; diff --git a/app/Services/Support/SupportProfileRequestParser.php b/app/Services/Support/SupportProfileRequestParser.php new file mode 100644 index 000000000..ff1d756a5 --- /dev/null +++ b/app/Services/Support/SupportProfileRequestParser.php @@ -0,0 +1,100 @@ + */ + private const PLACEHOLDER_NAMES = [ + 'first name', + 'last name', + 'your details', + 'display email', + 'leading teacher tag', + 'twitter (optional)', + 'your website', + 'n/a', + '-', + ]; + + /** + * @return array{email: ?string, firstname: ?string, lastname: ?string} + */ + 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), + ]; + } + + private function extractFirstEmail(string $text): ?string + { + preg_match_all('/[a-z0-9._%+\\-]+@[a-z0-9.\\-]+\\.[a-z]{2,}/i', $text, $m); + $emails = array_map(fn ($e) => Str::lower(trim($e)), $m[0] ?? []); + + return $emails[0] ?? null; + } + + /** + * @param list $labels + */ + private function extractLabelledValue(string $text, array $labels): ?string + { + foreach ($labels as $label) { + $pattern = '/'.preg_quote($label, '/').'\s*[\*:]?\s*(.+?)(?:\n|$)/iu'; + if (preg_match($pattern, $text, $m)) { + $value = trim($m[1]); + if ($value !== '') { + return $value; + } + } + } + + return null; + } + + private function sanitizeName(?string $value): ?string + { + if ($value === null) { + return null; + } + + $value = trim(preg_replace('/\s+/', ' ', $value) ?? ''); + if ($value === '') { + return null; + } + + if (in_array(Str::lower($value), self::PLACEHOLDER_NAMES, true)) { + return null; + } + + if (mb_strlen($value) > 255) { + $value = mb_substr($value, 0, 255); + } + + return $value; + } +} diff --git a/app/Services/Support/UserProfileUpdateService.php b/app/Services/Support/UserProfileUpdateService.php new file mode 100644 index 000000000..0166434a1 --- /dev/null +++ b/app/Services/Support/UserProfileUpdateService.php @@ -0,0 +1,155 @@ +parser->parse((string) ($case->normalized_message ?? $case->raw_message ?? '')); + $email = $case->target_email ?: $parsed['email']; + + return $this->updateProfile( + case: $case, + email: (string) $email, + firstname: $parsed['firstname'], + lastname: $parsed['lastname'], + dryRun: $dryRun, + viaEmailApproval: $viaEmailApproval, + ); + } + + public function updateProfile( + SupportCase $case, + string $email, + ?string $firstname, + ?string $lastname, + bool $dryRun, + bool $viaEmailApproval = false, + ): array { + $tool = 'user_profile_update'; + $input = [ + 'email' => $email, + 'firstname' => $firstname, + 'lastname' => $lastname, + 'dry_run' => $dryRun, + ]; + + if (!$this->isValidEmail($email)) { + return SupportJson::fail($tool, $input, 'invalid_email'); + } + + if ($firstname === null && $lastname === null) { + return SupportJson::fail($tool, $input, 'no_profile_fields_to_update'); + } + + $normalized = trim(Str::lower($email)); + $matches = User::query() + ->whereRaw('LOWER(email) = ?', [$normalized]) + ->orWhereRaw('LOWER(email_display) = ?', [$normalized]) + ->get(); + + if ($matches->isEmpty()) { + return SupportJson::fail($tool, $input, 'no_matching_user_found'); + } + + if ($matches->count() !== 1) { + return SupportJson::fail($tool, $input, 'ambiguous_user_match'); + } + + /** @var User $user */ + $user = $matches->first(); + + $before = [ + 'user_id' => $user->id, + 'firstname' => $user->firstname, + 'lastname' => $user->lastname, + ]; + + $changes = []; + if ($firstname !== null && $firstname !== $user->firstname) { + $changes['firstname'] = $firstname; + } + if ($lastname !== null && $lastname !== $user->lastname) { + $changes['lastname'] = $lastname; + } + + if ($changes === []) { + return SupportJson::ok($tool, $input, [ + 'dry_run' => $dryRun, + 'changes_planned' => [], + 'changes_applied' => [], + 'before' => $before, + 'after' => $before, + 'note' => 'profile_already_matches_requested_values', + ]); + } + + $planned = [ + 'model' => 'user', + 'user_id' => $user->id, + 'change' => 'update_profile_names', + 'fields' => array_keys($changes), + ]; + + if ($dryRun) { + $after = array_merge($before, $changes); + + return SupportJson::ok($tool, $input, [ + 'dry_run' => true, + 'changes_planned' => [$planned], + 'changes_applied' => [], + 'before' => $before, + 'after' => $after, + ]); + } + + if (config('support_gmail.dry_run') && !$viaEmailApproval) { + return SupportJson::fail($tool, $input, 'dry_run_mode_requires_email_approval'); + } + + DB::transaction(function () use ($user, $changes) { + if (isset($changes['firstname'])) { + $user->firstname = $changes['firstname']; + } + if (isset($changes['lastname'])) { + $user->lastname = $changes['lastname']; + } + $user->save(); + }); + + $user->refresh(); + + $after = [ + 'user_id' => $user->id, + 'firstname' => $user->firstname, + 'lastname' => $user->lastname, + ]; + + return SupportJson::ok($tool, $input, [ + 'dry_run' => false, + 'changes_planned' => [$planned], + 'changes_applied' => [$planned], + 'before' => $before, + 'after' => $after, + ]); + } + + private function isValidEmail(string $email): bool + { + return filter_var($email, FILTER_VALIDATE_EMAIL) !== false; + } +} diff --git a/app/Services/Support/UserRestoreService.php b/app/Services/Support/UserRestoreService.php index 754ea3bdb..29456477a 100644 --- a/app/Services/Support/UserRestoreService.php +++ b/app/Services/Support/UserRestoreService.php @@ -15,7 +15,7 @@ public function __construct( ) { } - public function restore(SupportCase $case, string $email, bool $dryRun, ?float $confidence = null): array + public function restore(SupportCase $case, string $email, bool $dryRun, ?float $confidence = null, bool $viaEmailApproval = false): array { $tool = 'user_restore'; $input = ['email' => $email, 'dry_run' => $dryRun]; @@ -93,7 +93,7 @@ public function restore(SupportCase $case, string $email, bool $dryRun, ?float $ ]); } - if (config('support_gmail.dry_run')) { + if (config('support_gmail.dry_run') && !$viaEmailApproval) { return SupportJson::fail($tool, $input, 'dry_run_mode_requires_email_approval'); } diff --git a/config/support_gmail.php b/config/support_gmail.php index b6e0e7b5e..e7c2869ac 100644 --- a/config/support_gmail.php +++ b/config/support_gmail.php @@ -64,5 +64,16 @@ // Subject prefix for approval threads: "[CW-SUPPORT #123] ..." 'approval_subject_prefix' => '[CW-SUPPORT', + + /* + |-------------------------------------------------------------------------- + | Allowed automated write actions (email APPROVE or CLI) + |-------------------------------------------------------------------------- + | See docs/support-copilot-allowed-actions.md for the full matrix. + */ + 'allowed_write_actions' => [ + 'user_restore', + 'user_profile_update', + ], ]; diff --git a/docs/support-copilot-allowed-actions.md b/docs/support-copilot-allowed-actions.md new file mode 100644 index 000000000..90e85f388 --- /dev/null +++ b/docs/support-copilot-allowed-actions.md @@ -0,0 +1,135 @@ +# CodeWeek Support Copilot — Allowed actions reference + +**Version:** V1 · **Last updated:** May 2026 + +This document lists everything the support copilot **allows**, **automates**, and **blocks**. + +--- + +## 1. Who can send tickets (ingest) + +| Rule | Allowed | +|------|---------| +| Sender email domain | `@matrixinternet.ie`, `@codeweek.eu` (config: `SUPPORT_GMAIL_ALLOWED_DOMAINS`) | +| Extra explicit senders | Optional list via `SUPPORT_GMAIL_ALLOWED_SENDERS` | +| Subject line | Must contain `codeweek-support` (config: `SUPPORT_GMAIL_SUBJECT_PREFIX`) | +| Teacher / parent Gmail, schools, etc. | **Not ingested** — staff must send a new email from an allowed domain | + +--- + +## 2. Who can approve writes (email reply) + +| Rule | Allowed | +|------|---------| +| Reply keywords (first line only) | `APPROVE`, `YES`, `PROCEED` (case-insensitive) | +| Sender domain | Same as ingest: `@matrixinternet.ie`, `@codeweek.eu` | +| Thread | Reply in the same Gmail thread as the `[CW-SUPPORT #…]` summary when possible | + +--- + +## 3. Automated write actions (require APPROVE when `SUPPORT_GMAIL_DRY_RUN=true`) + +These are the **only** actions that can change production data via the email pipeline after **APPROVE**: + +| Action ID | Case type | What it does | Required in ticket | +|-----------|-----------|--------------|-------------------| +| `user_restore` | `account_restore` | Restores a **soft-deleted** user | User email + words like *restore*, *deleted account* | +| `user_profile_update` | `profile_update` | Updates `firstname` and/or `lastname` on `users` | User email + requested first/last name | + +Configured in `config/support_gmail.php` → `allowed_write_actions`. + +--- + +## 4. Case types (triage classification) + +| Case type | Trigger keywords (examples) | Automated write? | +|-----------|----------------------------|----------------| +| `account_restore` | soft-deleted, deleted, restore account | Yes → `user_restore` + APPROVE | +| `profile_update` | update profile, first name, last name, change name | Yes → `user_profile_update` + APPROVE | +| `certificate_issue` | certificate, cert | **No** — summary / manual reply | +| `missing_events` | missing event, events missing | **No** | +| `duplicate_account` | duplicate, two accounts | **No** | +| `role_issue` | role, permission | **No** | +| `unknown` | (none matched) | **No** | + +--- + +## 5. Read-only / manual tools (no email APPROVE) + +| Tool | CLI | Internal API | Email APPROVE | +|------|-----|--------------|---------------| +| User audit | — | `POST /tools/user-audit` | No | +| Event audit | `support:event-audit` (if registered) | `POST /tools/event-audit` | No | +| Email change | `support:user-update-email {from} {to}` | — | **No** (CLI / dev only) | +| Profile name change | `support:user-update-profile {email} --firstname= --lastname=` | `POST /tools/user-profile-update` | Yes (via pipeline + APPROVE) | +| Gmail test | `support:gmail:test` | — | N/A | +| Gmail poll | `support:gmail:poll` | — | N/A | + +--- + +## 6. Profile update — allowed fields + +| Field | Column | Allowed via copilot? | +|-------|--------|----------------------| +| First name | `users.firstname` | **Yes** | +| Last name | `users.lastname` | **Yes** | +| Email | `users.email` | **No** (use `support:user-update-email`) | +| Display email | `users.email_display` | **No** | +| Twitter, website, bio, tag, country | various | **No** (user edits in UI or manual) | +| Delete / restore account | soft delete | Restore only via `user_restore` | + +**Rejected as names:** placeholder text such as `Last Name`, `First Name`, `Your details`, empty strings. + +--- + +## 7. Safety flags (environment) + +| Variable | Typical prod | Effect | +|----------|--------------|--------| +| `SUPPORT_GMAIL_ENABLED` | `true` | Turns on polling | +| `SUPPORT_GMAIL_DRY_RUN` | `true` | Pipeline sends summaries; writes need **APPROVE** | +| `SUPPORT_GMAIL_NOTIFY_EMAIL` | `codeweek@matrixinternet.ie` | Dry-run summary recipient | +| `SUPPORT_GMAIL_ALLOWED_DOMAINS` | `matrixinternet.ie,codeweek.eu` | Ingest + approve senders | + +--- + +## 8. Gmail API scopes + +| Scope | Purpose | +|-------|---------| +| `gmail.readonly` | Poll inbox, read approval replies | +| `gmail.send` | Send dry-run summaries | + +--- + +## 9. Not allowed / out of scope (V1) + +- Ingest mail without `codeweek-support` in subject +- Approve from non-allowlisted domains +- Auto-change email via support email (CLI only) +- Auto-issue or regenerate Excellence certificates from email +- Auto-merge duplicate accounts +- Auto-change roles/permissions +- LLM-only decisions without DB checks (not implemented) + +--- + +## 10. Quick test commands (technical) + +```bash +# Profile update dry-run +php artisan support:user-update-profile bernard@matrixinternet.ie --firstname=Bernard --lastname=Hanna --dry-run + +# Profile update live (bypasses email; use on server with care) +php artisan support:user-update-profile bernard@matrixinternet.ie --firstname=Bernard --lastname=Hanna + +# Simulated pipeline + summary email +php artisan support:gmail:test + +# Check config +php artisan support:gmail:setup-check +``` + +--- + +See also: [support-copilot-stakeholder-guide.md](./support-copilot-stakeholder-guide.md) diff --git a/docs/support-copilot-stakeholder-guide.md b/docs/support-copilot-stakeholder-guide.md new file mode 100644 index 000000000..08dfc2705 --- /dev/null +++ b/docs/support-copilot-stakeholder-guide.md @@ -0,0 +1,448 @@ +# CodeWeek Support Copilot — Stakeholder guide + +**Version:** V1 (dry-run) · **Last updated:** May 2026 + +--- + +## What this is + +CodeWeek has an automated **support assistant** that: + +- Watches a dedicated support inbox +- Picks up qualifying emails automatically (about every 5 minutes) +- Runs basic checks on the user account mentioned in the email +- Sends a **summary email** to **codeweek@matrixinternet.ie** so the team can review before any change is made + +**Nothing is changed automatically** unless the summary explicitly asks for approval and someone approves by email. + +--- + +## Who should use this + +Anyone at **Matrix Internet** or **CodeWeek** (`@matrixinternet.ie` or `@codeweek.eu`) who needs help with a CodeWeek user account (login issues, deleted account, etc.). + +--- + +## How to log a support request (3 steps) + +### Step 1 — Send an email to the support inbox + +**To:** `________________________` + +*(Fill in: the Gmail inbox connected to the copilot — ask your technical contact if unsure.)* + +### Step 2 — Use the correct subject line + +The subject **must include**: + +```text +codeweek-support +``` + +**Good examples:** + +- `codeweek-support — user cannot log in` +- `codeweek-support — restore account for parent@example.com` + +**Bad examples (will not be processed):** + +- `User cannot log in` *(missing codeweek-support)* +- `Fwd: RE: login issue` *(forward only — send a new email with the prefix)* + +### Step 3 — Write a clear message + +Include: + +1. **The user’s email address** on CodeWeek (required) +2. **What the problem is** in plain language +3. **What you want done** (e.g. “check if account is deleted and restore if possible”) + +**Example email:** + +```text +To: [support inbox] +From: colleague@matrixinternet.ie +Subject: codeweek-support — account restore request + +Hello, + +Please investigate user: serin34@yahoo.com + +They cannot log in. The account may have been soft-deleted. +Please check and restore if appropriate. + +Thanks, +[Name] +``` + +**Rules:** + +| Requirement | Detail | +|-------------|--------| +| **Send from** | `@matrixinternet.ie` or `@codeweek.eu` only | +| **Subject** | Must contain `codeweek-support` | +| **Body** | Must include the affected user’s email address | + +Emails from other domains (Gmail, Yahoo, schools, etc.) are **not processed**, even if forwarded. + +--- + +## Typical support tickets (examples) + +These are real-world requests the copilot is designed for. **Staff must not forward the teacher’s email as-is** — copy the facts into a new email from `@matrixinternet.ie` or `@codeweek.eu` with `codeweek-support` in the subject. + +--- + +### Example 1 — Teacher accidentally deleted account (restore) + +**Situation (incoming request to the team)** + +A colleague or EU CodeWeek coordinator receives a message like this: + +```text +Hi Bernard, + +The teacher below has accidentally deleted her account and asks to be recovered. + +Registration email: laurafuso1@gmail.com +CodeWeek 4all code: cw25-x6LtQ + +Thank you, +Regards, +Rachele +``` + +The public-facing team may already have replied (e.g. asking for email and activity codes). Once you have those details, **log the ticket for the copilot** as below. + +**What to send to the support copilot** + +```text +To: [support inbox] +From: rachele@matrixinternet.ie +Subject: codeweek-support — restore deleted teacher account + +Hello, + +Request type: restore deleted account + +User registration email: laurafuso1@gmail.com +CodeWeek 4all code: cw25-x6LtQ + +The teacher accidentally deleted her account and needs it recovered. +Please check if the account is soft-deleted and restore if appropriate. + +Reported by: Rachele (EU CodeWeek coordination) +``` + +**Why this wording matters** + +| Detail in the ticket | Purpose | +|----------------------|---------| +| `codeweek-support` in subject | Required so the inbox is polled | +| `restore` / `deleted account` | Helps triage classify as **account restore** | +| `laurafuso1@gmail.com` | Target user for diagnostics | +| `cw25-x6LtQ` | Extra context for manual verification (V1 may not auto-use the code yet) | +| Work email sender | Required for ingest and for any later **APPROVE** reply | + +**What the team receives (~5 minutes later)** + +An email to **codeweek@matrixinternet.ie** similar to: + +```text +[CW-SUPPORT #…] Support copilot - dry run review + +Case #… +Subject: codeweek-support — restore deleted teacher account +Type: account_restore +Risk: low +Target: laurafuso1@gmail.com + +Diagnostics findings: … + +Proposed action: user_restore +To execute this change, reply to this email with a single line: +APPROVE +``` + +If diagnostics show the user is soft-deleted and restore is safe, a reviewer replies **APPROVE** (from `@matrixinternet.ie` or `@codeweek.eu`) in the **same thread**. + +**After restore** + +Tell the teacher they can sign in again and download certificates from: + +https://codeweek.eu/certificates + +*(Same guidance as in the standard EU CodeWeek reply template.)* + +**If the summary says “No automated write action proposed”** + +- Check Nova → Support Cases for the case number +- Escalate to the technical team with the case # and the 4all code + +--- + +### Example 2 — “Did not receive Certificate of Excellence” (investigation, often not a bug) + +**Situation (incoming request to the team)** + +EU CodeWeek receives a contact form message; a coordinator forwards it internally: + +```text +Hi Bernard, + +Can you please see the below? They didn't receive the certificate. + +Code: cw25-iKlWI + +--- + +From: Code Week +Subject: New Contact Form Submission + +First Name: Concetta +Last Name: Garufo +Email: concetta.garufo@gmail.com +Subject: Certificates + +Message: +Good morning, I am writing to request information regarding the 2025 Certificate +of Excellence, which we have not yet received. Our school participated in the +activities using the code cw25-iKlWI. The name of the school is I.C. "S. Gangitano", +Canicatti (Ag). I look forward to hearing from you soon. Kind regards +``` + +**What to send to the support copilot** + +```text +To: [support inbox] +From: bernard@matrixinternet.ie +Subject: codeweek-support — Certificate of Excellence not received + +Hello, + +Request type: certificate inquiry + +Contact email: concetta.garufo@gmail.com +CodeWeek 4all code: cw25-iKlWI +School: I.C. "S. Gangitano", Canicatti (Ag) + +The school reports they did not receive the 2025 Certificate of Excellence. +Please check eligibility for this code and summarise why a certificate was or +was not issued. + +Reported via: EU CodeWeek contact form (forwarded by Rachele) +``` + +**What the copilot does today (V1)** + +| Step | Automatic? | Result | +|------|------------|--------| +| Ingest email | Yes | Case created if rules met | +| Triage | Yes | Type **`certificate_issue`** (keyword “certificate”) | +| User lookup | Yes | Audit for `concetta.garufo@gmail.com` | +| **4all code eligibility report** | **Not yet** | Code `cw25-iKlWI` is **not** auto-analysed in V1 | +| Proposed write / APPROVE | No | **No account change** — this is an explanation ticket | + +You still get a dry-run summary at **codeweek@matrixinternet.ie**, but a **human (or dev team)** must confirm eligibility using internal tools until the copilot is extended. + +**Actual outcome for this ticket (correct behaviour — not a platform bug)** + +Investigation showed code **cw25-iKlWI** was **not** selected as a **2025 Certificate of Excellence** winner, so **no Excellence certificate was generated or sent**. + +For **2025**, a code qualifies if it reaches **either**: + +- **At least 10 organisers** across approved activities, **or** +- **At least 3 countries** across approved activities + +**This code’s stats:** + +| Metric | Value | Excellence threshold (2025) | Met? | +|--------|-------|-----------------------------|------| +| Activities | 10 | — | — | +| Participants | 428 | — | — | +| Organisers | 8 | ≥ 10 | No | +| Countries | 1 | ≥ 3 | No | + +**Suggested reply to the school / coordinator** + +```text +We checked CodeWeek 4all code cw25-iKlWI for 2025. It was not selected as a +Certificate of Excellence winner, so no Excellence certificate was generated +or sent for this code. + +For 2025, a code qualifies if it reaches either at least 10 organisers or +at least 3 countries across approved activities. This code recorded +10 activities, 428 participants, 8 organisers, and 1 country, so it did not +meet the 2025 Excellence thresholds. + +Participation certificates (where applicable) can still be available from the +user’s account at https://codeweek.eu/certificates after signing in. +``` + +**Will emailing the inbox “fix” this?** + +**No automated fix** — there is nothing broken to repair. The goal is a **clear summary of why** no Excellence certificate was issued so the team can reply accurately. + +**V1:** Email → case + user audit → manual eligibility check → reply using template above. + +**Planned improvement:** Copilot reads the **4all code**, compares activity stats to Excellence rules, and includes a **ready-made explanation** in the dry-run email (no APPROVE needed). + +**If the summary says “No automated write action proposed”** + +That is **expected** for this ticket type. Use Nova case # + internal checks, then send the explanation to the teacher. + +--- + +### Example 3 — Fix profile first / last name (with APPROVE) + +**Situation:** A user’s profile shows the full name in the first-name field (e.g. First name `Bernard Hanna`, Last name `Last Name`). + +**What to send to the support copilot:** + +```text +To: [support inbox] +From: bernard@matrixinternet.ie +Subject: codeweek-support — update user profile name + +Email: bernard@matrixinternet.ie + +Current first name: Bernard Hanna +Current last name: Last Name + +Requested first name: Bernard +Requested last name: Hanna + +Please update the profile name fields. Email address must stay the same. +``` + +**Expected:** Dry-run summary with **Before** / **After** names and **Proposed action: user_profile_update**. Reply **APPROVE** to apply. + +**Full list of allowed actions:** see [support-copilot-allowed-actions.md](./support-copilot-allowed-actions.md). + +--- + +## What happens after you send the email + +| When | What happens | +|------|----------------| +| **Within ~5 minutes** | The system reads the inbox and creates a support case | +| **Shortly after** | A summary is emailed to **codeweek@matrixinternet.ie** | +| **Subject of summary** | `[CW-SUPPORT #123] Support copilot - dry run review` | +| **Sender** | Code Week Bot (automated) | + +The summary includes: + +- Case number +- Your original subject +- User email checked +- What the system found +- Whether it **proposes an action** or only reports findings + +**You do not need to use admin tools or run commands** — logging the ticket is done by email only. + +--- + +## How to approve a change (only when asked) + +Read the summary email carefully. + +### If it says: *“No automated write action proposed”* + +- **Do not** reply `APPROVE` +- Handle the case manually (or ask the dev team / check Nova if you have access) + +### If it says: *“To execute this change, reply… APPROVE”* + +1. **Reply in the same email thread** (use Reply, not a new email) +2. Put **only this on the first line** of your reply: + + ```text + APPROVE + ``` + + (`YES` or `PROCEED` also work.) +3. Send from **`@matrixinternet.ie`** or **`@codeweek.eu`** +4. Wait up to ~5 minutes for the system to process your reply + +--- + +## What the system can and cannot do (V1) + +| Request | Supported via email? | Notes | +|---------|----------------------|--------| +| Check user / account status | Yes | You get a summary | +| **Restore soft-deleted account** | Yes, with approval | Use words like *restore*, *soft-deleted*, *deleted account*; reply **APPROVE** if proposed | +| **Update profile first / last name** | Yes, with approval | Use *update profile*, *first name*, *last name*; reply **APPROVE** if proposed | +| Change user email address | **No** | Contact dev team — use CLI `support:user-update-email` | +| **Certificate of Excellence not received** | Partial (V1) | Classifies ticket + user audit; **4all code eligibility explanation is manual** (see Example 2) | +| Missing events / roles | Partial | Summary only; manual follow-up | +| Vague or unclear requests | Limited | May show “unknown” type — team reviews manually | + +--- + +## Frequently asked questions + +**Q: I didn’t get a summary at codeweek@matrixinternet.ie** + +Check: correct inbox, subject contains `codeweek-support`, sent from `@matrixinternet.ie` or `@codeweek.eu`, user email in body. Wait 10 minutes, then contact your technical lead. + +**Q: Can I forward an email from a teacher/parent?** + +Not as-is. Send a **new** email from your work address with `codeweek-support` in the subject and the user’s email in the body. + +**Q: Is it safe? Will it change production data without us knowing?** + +V1 is **dry-run by default**. Most tickets only send a summary. Writes (e.g. account restore) only run after an explicit **APPROVE** from an allowed address, and only when the summary proposes that action. + +**Q: Who gets the summary?** + +**codeweek@matrixinternet.ie** (team inbox). The person who logged the ticket does not automatically get a copy unless they’re on that distribution. + +**Q: Where can admins see full detail?** + +Nova → **Support Cases** (internal admin). + +--- + +## Escalation + +| Situation | Contact | +|-----------|---------| +| Email not picked up after 10+ minutes | Technical lead / dev team | +| Wrong user, urgent production issue | Dev team + Nova case # from summary | +| Email change, merge accounts, complex data fix | Dev team (not automated in V1) | + +**Technical contact:** `________________________` + +**Support inbox address:** `________________________` + +--- + +## Quick reference card + +```text +TO: [support inbox] +FROM: @matrixinternet.ie or @codeweek.eu +SUBJECT: codeweek-support — [short description] +BODY: User email: someone@example.com + Problem: ... + Request: ... + +WAIT: ~5 min → summary at codeweek@matrixinternet.ie + +APPROVE: Only if summary asks — reply with first line: APPROVE +``` + +--- + +## Internal reference (technical team) + +| Setting | Value | +|---------|--------| +| Subject prefix | `codeweek-support` | +| Summary recipient | `codeweek@matrixinternet.ie` | +| Allowed sender domains | `matrixinternet.ie`, `codeweek.eu` | +| Config | `config/support_gmail.php` | +| Verify on server | `php artisan support:gmail:setup-check` | +| **Allowed actions (full list)** | [support-copilot-allowed-actions.md](./support-copilot-allowed-actions.md) | +| CLI profile fix | `php artisan support:user-update-profile {email} --firstname= --lastname= [--dry-run]` | diff --git a/routes/api.php b/routes/api.php index fe59bdc4a..47dc65dc3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -43,6 +43,7 @@ // Tool endpoints (V1 tools) Route::post('/tools/user-audit', [ToolController::class, 'userAudit']); Route::post('/tools/user-restore', [ToolController::class, 'userRestore']); + Route::post('/tools/user-profile-update', [ToolController::class, 'userProfileUpdate']); Route::post('/tools/event-audit', [ToolController::class, 'eventAudit']); // Approvals diff --git a/tests/Unit/Support/SupportProfileRequestParserTest.php b/tests/Unit/Support/SupportProfileRequestParserTest.php new file mode 100644 index 000000000..fec7cd6c2 --- /dev/null +++ b/tests/Unit/Support/SupportProfileRequestParserTest.php @@ -0,0 +1,36 @@ +parse($text); + + $this->assertSame('bernard@matrixinternet.ie', $parsed['email']); + $this->assertSame('Bernard', $parsed['firstname']); + $this->assertSame('Hanna', $parsed['lastname']); + } + + public function test_rejects_placeholder_last_name(): void + { + $text = "Email: u@example.com\nLast name: Last Name\nFirst name: Jane"; + + $parsed = (new SupportProfileRequestParser())->parse($text); + + $this->assertSame('Jane', $parsed['firstname']); + $this->assertNull($parsed['lastname']); + } +} diff --git a/tests/Unit/Support/UserProfileUpdateServiceTest.php b/tests/Unit/Support/UserProfileUpdateServiceTest.php new file mode 100644 index 000000000..35cd6fef9 --- /dev/null +++ b/tests/Unit/Support/UserProfileUpdateServiceTest.php @@ -0,0 +1,75 @@ +create([ + 'email' => 'profile@example.com', + 'firstname' => 'Bernard Hanna', + 'lastname' => 'Last Name', + ]); + + $case = SupportCase::create([ + 'source_channel' => 'manual', + 'processing_mode' => 'manual', + 'subject' => 'test', + 'raw_message' => 'test', + 'status' => 'investigating', + 'risk_level' => 'low', + 'correlation_id' => 'cid', + ]); + + $svc = app(UserProfileUpdateService::class); + $payload = $svc->updateProfile($case, 'profile@example.com', 'Bernard', 'Hanna', true); + + $this->assertTrue($payload['ok']); + $this->assertTrue($payload['result']['dry_run']); + $this->assertSame('Bernard', $payload['result']['after']['firstname']); + $this->assertSame('Hanna', $payload['result']['after']['lastname']); + + $u->refresh(); + $this->assertSame('Bernard Hanna', $u->firstname); + } + + public function test_profile_update_execute_changes_user(): void + { + config(['support_gmail.dry_run' => true]); + + /** @var User $u */ + $u = User::factory()->create([ + 'email' => 'profile2@example.com', + 'firstname' => 'Wrong', + 'lastname' => 'Name', + ]); + + $case = SupportCase::create([ + 'source_channel' => 'manual', + 'processing_mode' => 'manual', + 'subject' => 'test', + 'raw_message' => 'test', + 'status' => 'investigating', + 'risk_level' => 'low', + 'correlation_id' => 'cid', + ]); + + $svc = app(UserProfileUpdateService::class); + $payload = $svc->updateProfile($case, 'profile2@example.com', 'Bernard', 'Hanna', false, true); + + $this->assertTrue($payload['ok']); + $u->refresh(); + $this->assertSame('Bernard', $u->firstname); + $this->assertSame('Hanna', $u->lastname); + } +}