Skip to content
Merged
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
8 changes: 7 additions & 1 deletion app/Jobs/Support/ExecuteApprovedSupportActionJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Models\Support\SupportApproval;
use App\Models\Support\SupportCase;
use App\Services\Support\SupportActionLogger;
use App\Services\Support\SupportApprovalEmailService;
use App\Services\Support\UserProfileUpdateService;
use App\Services\Support\UserRestoreService;
use Illuminate\Bus\Queueable;
Expand All @@ -24,6 +25,7 @@ public function __construct(public int $supportApprovalId)
public function handle(
UserRestoreService $userRestore,
UserProfileUpdateService $userProfileUpdate,
SupportApprovalEmailService $approvalEmail,
SupportActionLogger $logger,
): void
{
Expand All @@ -50,17 +52,19 @@ public function handle(

if ($action === 'none') {
$case->update(['status' => 'resolved']);
$noneResult = ['ok' => true, 'tool' => 'none', 'input' => $payload, 'result' => ['note' => 'no_write_action'], 'errors' => []];
$logger->log(
case: $case,
actionName: 'approved_action_executed',
actionType: 'read',
input: ['approval_id' => $approval->id, 'action' => $action],
output: ['ok' => true, 'note' => 'no_write_action'],
output: $noneResult,
succeeded: true,
executedBy: 'system',
approvedBy: $approval->approved_by,
correlationId: $case->correlation_id,
);
$approvalEmail->sendActionCompletion($case, $approval, $action, $noneResult, true);

return;
}
Expand Down Expand Up @@ -109,6 +113,8 @@ public function handle(
correlationId: $case->correlation_id,
errorMessage: $ok ? null : implode(';', (array) ($result['errors'] ?? [])),
);

$approvalEmail->sendActionCompletion($case, $approval, $action, $result ?? [], $ok);
}
}

17 changes: 15 additions & 2 deletions app/Services/Support/Gmail/SupportGmailPollQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,18 @@ public static function resolve(): string
$parts = [];

$prefix = trim((string) config('support_gmail.subject_prefix', ''));
$approvalPrefix = trim((string) config('support_gmail.approval_subject_prefix', '[CW-SUPPORT'));
$baseQuery = trim((string) config('support_gmail.query', 'newer_than:90d'));

if ($prefix !== '' && !self::queryContainsSubjectFilter($baseQuery)) {
$parts[] = 'subject:'.self::quoteGmailSearchTerm($prefix);
// Ingest new tickets (codeweek-support) and APPROVE replies (Re: [CW-SUPPORT #…]).
$subjectFilters = ['subject:'.self::quoteGmailSearchTerm($prefix)];
if ($approvalPrefix !== '' && !self::sameSearchTerm($prefix, $approvalPrefix)) {
$subjectFilters[] = 'subject:'.self::quoteGmailSearchTerm($approvalPrefix);
}
$parts[] = count($subjectFilters) === 1
? $subjectFilters[0]
: '('.implode(' OR ', $subjectFilters).')';
}

if ($baseQuery !== '') {
Expand All @@ -37,10 +45,15 @@ private static function queryContainsSubjectFilter(string $query): bool

private static function quoteGmailSearchTerm(string $term): string
{
if (preg_match('/[\s"\']/', $term)) {
if (preg_match('/[\s"\'\[\]#]/', $term)) {
return '"'.str_replace('"', '', $term).'"';
}

return $term;
}

private static function sameSearchTerm(string $a, string $b): bool
{
return strcasecmp(trim($a), trim($b)) === 0;
}
}
96 changes: 96 additions & 0 deletions app/Services/Support/SupportApprovalEmailService.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,53 @@ 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
{
$prefix = (string) config('support_gmail.approval_subject_prefix', '[CW-SUPPORT');
$label = $succeeded ? 'action completed' : 'action failed';

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

/**
* Follow-up email after APPROVE is processed (success or failure).
*
* @param array<string, mixed> $result
*/
public function sendActionCompletion(
SupportCase $case,
SupportApproval $approval,
string $action,
array $result,
bool $succeeded,
): array {
if (!config('support_gmail.send_completion_email', true)) {
return SupportJson::ok('support_completion_email', ['case_id' => $case->id], ['skipped' => true]);
}

$to = SupportEmailAddress::normalize(
(string) ($approval->notify_email ?: $case->forwarded_by_email ?: config('support_gmail.notify_email')),
);
if ($to === null) {
return SupportJson::fail('support_completion_email', ['case_id' => $case->id], 'no_recipient_email');
}

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

$sent = $this->gmail->sendPlainText(
to: $to,
subject: $this->completionSubject($case, $succeeded),
body: $body,
threadId: $approval->gmail_thread_id,
);

return SupportJson::ok('support_completion_email', ['case_id' => $case->id, 'to' => $to], [
'succeeded' => $succeeded,
'gmail_message_id' => $sent['id'] ?? null,
'gmail_thread_id' => $sent['thread_id'] ?? $approval->gmail_thread_id,
]);
}

/**
* Send dry-run summary and open a pending approval for email reply.
*/
Expand Down Expand Up @@ -241,11 +288,60 @@ private function buildDryRunBody(SupportCase $case, array $proposedAction): stri
$lines[] = 'To execute this change, reply to this email with a single line:';
$lines[] = 'APPROVE';
$lines[] = '';
$lines[] = 'You will receive a follow-up email when the action has run (completed or failed).';
$lines[] = '';
$lines[] = '(Only @matrixinternet.ie and @codeweek.eu senders are accepted.)';
} else {
$lines[] = 'No automated write action proposed. Review the case in Nova if needed.';
}

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

/**
* @param array<string, mixed> $result
*/
private function buildCompletionBody(
SupportCase $case,
string $action,
array $result,
bool $succeeded,
string $approvedBy,
): string {
$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'),
'',
];

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

$errors = array_values(array_filter((array) ($result['errors'] ?? [])));
if ($errors !== []) {
$lines[] = 'Errors:';
foreach ($errors as $error) {
$lines[] = '- '.$error;
}
$lines[] = '';
}

if ($succeeded) {
$lines[] = 'The approved change has been applied. No further reply is required.';
} else {
$lines[] = 'The change was not applied. Review the case in Nova or contact the technical team.';
$lines[] = 'Include case #'.$case->id.' when escalating.';
}

return implode("\n", $lines);
}
}
3 changes: 3 additions & 0 deletions config/support_gmail.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,8 @@
'user_restore',
'user_profile_update',
],

// Send a follow-up email after an APPROVE action runs (success or failure).
'send_completion_email' => env('SUPPORT_GMAIL_SEND_COMPLETION_EMAIL', true),
];

16 changes: 14 additions & 2 deletions docs/support-copilot-allowed-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ This document lists everything the support copilot **allows**, **automates**, an
|------|---------|
| 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`) |
| Subject line (new tickets) | Must contain `codeweek-support` (config: `SUPPORT_GMAIL_SUBJECT_PREFIX`) |
| Subject line (APPROVE replies) | Must be in the **`[CW-SUPPORT #…]`** thread (poll also searches this prefix) |
| Teacher / parent Gmail, schools, etc. | **Not ingested** — staff must send a new email from an allowed domain |

---
Expand Down Expand Up @@ -98,7 +99,18 @@ Configured in `config/support_gmail.php` → `allowed_write_actions`.
| Scope | Purpose |
|-------|---------|
| `gmail.readonly` | Poll inbox, read approval replies |
| `gmail.send` | Send dry-run summaries |
| `gmail.send` | Send dry-run summaries and completion emails |

---

## 10. Email notifications (what you receive)

| When | Email subject (example) |
|------|-------------------------|
| Ticket processed (dry-run) | `[CW-SUPPORT #10] Support copilot - dry run review` |
| After you reply **APPROVE** and action runs | `[CW-SUPPORT #10] Support copilot - action completed` or `action failed` |

Completion emails go to the same recipient as the dry-run summary (`SUPPORT_GMAIL_NOTIFY_EMAIL` unless overridden). Disable with `SUPPORT_GMAIL_SEND_COMPLETION_EMAIL=false`.

---

Expand Down
3 changes: 3 additions & 0 deletions docs/support-copilot-stakeholder-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,9 @@ Read the summary email carefully.
(`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
5. You will receive a **follow-up email** in the same thread:
- **`action completed`** — change was applied
- **`action failed`** — change was not applied (see errors in the email; check Nova)

---

Expand Down
84 changes: 84 additions & 0 deletions tests/Unit/Support/SupportApprovalCompletionEmailTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?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 SupportApprovalCompletionEmailTest extends TestCase
{
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));
}

public function test_send_action_completion_calls_gmail(): void
{
config(['support_gmail.send_completion_email' => true]);

$case = SupportCase::create([
'source_channel' => 'manual',
'processing_mode' => 'manual',
'subject' => 'test',
'raw_message' => 'test',
'status' => 'verified',
'risk_level' => 'low',
'correlation_id' => 'cid',
]);

$approval = SupportApproval::create([
'support_case_id' => $case->id,
'requested_action' => 'user_profile_update',
'payload_json' => ['email' => 'u@example.com'],
'risk_level' => 'low',
'status' => 'approved',
'approved_by' => 'admin@matrixinternet.ie',
'notify_email' => 'notify@matrixinternet.ie',
'gmail_thread_id' => 'thread-1',
]);

$gmail = $this->createMock(GmailOutboundService::class);
$gmail->expects($this->once())
->method('sendPlainText')
->with(
'notify@matrixinternet.ie',
$this->stringContains('action completed'),
$this->stringContains('COMPLETED'),
'thread-1',
)
->willReturn(['id' => 'msg-1', 'thread_id' => 'thread-1']);

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

$payload = $svc->sendActionCompletion(
$case,
$approval,
'user_profile_update',
[
'ok' => true,
'result' => [
'before' => ['firstname' => 'A'],
'after' => ['firstname' => 'B'],
],
'errors' => [],
],
true,
);

$this->assertTrue($payload['ok']);
}
}
15 changes: 14 additions & 1 deletion tests/Unit/Support/SupportGmailPollQueryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,24 @@ public function test_builds_query_with_subject_prefix(): void
config()->set('support_gmail.query', 'newer_than:90d');

$this->assertSame(
'subject:codeweek-support newer_than:90d',
'(subject:codeweek-support OR subject:"[CW-SUPPORT") newer_than:90d',
SupportGmailPollQuery::resolve(),
);
}

public function test_includes_approval_reply_subject_for_poll(): void
{
config()->set('support_gmail.subject_prefix', 'codeweek-support');
config()->set('support_gmail.approval_subject_prefix', '[CW-SUPPORT');
config()->set('support_gmail.query', 'newer_than:90d');

$query = SupportGmailPollQuery::resolve();

$this->assertStringContainsString('codeweek-support', $query);
$this->assertStringContainsString('[CW-SUPPORT', $query);
$this->assertStringContainsString(' OR ', $query);
}

public function test_skips_duplicate_subject_filter_when_query_already_has_one(): void
{
config()->set('support_gmail.subject_prefix', 'codeweek-support');
Expand Down
Loading