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
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace App\Console\Commands\Support;

use App\Services\Support\EventParticipationCodeService;
use App\Services\Support\SupportJson;
use Illuminate\Console\Command;

class EventParticipationCodeUpdateCommand extends Command
{
protected $signature = 'support:event-participation-code-update
{old_code : Current Code Week 4 All participation code}
{new_code : Replacement participation code}
{--year= : Only events created in this year (required)}
{--month= : Only events created in this month (1-12)}
{--dry-run : Preview affected events without updating}
{--json}';

protected $description = 'Support tool: change Code Week 4 All participation code on scoped events';

public function handle(EventParticipationCodeService $service): int
{
$oldCode = (string) $this->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;
}
}
5 changes: 5 additions & 0 deletions app/Services/Support/Agents/CursorCliTriageProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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":"<start date>","end":"<end date>","--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:
Expand Down
64 changes: 54 additions & 10 deletions app/Services/Support/Agents/TriageAgentService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,6 +18,7 @@ public function __construct(
private readonly CursorCliTriageProvider $aiProvider,
private readonly SupportRoleRequestParser $roleParser,
private readonly CertificateKpiRequestParser $certificateKpiParser,
private readonly EventParticipationCodeRequestParser $participationCodeParser,
) {
}

Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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';
}
Expand Down Expand Up @@ -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',
];
Expand All @@ -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<string, mixed> $certificateKpi
* @param array<string, mixed> $participationCode
* @return array<string, mixed>
*/
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);
Expand Down
17 changes: 17 additions & 0 deletions app/Services/Support/Artisan/ArtisanActionRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
],
],
];
}

Expand Down Expand Up @@ -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,
Expand Down
108 changes: 108 additions & 0 deletions app/Services/Support/EventParticipationCodeRequestParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

namespace App\Services\Support;

/**
* Extract Code Week 4 All participation code swap requests from support email text.
*/
final class EventParticipationCodeRequestParser
{
private const CODE_PATTERN = 'cw\d{2}-[A-Za-z0-9]+';

/** @var array<string, int> */
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];
}
}
Loading
Loading