From 98f8da6978c70529060844a02f5bbd23f0d6f15c Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Fri, 10 Jul 2026 10:41:28 +0100 Subject: [PATCH] Add support bot certificate KPI reporting for date ranges. Lets staff request Organiser, Excellence, and Super Organiser counts via email by running a read-only allowlisted artisan command. Co-authored-by: Cursor --- .../Support/CertificateKpiReportCommand.php | 37 ++++ .../Agents/CursorCliTriageProvider.php | 7 +- .../Support/Agents/TriageAgentService.php | 20 ++ .../Support/Artisan/ArtisanActionRegistry.php | 12 ++ .../Support/CertificateKpiReportService.php | 180 ++++++++++++++++++ .../Support/CertificateKpiRequestParser.php | 67 +++++++ .../Unit/Support/ArtisanCommandRunnerTest.php | 17 ++ .../CertificateKpiReportServiceTest.php | 55 ++++++ .../CertificateKpiRequestParserTest.php | 43 +++++ 9 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/Support/CertificateKpiReportCommand.php create mode 100644 app/Services/Support/CertificateKpiReportService.php create mode 100644 app/Services/Support/CertificateKpiRequestParser.php create mode 100644 tests/Unit/Support/CertificateKpiReportServiceTest.php create mode 100644 tests/Unit/Support/CertificateKpiRequestParserTest.php diff --git a/app/Console/Commands/Support/CertificateKpiReportCommand.php b/app/Console/Commands/Support/CertificateKpiReportCommand.php new file mode 100644 index 000000000..63b3a250e --- /dev/null +++ b/app/Console/Commands/Support/CertificateKpiReportCommand.php @@ -0,0 +1,37 @@ +argument('start'); + $end = (string) $this->argument('end'); + $input = ['start' => $start, 'end' => $end]; + + try { + $result = $service->report($start, $end); + $payload = SupportJson::ok('certificate_kpi_report', $input, $result); + } catch (\InvalidArgumentException $e) { + $payload = SupportJson::fail('certificate_kpi_report', $input, $e->getMessage()); + } catch (\Throwable $e) { + $payload = SupportJson::fail('certificate_kpi_report', $input, $e->getMessage()); + } + + $this->output->writeln(json_encode($payload, JSON_UNESCAPED_SLASHES)); + + return $payload['ok'] ? self::SUCCESS : self::FAILURE; + } +} diff --git a/app/Services/Support/Agents/CursorCliTriageProvider.php b/app/Services/Support/Agents/CursorCliTriageProvider.php index eb6e0771c..4c8045352 100644 --- a/app/Services/Support/Agents/CursorCliTriageProvider.php +++ b/app/Services/Support/Agents/CursorCliTriageProvider.php @@ -194,7 +194,12 @@ private function artisanPromptBlock(): string return <<","end":"","--json":true}. Dates may be YYYY-MM-DD or DD.MM.YYYY. 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 4d792db62..fd3446541 100644 --- a/app/Services/Support/Agents/TriageAgentService.php +++ b/app/Services/Support/Agents/TriageAgentService.php @@ -3,6 +3,7 @@ namespace App\Services\Support\Agents; use App\Models\Support\SupportCase; +use App\Services\Support\CertificateKpiRequestParser; use App\Services\Support\SupportEmailChangeRequestParser; use App\Services\Support\SupportProfileRequestParser; use App\Services\Support\SupportRoleRequestParser; @@ -15,6 +16,7 @@ public function __construct( private readonly SupportEmailChangeRequestParser $emailChangeParser, private readonly CursorCliTriageProvider $aiProvider, private readonly SupportRoleRequestParser $roleParser, + private readonly CertificateKpiRequestParser $certificateKpiParser, ) { } @@ -62,6 +64,7 @@ private function heuristicTriage(SupportCase $case): array $profile = $this->profileParser->parse($rawText); $emailChange = $this->emailChangeParser->parse($rawText); $roleRequest = $this->roleParser->parse($rawText); + $certificateKpi = $this->certificateKpiParser->parse($rawText); $hasEmailChangeRequest = $emailChange['from_email'] !== null && $emailChange['to_email'] !== null; $hasRoleAddRequest = $roleRequest['role'] !== null && $roleRequest['operation'] === 'add' @@ -103,6 +106,9 @@ private function heuristicTriage(SupportCase $case): array } elseif (Str::contains($text, ['missing event', 'events missing'])) { $caseType = 'missing_events'; $runbook = 'missing_events'; + } elseif ($certificateKpi['looks_like_kpi_request'] && $certificateKpi['start'] && $certificateKpi['end']) { + $caseType = 'artisan_command'; + $runbook = 'certificate_kpi_report'; } elseif (Str::contains($text, ['certificate', 'cert'])) { $caseType = 'certificate_issue'; $runbook = 'missing_certificate'; @@ -130,6 +136,9 @@ private function heuristicTriage(SupportCase $case): array if ($caseType === 'email_change') { $risk = 'medium'; } + if ($caseType === 'artisan_command' && $runbook === 'certificate_kpi_report') { + $risk = 'low'; + } if ($hasRoleRequest && $this->roleLooksPrivileged((string) $roleRequest['role'])) { $risk = 'high'; } @@ -165,6 +174,17 @@ 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_raw_command' => null, 'triage_source' => 'heuristic', ]; } diff --git a/app/Services/Support/Artisan/ArtisanActionRegistry.php b/app/Services/Support/Artisan/ArtisanActionRegistry.php index f056bc9cc..a9cab516d 100644 --- a/app/Services/Support/Artisan/ArtisanActionRegistry.php +++ b/app/Services/Support/Artisan/ArtisanActionRegistry.php @@ -35,6 +35,16 @@ public function all(): array 'arguments' => ['identifier' => ['type' => 'token', 'required' => true]], 'options' => ['--json' => ['type' => 'flag']], ], + 'support:certificate-kpi-report' => [ + 'description' => 'Certificate KPI counts for a date window (Organiser, Excellence, Super Organiser).', + 'is_write' => false, + 'supports_dry_run' => false, + 'arguments' => [ + 'start' => ['type' => 'date', 'required' => true], + 'end' => ['type' => 'date', 'required' => true], + ], + 'options' => ['--json' => ['type' => 'flag']], + ], 'support:user-restore' => [ 'description' => 'Restore a soft-deleted user.', 'is_write' => true, @@ -93,6 +103,8 @@ public function validateValue(string $type, mixed $value): bool return match ($type) { 'email' => filter_var(trim($value), FILTER_VALIDATE_EMAIL) !== false, + 'date' => (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', trim($value)) + || (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)), // Names: no shell metacharacters or control chars. diff --git a/app/Services/Support/CertificateKpiReportService.php b/app/Services/Support/CertificateKpiReportService.php new file mode 100644 index 000000000..2ecd23247 --- /dev/null +++ b/app/Services/Support/CertificateKpiReportService.php @@ -0,0 +1,180 @@ + + */ + public function report(string $startInput, string $endInput): array + { + $start = $this->normalizeBoundary($startInput, false); + $end = $this->normalizeBoundary($endInput, true); + + if ($start->greaterThan($end)) { + throw new \InvalidArgumentException('start_date_after_end_date'); + } + + $organiser = $this->countOrganiserCertificates($start, $end); + $excellence = $this->countExcellenceCertificates($start, $end); + $superOrganiser = $this->countSuperOrganiserCertificates($start, $end); + + return [ + 'window' => [ + 'start' => $start->toDateTimeString(), + 'end' => $end->toDateTimeString(), + 'start_input' => trim($startInput), + 'end_input' => trim($endInput), + ], + 'counts' => [ + 'organiser' => $organiser, + 'excellence' => $excellence, + 'super_organiser' => $superOrganiser, + 'total' => $organiser + $excellence + $superOrganiser, + ], + 'monthly' => [ + 'organiser' => $this->monthlyOrganiser($start, $end), + 'excellence' => $this->monthlyExcellence($start, $end), + 'super_organiser' => $this->monthlySuperOrganiser($start, $end), + ], + 'labels' => [ + 'organiser' => 'Organiser (reported activity certificates)', + 'excellence' => 'Excellence', + 'super_organiser' => 'Super Organiser', + ], + ]; + } + + public function normalizeBoundary(string $input, bool $endOfDay): Carbon + { + $value = trim($input); + if ($value === '') { + throw new \InvalidArgumentException('empty_date'); + } + + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { + $date = Carbon::createFromFormat('Y-m-d', $value); + + return $endOfDay ? $date->endOfDay() : $date->startOfDay(); + } + + if (preg_match('/^(\d{1,2})[.\/\-](\d{1,2})[.\/\-](\d{4})$/', $value, $m)) { + $date = Carbon::createFromFormat('Y-m-d', sprintf('%04d-%02d-%02d', (int) $m[3], (int) $m[2], (int) $m[1])); + + return $endOfDay ? $date->endOfDay() : $date->startOfDay(); + } + + throw new \InvalidArgumentException('invalid_date_format'); + } + + private function countOrganiserCertificates(Carbon $start, Carbon $end): int + { + return Event::query() + ->where('status', 'APPROVED') + ->whereNotNull('reported_at') + ->whereNotNull('certificate_url') + ->whereBetween('reported_at', [$start, $end]) + ->count(); + } + + private function countExcellenceCertificates(Carbon $start, Carbon $end): int + { + return Excellence::query() + ->where('type', 'Excellence') + ->whereNotNull('certificate_url') + ->whereBetween('created_at', [$start, $end]) + ->count(); + } + + private function countSuperOrganiserCertificates(Carbon $start, Carbon $end): int + { + return Excellence::query() + ->where('type', 'SuperOrganiser') + ->whereNotNull('certificate_url') + ->whereBetween('created_at', [$start, $end]) + ->count(); + } + + /** + * @return array + */ + private function monthlyOrganiser(Carbon $start, Carbon $end): array + { + return $this->monthlyCounts( + Event::query() + ->where('status', 'APPROVED') + ->whereNotNull('reported_at') + ->whereNotNull('certificate_url') + ->whereBetween('reported_at', [$start, $end]), + 'reported_at' + ); + } + + /** + * @return array + */ + private function monthlyExcellence(Carbon $start, Carbon $end): array + { + return $this->monthlyCounts( + Excellence::query() + ->where('type', 'Excellence') + ->whereNotNull('certificate_url') + ->whereBetween('created_at', [$start, $end]), + 'created_at' + ); + } + + /** + * @return array + */ + private function monthlySuperOrganiser(Carbon $start, Carbon $end): array + { + return $this->monthlyCounts( + Excellence::query() + ->where('type', 'SuperOrganiser') + ->whereNotNull('certificate_url') + ->whereBetween('created_at', [$start, $end]), + 'created_at' + ); + } + + /** + * @param \Illuminate\Database\Eloquent\Builder<\Illuminate\Database\Eloquent\Model> $query + * @return array + */ + private function monthlyCounts($query, string $column): array + { + $driver = DB::connection()->getDriverName(); + $expr = $driver === 'sqlite' + ? "strftime('%Y-%m', {$column})" + : "DATE_FORMAT({$column}, '%Y-%m')"; + + $rows = (clone $query) + ->selectRaw("{$expr} as yyyymm, COUNT(*) as cnt") + ->groupBy('yyyymm') + ->orderBy('yyyymm') + ->get(); + + $out = []; + foreach ($rows as $row) { + if (!empty($row->yyyymm)) { + $out[(string) $row->yyyymm] = (int) $row->cnt; + } + } + + return $out; + } +} diff --git a/app/Services/Support/CertificateKpiRequestParser.php b/app/Services/Support/CertificateKpiRequestParser.php new file mode 100644 index 000000000..a7c22834d --- /dev/null +++ b/app/Services/Support/CertificateKpiRequestParser.php @@ -0,0 +1,67 @@ +looksLikeKpiRequest($lower); + [$start, $end] = $this->extractDateRange($normalized); + + return [ + 'start' => $start, + 'end' => $end, + 'looks_like_kpi_request' => $looksLikeKpi, + ]; + } + + private function looksLikeKpiRequest(string $lower): bool + { + if (!str_contains($lower, 'certificate') && !str_contains($lower, 'cert')) { + return false; + } + + return str_contains($lower, 'kpi') + || str_contains($lower, 'dashboard') + || str_contains($lower, 'database') + || str_contains($lower, 'statistics') + || str_contains($lower, 'stats') + || str_contains($lower, 'provide the information') + || str_contains($lower, 'between') + || str_contains($lower, 'super organiser') + || str_contains($lower, 'excellence'); + } + + /** + * @return array{0: ?string, 1: ?string} + */ + private function extractDateRange(string $text): array + { + $patterns = [ + '/between\s+('.self::DATE_PATTERN.')\s+(?:and|to)\s+('.self::DATE_PATTERN.')/iu', + '/from\s+('.self::DATE_PATTERN.')\s+(?:to|until|-)\s+('.self::DATE_PATTERN.')/iu', + '/('.self::DATE_PATTERN.')\s*(?:to|–|—|-)\s*('.self::DATE_PATTERN.')/u', + '/data\s+between\s+('.self::DATE_PATTERN.')\s+(?:and|to)\s+('.self::DATE_PATTERN.')/iu', + ]; + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $text, $m)) { + return [trim($m[1]), trim($m[2])]; + } + } + + return [null, null]; + } +} diff --git a/tests/Unit/Support/ArtisanCommandRunnerTest.php b/tests/Unit/Support/ArtisanCommandRunnerTest.php index ebc0f059e..53260c71e 100644 --- a/tests/Unit/Support/ArtisanCommandRunnerTest.php +++ b/tests/Unit/Support/ArtisanCommandRunnerTest.php @@ -35,6 +35,23 @@ public function test_disabled_when_flag_off(): void } public function test_registry_command_builds_validated_tokens(): void + { + $plan = $this->runner->plan('support:certificate-kpi-report', [ + 'start' => '17.03.2026', + 'end' => '07.07.2026', + '--json' => true, + ]); + + $this->assertTrue($plan['ok']); + $this->assertSame('registry', $plan['result']['mode']); + $this->assertFalse($plan['result']['is_write']); + $this->assertSame( + ['support:certificate-kpi-report', '17.03.2026', '07.07.2026', '--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/CertificateKpiReportServiceTest.php b/tests/Unit/Support/CertificateKpiReportServiceTest.php new file mode 100644 index 000000000..a9cdbb7a4 --- /dev/null +++ b/tests/Unit/Support/CertificateKpiReportServiceTest.php @@ -0,0 +1,55 @@ +create(); + + Event::factory()->create([ + 'status' => 'APPROVED', + 'creator_id' => $user->id, + 'reported_at' => '2026-04-01 10:00:00', + 'certificate_url' => 'https://example.test/organiser.pdf', + ]); + + Event::factory()->create([ + 'status' => 'APPROVED', + 'creator_id' => $user->id, + 'reported_at' => '2026-01-01 10:00:00', + 'certificate_url' => 'https://example.test/old.pdf', + ]); + + Excellence::factory()->create([ + 'user_id' => $user->id, + 'type' => 'Excellence', + 'certificate_url' => 'https://example.test/excellence.pdf', + 'created_at' => '2026-05-10 12:00:00', + ]); + + Excellence::factory()->create([ + 'user_id' => $user->id, + 'type' => 'SuperOrganiser', + 'certificate_url' => 'https://example.test/super.pdf', + 'created_at' => '2026-06-15 12:00:00', + ]); + + $report = app(CertificateKpiReportService::class)->report('17.03.2026', '07.07.2026'); + + $this->assertSame(1, $report['counts']['organiser']); + $this->assertSame(1, $report['counts']['excellence']); + $this->assertSame(1, $report['counts']['super_organiser']); + $this->assertSame(3, $report['counts']['total']); + } +} diff --git a/tests/Unit/Support/CertificateKpiRequestParserTest.php b/tests/Unit/Support/CertificateKpiRequestParserTest.php new file mode 100644 index 000000000..d5e97f1da --- /dev/null +++ b/tests/Unit/Support/CertificateKpiRequestParserTest.php @@ -0,0 +1,43 @@ +parser = new CertificateKpiRequestParser(); + } + + public function test_parses_between_dates_from_semester_style_email(): void + { + $text = <<<'TEXT' +As we are wrapping up the semester, we would like to update our database and KPI dashboard. +May I ask you to provide the information for the Code Week certificates? I need the data between 17.03.2026 to 07.07.2026. +Organiser +Excellence +Super Organiser +TEXT; + + $parsed = $this->parser->parse($text); + + $this->assertTrue($parsed['looks_like_kpi_request']); + $this->assertSame('17.03.2026', $parsed['start']); + $this->assertSame('07.07.2026', $parsed['end']); + } + + public function test_ignores_non_kpi_certificate_requests(): void + { + $parsed = $this->parser->parse('I did not receive my participation certificate for my event.'); + + $this->assertFalse($parsed['looks_like_kpi_request']); + $this->assertNull($parsed['start']); + $this->assertNull($parsed['end']); + } +}