diff --git a/app/Console/Commands/Support/EventParticipationCodeUpdateCommand.php b/app/Console/Commands/Support/EventParticipationCodeUpdateCommand.php new file mode 100644 index 000000000..403817cf7 --- /dev/null +++ b/app/Console/Commands/Support/EventParticipationCodeUpdateCommand.php @@ -0,0 +1,49 @@ +argument('old_code'); + $newCode = (string) $this->argument('new_code'); + $yearOption = $this->option('year'); + $monthOption = $this->option('month'); + $dryRun = (bool) $this->option('dry-run'); + + if ($yearOption === null || trim((string) $yearOption) === '') { + $payload = SupportJson::fail('event_participation_code_update', [ + 'old_code' => $oldCode, + 'new_code' => $newCode, + ], 'missing_year_scope'); + $this->output->writeln(json_encode($payload, JSON_UNESCAPED_SLASHES)); + + return self::FAILURE; + } + + $year = (int) $yearOption; + $month = ($monthOption !== null && trim((string) $monthOption) !== '') + ? (int) $monthOption + : null; + + $payload = $service->update($oldCode, $newCode, $year, $month, $dryRun); + $this->output->writeln(json_encode($payload, JSON_UNESCAPED_SLASHES)); + + return ($payload['ok'] ?? false) ? self::SUCCESS : self::FAILURE; + } +} diff --git a/app/Services/Support/Agents/CursorCliTriageProvider.php b/app/Services/Support/Agents/CursorCliTriageProvider.php index 4c8045352..12b16af91 100644 --- a/app/Services/Support/Agents/CursorCliTriageProvider.php +++ b/app/Services/Support/Agents/CursorCliTriageProvider.php @@ -200,6 +200,11 @@ private function artisanPromptBlock(): string and Super Organiser counts between two dates, use case_type "artisan_command" with "artisan_command_name": "support:certificate-kpi-report" and artisan_args {"start":"","end":"","--json":true}. Dates may be YYYY-MM-DD or DD.MM.YYYY. +For requests to swap a Code Week 4 All participation code on activities (e.g. change cw25-XXXX +to cw26-YYYY for events registered in June 2026), use case_type "artisan_command" with +"artisan_command_name": "support:event-participation-code-update" and artisan_args +{"old_code":"cw25-OLD","new_code":"cw26-NEW","--year":"2026","--month":"6","--json":true}. +Always include --year; include --month when the request scopes to a specific month. Prefer an allowlisted command and put its name in "artisan_command_name" with values in "artisan_args" (keys = argument/option names, e.g. {"email":"user@example.com","--firstname":"Ada"}). Allowlisted commands: diff --git a/app/Services/Support/Agents/TriageAgentService.php b/app/Services/Support/Agents/TriageAgentService.php index fd3446541..082d75e4d 100644 --- a/app/Services/Support/Agents/TriageAgentService.php +++ b/app/Services/Support/Agents/TriageAgentService.php @@ -4,6 +4,7 @@ use App\Models\Support\SupportCase; use App\Services\Support\CertificateKpiRequestParser; +use App\Services\Support\EventParticipationCodeRequestParser; use App\Services\Support\SupportEmailChangeRequestParser; use App\Services\Support\SupportProfileRequestParser; use App\Services\Support\SupportRoleRequestParser; @@ -17,6 +18,7 @@ public function __construct( private readonly CursorCliTriageProvider $aiProvider, private readonly SupportRoleRequestParser $roleParser, private readonly CertificateKpiRequestParser $certificateKpiParser, + private readonly EventParticipationCodeRequestParser $participationCodeParser, ) { } @@ -65,6 +67,7 @@ private function heuristicTriage(SupportCase $case): array $emailChange = $this->emailChangeParser->parse($rawText); $roleRequest = $this->roleParser->parse($rawText); $certificateKpi = $this->certificateKpiParser->parse($rawText); + $participationCode = $this->participationCodeParser->parse($rawText); $hasEmailChangeRequest = $emailChange['from_email'] !== null && $emailChange['to_email'] !== null; $hasRoleAddRequest = $roleRequest['role'] !== null && $roleRequest['operation'] === 'add' @@ -106,6 +109,12 @@ private function heuristicTriage(SupportCase $case): array } elseif (Str::contains($text, ['missing event', 'events missing'])) { $caseType = 'missing_events'; $runbook = 'missing_events'; + } elseif ($participationCode['looks_like_code_change_request'] + && $participationCode['old_code'] + && $participationCode['new_code'] + && $participationCode['year']) { + $caseType = 'artisan_command'; + $runbook = 'event_participation_code_update'; } elseif ($certificateKpi['looks_like_kpi_request'] && $certificateKpi['start'] && $certificateKpi['end']) { $caseType = 'artisan_command'; $runbook = 'certificate_kpi_report'; @@ -139,6 +148,9 @@ private function heuristicTriage(SupportCase $case): array if ($caseType === 'artisan_command' && $runbook === 'certificate_kpi_report') { $risk = 'low'; } + if ($caseType === 'artisan_command' && $runbook === 'event_participation_code_update') { + $risk = 'medium'; + } if ($hasRoleRequest && $this->roleLooksPrivileged((string) $roleRequest['role'])) { $risk = 'high'; } @@ -174,16 +186,16 @@ private function heuristicTriage(SupportCase $case): array 'change_summary' => null, 'change_area' => null, 'cursor_prompt' => null, - 'artisan_command_name' => ($caseType === 'artisan_command' && $runbook === 'certificate_kpi_report') - ? 'support:certificate-kpi-report' - : null, - 'artisan_args' => ($caseType === 'artisan_command' && $runbook === 'certificate_kpi_report') - ? [ - 'start' => (string) $certificateKpi['start'], - 'end' => (string) $certificateKpi['end'], - '--json' => true, - ] - : [], + 'artisan_command_name' => match ($runbook) { + 'certificate_kpi_report' => 'support:certificate-kpi-report', + 'event_participation_code_update' => 'support:event-participation-code-update', + default => null, + }, + 'artisan_args' => $this->artisanArgsForRunbook( + $runbook, + $certificateKpi, + $participationCode, + ), 'artisan_raw_command' => null, 'triage_source' => 'heuristic', ]; @@ -194,6 +206,38 @@ private function roleLooksPrivileged(string $roleName): bool return (bool) preg_match('/\b(admin|administrator|super|owner|root|staff)\b/i', $roleName); } + /** + * @param array $certificateKpi + * @param array $participationCode + * @return array + */ + private function artisanArgsForRunbook(string $runbook, array $certificateKpi, array $participationCode): array + { + if ($runbook === 'certificate_kpi_report') { + return [ + 'start' => (string) $certificateKpi['start'], + 'end' => (string) $certificateKpi['end'], + '--json' => true, + ]; + } + + if ($runbook === 'event_participation_code_update') { + $args = [ + 'old_code' => (string) $participationCode['old_code'], + 'new_code' => (string) $participationCode['new_code'], + '--year' => (string) $participationCode['year'], + '--json' => true, + ]; + if ($participationCode['month'] !== null) { + $args['--month'] = (string) $participationCode['month']; + } + + return $args; + } + + return []; + } + private function extractFirstEmail(string $text): ?string { $all = $this->extractAllEmails($text); diff --git a/app/Services/Support/Artisan/ArtisanActionRegistry.php b/app/Services/Support/Artisan/ArtisanActionRegistry.php index a9cab516d..23fc57497 100644 --- a/app/Services/Support/Artisan/ArtisanActionRegistry.php +++ b/app/Services/Support/Artisan/ArtisanActionRegistry.php @@ -63,6 +63,20 @@ public function all(): array '--json' => ['type' => 'flag'], ], ], + 'support:event-participation-code-update' => [ + 'description' => 'Change Code Week 4 All participation code on scoped events.', + 'is_write' => true, + 'supports_dry_run' => true, + 'arguments' => [ + 'old_code' => ['type' => 'participation_code', 'required' => true], + 'new_code' => ['type' => 'participation_code', 'required' => true], + ], + 'options' => [ + '--year' => ['type' => 'year'], + '--month' => ['type' => 'month'], + '--json' => ['type' => 'flag'], + ], + ], ]; } @@ -107,6 +121,9 @@ public function validateValue(string $type, mixed $value): bool || (bool) preg_match('/^\d{1,2}[.\/\-]\d{1,2}[.\/\-]\d{4}$/', trim($value)), // Event codes / identifiers: letters, digits, dot, dash, underscore. 'token' => (bool) preg_match('/^[A-Za-z0-9._-]{1,64}$/', trim($value)), + 'participation_code' => (bool) preg_match('/^cw\d{2}-[A-Za-z0-9]+$/', trim($value)), + 'year' => (bool) preg_match('/^(20\d{2}|2100)$/', trim($value)), + 'month' => (bool) preg_match('/^([1-9]|1[0-2])$/', trim($value)), // Names: no shell metacharacters or control chars. 'name' => (bool) preg_match('/^[^\x00-\x1f;|&$`<>\\\\"\']{1,80}$/u', trim($value)), default => false, diff --git a/app/Services/Support/EventParticipationCodeRequestParser.php b/app/Services/Support/EventParticipationCodeRequestParser.php new file mode 100644 index 000000000..52a0d555c --- /dev/null +++ b/app/Services/Support/EventParticipationCodeRequestParser.php @@ -0,0 +1,108 @@ + */ + private const MONTHS = [ + 'january' => 1, 'february' => 2, 'march' => 3, 'april' => 4, + 'may' => 5, 'june' => 6, 'july' => 7, 'august' => 8, + 'september' => 9, 'october' => 10, 'november' => 11, 'december' => 12, + ]; + + /** + * @return array{ + * looks_like_code_change_request: bool, + * old_code: ?string, + * new_code: ?string, + * year: ?int, + * month: ?int, + * } + */ + public function parse(string $text): array + { + $normalized = str_replace("\r\n", "\n", $text); + $lower = mb_strtolower($normalized); + + [$oldCode, $newCode] = $this->extractCodePair($normalized); + [$year, $month] = $this->extractPeriod($lower); + + $looksLike = $this->looksLikeCodeChangeRequest($lower, $oldCode, $newCode); + + return [ + 'looks_like_code_change_request' => $looksLike, + 'old_code' => $oldCode, + 'new_code' => $newCode, + 'year' => $year, + 'month' => $month, + ]; + } + + private function looksLikeCodeChangeRequest(string $lower, ?string $oldCode, ?string $newCode): bool + { + if ($oldCode === null || $newCode === null) { + return false; + } + + return str_contains($lower, 'code week') + || str_contains($lower, 'codeweek') + || str_contains($lower, 'participation code') + || str_contains($lower, 'change the code') + || str_contains($lower, 'change code') + || str_contains($lower, 'replace') + || str_contains($lower, 'with the one') + || (str_contains($lower, 'code') && str_contains($lower, 'activit')); + } + + /** + * @return array{0: ?string, 1: ?string} + */ + private function extractCodePair(string $text): array + { + $patterns = [ + '/change\s+(?:the\s+)?code\s+('.self::CODE_PATTERN.')\s+with\s+(?:the\s+one\s+)?('.self::CODE_PATTERN.')/iu', + '/change\s+(?:the\s+)?code\s+('.self::CODE_PATTERN.')\s+to\s+('.self::CODE_PATTERN.')/iu', + '/replace\s+('.self::CODE_PATTERN.')\s+with\s+('.self::CODE_PATTERN.')/iu', + '/('.self::CODE_PATTERN.')\s+(?:with|to)\s+(?:the\s+one\s+)?('.self::CODE_PATTERN.')/iu', + ]; + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $text, $m)) { + return [$m[1], $m[2]]; + } + } + + if (preg_match_all('/'.self::CODE_PATTERN.'/i', $text, $all) && count($all[0]) >= 2) { + return [$all[0][0], $all[0][1]]; + } + + return [null, null]; + } + + /** + * @return array{0: ?int, 1: ?int} + */ + private function extractPeriod(string $lower): array + { + foreach (self::MONTHS as $name => $number) { + if (preg_match('/\b'.preg_quote($name, '/').'\s+(\d{4})\b/', $lower, $m)) { + return [(int) $m[1], $number]; + } + if (preg_match('/\b(\d{4})\s+'.preg_quote($name, '/').'\b/', $lower, $m)) { + return [(int) $m[1], $number]; + } + } + + if (preg_match('/\b(?:registered|created)\s+in\s+(\d{4})\b/', $lower, $m)) { + return [(int) $m[1], null]; + } + + return [null, null]; + } +} diff --git a/app/Services/Support/EventParticipationCodeService.php b/app/Services/Support/EventParticipationCodeService.php new file mode 100644 index 000000000..887dbbdf4 --- /dev/null +++ b/app/Services/Support/EventParticipationCodeService.php @@ -0,0 +1,112 @@ + + */ + public function update( + string $oldCode, + string $newCode, + int $year, + ?int $month, + bool $dryRun, + ): array { + $tool = 'event_participation_code_update'; + $input = [ + 'old_code' => $oldCode, + 'new_code' => $newCode, + 'year' => $year, + 'month' => $month, + 'dry_run' => $dryRun, + ]; + + $oldCode = trim($oldCode); + $newCode = trim($newCode); + + if (!$this->isValidCode($oldCode) || !$this->isValidCode($newCode)) { + return SupportJson::fail($tool, $input, 'invalid_participation_code'); + } + + if ($oldCode === $newCode) { + return SupportJson::fail($tool, $input, 'codes_must_differ'); + } + + if ($year < 2000 || $year > 2100) { + return SupportJson::fail($tool, $input, 'invalid_year'); + } + + if ($month !== null && ($month < 1 || $month > 12)) { + return SupportJson::fail($tool, $input, 'invalid_month'); + } + + $query = $this->scopedQuery($oldCode, $year, $month); + $events = (clone $query) + ->orderBy('created_at') + ->get(['id', 'title', 'status', 'created_at', 'codeweek_for_all_participation_code']); + + if ($events->isEmpty()) { + return SupportJson::fail($tool, $input, 'no_matching_events'); + } + + $preview = $events->map(static fn (Event $event) => [ + 'id' => $event->id, + 'title' => $event->title, + 'status' => $event->status, + 'created_at' => $event->created_at?->toDateTimeString(), + 'codeweek_for_all_participation_code' => $event->codeweek_for_all_participation_code, + ])->values()->all(); + + if ($dryRun) { + return SupportJson::ok($tool, $input, [ + 'dry_run' => true, + 'matched_count' => $events->count(), + 'events' => $preview, + 'would_update_to' => $newCode, + ]); + } + + $updated = $query->update([ + 'codeweek_for_all_participation_code' => $newCode, + 'updated_at' => now(), + ]); + + return SupportJson::ok($tool, $input, [ + 'dry_run' => false, + 'updated_count' => $updated, + 'events' => $preview, + 'new_code' => $newCode, + ]); + } + + private function isValidCode(string $code): bool + { + return (bool) preg_match(self::CODE_PATTERN, $code); + } + + /** + * @return Builder + */ + private function scopedQuery(string $oldCode, int $year, ?int $month): Builder + { + $query = Event::query() + ->where('codeweek_for_all_participation_code', $oldCode) + ->whereYear('created_at', $year); + + if ($month !== null) { + $query->whereMonth('created_at', $month); + } + + return $query; + } +} diff --git a/tests/Unit/Support/ArtisanCommandRunnerTest.php b/tests/Unit/Support/ArtisanCommandRunnerTest.php index 53260c71e..7d88aa678 100644 --- a/tests/Unit/Support/ArtisanCommandRunnerTest.php +++ b/tests/Unit/Support/ArtisanCommandRunnerTest.php @@ -51,6 +51,26 @@ public function test_registry_command_builds_validated_tokens(): void ); } + public function test_registry_command_builds_validated_tokens_for_participation_code_update(): void + { + $plan = $this->runner->plan('support:event-participation-code-update', [ + 'old_code' => 'cw25-E6CDg', + 'new_code' => 'cw26-wNc6o', + '--year' => '2026', + '--month' => '6', + '--json' => true, + ]); + + $this->assertTrue($plan['ok']); + $this->assertSame('registry', $plan['result']['mode']); + $this->assertTrue($plan['result']['is_write']); + $this->assertTrue($plan['result']['supports_dry_run']); + $this->assertSame( + ['support:event-participation-code-update', 'cw25-E6CDg', 'cw26-wNc6o', '--year=2026', '--month=6', '--json'], + $plan['result']['tokens'] + ); + } + public function test_registry_command_builds_validated_tokens_for_user_restore(): void { $plan = $this->runner->plan('support:user-restore', ['email' => 'user@example.com', '--json' => true]); diff --git a/tests/Unit/Support/EventParticipationCodeRequestParserTest.php b/tests/Unit/Support/EventParticipationCodeRequestParserTest.php new file mode 100644 index 000000000..1d523d80f --- /dev/null +++ b/tests/Unit/Support/EventParticipationCodeRequestParserTest.php @@ -0,0 +1,35 @@ +parser = new EventParticipationCodeRequestParser(); + } + + public function test_parses_rosa_montalbano_style_request(): void + { + $text = <<<'TEXT' +Hi, +I'd like to know if it is possible to change the code cw25-E6CDg with the one cw26-wNc6o for the activities registered in June 2026 +Best regards, +Rosa Montalbano +TEXT; + + $parsed = $this->parser->parse($text); + + $this->assertTrue($parsed['looks_like_code_change_request']); + $this->assertSame('cw25-E6CDg', $parsed['old_code']); + $this->assertSame('cw26-wNc6o', $parsed['new_code']); + $this->assertSame(2026, $parsed['year']); + $this->assertSame(6, $parsed['month']); + } +} diff --git a/tests/Unit/Support/EventParticipationCodeServiceTest.php b/tests/Unit/Support/EventParticipationCodeServiceTest.php new file mode 100644 index 000000000..7b466a824 --- /dev/null +++ b/tests/Unit/Support/EventParticipationCodeServiceTest.php @@ -0,0 +1,73 @@ +create(); + + Event::factory()->create([ + 'creator_id' => $user->id, + 'codeweek_for_all_participation_code' => 'cw25-E6CDg', + 'created_at' => '2026-06-10 10:00:00', + ]); + + Event::factory()->create([ + 'creator_id' => $user->id, + 'codeweek_for_all_participation_code' => 'cw25-E6CDg', + 'created_at' => '2026-05-10 10:00:00', + ]); + + $result = app(EventParticipationCodeService::class)->update( + 'cw25-E6CDg', + 'cw26-wNc6o', + 2026, + 6, + dryRun: true, + ); + + $this->assertTrue($result['ok']); + $this->assertSame(1, $result['result']['matched_count']); + $this->assertSame('cw26-wNc6o', $result['result']['would_update_to']); + } + + public function test_update_changes_only_scoped_events(): void + { + $user = User::factory()->create(); + + $june = Event::factory()->create([ + 'creator_id' => $user->id, + 'codeweek_for_all_participation_code' => 'cw25-E6CDg', + 'created_at' => '2026-06-10 10:00:00', + ]); + + $may = Event::factory()->create([ + 'creator_id' => $user->id, + 'codeweek_for_all_participation_code' => 'cw25-E6CDg', + 'created_at' => '2026-05-10 10:00:00', + ]); + + $result = app(EventParticipationCodeService::class)->update( + 'cw25-E6CDg', + 'cw26-wNc6o', + 2026, + 6, + dryRun: false, + ); + + $this->assertTrue($result['ok']); + $this->assertSame(1, $result['result']['updated_count']); + $this->assertSame('cw26-wNc6o', $june->fresh()->codeweek_for_all_participation_code); + $this->assertSame('cw25-E6CDg', $may->fresh()->codeweek_for_all_participation_code); + } +}