Skip to content

Commit bf4edd0

Browse files
Add support bot handling for scoped participation code swaps.
Lets staff preview and approve changing Code Week 4 All codes on events registered in a given year/month, matching requests like Rosa Montalbano's. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7eb938b commit bf4edd0

9 files changed

Lines changed: 473 additions & 10 deletions
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
namespace App\Console\Commands\Support;
4+
5+
use App\Services\Support\EventParticipationCodeService;
6+
use App\Services\Support\SupportJson;
7+
use Illuminate\Console\Command;
8+
9+
class EventParticipationCodeUpdateCommand extends Command
10+
{
11+
protected $signature = 'support:event-participation-code-update
12+
{old_code : Current Code Week 4 All participation code}
13+
{new_code : Replacement participation code}
14+
{--year= : Only events created in this year (required)}
15+
{--month= : Only events created in this month (1-12)}
16+
{--dry-run : Preview affected events without updating}
17+
{--json}';
18+
19+
protected $description = 'Support tool: change Code Week 4 All participation code on scoped events';
20+
21+
public function handle(EventParticipationCodeService $service): int
22+
{
23+
$oldCode = (string) $this->argument('old_code');
24+
$newCode = (string) $this->argument('new_code');
25+
$yearOption = $this->option('year');
26+
$monthOption = $this->option('month');
27+
$dryRun = (bool) $this->option('dry-run');
28+
29+
if ($yearOption === null || trim((string) $yearOption) === '') {
30+
$payload = SupportJson::fail('event_participation_code_update', [
31+
'old_code' => $oldCode,
32+
'new_code' => $newCode,
33+
], 'missing_year_scope');
34+
$this->output->writeln(json_encode($payload, JSON_UNESCAPED_SLASHES));
35+
36+
return self::FAILURE;
37+
}
38+
39+
$year = (int) $yearOption;
40+
$month = ($monthOption !== null && trim((string) $monthOption) !== '')
41+
? (int) $monthOption
42+
: null;
43+
44+
$payload = $service->update($oldCode, $newCode, $year, $month, $dryRun);
45+
$this->output->writeln(json_encode($payload, JSON_UNESCAPED_SLASHES));
46+
47+
return ($payload['ok'] ?? false) ? self::SUCCESS : self::FAILURE;
48+
}
49+
}

app/Services/Support/Agents/CursorCliTriageProvider.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,11 @@ private function artisanPromptBlock(): string
200200
and Super Organiser counts between two dates, use case_type "artisan_command" with
201201
"artisan_command_name": "support:certificate-kpi-report" and artisan_args
202202
{"start":"<start date>","end":"<end date>","--json":true}. Dates may be YYYY-MM-DD or DD.MM.YYYY.
203+
For requests to swap a Code Week 4 All participation code on activities (e.g. change cw25-XXXX
204+
to cw26-YYYY for events registered in June 2026), use case_type "artisan_command" with
205+
"artisan_command_name": "support:event-participation-code-update" and artisan_args
206+
{"old_code":"cw25-OLD","new_code":"cw26-NEW","--year":"2026","--month":"6","--json":true}.
207+
Always include --year; include --month when the request scopes to a specific month.
203208
Prefer an allowlisted command and put its name in "artisan_command_name" with values in "artisan_args"
204209
(keys = argument/option names, e.g. {"email":"user@example.com","--firstname":"Ada"}).
205210
Allowlisted commands:

app/Services/Support/Agents/TriageAgentService.php

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use App\Models\Support\SupportCase;
66
use App\Services\Support\CertificateKpiRequestParser;
7+
use App\Services\Support\EventParticipationCodeRequestParser;
78
use App\Services\Support\SupportEmailChangeRequestParser;
89
use App\Services\Support\SupportProfileRequestParser;
910
use App\Services\Support\SupportRoleRequestParser;
@@ -17,6 +18,7 @@ public function __construct(
1718
private readonly CursorCliTriageProvider $aiProvider,
1819
private readonly SupportRoleRequestParser $roleParser,
1920
private readonly CertificateKpiRequestParser $certificateKpiParser,
21+
private readonly EventParticipationCodeRequestParser $participationCodeParser,
2022
) {
2123
}
2224

@@ -65,6 +67,7 @@ private function heuristicTriage(SupportCase $case): array
6567
$emailChange = $this->emailChangeParser->parse($rawText);
6668
$roleRequest = $this->roleParser->parse($rawText);
6769
$certificateKpi = $this->certificateKpiParser->parse($rawText);
70+
$participationCode = $this->participationCodeParser->parse($rawText);
6871
$hasEmailChangeRequest = $emailChange['from_email'] !== null && $emailChange['to_email'] !== null;
6972
$hasRoleAddRequest = $roleRequest['role'] !== null
7073
&& $roleRequest['operation'] === 'add'
@@ -106,6 +109,12 @@ private function heuristicTriage(SupportCase $case): array
106109
} elseif (Str::contains($text, ['missing event', 'events missing'])) {
107110
$caseType = 'missing_events';
108111
$runbook = 'missing_events';
112+
} elseif ($participationCode['looks_like_code_change_request']
113+
&& $participationCode['old_code']
114+
&& $participationCode['new_code']
115+
&& $participationCode['year']) {
116+
$caseType = 'artisan_command';
117+
$runbook = 'event_participation_code_update';
109118
} elseif ($certificateKpi['looks_like_kpi_request'] && $certificateKpi['start'] && $certificateKpi['end']) {
110119
$caseType = 'artisan_command';
111120
$runbook = 'certificate_kpi_report';
@@ -139,6 +148,9 @@ private function heuristicTriage(SupportCase $case): array
139148
if ($caseType === 'artisan_command' && $runbook === 'certificate_kpi_report') {
140149
$risk = 'low';
141150
}
151+
if ($caseType === 'artisan_command' && $runbook === 'event_participation_code_update') {
152+
$risk = 'medium';
153+
}
142154
if ($hasRoleRequest && $this->roleLooksPrivileged((string) $roleRequest['role'])) {
143155
$risk = 'high';
144156
}
@@ -174,16 +186,16 @@ private function heuristicTriage(SupportCase $case): array
174186
'change_summary' => null,
175187
'change_area' => null,
176188
'cursor_prompt' => null,
177-
'artisan_command_name' => ($caseType === 'artisan_command' && $runbook === 'certificate_kpi_report')
178-
? 'support:certificate-kpi-report'
179-
: null,
180-
'artisan_args' => ($caseType === 'artisan_command' && $runbook === 'certificate_kpi_report')
181-
? [
182-
'start' => (string) $certificateKpi['start'],
183-
'end' => (string) $certificateKpi['end'],
184-
'--json' => true,
185-
]
186-
: [],
189+
'artisan_command_name' => match ($runbook) {
190+
'certificate_kpi_report' => 'support:certificate-kpi-report',
191+
'event_participation_code_update' => 'support:event-participation-code-update',
192+
default => null,
193+
},
194+
'artisan_args' => $this->artisanArgsForRunbook(
195+
$runbook,
196+
$certificateKpi,
197+
$participationCode,
198+
),
187199
'artisan_raw_command' => null,
188200
'triage_source' => 'heuristic',
189201
];
@@ -194,6 +206,38 @@ private function roleLooksPrivileged(string $roleName): bool
194206
return (bool) preg_match('/\b(admin|administrator|super|owner|root|staff)\b/i', $roleName);
195207
}
196208

209+
/**
210+
* @param array<string, mixed> $certificateKpi
211+
* @param array<string, mixed> $participationCode
212+
* @return array<string, mixed>
213+
*/
214+
private function artisanArgsForRunbook(string $runbook, array $certificateKpi, array $participationCode): array
215+
{
216+
if ($runbook === 'certificate_kpi_report') {
217+
return [
218+
'start' => (string) $certificateKpi['start'],
219+
'end' => (string) $certificateKpi['end'],
220+
'--json' => true,
221+
];
222+
}
223+
224+
if ($runbook === 'event_participation_code_update') {
225+
$args = [
226+
'old_code' => (string) $participationCode['old_code'],
227+
'new_code' => (string) $participationCode['new_code'],
228+
'--year' => (string) $participationCode['year'],
229+
'--json' => true,
230+
];
231+
if ($participationCode['month'] !== null) {
232+
$args['--month'] = (string) $participationCode['month'];
233+
}
234+
235+
return $args;
236+
}
237+
238+
return [];
239+
}
240+
197241
private function extractFirstEmail(string $text): ?string
198242
{
199243
$all = $this->extractAllEmails($text);

app/Services/Support/Artisan/ArtisanActionRegistry.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,20 @@ public function all(): array
6363
'--json' => ['type' => 'flag'],
6464
],
6565
],
66+
'support:event-participation-code-update' => [
67+
'description' => 'Change Code Week 4 All participation code on scoped events.',
68+
'is_write' => true,
69+
'supports_dry_run' => true,
70+
'arguments' => [
71+
'old_code' => ['type' => 'participation_code', 'required' => true],
72+
'new_code' => ['type' => 'participation_code', 'required' => true],
73+
],
74+
'options' => [
75+
'--year' => ['type' => 'year'],
76+
'--month' => ['type' => 'month'],
77+
'--json' => ['type' => 'flag'],
78+
],
79+
],
6680
];
6781
}
6882

@@ -107,6 +121,9 @@ public function validateValue(string $type, mixed $value): bool
107121
|| (bool) preg_match('/^\d{1,2}[.\/\-]\d{1,2}[.\/\-]\d{4}$/', trim($value)),
108122
// Event codes / identifiers: letters, digits, dot, dash, underscore.
109123
'token' => (bool) preg_match('/^[A-Za-z0-9._-]{1,64}$/', trim($value)),
124+
'participation_code' => (bool) preg_match('/^cw\d{2}-[A-Za-z0-9]+$/', trim($value)),
125+
'year' => (bool) preg_match('/^(20\d{2}|2100)$/', trim($value)),
126+
'month' => (bool) preg_match('/^([1-9]|1[0-2])$/', trim($value)),
110127
// Names: no shell metacharacters or control chars.
111128
'name' => (bool) preg_match('/^[^\x00-\x1f;|&$`<>\\\\"\']{1,80}$/u', trim($value)),
112129
default => false,
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
namespace App\Services\Support;
4+
5+
/**
6+
* Extract Code Week 4 All participation code swap requests from support email text.
7+
*/
8+
final class EventParticipationCodeRequestParser
9+
{
10+
private const CODE_PATTERN = 'cw\d{2}-[A-Za-z0-9]+';
11+
12+
/** @var array<string, int> */
13+
private const MONTHS = [
14+
'january' => 1, 'february' => 2, 'march' => 3, 'april' => 4,
15+
'may' => 5, 'june' => 6, 'july' => 7, 'august' => 8,
16+
'september' => 9, 'october' => 10, 'november' => 11, 'december' => 12,
17+
];
18+
19+
/**
20+
* @return array{
21+
* looks_like_code_change_request: bool,
22+
* old_code: ?string,
23+
* new_code: ?string,
24+
* year: ?int,
25+
* month: ?int,
26+
* }
27+
*/
28+
public function parse(string $text): array
29+
{
30+
$normalized = str_replace("\r\n", "\n", $text);
31+
$lower = mb_strtolower($normalized);
32+
33+
[$oldCode, $newCode] = $this->extractCodePair($normalized);
34+
[$year, $month] = $this->extractPeriod($lower);
35+
36+
$looksLike = $this->looksLikeCodeChangeRequest($lower, $oldCode, $newCode);
37+
38+
return [
39+
'looks_like_code_change_request' => $looksLike,
40+
'old_code' => $oldCode,
41+
'new_code' => $newCode,
42+
'year' => $year,
43+
'month' => $month,
44+
];
45+
}
46+
47+
private function looksLikeCodeChangeRequest(string $lower, ?string $oldCode, ?string $newCode): bool
48+
{
49+
if ($oldCode === null || $newCode === null) {
50+
return false;
51+
}
52+
53+
return str_contains($lower, 'code week')
54+
|| str_contains($lower, 'codeweek')
55+
|| str_contains($lower, 'participation code')
56+
|| str_contains($lower, 'change the code')
57+
|| str_contains($lower, 'change code')
58+
|| str_contains($lower, 'replace')
59+
|| str_contains($lower, 'with the one')
60+
|| (str_contains($lower, 'code') && str_contains($lower, 'activit'));
61+
}
62+
63+
/**
64+
* @return array{0: ?string, 1: ?string}
65+
*/
66+
private function extractCodePair(string $text): array
67+
{
68+
$patterns = [
69+
'/change\s+(?:the\s+)?code\s+('.self::CODE_PATTERN.')\s+with\s+(?:the\s+one\s+)?('.self::CODE_PATTERN.')/iu',
70+
'/change\s+(?:the\s+)?code\s+('.self::CODE_PATTERN.')\s+to\s+('.self::CODE_PATTERN.')/iu',
71+
'/replace\s+('.self::CODE_PATTERN.')\s+with\s+('.self::CODE_PATTERN.')/iu',
72+
'/('.self::CODE_PATTERN.')\s+(?:with|to)\s+(?:the\s+one\s+)?('.self::CODE_PATTERN.')/iu',
73+
];
74+
75+
foreach ($patterns as $pattern) {
76+
if (preg_match($pattern, $text, $m)) {
77+
return [$m[1], $m[2]];
78+
}
79+
}
80+
81+
if (preg_match_all('/'.self::CODE_PATTERN.'/i', $text, $all) && count($all[0]) >= 2) {
82+
return [$all[0][0], $all[0][1]];
83+
}
84+
85+
return [null, null];
86+
}
87+
88+
/**
89+
* @return array{0: ?int, 1: ?int}
90+
*/
91+
private function extractPeriod(string $lower): array
92+
{
93+
foreach (self::MONTHS as $name => $number) {
94+
if (preg_match('/\b'.preg_quote($name, '/').'\s+(\d{4})\b/', $lower, $m)) {
95+
return [(int) $m[1], $number];
96+
}
97+
if (preg_match('/\b(\d{4})\s+'.preg_quote($name, '/').'\b/', $lower, $m)) {
98+
return [(int) $m[1], $number];
99+
}
100+
}
101+
102+
if (preg_match('/\b(?:registered|created)\s+in\s+(\d{4})\b/', $lower, $m)) {
103+
return [(int) $m[1], null];
104+
}
105+
106+
return [null, null];
107+
}
108+
}

0 commit comments

Comments
 (0)