From 0aa2827904c65ba123042eb82e6cc2f0a4e1a6c6 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 16 May 2026 18:24:47 -0300 Subject: [PATCH 01/12] docs(moderation/integration-discord/bot-discord): add context maps and adr for hybrid pipeline refactor Establishes domain documentation for the moderation pipeline refactor: - CONTEXT-MAP.md at root with module dependency rules - Per-module CONTEXT.md with glossary, boundaries, and flows - ADR-0001 documenting the hybrid pipeline architecture decision --- .agents/skills/configure-nightwatch/SKILL.md | 405 ++++++++++++++++++ .../skills/configure-nightwatch/reference.md | 102 +++++ .agents/skills/fluxui-development/SKILL.md | 85 ++++ .agents/skills/laravel-attributes | 1 + .../skills/laravel-best-practices/SKILL.md | 190 ++++++++ .../rules/advanced-queries.md | 106 +++++ .../rules/architecture.md | 211 +++++++++ .../rules/blade-views.md | 41 ++ .../laravel-best-practices/rules/caching.md | 72 ++++ .../rules/collections.md | 45 ++ .../laravel-best-practices/rules/config.md | 79 ++++ .../rules/db-performance.md | 205 +++++++++ .../laravel-best-practices/rules/eloquent.md | 164 +++++++ .../rules/error-handling.md | 75 ++++ .../rules/events-notifications.md | 52 +++ .../rules/http-client.md | 168 ++++++++ .../laravel-best-practices/rules/mail.md | 27 ++ .../rules/migrations.md | 129 ++++++ .../rules/queue-jobs.md | 145 +++++++ .../laravel-best-practices/rules/routing.md | 105 +++++ .../rules/scheduling.md | 41 ++ .../laravel-best-practices/rules/security.md | 208 +++++++++ .../laravel-best-practices/rules/style.md | 133 ++++++ .../laravel-best-practices/rules/testing.md | 43 ++ .../rules/validation.md | 79 ++++ .agents/skills/modular/SKILL.md | 188 ++++++++ .agents/skills/pest-testing/SKILL.md | 171 ++++++++ .../skills/tailwindcss-development/SKILL.md | 123 ++++++ .ai/guidelines/01-domain.blade.php | 16 + .ai/guidelines/02-issue-tracker.blade.php | 16 + .ai/guidelines/03-triage-labels.blade.php | 7 + .ai/guidelines/99-knowledge-base.blade.php | 21 + CONTEXT-MAP.md | 41 ++ app-modules/bot-discord/CONTEXT.md | 38 ++ app-modules/integration-discord/CONTEXT.md | 57 +++ app-modules/moderation/CONTEXT.md | 112 +++++ ...-pipeline-with-event-driven-enforcement.md | 86 ++++ .../ExternalIdentityResource.php | 102 +++++ .../Pages/CreateExternalIdentity.php | 20 + .../Pages/EditExternalIdentity.php | 25 ++ .../Pages/ListExternalIdentities.php | 21 + .../MessagesRelationManager.php | 299 +++++++++++++ .../Schemas/ExternalIdentityForm.php | 68 +++ .../Schemas/ExternalIdentityInfolist.php | 60 +++ .../Tables/ExternalIdentitiesTable.php | 85 ++++ docs/agents/domain.md | 43 ++ docs/agents/issue-tracker.md | 22 + docs/agents/triage-labels.md | 15 + opencode.json | 10 + 49 files changed, 4557 insertions(+) create mode 100644 .agents/skills/configure-nightwatch/SKILL.md create mode 100644 .agents/skills/configure-nightwatch/reference.md create mode 100644 .agents/skills/fluxui-development/SKILL.md create mode 120000 .agents/skills/laravel-attributes create mode 100644 .agents/skills/laravel-best-practices/SKILL.md create mode 100644 .agents/skills/laravel-best-practices/rules/advanced-queries.md create mode 100644 .agents/skills/laravel-best-practices/rules/architecture.md create mode 100644 .agents/skills/laravel-best-practices/rules/blade-views.md create mode 100644 .agents/skills/laravel-best-practices/rules/caching.md create mode 100644 .agents/skills/laravel-best-practices/rules/collections.md create mode 100644 .agents/skills/laravel-best-practices/rules/config.md create mode 100644 .agents/skills/laravel-best-practices/rules/db-performance.md create mode 100644 .agents/skills/laravel-best-practices/rules/eloquent.md create mode 100644 .agents/skills/laravel-best-practices/rules/error-handling.md create mode 100644 .agents/skills/laravel-best-practices/rules/events-notifications.md create mode 100644 .agents/skills/laravel-best-practices/rules/http-client.md create mode 100644 .agents/skills/laravel-best-practices/rules/mail.md create mode 100644 .agents/skills/laravel-best-practices/rules/migrations.md create mode 100644 .agents/skills/laravel-best-practices/rules/queue-jobs.md create mode 100644 .agents/skills/laravel-best-practices/rules/routing.md create mode 100644 .agents/skills/laravel-best-practices/rules/scheduling.md create mode 100644 .agents/skills/laravel-best-practices/rules/security.md create mode 100644 .agents/skills/laravel-best-practices/rules/style.md create mode 100644 .agents/skills/laravel-best-practices/rules/testing.md create mode 100644 .agents/skills/laravel-best-practices/rules/validation.md create mode 100644 .agents/skills/modular/SKILL.md create mode 100644 .agents/skills/pest-testing/SKILL.md create mode 100644 .agents/skills/tailwindcss-development/SKILL.md create mode 100644 .ai/guidelines/01-domain.blade.php create mode 100644 .ai/guidelines/02-issue-tracker.blade.php create mode 100644 .ai/guidelines/03-triage-labels.blade.php create mode 100644 .ai/guidelines/99-knowledge-base.blade.php create mode 100644 CONTEXT-MAP.md create mode 100644 app-modules/bot-discord/CONTEXT.md create mode 100644 app-modules/integration-discord/CONTEXT.md create mode 100644 app-modules/moderation/CONTEXT.md create mode 100644 app-modules/moderation/docs/adr/0001-hybrid-pipeline-with-event-driven-enforcement.md create mode 100644 app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/ExternalIdentityResource.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Pages/CreateExternalIdentity.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Pages/EditExternalIdentity.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Pages/ListExternalIdentities.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/RelationManagers/MessagesRelationManager.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Schemas/ExternalIdentityForm.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Schemas/ExternalIdentityInfolist.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Tables/ExternalIdentitiesTable.php create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md create mode 100644 docs/agents/triage-labels.md create mode 100644 opencode.json diff --git a/.agents/skills/configure-nightwatch/SKILL.md b/.agents/skills/configure-nightwatch/SKILL.md new file mode 100644 index 000000000..4438533d0 --- /dev/null +++ b/.agents/skills/configure-nightwatch/SKILL.md @@ -0,0 +1,405 @@ +--- +name: configure-nightwatch +description: Configures Laravel Nightwatch data collection, sampling rates, filtering rules, and redaction policies. Use when setting up Nightwatch, managing data volume, protecting sensitive data (PII), or optimizing event collection for production workloads. +license: MIT +metadata: + author: laravel +--- + +# Nightwatch Configuration Guide + +This skill helps configure Laravel Nightwatch data collection to balance observability, performance, and privacy. Covers sampling strategies, filtering rules, and redaction methods across all event types. + +## Documentation Reference + +The [Nightwatch Documentation](https://nightwatch.laravel.com/docs) is the definitive and up-to-date source of information for all Nightwatch configuration options. This skill provides practical guidance and common patterns, but always consult the official documentation as the primary source of truth for specific details, environment variables, and API behavior. The documentation includes comprehensive coverage of: + +- [Filtering and Configuration](https://nightwatch.laravel.com/docs/filtering) - Core concepts for sampling, filtering, and redaction +- Individual event type pages with specific configuration options: + - [Requests](https://nightwatch.laravel.com/docs/requests) - Request sampling, header handling, payload capture + - [Commands](https://nightwatch.laravel.com/docs/commands) - Command sampling and redaction + - [Queries](https://nightwatch.laravel.com/docs/queries) - Query filtering and redaction + - [Cache](https://nightwatch.laravel.com/docs/cache) - Cache event filtering by key or pattern + - [Jobs](https://nightwatch.laravel.com/docs/jobs) - Job filtering and sampling decoupling + - [Mail](https://nightwatch.laravel.com/docs/mail) - Mail event filtering + - [Notifications](https://nightwatch.laravel.com/docs/notifications) - Notification filtering by channel + - [Exceptions](https://nightwatch.laravel.com/docs/exceptions) - Exception sampling and throttling + - [Outgoing Requests](https://nightwatch.laravel.com/docs/outgoing-requests) - HTTP request filtering +- [reference.md](reference.md) - Quick lookup table by event type, production presets, and verification checklist + +## Data Collection Flow + +Nightwatch processes events through three stages: + +1. **Sampling** - Controls which entry points are captured (requests, commands, scheduled tasks) +2. **Filtering** - Excludes specific events after sampling (queries, cache, mail, etc.) +3. **Redaction** - Modifies captured data to remove/obfuscate sensitive information + +``` +Request/Command/Scheduled Task + | + v + [Sampling?] ----NO----> Drop entire trace + | YES + v + Events generated + | + v + [Filtering?] ----YES---> Drop specific event + | NO + v + [Redaction] ----------> Store modified data +``` + +--- + +## Sampling Configuration + +Sampling determines which entry points (requests, commands, scheduled tasks) trigger full trace collection. When an entry point is sampled, all related events are captured. + +### Global Sample Rates + +Configure via environment variables: + +```bash + +# Default: 100% sampling (all requests/commands captured) + +NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1 # Recommended: 10% of requests + +NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0 # Capture all commands + +NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions + +``` + +**Recommendation**: Start with `0.1` (10%) for requests in production, adjust based on volume and needs. + +### Route-Based Sampling + +Apply different rates to specific routes using the `Sample` middleware: + +```php routes/web.php +use Illuminate\Support\Facades\Route; +use Laravel\Nightwatch\Http\Middleware\Sample; + +// Sample admin routes at 100% +Route::middleware(Sample::rate(1.0)) + ->prefix('admin') + ->group(function () { + // All admin routes sampled fully + }); + +// Sample API routes at 5% +Route::middleware(Sample::rate(0.05)) + ->prefix('api') + ->group(function () { + // API routes sampled sparingly + }); + +// Always sample critical endpoints +Route::post('/checkout', [CheckoutController::class, 'process'])->middleware(Sample::always()); + +// Never sample health checks +Route::get('/health', [HealthController::class, 'check'])->middleware(Sample::never()); +``` + +### Unmatched Route Sampling + +Handle 404/bot traffic with reduced sampling: + +```php routes/web.php +Route::fallback(fn() => abort(404))->middleware(Sample::rate(0.01)); // 1% sampling for unmatched routes +``` + +### Dynamic Sampling + +Sample based on runtime conditions (user role, request attributes): + +```php app/Http/Middleware/SampleAdminRequests.php +use Closure; +use Illuminate\Http\Request; +use Laravel\Nightwatch\Facades\Nightwatch; + +class SampleAdminRequests +{ + public function handle(Request $request, Closure $next) + { + if ($request->user()?->isAdmin()) { + Nightwatch::sample(); // Always sample admin requests + } + return $next($request); + } +} +``` + +### Command Sampling + +Exclude specific commands from sampling: + +```php AppServiceProvider.php +use Illuminate\Console\Events\CommandStarting; +use Illuminate\Support\Facades\Event; +use Laravel\Nightwatch\Facades\Nightwatch; + +public function boot(): void +{ + Event::listen(function (CommandStarting $event) { + if (in_array($event->command, ['schedule:finish', 'horizon:snapshot'])) { + Nightwatch::dontSample(); + } + }); +} +``` + +### Vendor Commands + +Nightwatch automatically ignores framework/internal commands. Opt-in to capture them: + +```php +Nightwatch::captureDefaultVendorCommands(); +``` + +--- + +## Filtering Configuration + +Filtering excludes specific events from collection after sampling. Use filtering to reduce noise and quota usage. + +### Database Queries + +**Filter all queries** (disable query collection): + +```bash +NIGHTWATCH_IGNORE_QUERIES=true +``` + +**Filter specific queries** by SQL pattern: + +```php AppServiceProvider.php +use Laravel\Nightwatch\Facades\Nightwatch; +use Laravel\Nightwatch\Records\Query; + +public function boot(): void +{ + // Filter job table queries (PostgreSQL) + Nightwatch::rejectQueries(function (Query $query) { + return str_contains($query->sql, 'into "jobs"'); + }); + + // Filter cache table queries (MySQL) + Nightwatch::rejectQueries(function (Query $query) { + return str_contains($query->sql, 'from `cache`') + || str_contains($query->sql, 'into `cache`'); + }); +} +``` + +### Cache Events + +**Filter all cache events**: + +```bash +NIGHTWATCH_IGNORE_CACHE_EVENTS=true +``` + +**Filter by cache key patterns**: + +```php +Nightwatch::rejectCacheKeys([ + 'my-app:users', // Exact match + '/^my-app:posts:/', // Regex: starts with my-app:posts: + '/^[a-zA-Z0-9]{40}$/', // Regex: session IDs +]); +``` + +**Filter with callback**: + +```php +use Laravel\Nightwatch\Records\CacheEvent; + +Nightwatch::rejectCacheEvents(function (CacheEvent $cacheEvent) { + return str_starts_with($cacheEvent->key, 'temp:'); +}); +``` + +### Mail Events + +**Filter all mail**: + +```bash +NIGHTWATCH_IGNORE_MAIL=true +``` + +**Filter specific mail**: + +```php +use Laravel\Nightwatch\Records\Mail; + +Nightwatch::rejectMail(function (Mail $mail) { + return str_contains($mail->subject, 'Newsletter'); +}); +``` + +### Notification Events + +**Filter all notifications**: + +```bash +NIGHTWATCH_IGNORE_NOTIFICATIONS=true +``` + +**Filter by channel**: + +```php +use Laravel\Nightwatch\Records\Notification; + +Nightwatch::rejectNotifications(function (Notification $notification) { + return $notification->channel === 'database'; +}); +``` + +### Outgoing HTTP Requests + +**Filter all outgoing requests**: + +```bash +NIGHTWATCH_IGNORE_OUTGOING_REQUESTS=true +``` + +**Filter by URL**: + +```php +use Laravel\Nightwatch\Records\OutgoingRequest; + +Nightwatch::rejectOutgoingRequests(function (OutgoingRequest $request) { + return str_contains($request->url, 'analytics.example.com'); +}); +``` + +### Queued Jobs + +**Filter specific jobs**: + +```php +use Laravel\Nightwatch\Records\QueuedJob; + +Nightwatch::rejectQueuedJobs(function (QueuedJob $job) { + return $job->name === 'App\Jobs\LowPriorityJob'; +}); +``` + +### Decoupling Job Sampling + +Sample jobs independently from parent contexts: + +```php +use Illuminate\Support\Facades\Queue; + +public function boot(): void +{ + Queue::before(fn () => Nightwatch::sample(rate: 0.5)); +} +``` + +--- + +## Redaction Configuration + +Redaction modifies captured data to remove or obfuscate sensitive information. Unlike filtering, redaction keeps the event but sanitizes its content. + +### Request Redaction + +**Redact sensitive headers** (automatically redacts: Authorization, Cookie, X-XSRF-TOKEN): + +```bash + +# Customize redacted headers + +NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-API-Key +``` + +**Redact request payloads** (disabled by default): + +```bash + +# Enable payload capture + +NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=true + +# Customize redacted fields + +NIGHTWATCH_REDACT_PAYLOAD_FIELDS=password,password_confirmation,ssn,credit_card +``` + +**Programmatic redaction**: + +```php +use Laravel\Nightwatch\Facades\Nightwatch; +use Laravel\Nightwatch\Records\Request; + +Nightwatch::redactRequests(function (Request $request) { + $request->url = str_replace('secret', '***', $request->url); + $request->ip = preg_replace('/\d+$/', '***', $request->ip); +}); +``` + +### Query Redaction + +```php +use Laravel\Nightwatch\Records\Query; + +Nightwatch::redactQueries(function (Query $query) { + $query->sql = str_replace('secret_token', '***', $query->sql); +}); +``` + +### Cache Redaction + +```php +use Laravel\Nightwatch\Records\CacheEvent; + +Nightwatch::redactCacheEvents(function (CacheEvent $cacheEvent) { + $cacheEvent->key = str_replace('user:', 'user:***:', $cacheEvent->key); +}); +``` + +### Command Redaction + +```php +use Laravel\Nightwatch\Records\Command; + +Nightwatch::redactCommands(function (Command $command) { + $command->command = preg_replace('/--password=\S+/', '--password=***', $command->command); +}); +``` + +### Exception Redaction + +```php +use Laravel\Nightwatch\Records\Exception; + +Nightwatch::redactExceptions(function (Exception $exception) { + $exception->message = str_replace('secret', '***', $exception->message); +}); +``` + +### Mail Redaction + +```php +use Laravel\Nightwatch\Records\Mail; + +Nightwatch::redactMail(function (Mail $mail) { + $mail->subject = str_replace('Invoice #', 'Invoice ***', $mail->subject); +}); +``` + +### Outgoing Request Redaction + +```php +use Laravel\Nightwatch\Records\OutgoingRequest; + +Nightwatch::redactOutgoingRequests(function (OutgoingRequest $outgoingRequest) { + $outgoingRequest->url = preg_replace('/api_key=\w+/', 'api_key=***', $outgoingRequest->url); +}); +``` diff --git a/.agents/skills/configure-nightwatch/reference.md b/.agents/skills/configure-nightwatch/reference.md new file mode 100644 index 000000000..d9995dd4d --- /dev/null +++ b/.agents/skills/configure-nightwatch/reference.md @@ -0,0 +1,102 @@ +# Nightwatch Configuration Reference + +## Configuration Summary by Event Type + +| Event Type | Sampling | Filtering | Redaction | +| --------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------- | +| **Requests** | `NIGHTWATCH_REQUEST_SAMPLE_RATE`, Route middleware | Not applicable | Headers, payload, URL, IP | +| **Commands** | `NIGHTWATCH_COMMAND_SAMPLE_RATE`, Event listener | Not applicable | Command arguments | +| **Queries** | Parent context | `rejectQueries()`, `NIGHTWATCH_IGNORE_QUERIES` | SQL statement | +| **Cache** | Parent context | `rejectCacheKeys()`, `rejectCacheEvents()`, `NIGHTWATCH_IGNORE_CACHE_EVENTS` | Cache key | +| **Jobs** | Parent context, Queue::before | `rejectQueuedJobs()` | Not applicable | +| **Mail** | Parent context | `rejectMail()`, `NIGHTWATCH_IGNORE_MAIL` | Subject | +| **Notifications** | Parent context | `rejectNotifications()`, `NIGHTWATCH_IGNORE_NOTIFICATIONS` | Not applicable | +| **Outgoing Requests** | Parent context | `rejectOutgoingRequests()`, `NIGHTWATCH_IGNORE_OUTGOING_REQUESTS` | URL | +| **Exceptions** | `NIGHTWATCH_EXCEPTION_SAMPLE_RATE` | Not applicable | Exception message | + +--- + +## Production Recommendations + +### High-Traffic Applications + +```bash + +# Conservative sampling + +NIGHTWATCH_REQUEST_SAMPLE_RATE=0.01 # 1% of requests + +NIGHTWATCH_COMMAND_SAMPLE_RATE=0.1 # 10% of commands + +NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions + +# Filter noisy events + +NIGHTWATCH_IGNORE_CACHE_EVENTS=true +NIGHTWATCH_IGNORE_QUERIES=true # Or filter specific queries programmatically + +``` + +### Privacy-Conscious Applications + +```bash + +# Disable sensitive data collection + +NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=false +NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-XSRF-TOKEN + +# Or use redaction in AppServiceProvider + +``` + +### Balanced Configuration (Recommended Start) + +```bash + +# Sample rates + +NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1 +NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0 +NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 + +# Filter obvious noise programmatically + +# Redact PII as needed + +``` + +--- + +## Verification Checklist + +After configuration: + +- [ ] Sampling rates appropriate for traffic volume +- [ ] Noisy events filtered (cache, certain queries) +- [ ] Sensitive data redacted (PII, tokens, credentials) +- [ ] Exceptions always captured for debugging +- [ ] Test in development with `NIGHTWATCH_REQUEST_SAMPLE_RATE=1.0` +- [ ] Monitor event quota usage in Nightwatch dashboard + +--- + +## Common Patterns + +### Filter Health Checks + Reduce Sampling + +```php +Route::get('/health', fn() => ['status' => 'ok'])->middleware(Sample::never()); +``` + +### Exclude Internal/Vendor Queries + +```php +Nightwatch::rejectQueries(fn($q) => str_contains($q->sql, 'telescope') || str_contains($q->sql, 'pulse')); +``` + +### Protect User Data in Cache Keys + +```php +Nightwatch::redactCacheEvents(fn($e) => ($e->key = preg_replace('/user:\d+/', 'user:***', $e->key))); +``` diff --git a/.agents/skills/fluxui-development/SKILL.md b/.agents/skills/fluxui-development/SKILL.md new file mode 100644 index 000000000..6e5b06719 --- /dev/null +++ b/.agents/skills/fluxui-development/SKILL.md @@ -0,0 +1,85 @@ +--- +name: fluxui-development +description: 'Use this skill for Flux UI development in Livewire applications only. Trigger when working with components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling.' +license: MIT +metadata: + author: laravel +--- + +# Flux UI Development + +## Documentation + +Use `search-docs` for detailed Flux UI patterns and documentation. + +## Basic Usage + +This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components. + +Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize. + +Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs. + + + +```blade +Click me +``` + +## Available Components (Free Edition) + +Available: avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, profile, radio, select, separator, skeleton, switch, text, textarea, tooltip + +## Icons + +Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names. + + + +```blade +Export +``` + +For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command: + +```bash +php artisan flux:icon crown grip-vertical github +``` + +## Common Patterns + +### Form Fields + + + +```blade + + Email + + + +``` + +### Modals + + + +```blade + + Title +

Content

+
+``` + +## Verification + +1. Check component renders correctly +2. Test interactive states +3. Verify mobile responsiveness + +## Common Pitfalls + +- Trying to use Pro-only components in the free edition +- Not checking if a Flux component exists before creating custom implementations +- Forgetting to use the `search-docs` tool for component-specific documentation +- Not following existing project patterns for Flux usage diff --git a/.agents/skills/laravel-attributes b/.agents/skills/laravel-attributes new file mode 120000 index 000000000..caca720bd --- /dev/null +++ b/.agents/skills/laravel-attributes @@ -0,0 +1 @@ +../../.ai/skills/laravel-attributes \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/SKILL.md b/.agents/skills/laravel-best-practices/SKILL.md new file mode 100644 index 000000000..d2d1147b1 --- /dev/null +++ b/.agents/skills/laravel-best-practices/SKILL.md @@ -0,0 +1,190 @@ +--- +name: laravel-best-practices +description: 'Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.' +license: MIT +metadata: + author: laravel +--- + +# Laravel Best Practices + +Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`. + +## Consistency First + +Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. + +Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides. + +## Quick Reference + +### 1. Database Performance → `rules/db-performance.md` + +- Eager load with `with()` to prevent N+1 queries +- Enable `Model::preventLazyLoading()` in development +- Select only needed columns, avoid `SELECT *` +- `chunk()` / `chunkById()` for large datasets +- Index columns used in `WHERE`, `ORDER BY`, `JOIN` +- `withCount()` instead of loading relations to count +- `cursor()` for memory-efficient read-only iteration +- Never query in Blade templates + +### 2. Advanced Query Patterns → `rules/advanced-queries.md` + +- `addSelect()` subqueries over eager-loading entire has-many for a single value +- Dynamic relationships via subquery FK + `belongsTo` +- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries +- `setRelation()` to prevent circular N+1 queries +- `whereIn` + `pluck()` over `whereHas` for better index usage +- Two simple queries can beat one complex query +- Compound indexes matching `orderBy` column order +- Correlated subqueries in `orderBy` for has-many sorting (avoid joins) + +### 3. Security → `rules/security.md` + +- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates +- No raw SQL with user input — use Eloquent or query builder +- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes +- Validate MIME type, extension, and size for file uploads +- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields + +### 4. Caching → `rules/caching.md` + +- `Cache::remember()` over manual get/put +- `Cache::flexible()` for stale-while-revalidate on high-traffic data +- `Cache::memo()` to avoid redundant cache hits within a request +- Cache tags to invalidate related groups +- `Cache::add()` for atomic conditional writes +- `once()` to memoize per-request or per-object lifetime +- `Cache::lock()` / `lockForUpdate()` for race conditions +- Failover cache stores in production + +### 5. Eloquent Patterns → `rules/eloquent.md` + +- Correct relationship types with return type hints +- Local scopes for reusable query constraints +- Global scopes sparingly — document their existence +- Attribute casts in the `casts()` method +- Cast date columns, use Carbon instances in templates +- `whereBelongsTo($model)` for cleaner queries +- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries + +### 6. Validation & Forms → `rules/validation.md` + +- Form Request classes, not inline validation +- Array notation `['required', 'email']` for new code; follow existing convention +- `$request->validated()` only — never `$request->all()` +- `Rule::when()` for conditional validation +- `after()` instead of `withValidator()` + +### 7. Configuration → `rules/config.md` + +- `env()` only inside config files +- `App::environment()` or `app()->isProduction()` +- Config, lang files, and constants over hardcoded text + +### 8. Testing Patterns → `rules/testing.md` + +- `LazilyRefreshDatabase` over `RefreshDatabase` for speed +- `assertModelExists()` over raw `assertDatabaseHas()` +- Factory states and sequences over manual overrides +- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before +- `recycle()` to share relationship instances across factories + +### 9. Queue & Job Patterns → `rules/queue-jobs.md` + +- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]` +- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release +- Always implement `failed()`; with `retryUntil()`, set `$tries = 0` +- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs +- Horizon for complex multi-queue scenarios + +### 10. Routing & Controllers → `rules/routing.md` + +- Implicit route model binding +- Scoped bindings for nested resources +- `Route::resource()` or `apiResource()` +- Methods under 10 lines — extract to actions/services +- Type-hint Form Requests for auto-validation + +### 11. HTTP Client → `rules/http-client.md` + +- Explicit `timeout` and `connectTimeout` on every request +- `retry()` with exponential backoff for external APIs +- Check response status or use `throw()` +- `Http::pool()` for concurrent independent requests +- `Http::fake()` and `preventStrayRequests()` in tests + +### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md` + +- Event discovery over manual registration; `event:cache` in production +- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions +- Queue notifications and mailables with `ShouldQueue` +- On-demand notifications for non-user recipients +- `HasLocalePreference` on notifiable models +- `assertQueued()` not `assertSent()` for queued mailables +- Markdown mailables for transactional emails + +### 13. Error Handling → `rules/error-handling.md` + +- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern +- `ShouldntReport` for exceptions that should never log +- Throttle high-volume exceptions to protect log sinks +- `dontReportDuplicates()` for multi-catch scenarios +- Force JSON rendering for API routes +- Structured context via `context()` on exception classes + +### 14. Task Scheduling → `rules/scheduling.md` + +- `withoutOverlapping()` on variable-duration tasks +- `onOneServer()` on multi-server deployments +- `runInBackground()` for concurrent long tasks +- `environments()` to restrict to appropriate environments +- `takeUntilTimeout()` for time-bounded processing +- Schedule groups for shared configuration + +### 15. Architecture → `rules/architecture.md` + +- Single-purpose Action classes; dependency injection over `app()` helper +- Prefer official Laravel packages and follow conventions, don't override defaults +- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety +- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution + +### 16. Migrations → `rules/migrations.md` + +- Generate migrations with `php artisan make:migration` +- `constrained()` for foreign keys +- Never modify migrations that have run in production +- Add indexes in the migration, not as an afterthought +- Mirror column defaults in model `$attributes` +- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes +- One concern per migration — never mix DDL and DML + +### 17. Collections → `rules/collections.md` + +- Higher-order messages for simple collection operations +- `cursor()` vs. `lazy()` — choose based on relationship needs +- `lazyById()` when updating records while iterating +- `toQuery()` for bulk operations on collections + +### 18. Blade & Views → `rules/blade-views.md` + +- `$attributes->merge()` in component templates +- Blade components over `@include`; `@pushOnce` for per-component scripts +- View Composers for shared view data +- `@aware` for deeply nested component props + +### 19. Conventions & Style → `rules/style.md` + +- Follow Laravel naming conventions for all entities +- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions +- No JS/CSS in Blade, no HTML in PHP classes +- Code should be readable; comments only for config files + +## How to Apply + +Always use a sub-agent to read rule files and explore this skill's content. + +1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10) +2. Check sibling files for existing patterns — follow those first per Consistency First +3. Verify API syntax with `search-docs` for the installed Laravel version diff --git a/.agents/skills/laravel-best-practices/rules/advanced-queries.md b/.agents/skills/laravel-best-practices/rules/advanced-queries.md new file mode 100644 index 000000000..24bb3f307 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/advanced-queries.md @@ -0,0 +1,106 @@ +# Advanced Query Patterns + +## Use `addSelect()` Subqueries for Single Values from Has-Many + +Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries. + +```php +public function scopeWithLastLoginAt($query): void +{ + $query->addSelect([ + 'last_login_at' => Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->withCasts(['last_login_at' => 'datetime']); +} +``` + +## Create Dynamic Relationships via Subquery FK + +Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection. + +```php +public function lastLogin(): BelongsTo +{ + return $this->belongsTo(Login::class); +} + +public function scopeWithLastLogin($query): void +{ + $query->addSelect([ + 'last_login_id' => Login::select('id') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->with('lastLogin'); +} +``` + +## Use Conditional Aggregates Instead of Multiple Count Queries + +Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values. + +```php +$statuses = Feature::toBase() + ->selectRaw("count(case when status = 'Requested' then 1 end) as requested") + ->selectRaw("count(case when status = 'Planned' then 1 end) as planned") + ->selectRaw("count(case when status = 'Completed' then 1 end) as completed") + ->first(); +``` + +## Use `setRelation()` to Prevent Circular N+1 + +When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries. + +```php +$feature->load('comments.user'); +$feature->comments->each->setRelation('feature', $feature); +``` + +## Prefer `whereIn` + Subquery Over `whereHas` + +`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory. + +Incorrect (correlated EXISTS re-executes per row): + +```php +$query->whereHas('company', fn($q) => $q->where('name', 'like', $term)); +``` + +Correct (index-friendly subquery, no PHP memory overhead): + +```php +$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id')); +``` + +## Sometimes Two Simple Queries Beat One Complex Query + +Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index. + +## Use Compound Indexes Matching `orderBy` Column Order + +When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index. + +```php +// Migration +$table->index(['last_name', 'first_name']); + +// Query — column order must match the index +User::query()->orderBy('last_name')->orderBy('first_name')->paginate(); +``` + +## Use Correlated Subqueries for Has-Many Ordering + +When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading. + +```php +public function scopeOrderByLastLogin($query): void +{ + $query->orderByDesc(Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1) + ); +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/architecture.md b/.agents/skills/laravel-best-practices/rules/architecture.md new file mode 100644 index 000000000..1ef4186ea --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/architecture.md @@ -0,0 +1,211 @@ +# Architecture Best Practices + +## Single-Purpose Action Classes + +Extract discrete business operations into invokable Action classes. + +```php +class CreateOrderAction +{ + public function __construct(private InventoryService $inventory) {} + + public function execute(array $data): Order + { + $order = Order::create($data); + $this->inventory->reserve($order); + + return $order; + } +} +``` + +## Use Dependency Injection + +Always use constructor injection. Avoid `app()` or `resolve()` inside classes. + +Incorrect: + +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request) + { + $service = app(OrderService::class); + + return $service->create($request->validated()); + } +} +``` + +Correct: + +```php +class OrderController extends Controller +{ + public function __construct(private OrderService $service) {} + + public function store(StoreOrderRequest $request) + { + return $this->service->create($request->validated()); + } +} +``` + +## Code to Interfaces + +Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability. + +Incorrect (concrete dependency): + +```php +class OrderService +{ + public function __construct(private StripeGateway $gateway) {} +} +``` + +Correct (interface dependency): + +```php +interface PaymentGateway +{ + public function charge(int $amount, string $customerId): PaymentResult; +} + +class OrderService +{ + public function __construct(private PaymentGateway $gateway) {} +} +``` + +Bind in a service provider: + +```php +$this->app->bind(PaymentGateway::class, StripeGateway::class); +``` + +## Default Sort by Descending + +When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined. + +Incorrect: + +```php +$posts = Post::paginate(); +``` + +Correct: + +```php +$posts = Post::latest()->paginate(); +``` + +## Use Atomic Locks for Race Conditions + +Prevent race conditions with `Cache::lock()` or `lockForUpdate()`. + +```php +Cache::lock('order-processing-' . $order->id, 10)->block(5, function () use ($order) { + $order->process(); +}); + +// Or at query level +$product = Product::where('id', $id)->lockForUpdate()->first(); +``` + +## Use `mb_*` String Functions + +When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters. + +Incorrect: + +```php +strlen('José'); // 5 (bytes, not characters) +strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte +``` + +Correct: + +```php +mb_strlen('José'); // 4 (characters) +mb_strtolower('MÜNCHEN'); // 'münchen' + +// Prefer Laravel's Str helpers when available +Str::length('José'); // 4 +Str::lower('MÜNCHEN'); // 'münchen' +``` + +## Use `defer()` for Post-Response Work + +For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead. + +Incorrect (job overhead for trivial work): + +```php +dispatch(new LogPageView($page)); +``` + +Correct (runs after response, same process): + +```php +defer(fn() => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()])); +``` + +Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work. + +## Use `Context` for Request-Scoped Data + +The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually. + +```php +// In middleware +Context::add('tenant_id', $request->header('X-Tenant-ID')); + +// Anywhere later — controllers, jobs, log context +$tenantId = Context::get('tenant_id'); +``` + +Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`. + +## Use `Concurrency::run()` for Parallel Execution + +Run independent operations in parallel using child processes — no async libraries needed. + +```php +use Illuminate\Support\Facades\Concurrency; + +[$users, $orders] = Concurrency::run([fn() => User::count(), fn() => Order::where('status', 'pending')->count()]); +``` + +Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially. + +## Convention Over Configuration + +Follow Laravel conventions. Don't override defaults unnecessarily. + +Incorrect: + +```php +class Customer extends Model +{ + protected $table = 'Customer'; + protected $primaryKey = 'customer_id'; + + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id'); + } +} +``` + +Correct: + +```php +class Customer extends Model +{ + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class); + } +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/blade-views.md b/.agents/skills/laravel-best-practices/rules/blade-views.md new file mode 100644 index 000000000..f66e0d448 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/blade-views.md @@ -0,0 +1,41 @@ +# Blade & Views Best Practices + +## Use `$attributes->merge()` in Component Templates + +Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly. + +```blade +
merge([ + 'class' => 'alert alert-' . $type, + ]) + }} +> + {{ $message }} +
+``` + +## Use `@pushOnce` for Per-Component Scripts + +If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once. + +## Prefer Blade Components Over `@include` + +`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots. + +## Use View Composers for Shared View Data + +If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it. + +## Use Blade Fragments for Partial Re-Renders (htmx/Turbo) + +A single view can return either the full page or just a fragment, keeping routing clean. + +```php +return view('dashboard', compact('users'))->fragmentIf($request->hasHeader('HX-Request'), 'user-list'); +``` + +## Use `@aware` for Deeply Nested Component Props + +Avoids re-passing parent props through every level of nested components. diff --git a/.agents/skills/laravel-best-practices/rules/caching.md b/.agents/skills/laravel-best-practices/rules/caching.md new file mode 100644 index 000000000..5f201316f --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/caching.md @@ -0,0 +1,72 @@ +# Caching Best Practices + +## Use `Cache::remember()` Instead of Manual Get/Put + +Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions. + +Incorrect: + +```php +$val = Cache::get('stats'); +if (!$val) { + $val = $this->computeStats(); + Cache::put('stats', $val, 60); +} +``` + +Correct: + +```php +$val = Cache::remember('stats', 60, fn() => $this->computeStats()); +``` + +## Use `Cache::flexible()` for Stale-While-Revalidate + +On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background. + +Incorrect: `Cache::remember('users', 300, fn () => User::all());` + +Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function. + +## Use `Cache::memo()` to Avoid Redundant Hits Within a Request + +If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory. + +`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5. + +## Use Cache Tags to Invalidate Related Groups + +Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`. + +```php +Cache::tags(['user-1'])->flush(); +``` + +## Use `Cache::add()` for Atomic Conditional Writes + +`add()` only writes if the key does not exist — atomic, no race condition between checking and writing. + +Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }` + +Correct: `Cache::add('lock', true, 10);` + +## Use `once()` for Per-Request Memoization + +`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory. + +```php +public function roles(): Collection +{ + return once(fn () => $this->loadRoles()); +} +``` + +Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching. + +## Configure Failover Cache Stores in Production + +If Redis goes down, the app falls back to a secondary store automatically. + +```php +'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], +``` diff --git a/.agents/skills/laravel-best-practices/rules/collections.md b/.agents/skills/laravel-best-practices/rules/collections.md new file mode 100644 index 000000000..6e8af3e85 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/collections.md @@ -0,0 +1,45 @@ +# Collection Best Practices + +## Use Higher-Order Messages for Simple Operations + +Incorrect: + +```php +$users->each(function (User $user) { + $user->markAsVip(); +}); +``` + +Correct: `$users->each->markAsVip();` + +Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc. + +## Choose `cursor()` vs. `lazy()` Correctly + +- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk). +- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading. + +Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored. + +Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work. + +## Use `lazyById()` When Updating Records While Iterating + +`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation. + +## Use `toQuery()` for Bulk Operations on Collections + +Avoids manual `whereIn` construction. + +Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);` + +Correct: `$users->toQuery()->update([...]);` + +## Use `#[CollectedBy]` for Custom Collection Classes + +More declarative than overriding `newCollection()`. + +```php +#[CollectedBy(UserCollection::class)] +class User extends Model {} +``` diff --git a/.agents/skills/laravel-best-practices/rules/config.md b/.agents/skills/laravel-best-practices/rules/config.md new file mode 100644 index 000000000..eb1d9d778 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/config.md @@ -0,0 +1,79 @@ +# Configuration Best Practices + +## `env()` Only in Config Files + +Direct `env()` calls may return `null` when config is cached. + +Incorrect: + +```php +$key = env('API_KEY'); +``` + +Correct: + +```php +// config/services.php +'key' => env('API_KEY'), + +// Application code +$key = config('services.key'); +``` + +## Use Encrypted Env or External Secrets + +Never store production secrets in plain `.env` files in version control. + +Incorrect: + +```bash + +# .env committed to repo or shared in Slack + +STRIPE_SECRET=sk_live_abc123 +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI +``` + +Correct: + +```bash +php artisan env:encrypt --env=production --readable +php artisan env:decrypt --env=production +``` + +For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime. + +## Use `App::environment()` for Environment Checks + +Incorrect: + +```php +if (env('APP_ENV') === 'production') { +``` + +Correct: + +```php +if (app()->isProduction()) { +// or +if (App::environment('production')) { +``` + +## Use Constants and Language Files + +Use class constants instead of hardcoded magic strings for model states, types, and statuses. + +```php +// Incorrect +return $this->type === 'normal'; + +// Correct +return $this->type === self::TYPE_NORMAL; +``` + +If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there. + +```php +// Only when lang files already exist in the project +return back()->with('message', __('app.article_added')); +``` diff --git a/.agents/skills/laravel-best-practices/rules/db-performance.md b/.agents/skills/laravel-best-practices/rules/db-performance.md new file mode 100644 index 000000000..ec444381f --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/db-performance.md @@ -0,0 +1,205 @@ +# Database Performance Best Practices + +## Always Eager Load Relationships + +Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront. + +Incorrect (N+1 — executes 1 + N queries): + +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Correct (2 queries total): + +```php +$posts = Post::with('author')->get(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Constrain eager loads to select only needed columns (always include the foreign key): + +```php +$users = User::with([ + 'posts' => function ($query) { + $query->select('id', 'user_id', 'title')->where('published', true)->latest()->limit(10); + }, +])->get(); +``` + +## Prevent Lazy Loading in Development + +Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development. + +```php +public function boot(): void +{ + Model::preventLazyLoading(! app()->isProduction()); +} +``` + +Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded. + +## Select Only Needed Columns + +Avoid `SELECT *` — especially when tables have large text or JSON columns. + +Incorrect: + +```php +$posts = Post::with('author')->get(); +``` + +Correct: + +```php +$posts = Post::select('id', 'title', 'user_id', 'created_at') + ->with(['author:id,name,avatar']) + ->get(); +``` + +When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match. + +## Chunk Large Datasets + +Never load thousands of records at once. Use chunking for batch processing. + +Incorrect: + +```php +$users = User::all(); +foreach ($users as $user) { + $user->notify(new WeeklyDigest()); +} +``` + +Correct: + +```php +User::where('subscribed', true)->chunk(200, function ($users) { + foreach ($users as $user) { + $user->notify(new WeeklyDigest()); + } +}); +``` + +Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change: + +```php +User::where('active', false)->chunkById(200, function ($users) { + $users->each->delete(); +}); +``` + +## Add Database Indexes + +Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses. + +Incorrect: + +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: + +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->index()->constrained(); + $table->string('status')->index(); + $table->timestamps(); + $table->index(['status', 'created_at']); +}); +``` + +Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`). + +## Use `withCount()` for Counting Relations + +Never load entire collections just to count them. + +Incorrect: + +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->comments->count(); +} +``` + +Correct: + +```php +$posts = Post::withCount('comments')->get(); +foreach ($posts as $post) { + echo $post->comments_count; +} +``` + +Conditional counting: + +```php +$posts = Post::withCount([ + 'comments', + 'comments as approved_comments_count' => function ($query) { + $query->where('approved', true); + }, +])->get(); +``` + +## Use `cursor()` for Memory-Efficient Iteration + +For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator. + +Incorrect: + +```php +$users = User::where('active', true)->get(); +``` + +Correct: + +```php +foreach (User::where('active', true)->cursor() as $user) { + ProcessUser::dispatch($user->id); +} +``` + +Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records. + +## No Queries in Blade Templates + +Never execute queries in Blade templates. Pass data from controllers. + +Incorrect: + +```blade +@foreach (User::all() as $user) + {{ $user->profile->name }} +@endforeach +``` + +Correct: + +```php +// Controller +$users = User::with('profile')->get(); +return view('users.index', compact('users')); +``` + +```blade +@foreach ($users as $user) + {{ $user->profile->name }} +@endforeach +``` diff --git a/.agents/skills/laravel-best-practices/rules/eloquent.md b/.agents/skills/laravel-best-practices/rules/eloquent.md new file mode 100644 index 000000000..50c1a486e --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/eloquent.md @@ -0,0 +1,164 @@ +# Eloquent Best Practices + +## Use Correct Relationship Types + +Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints. + +```php +public function comments(): HasMany +{ + return $this->hasMany(Comment::class); +} + +public function author(): BelongsTo +{ + return $this->belongsTo(User::class, 'user_id'); +} +``` + +## Use Local Scopes for Reusable Queries + +Extract reusable query constraints into local scopes to avoid duplication. + +Incorrect: + +```php +$active = User::where('verified', true)->whereNotNull('activated_at')->get(); +$articles = Article::whereHas('user', function ($q) { + $q->where('verified', true)->whereNotNull('activated_at'); +})->get(); +``` + +Correct: + +```php +public function scopeActive(Builder $query): Builder +{ + return $query->where('verified', true)->whereNotNull('activated_at'); +} + +// Usage +$active = User::active()->get(); +$articles = Article::whereHas('user', fn ($q) => $q->active())->get(); +``` + +## Apply Global Scopes Sparingly + +Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy. + +Incorrect (global scope for a conditional filter): + +```php +class PublishedScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + $builder->where('published', true); + } +} +// Now admin panels, reports, and background jobs all silently skip drafts +``` + +Correct (local scope you opt into): + +```php +public function scopePublished(Builder $query): Builder +{ + return $query->where('published', true); +} + +Post::published()->paginate(); // Explicit +Post::paginate(); // Admin sees all +``` + +## Define Attribute Casts + +Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. + +```php +protected function casts(): array +{ + return [ + 'is_active' => 'boolean', + 'metadata' => 'array', + 'total' => 'decimal:2', + ]; +} +``` + +## Cast Date Columns Properly + +Always cast date columns. Use Carbon instances in templates instead of formatting strings manually. + +Incorrect: + +```blade +{{ + Carbon::createFromFormat( + 'Y-d-m H-i', + $order->ordered_at, + )->toDateString() +}} +``` + +Correct: + +```php +protected function casts(): array +{ + return [ + 'ordered_at' => 'datetime', + ]; +} +``` + +```blade +{{ $order->ordered_at->toDateString() }} {{ $order->ordered_at->format('m-d') }} +``` + +## Use `whereBelongsTo()` for Relationship Queries + +Cleaner than manually specifying foreign keys. + +Incorrect: + +```php +Post::where('user_id', $user->id)->get(); +``` + +Correct: + +```php +Post::whereBelongsTo($user)->get(); +Post::whereBelongsTo($user, 'author')->get(); +``` + +## Avoid Hardcoded Table Names in Queries + +Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string). + +Incorrect: + +```php +DB::table('users')->where('active', true)->get(); + +$query->join('companies', 'companies.id', '=', 'users.company_id'); + +DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); +``` + +Correct — reference the model's table: + +```php +DB::table(new User()->getTable()) + ->where('active', true) + ->get(); + +// Even better — use Eloquent or the query builder instead of raw SQL +User::where('active', true)->get(); +Order::where('status', 'pending')->get(); +``` + +Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable. + +**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration. diff --git a/.agents/skills/laravel-best-practices/rules/error-handling.md b/.agents/skills/laravel-best-practices/rules/error-handling.md new file mode 100644 index 000000000..f30917a50 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/error-handling.md @@ -0,0 +1,75 @@ +# Error Handling Best Practices + +## Exception Reporting and Rendering + +There are two valid approaches — choose one and apply it consistently across the project. + +**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find: + +```php +class InvalidOrderException extends Exception +{ + public function report(): void + { + /* custom reporting */ + } + + public function render(Request $request): Response + { + return response()->view('errors.invalid-order', status: 422); + } +} +``` + +**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture: + +```php +->withExceptions(function (Exceptions $exceptions) { + $exceptions->report(function (InvalidOrderException $e) { /* ... */ }); + $exceptions->render(function (InvalidOrderException $e, Request $request) { + return response()->view('errors.invalid-order', status: 422); + }); +}) +``` + +Check the existing codebase and follow whichever pattern is already established. + +## Use `ShouldntReport` for Exceptions That Should Never Log + +More discoverable than listing classes in `dontReport()`. + +```php +class PodcastProcessingException extends Exception implements ShouldntReport {} +``` + +## Throttle High-Volume Exceptions + +A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type. + +## Enable `dontReportDuplicates()` + +Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks. + +## Force JSON Error Rendering for API Routes + +Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes. + +```php +$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + return $request->is('api/*') || $request->expectsJson(); +}); +``` + +## Add Context to Exception Classes + +Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry. + +```php +class InvalidOrderException extends Exception +{ + public function context(): array + { + return ['order_id' => $this->orderId]; + } +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/events-notifications.md b/.agents/skills/laravel-best-practices/rules/events-notifications.md new file mode 100644 index 000000000..eeab72096 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/events-notifications.md @@ -0,0 +1,52 @@ +# Events & Notifications Best Practices + +## Rely on Event Discovery + +Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`. + +## Run `event:cache` in Production Deploy + +Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`. + +## Use `ShouldDispatchAfterCommit` Inside Transactions + +Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet. + +```php +class OrderShipped implements ShouldDispatchAfterCommit {} +``` + +## Always Queue Notifications + +Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response. + +```php +class InvoicePaid extends Notification implements ShouldQueue +{ + use Queueable; +} +``` + +## Use `afterCommit()` on Notifications in Transactions + +Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits. + +```php +$user->notify(new InvoicePaid($invoice)->afterCommit()); +``` + +## Route Notification Channels to Dedicated Queues + +Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues. + +## Use On-Demand Notifications for Non-User Recipients + +Avoid creating dummy models to send notifications to arbitrary addresses. + +```php +Notification::route('mail', 'admin@example.com')->notify(new SystemAlert()); +``` + +## Implement `HasLocalePreference` on Notifiable Models + +Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. diff --git a/.agents/skills/laravel-best-practices/rules/http-client.md b/.agents/skills/laravel-best-practices/rules/http-client.md new file mode 100644 index 000000000..a47720df9 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/http-client.md @@ -0,0 +1,168 @@ +# HTTP Client Best Practices + +## Always Set Explicit Timeouts + +The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast. + +Incorrect: + +```php +$response = Http::get('https://api.example.com/users'); +``` + +Correct: + +```php +$response = Http::timeout(5)->connectTimeout(3)->get('https://api.example.com/users'); +``` + +For service-specific clients, define timeouts in a macro: + +```php +Http::macro('github', function () { + return Http::baseUrl('https://api.github.com') + ->timeout(10) + ->connectTimeout(3) + ->withToken(config('services.github.token')); +}); + +$response = Http::github()->get('/repos/laravel/framework'); +``` + +## Use Retry with Backoff for External APIs + +External APIs have transient failures. Use `retry()` with increasing delays. + +Incorrect: + +```php +$response = Http::post('https://api.stripe.com/v1/charges', $data); + +if ($response->failed()) { + throw new PaymentFailedException('Charge failed'); +} +``` + +Correct: + +```php +$response = Http::retry([100, 500, 1000]) + ->timeout(10) + ->post('https://api.stripe.com/v1/charges', $data); +``` + +Only retry on specific errors: + +```php +$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) { + return $exception instanceof ConnectionException || + ($exception instanceof RequestException && $exception->response->serverError()); +})->post('https://api.example.com/data'); +``` + +## Handle Errors Explicitly + +The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`. + +Incorrect: + +```php +$response = Http::get('https://api.example.com/users/1'); +$user = $response->json(); // Could be an error body +``` + +Correct: + +```php +$response = Http::timeout(5)->get('https://api.example.com/users/1')->throw(); + +$user = $response->json(); +``` + +For graceful degradation: + +```php +$response = Http::get('https://api.example.com/users/1'); + +if ($response->successful()) { + return $response->json(); +} + +if ($response->notFound()) { + return null; +} + +$response->throw(); +``` + +## Use Request Pooling for Concurrent Requests + +When making multiple independent API calls, use `Http::pool()` instead of sequential calls. + +Incorrect: + +```php +$users = Http::get('https://api.example.com/users')->json(); +$posts = Http::get('https://api.example.com/posts')->json(); +$comments = Http::get('https://api.example.com/comments')->json(); +``` + +Correct: + +```php +use Illuminate\Http\Client\Pool; + +$responses = Http::pool( + fn(Pool $pool) => [ + $pool->as('users')->get('https://api.example.com/users'), + $pool->as('posts')->get('https://api.example.com/posts'), + $pool->as('comments')->get('https://api.example.com/comments'), + ], +); + +$users = $responses['users']->json(); +$posts = $responses['posts']->json(); +``` + +## Fake HTTP Calls in Tests + +Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`. + +Incorrect: + +```php +it('syncs user from API', function () { + $service = new UserSyncService(); + $service->sync(1); // Hits the real API +}); +``` + +Correct: + +```php +it('syncs user from API', function () { + Http::preventStrayRequests(); + + Http::fake([ + 'api.example.com/users/1' => Http::response([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]), + ]); + + $service = new UserSyncService(); + $service->sync(1); + + Http::assertSent(function (Request $request) { + return $request->url() === 'https://api.example.com/users/1'; + }); +}); +``` + +Test failure scenarios too: + +```php +Http::fake([ + 'api.example.com/*' => Http::failedConnection(), +]); +``` diff --git a/.agents/skills/laravel-best-practices/rules/mail.md b/.agents/skills/laravel-best-practices/rules/mail.md new file mode 100644 index 000000000..7c717336d --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/mail.md @@ -0,0 +1,27 @@ +# Mail Best Practices + +## Implement `ShouldQueue` on the Mailable Class + +Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it. + +## Use `afterCommit()` on Mailables Inside Transactions + +A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor. + +## Use `assertQueued()` Not `assertSent()` for Queued Mailables + +`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint. + +Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. + +Correct: `Mail::assertQueued(OrderShipped::class);` + +## Use Markdown Mailables for Transactional Emails + +Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag. + +## Separate Content Tests from Sending Tests + +Content tests: instantiate the mailable directly, call `assertSeeInHtml()`. +Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`. +Don't mix them — it conflates concerns and makes tests brittle. diff --git a/.agents/skills/laravel-best-practices/rules/migrations.md b/.agents/skills/laravel-best-practices/rules/migrations.md new file mode 100644 index 000000000..c295476ee --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/migrations.md @@ -0,0 +1,129 @@ +# Migration Best Practices + +## Generate Migrations with Artisan + +Always use `php artisan make:migration` for consistent naming and timestamps. + +Incorrect (manually created file): + +```php +// database/migrations/posts_migration.php ← wrong naming, no timestamp +``` + +Correct (Artisan-generated): + +```bash +php artisan make:migration create_posts_table +php artisan make:migration add_slug_to_posts_table +``` + +## Use `constrained()` for Foreign Keys + +Automatic naming and referential integrity. + +```php +$table->foreignId('user_id')->constrained()->cascadeOnDelete(); + +// Non-standard names +$table->foreignId('author_id')->constrained('users'); +``` + +## Never Modify Deployed Migrations + +Once a migration has run in production, treat it as immutable. Create a new migration to change the table. + +Incorrect (editing a deployed migration): + +```php +// 2024_01_01_create_posts_table.php — already in production +$table->string('slug')->unique(); // ← added after deployment +``` + +Correct (new migration to alter): + +```php +// 2024_03_15_add_slug_to_posts_table.php +Schema::table('posts', function (Blueprint $table) { + $table->string('slug')->unique()->after('title'); +}); +``` + +## Add Indexes in the Migration + +Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes. + +Incorrect: + +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: + +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->index(); + $table->string('status')->index(); + $table->timestamp('shipped_at')->nullable()->index(); + $table->timestamps(); +}); +``` + +## Mirror Defaults in Model `$attributes` + +When a column has a database default, mirror it in the model so new instances have correct values before saving. + +```php +// Migration +$table->string('status')->default('pending'); + +// Model +protected $attributes = [ + 'status' => 'pending', +]; +``` + +## Write Reversible `down()` Methods by Default + +Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments. + +```php +public function down(): void +{ + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn('slug'); + }); +} +``` + +For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported. + +## Keep Migrations Focused + +One concern per migration. Never mix DDL (schema changes) and DML (data manipulation). + +Incorrect (partial failure creates unrecoverable state): + +```php +public function up(): void +{ + Schema::create('settings', function (Blueprint $table) { ... }); + DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +} +``` + +Correct (separate migrations): + +```php +// Migration 1: create_settings_table +Schema::create('settings', function (Blueprint $table) { ... }); + +// Migration 2: seed_default_settings +DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +``` diff --git a/.agents/skills/laravel-best-practices/rules/queue-jobs.md b/.agents/skills/laravel-best-practices/rules/queue-jobs.md new file mode 100644 index 000000000..db67e9e17 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/queue-jobs.md @@ -0,0 +1,145 @@ +# Queue & Job Best Practices + +## Set `retry_after` Greater Than `timeout` + +If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution. + +Incorrect (`retry_after` ≤ `timeout`): + +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 90 ← job retried while still running! +``` + +Correct (`retry_after` > `timeout`): + +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 180 ← safely longer than any job timeout +``` + +## Use Exponential Backoff + +Use progressively longer delays between retries to avoid hammering failing services. + +Incorrect (fixed retry interval): + +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + // Default: retries immediately, overwhelming the API +} +``` + +Correct (exponential backoff): + +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + public $backoff = [1, 5, 10]; +} +``` + +## Implement `ShouldBeUnique` + +Prevent duplicate job processing. + +```php +class GenerateInvoice implements ShouldQueue, ShouldBeUnique +{ + public function uniqueId(): string + { + return $this->order->id; + } + + public $uniqueFor = 3600; +} +``` + +## Always Implement `failed()` + +Handle errors explicitly — don't rely on silent failure. + +```php +public function failed(?Throwable $exception): void +{ + $this->podcast->update(['status' => 'failed']); + Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]); +} +``` + +## Rate Limit External API Calls in Jobs + +Use `RateLimited` middleware to throttle jobs calling third-party APIs. + +```php +public function middleware(): array +{ + return [new RateLimited('external-api')]; +} +``` + +## Batch Related Jobs + +Use `Bus::batch()` when jobs should succeed or fail together. + +```php +Bus::batch([new ImportCsvChunk($chunk1), new ImportCsvChunk($chunk2)]) + ->then(fn(Batch $batch) => Notification::send($user, new ImportComplete())) + ->catch(fn(Batch $batch, Throwable $e) => Log::error('Batch failed')) + ->dispatch(); +``` + +## `retryUntil()` Needs `$tries = 0` + +When using time-based retry limits, set `$tries = 0` to avoid premature failure. + +```php +public $tries = 0; + +public function retryUntil(): \DateTimeInterface +{ + return now()->addHours(4); +} +``` + +## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release + +`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue. + +```php +class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing +{ + // Lock releases when processing begins, not when it finishes +} +``` + +## Use Horizon for Complex Queue Scenarios + +Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities. + +```php +// config/horizon.php +'environments' => [ + 'production' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['high', 'default', 'low'], + 'balance' => 'auto', + 'minProcesses' => 1, + 'maxProcesses' => 10, + 'tries' => 3, + ], + ], +], +``` diff --git a/.agents/skills/laravel-best-practices/rules/routing.md b/.agents/skills/laravel-best-practices/rules/routing.md new file mode 100644 index 000000000..2ce6d7f28 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/routing.md @@ -0,0 +1,105 @@ +# Routing & Controllers Best Practices + +## Use Implicit Route Model Binding + +Let Laravel resolve models automatically from route parameters. + +Incorrect: + +```php +public function show(int $id) +{ + $post = Post::findOrFail($id); +} +``` + +Correct: + +```php +public function show(Post $post) +{ + return view('posts.show', ['post' => $post]); +} +``` + +## Use Scoped Bindings for Nested Resources + +Enforce parent-child relationships automatically. + +```php +Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { + // $post is automatically scoped to $user +})->scopeBindings(); +``` + +## Use Resource Controllers + +Use `Route::resource()` or `apiResource()` for RESTful endpoints. + +```php +Route::resource('posts', PostController::class); +// In routes/api.php — the /api prefix is applied automatically +Route::apiResource('posts', Api\PostController::class); +``` + +## Keep Controllers Thin + +Aim for under 10 lines per method. Extract business logic to action or service classes. + +Incorrect: + +```php +public function store(Request $request) +{ + $validated = $request->validate([...]); + if ($request->hasFile('image')) { + $request->file('image')->move(public_path('images')); + } + $post = Post::create($validated); + $post->tags()->sync($validated['tags']); + event(new PostCreated($post)); + return redirect()->route('posts.show', $post); +} +``` + +Correct: + +```php +public function store(StorePostRequest $request, CreatePostAction $create) +{ + $post = $create->execute($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +## Type-Hint Form Requests + +Type-hinting Form Requests triggers automatic validation and authorization before the method executes. + +Incorrect: + +```php +public function store(Request $request): RedirectResponse +{ + $validated = $request->validate([ + 'title' => ['required', 'max:255'], + 'body' => ['required'], + ]); + + Post::create($validated); + + return redirect()->route('posts.index'); +} +``` + +Correct: + +```php +public function store(StorePostRequest $request): RedirectResponse +{ + Post::create($request->validated()); + + return redirect()->route('posts.index'); +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/scheduling.md b/.agents/skills/laravel-best-practices/rules/scheduling.md new file mode 100644 index 000000000..4752d59b9 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/scheduling.md @@ -0,0 +1,41 @@ +# Task Scheduling Best Practices + +## Use `withoutOverlapping()` on Variable-Duration Tasks + +Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion. + +## Use `onOneServer()` on Multi-Server Deployments + +Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached). + +## Use `runInBackground()` for Concurrent Long Tasks + +By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes. + +## Use `environments()` to Restrict Tasks + +Prevent accidental execution of production-only tasks (billing, reporting) on staging. + +```php +Schedule::command('billing:charge') + ->monthly() + ->environments(['production']); +``` + +## Use `takeUntilTimeout()` for Time-Bounded Processing + +A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time. + +## Use Schedule Groups for Shared Configuration + +Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks. + +```php +Schedule::daily() + ->onOneServer() + ->timezone('America/New_York') + ->group(function () { + Schedule::command('emails:send --force'); + Schedule::command('emails:prune'); + }); +``` diff --git a/.agents/skills/laravel-best-practices/rules/security.md b/.agents/skills/laravel-best-practices/rules/security.md new file mode 100644 index 000000000..2afd8d3d1 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/security.md @@ -0,0 +1,208 @@ +# Security Best Practices + +## Mass Assignment Protection + +Every model must define `$fillable` (whitelist) or `$guarded` (blacklist). + +Incorrect: + +```php +class User extends Model +{ + protected $guarded = []; // All fields are mass assignable +} +``` + +Correct: + +```php +class User extends Model +{ + protected $fillable = ['name', 'email', 'password']; +} +``` + +Never use `$guarded = []` on models that accept user input. + +## Authorize Every Action + +Use policies or gates in controllers. Never skip authorization. + +Incorrect: + +```php +public function update(UpdatePostRequest $request, Post $post) +{ + $post->update($request->validated()); +} +``` + +Correct: + +```php +public function update(UpdatePostRequest $request, Post $post) +{ + Gate::authorize('update', $post); + + $post->update($request->validated()); +} +``` + +Or via Form Request: + +```php +public function authorize(): bool +{ + return $this->user()->can('update', $this->route('post')); +} +``` + +## Prevent SQL Injection + +Always use parameter binding. Never interpolate user input into queries. + +Incorrect: + +```php +DB::select("SELECT * FROM users WHERE name = '{$request->name}'"); +``` + +Correct: + +```php +User::where('name', $request->name)->get(); + +// Raw expressions with bindings +User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get(); +``` + +## Escape Output to Prevent XSS + +Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content. + +Incorrect: + +```blade +{!! $user->bio !!} +``` + +Correct: + +```blade +{{ $user->bio }} +``` + +## CSRF Protection + +Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied. + +Incorrect: + +```blade +
+ +
+``` + +Correct: + +```blade +
+ @csrf + +
+``` + +## Rate Limit Auth and API Routes + +Apply `throttle` middleware to authentication and API routes. + +```php +RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by($request->ip()); +}); + +Route::post('/login', LoginController::class)->middleware('throttle:login'); +``` + +## Validate File Uploads + +Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames. + +```php +public function rules(): array +{ + return [ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; +} +``` + +Store with generated filenames: + +```php +$path = $request->file('avatar')->store('avatars', 'public'); +``` + +## Keep Secrets Out of Code + +Never commit `.env`. Access secrets via `config()` only. + +Incorrect: + +```php +$key = env('API_KEY'); +``` + +Correct: + +```php +// config/services.php +'api_key' => env('API_KEY'), + +// In application code +$key = config('services.api_key'); +``` + +## Audit Dependencies + +Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment. + +```bash +composer audit +``` + +## Encrypt Sensitive Database Fields + +Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`. + +Incorrect: + +```php +class Integration extends Model +{ + protected function casts(): array + { + return [ + 'api_key' => 'string', + ]; + } +} +``` + +Correct: + +```php +class Integration extends Model +{ + protected $hidden = ['api_key', 'api_secret']; + + protected function casts(): array + { + return [ + 'api_key' => 'encrypted', + 'api_secret' => 'encrypted', + ]; + } +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/style.md b/.agents/skills/laravel-best-practices/rules/style.md new file mode 100644 index 000000000..6e975df17 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/style.md @@ -0,0 +1,133 @@ +# Conventions & Style + +## Follow Laravel Naming Conventions + +| What | Convention | Good | Bad | +| ----------- | ------------------------- | ------------------------- | ------------------------ | +| Controller | singular | `ArticleController` | `ArticlesController` | +| Model | singular | `User` | `Users` | +| Table | plural, snake_case | `article_comments` | `articleComments` | +| Pivot table | singular alphabetical | `article_user` | `user_article` | +| Column | snake_case, no model name | `meta_title` | `article_meta_title` | +| Foreign key | singular model + `_id` | `article_id` | `articles_id` | +| Route | plural | `articles/1` | `article/1` | +| Route name | snake_case with dots | `users.show_active` | `users.show-active` | +| Method | camelCase | `getAll` | `get_all` | +| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` | +| Collection | descriptive, plural | `$activeUsers` | `$data` | +| Object | descriptive, singular | `$activeUser` | `$users` | +| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` | +| Config | snake_case | `google_calendar.php` | `googleCalendar.php` | +| Enum | singular | `UserType` | `UserTypes` | + +## Prefer Shorter Readable Syntax + +| Verbose | Shorter | +| ---------------------------------- | ---------------------- | +| `Session::get('cart')` | `session('cart')` | +| `$request->session()->get('cart')` | `session('cart')` | +| `$request->input('name')` | `$request->name` | +| `return Redirect::back()` | `return back()` | +| `Carbon::now()` | `now()` | +| `App::make('Class')` | `app('Class')` | +| `->where('column', '=', 1)` | `->where('column', 1)` | +| `->orderBy('created_at', 'desc')` | `->latest()` | +| `->orderBy('created_at', 'asc')` | `->oldest()` | +| `->first()->name` | `->value('name')` | + +## Use Laravel String & Array Helpers + +Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them. + +Strings — use `Str` and fluent `Str::of()` over raw PHP: + +```php +// Incorrect +$slug = strtolower(str_replace(' ', '-', $title)); +$short = substr($text, 0, 100) . '...'; +$class = substr(strrchr('App\Models\User', '\'), 1); + +// Correct +$slug = Str::slug($title); +$short = Str::limit($text, 100); +$class = class_basename('App\Models\User'); +``` + +Fluent strings — chain operations for complex transformations: + +```php +// Incorrect +$result = strtolower(trim(str_replace('_', '-', $input))); + +// Correct +$result = Str::of($input)->trim()->replace('_', '-')->lower(); +``` + +Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`. + +Arrays — use `Arr` over raw PHP: + +```php +// Incorrect +$name = isset($array['user']['name']) ? $array['user']['name'] : 'default'; + +// Correct +$name = Arr::get($array, 'user.name', 'default'); +``` + +Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`. + +Numbers — use `Number` for display formatting: + +```php +Number::format(1000000); // "1,000,000" +Number::currency(1500, 'USD'); // "$1,500.00" +Number::abbreviate(1000000); // "1M" +Number::fileSize(1024 * 1024); // "1 MB" +Number::percentage(75.5); // "75.5%" +``` + +URIs — use `Uri` for URL manipulation: + +```php +$uri = Uri::of('https://example.com/search')->withQuery(['q' => 'laravel', 'page' => 1]); +``` + +Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining. + +Use `search-docs` for the full list of available methods — these helpers are extensive. + +## No Inline JS/CSS in Blade + +Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes. + +Incorrect: + +```blade +let article = `{{ json_encode($article) }}`; +``` + +Correct: + +```blade + +``` + +Pass data to JS via data attributes or use a dedicated PHP-to-JS package. + +## No Unnecessary Comments + +Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected. + +Incorrect: + +```php +// Check if there are any joins +if (count((array) $builder->getQuery()->joins) > 0) +``` + +Correct: + +```php +if ($this->hasJoins()) +``` diff --git a/.agents/skills/laravel-best-practices/rules/testing.md b/.agents/skills/laravel-best-practices/rules/testing.md new file mode 100644 index 000000000..4fbf12f8a --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/testing.md @@ -0,0 +1,43 @@ +# Testing Best Practices + +## Use `LazilyRefreshDatabase` Over `RefreshDatabase` + +`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date. + +## Use Model Assertions Over Raw Database Assertions + +Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);` + +Correct: `$this->assertModelExists($user);` + +More expressive, type-safe, and fails with clearer messages. + +## Use Factory States and Sequences + +Named states make tests self-documenting. Sequences eliminate repetitive setup. + +Incorrect: `User::factory()->create(['email_verified_at' => null]);` + +Correct: `User::factory()->unverified()->create();` + +## Use `Exceptions::fake()` to Assert Exception Reporting + +Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally. + +## Call `Event::fake()` After Factory Setup + +Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models. + +Incorrect: `Event::fake(); $user = User::factory()->create();` + +Correct: `$user = User::factory()->create(); Event::fake();` + +## Use `recycle()` to Share Relationship Instances Across Factories + +Without `recycle()`, nested factories create separate instances of the same conceptual entity. + +```php +Ticket::factory() + ->recycle(Airline::factory()->create()) + ->create(); +``` diff --git a/.agents/skills/laravel-best-practices/rules/validation.md b/.agents/skills/laravel-best-practices/rules/validation.md new file mode 100644 index 000000000..0484f905c --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/validation.md @@ -0,0 +1,79 @@ +# Validation & Forms Best Practices + +## Use Form Request Classes + +Extract validation from controllers into dedicated Form Request classes. + +Incorrect: + +```php +public function store(Request $request) +{ + $request->validate([ + 'title' => 'required|max:255', + 'body' => 'required', + ]); +} +``` + +Correct: + +```php +public function store(StorePostRequest $request) +{ + Post::create($request->validated()); +} +``` + +## Array vs. String Notation for Rules + +Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses. + +```php +// Preferred for new code +'email' => ['required', 'email', Rule::unique('users')], + +// Follow existing convention if the project uses string notation +'email' => 'required|email|unique:users', +``` + +## Always Use `validated()` + +Get only validated data. Never use `$request->all()` for mass operations. + +Incorrect: + +```php +Post::create($request->all()); +``` + +Correct: + +```php +Post::create($request->validated()); +``` + +## Use `Rule::when()` for Conditional Validation + +```php +'company_name' => [ + Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']), +], +``` + +## Use the `after()` Method for Custom Validation + +Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields. + +```php +public function after(): array +{ + return [ + function (Validator $validator) { + if ($this->quantity > Product::find($this->product_id)?->stock) { + $validator->errors()->add('quantity', 'Not enough stock.'); + } + }, + ]; +} +``` diff --git a/.agents/skills/modular/SKILL.md b/.agents/skills/modular/SKILL.md new file mode 100644 index 000000000..408ff8a32 --- /dev/null +++ b/.agents/skills/modular/SKILL.md @@ -0,0 +1,188 @@ +--- +name: modular +description: Create or modify Laravel modules using `internachi/modular`. Use when the user asks to create a module, add components to a module, scaffold module structure, or work on files in an `app-modules` directory. +argument-hint: [component-type] +--- + +# Laravel Modular Development + +You are helping with a Laravel application that uses `internachi/modular` for modular architecture. Modules live in `app-modules/` and follow Laravel package conventions. + +## Module Structure + +The structure of `app-modules` mimics a standard Laravel application, where what typically would be found in `app` is found in `src`: + +``` +app-modules/ + {module-name}/ + composer.json # PSR-4 autoload, Laravel provider discovery + + src/ + Providers/ + Models/ + Http/ + tests/ + Feature/ + Unit/ + routes/ + {module-name}-routes.php + resources/ + database/ + migrations/ + factories/ + seeders/ +``` + +## Creating a New Module + +When asked to create a new module: + +1. **Check if `internachi/modular` is installed:** + + ```bash + composer show internachi/modular + ``` + + If not installed, install it first: + + ```bash + composer require internachi/modular + ``` + +2. **Check the modular namespace:** + - Check for `config/app-modules.php` + - If present, get the `modules_vendor` value from that file + - If not, assume the vendor name is "modules" + +3. **Create the module:** + + ```bash + php artisan make:module {module-name} --no-interaction + ``` + +4. **Register with Composer:** + ```bash + composer update {module-vendor}/{module-name} + ``` +5. **Sync modules** + ```bash + php artisan modules:sync + ``` + +## Adding Components to a Module + +Use the `--module` flag with Laravel's make commands: + +| Component | Command | +| ---------- | ------------------------------------------------------------------------------------ | +| Model | `php artisan make:model {Name} --module={module} --no-interaction` | +| Controller | `php artisan make:controller {Name}Controller --module={module} --no-interaction` | +| Migration | `php artisan make:migration create_{table}_table --module={module} --no-interaction` | +| Factory | `php artisan make:factory {Name}Factory --module={module} --no-interaction` | +| Seeder | `php artisan make:seeder {Name}Seeder --module={module} --no-interaction` | +| Request | `php artisan make:request {Name}Request --module={module} --no-interaction` | +| Test | `php artisan make:test {Name}Test --module={module} --no-interaction` | +| Policy | `php artisan make:policy {Name}Policy --module={module} --no-interaction` | +| Event | `php artisan make:event {Name} --module={module} --no-interaction` | +| Listener | `php artisan make:listener {Name} --module={module} --no-interaction` | +| Job | `php artisan make:job {Name} --module={module} --no-interaction` | +| Middleware | `php artisan make:middleware {Name} --module={module} --no-interaction` | +| Resource | `php artisan make:resource {Name}Resource --module={module} --no-interaction` | +| Rule | `php artisan make:rule {Name} --module={module} --no-interaction` | +| Observer | `php artisan make:observer {Name}Observer --module={module} --no-interaction` | + +## Module Conventions + +### Namespacing + +- Default namespace: `{ModuleVendor}\{ModuleName}\` +- Example: `Modules\Billing\Models\Invoice` + +### composer.json Format + +```json +{ + "name": "{module-vendor}/{module-name}", + "require": {}, + "autoload": { + "psr-4": { + "{ModuleVendor}\\{ModuleName}\\": "src/" + } + }, + "extra": { + "laravel": { + "providers": ["{ModuleVendor}\\{ModuleName}\\Providers\\{ModuleName}ServiceProvider"] + } + } +} +``` + +### Routes + +- By convention, module routes are in `routes/{module-name}-routes.php` +- By convention, module route names are prefixed with the module name (eg. `billing::dashboards.index`) +- If necessary, break into separate files as needed (eg. `routes/{module-name}-api.php`) +- Routes are auto-discovered—no need to register them + +### Migrations + +- Place in `database/migrations/` +- Auto-discovered by Laravel's migrator +- Run with `php artisan migrate` + +### Factories + +- Place in `database/factories/` +- Auto-loaded for `factory()` calls +- Namespace: `{ModuleVendor}\{ModuleName}\Database\Factories` + +### Tests + +- Place in `tests/Feature/` and `tests/Unit/` +- Run module tests: `php artisan test app-modules/{module-name}/tests` +- All module tests can be run using the `Modules` testsuite configuration that is auto-generated by the `modules:sync` command + +### Cross-Module Dependencies + +- Import models/services from other modules directly +- Example: `use Modules\Billing\Models\Invoice;` +- Keep dependencies minimal and unidirectional when possible + +## Available Commands + +```bash + +# List all modules + +php artisan modules:list + +# Sync phpunit.xml and IDE configs + +php artisan modules:sync + +# Cache module configs + +php artisan modules:cache + +# Clear module cache + +php artisan modules:clear + +# Run module seeders + +php artisan db:seed --module={module-name} +``` + +## Best Practices + +1. **One domain per module** - Group related functionality together +2. **Minimal cross-dependencies** - Modules should be loosely coupled +3. **Follow Laravel conventions** - Use standard directory structure within modules (treat `src` like `app`) + +## When Processing Arguments + +If `$ARGUMENTS` contains: + +- Just a module name (e.g., "billing"): Create the module or list what can be added +- Module + component (e.g., "billing model Invoice"): Create that specific component +- "list": Show existing modules with `php artisan modules:list` diff --git a/.agents/skills/pest-testing/SKILL.md b/.agents/skills/pest-testing/SKILL.md new file mode 100644 index 000000000..21f8ccb67 --- /dev/null +++ b/.agents/skills/pest-testing/SKILL.md @@ -0,0 +1,171 @@ +--- +name: pest-testing +description: 'Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code.' +license: MIT +metadata: + author: laravel +--- + +# Pest Testing 4 + +## Documentation + +Use `search-docs` for detailed Pest 4 patterns and documentation. + +## Basic Usage + +### Creating Tests + +All tests must be written using Pest. Use `php artisan make:test --pest {name}`. + +The `{name}` argument should include only the path and test name, but should not include the test suite. + +- Incorrect: `php artisan make:test --pest Feature/SomeFeatureTest` will generate `tests/Feature/Feature/SomeFeatureTest.php` +- Correct: `php artisan make:test --pest SomeControllerTest` will generate `tests/Feature/SomeControllerTest.php` +- Incorrect: `php artisan make:test --pest --unit Unit/SomeServiceTest` will generate `tests/Unit/Unit/SomeServiceTest.php` +- Correct: `php artisan make:test --pest --unit SomeServiceTest` will generate `tests/Unit/SomeServiceTest.php` + +### Test Organization + +- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. +- Browser tests: `tests/Browser/` directory. +- Do NOT remove tests without approval - these are core application code. + +### Basic Test Structure + +Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`. + + + +```php +it('is true', function () { + expect(true)->toBeTrue(); +}); +``` + +### Running Tests + +- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`. +- Run all tests: `php artisan test --compact`. +- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`. + +## Assertions + +Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: + + + +```php +it('returns all', function () { + $this->postJson('/api/docs', [])->assertSuccessful(); +}); +``` + +| Use | Instead of | +| -------------------- | ------------------- | +| `assertSuccessful()` | `assertStatus(200)` | +| `assertNotFound()` | `assertStatus(404)` | +| `assertForbidden()` | `assertStatus(403)` | + +## Mocking + +Import mock function before use: `use function Pest\Laravel\mock;` + +## Datasets + +Use datasets for repetitive tests (validation rules, etc.): + + + +```php +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); +``` + +## Pest 4 Features + +| Feature | Purpose | +| -------------------- | --------------------------------------- | +| Browser Testing | Full integration tests in real browsers | +| Smoke Testing | Validate multiple pages quickly | +| Visual Regression | Compare screenshots for visual changes | +| Test Sharding | Parallel CI runs | +| Architecture Testing | Enforce code conventions | + +### Browser Test Example + +Browser tests run in real browsers for full integration testing: + +- Browser tests live in `tests/Browser/`. +- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories. +- Use `RefreshDatabase` for clean state per test. +- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures. +- Test on multiple browsers (Chrome, Firefox, Safari) if requested. +- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested. +- Switch color schemes (light/dark mode) when appropriate. +- Take screenshots or pause tests for debugging. + + + +```php +it('may reset the password', function () { + Notification::fake(); + + $this->actingAs(User::factory()->create()); + + $page = visit('/sign-in'); + + $page + ->assertSee('Sign In') + ->assertNoJavaScriptErrors() + ->click('Forgot Password?') + ->fill('email', 'nuno@laravel.com') + ->click('Send Reset Link') + ->assertSee('We have emailed your password reset link!'); + + Notification::assertSent(ResetPassword::class); +}); +``` + +### Smoke Testing + +Quickly validate multiple pages have no JavaScript errors: + + + +```php +$pages = visit(['/', '/about', '/contact']); + +$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs(); +``` + +### Visual Regression Testing + +Capture and compare screenshots to detect visual changes. + +### Test Sharding + +Split tests across parallel processes for faster CI runs. + +### Architecture Testing + +Pest 4 includes architecture testing (from Pest 3): + + + +```php +arch('controllers')->expect('App\Http\Controllers')->toExtendNothing()->toHaveSuffix('Controller'); +``` + +## Common Pitfalls + +- Not importing `use function Pest\Laravel\mock;` before using mock +- Using `assertStatus(200)` instead of `assertSuccessful()` +- Forgetting datasets for repetitive validation tests +- Deleting tests without approval +- Forgetting `assertNoJavaScriptErrors()` in browser tests +- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test` diff --git a/.agents/skills/tailwindcss-development/SKILL.md b/.agents/skills/tailwindcss-development/SKILL.md new file mode 100644 index 000000000..dba5867e7 --- /dev/null +++ b/.agents/skills/tailwindcss-development/SKILL.md @@ -0,0 +1,123 @@ +--- +name: tailwindcss-development +description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS." +license: MIT +metadata: + author: laravel +--- + +# Tailwind CSS Development + +## Documentation + +Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. + +## Basic Usage + +- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. +- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). +- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. + +## Tailwind CSS v4 Specifics + +- Always use Tailwind CSS v4 and avoid deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. + +### CSS-First Configuration + +In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: + + + +```css +@theme { + --color-brand: oklch(0.72 0.11 178); +} +``` + +### Import Syntax + +In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: + + + +```diff +- @tailwind base; +- @tailwind components; +- @tailwind utilities; ++ @import "tailwindcss"; +``` + +### Replaced Utilities + +Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. + +| Deprecated | Replacement | +| ---------------------- | -------------------- | +| bg-opacity-\* | bg-black/\* | +| text-opacity-\* | text-black/\* | +| border-opacity-\* | border-black/\* | +| divide-opacity-\* | divide-black/\* | +| ring-opacity-\* | ring-black/\* | +| placeholder-opacity-\* | placeholder-black/\* | +| flex-shrink-\* | shrink-\* | +| flex-grow-\* | grow-\* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + +## Spacing + +Use `gap` utilities instead of margins for spacing between siblings: + + + +```html +
+
Item 1
+
Item 2
+
+``` + +## Dark Mode + +If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: + + + +```html +
Content adapts to color scheme
+``` + +## Common Patterns + +### Flexbox Layout + + + +```html +
+
Left content
+
Right content
+
+``` + +### Grid Layout + + + +```html +
+
Card 1
+
Card 2
+
Card 3
+
+``` + +## Common Pitfalls + +- Using deprecated v3 utilities (bg-opacity-_, flex-shrink-_, etc.) +- Using `@tailwind` directives instead of `@import "tailwindcss"` +- Trying to use `tailwind.config.js` instead of CSS `@theme` directive +- Using margins for spacing between siblings instead of gap utilities +- Forgetting to add dark mode variants when the project uses dark mode diff --git a/.ai/guidelines/01-domain.blade.php b/.ai/guidelines/01-domain.blade.php new file mode 100644 index 000000000..0a6705407 --- /dev/null +++ b/.ai/guidelines/01-domain.blade.php @@ -0,0 +1,16 @@ +# Domain Docs How the engineering skills should consume this repo's domain documentation when exploring the codebase. ## +Before exploring, read these - **`CONTEXT-MAP.md`** at the repo root — it points at one `CONTEXT.md` per module. Read +each one relevant to the topic. - **`docs/adr/`** — read ADRs that touch the area you're about to work in. Also check +`app-modules//docs/adr/` for module-scoped decisions. If any of these files don't exist, **proceed silently**. Don't flag their + absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms + or decisions actually get resolved. ## File structure This is a multi-context repo (modular monorepo via + `internachi/modular`): ``` / ├── CONTEXT-MAP.md <- system-wide context map ├── docs/adr/ <- system-wide decisions + └── app-modules/ ├── moderation/ │ ├── CONTEXT.md │ └── docs/adr/ <- module-specific decisions ├── bot-discord/ │ + ├── CONTEXT.md │ └── docs/adr/ ├── identity/ │ ├── CONTEXT.md │ └── docs/adr/ └── ... ``` ## Use the glossary's + vocabulary When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test + name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. If the + concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't + use (reconsider) or there's a real gap (note it for `/grill-with-docs`). ## Flag ADR conflicts If your output + contradicts an existing ADR, surface it explicitly rather than silently overriding: > _Contradicts ADR-0007 — but + worth reopening because..._ diff --git a/.ai/guidelines/02-issue-tracker.blade.php b/.ai/guidelines/02-issue-tracker.blade.php new file mode 100644 index 000000000..cd080e616 --- /dev/null +++ b/.ai/guidelines/02-issue-tracker.blade.php @@ -0,0 +1,16 @@ +# Issue tracker: GitHub Issues and PRDs for this repo live as GitHub issues on `he4rt/he4rt-bot-api`. Use the `gh` CLI +for all operations. ## Conventions - **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc +for multi-line bodies. - **Read an issue**: `gh issue view + + --comments`, filtering comments by `jq` and also fetching labels. - **List issues**: `gh issue list --state open + --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: + [.comments[].body]}]'` with appropriate `--label` and `--state` filters. - **Comment on an issue**: `gh issue + comment + + --body "..."` - **Apply / remove labels**: `gh issue edit + + --add-label "..."` / `--remove-label "..."` - **Close**: `gh issue close + + --comment "..."` Infer the repo from `git remote -v` — `gh` does this automatically when run inside a + clone. ## When a skill says "publish to the issue tracker" Create a GitHub issue. ## When a skill says + "fetch the relevant ticket" Run `gh issue view --comments`. diff --git a/.ai/guidelines/03-triage-labels.blade.php b/.ai/guidelines/03-triage-labels.blade.php new file mode 100644 index 000000000..56df1b815 --- /dev/null +++ b/.ai/guidelines/03-triage-labels.blade.php @@ -0,0 +1,7 @@ +# Triage Labels The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label +strings used in this repo's issue tracker. | Label in skills | Label in our tracker | Meaning | | +-------------------------- | -------------------- | ---------------------------------------- | | `needs-triage` | +`needs-triage` | Maintainer needs to evaluate this issue | | `needs-info` | `needs-info` | Waiting on reporter for more +information | | `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | | `ready-for-human` | +`ready-for-human` | Requires human implementation | | `wontfix` | `wontfix` | Will not be actioned | When a skill +mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. diff --git a/.ai/guidelines/99-knowledge-base.blade.php b/.ai/guidelines/99-knowledge-base.blade.php new file mode 100644 index 000000000..66960b326 --- /dev/null +++ b/.ai/guidelines/99-knowledge-base.blade.php @@ -0,0 +1,21 @@ +# Knowledge Base Documentation This project uses `guava/filament-knowledge-base` for embedded docs inside the Filament +admin panel. Docs are Markdown files rendered in the sidebar. ## Structure All files live in `docs/admin/{lang}/`: ``` +docs/admin/{lang}/ ├── introduction.md ├── getting-started.md (type: group) │ └── getting-started/ │ ├── +navigating-the-panel.md │ ├── dashboard.md │ └── profile.md ├── users.md (type: group) │ └── users/ │ ├── +managing-users.md │ ├── roles.md │ ├── teams.md │ └── authentication.md └── system.md (type: group) └── system/ ├── +activity-logs.md ├── emails.md └── configuration.md ``` ### Rules - Maximum **3 levels** of nesting. - Group directories +require a matching `.md` file at the same level with `type: group` in front matter. - All files require YAML front +matter: `title`, `icon`, `order`. - Use `heroicon-o-*` icons (Heroicons outlined set). ### Front Matter ```yaml --- +title: Page Title icon: heroicon-o-document order: 1 --- ``` For groups, add `type: group`: ```yaml --- title: Group +Name icon: heroicon-o-folder order: 2 type: group --- ``` ## Keeping Docs in Sync When changes affect user-facing +behavior, update `docs/admin/en/`: - **New resource/page** — add a doc file under the appropriate group. - **Changed nav +groups/labels** — update the group `.md` and children. - **Added/removed/renamed form fields** — update the resource's +doc page. - **Auth/authorization changes** — update `users/authentication.md` and `users/roles.md`. - **System +features** (logs, emails, config) — update under `system/`. ## Key Files - +`app/Filament/Plugins/BetterKnowledgeBase.php` — sidebar navigation builder - `config/filament-knowledge-base.php` — +plugin config (cache TTL, icons, model) - `resources/views/vendor/filament-knowledge-base/livewire/help-menu.blade.php` +— contextual help popover - `lang/{en,pt_BR}/knowledge_base.php` — KB UI translations ## Contextual Help +(HasKnowledgeBase) Resources can implement `HasKnowledgeBase` for per-resource sidebar help: ```php use +Guava\FilamentKnowledgeBase\Contracts\HasKnowledgeBase; class UserResource extends Resource implements HasKnowledgeBase +{ public static function getDocumentation(): array { return ['users.managing-users', 'users.roles']; } } ``` Doc IDs +follow `{group}.{file-slug}` matching paths under `docs/admin/en/`. diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md new file mode 100644 index 000000000..230143beb --- /dev/null +++ b/CONTEXT-MAP.md @@ -0,0 +1,41 @@ +# Context Map + +This is a modular monorepo (`internachi/modular`). Each bounded context lives under `app-modules/` with its own `CONTEXT.md` and optional `docs/adr/`. + +## Contexts + +| Context | Path | Description | +| ------------------- | ---------------------------------- | --------------------------------------------------------------------------- | +| Moderation | `app-modules/moderation/` | Content moderation pipeline — classification, routing, enforcement, appeals | +| Bot Discord | `app-modules/bot-discord/` | Discord bot runtime (Laracord websocket, slash commands, event handlers) | +| Integration Discord | `app-modules/integration-discord/` | Discord platform transport (REST API via Saloon), OAuth, ETL | +| Identity | `app-modules/identity/` | Users, tenants, external identities, authentication | + +## Relationships + +``` +┌─────────────────┐ ┌──────────────────────┐ +│ Bot Discord │ │ Integration Discord │ +│ (runtime/ws) │────────▶│ (transport/rest) │ +└────────┬────────┘ └──────────┬───────────┘ + │ │ + │ listens to events │ provides DiscordConnector + ▼ │ +┌─────────────────┐ │ +│ Moderation │◀───────────────────┘ +│ (domain core) │ +└────────┬────────┘ + │ resolves identities + ▼ +┌─────────────────┐ +│ Identity │ +│ (users/tenants) │ +└─────────────────┘ +``` + +### Dependency rules + +- **Moderation** is platform-agnostic. It never imports from `bot-discord` or `integration-discord`. +- **Bot Discord** depends on Moderation (listens to domain events) and Integration Discord (uses transport). +- **Integration Discord** depends on Identity (OAuth user resolution). It never imports from Moderation. +- **Identity** has no upstream dependencies on other contexts listed here. diff --git a/app-modules/bot-discord/CONTEXT.md b/app-modules/bot-discord/CONTEXT.md new file mode 100644 index 000000000..de05e8608 --- /dev/null +++ b/app-modules/bot-discord/CONTEXT.md @@ -0,0 +1,38 @@ +# Bot Discord Context + +Discord bot runtime powered by Laracord. Handles websocket events, slash commands, and acts as the platform-specific glue between the Discord ecosystem and domain modules (moderation, activity, gamification). + +## Glossary + +| Term | Definition | Not to be confused with | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| **Event Handler** | A Laracord event class that reacts to Discord websocket events (MESSAGE_CREATE, VOICE_STATE_UPDATE, etc.). Thin orchestrators that delegate to domain modules. | Domain events (like `CaseQueued` from moderation) | +| **Listener** | A Laravel event listener that reacts to domain events emitted by other modules. E.g., `NotifyModerationChannel` listens to `CaseQueued`. | Laracord event handlers (which react to Discord websocket) | +| **DiscordModerationAdapter** | Implements `ModerationPlatformContract`. Orchestrates moderation enforcement on Discord (mute, kick, ban) by delegating HTTP calls to `integration-discord` Transport. | The moderation domain itself (which is platform-agnostic) | +| **ModerationEmbedBuilder** | Formats moderation case data into Discord embed objects for channel notifications. | Transport (which just sends the formatted embed) | +| **AutoExecuteAction** | Listener for `CaseReadyForEnforcement`. Creates a `ModerationAction` and dispatches `ExecuteAction` when moderation domain signals auto-execution is safe. | The execution itself (which is done by `ExecuteAction` job) | + +## Module Boundaries + +### This module owns: + +- Laracord bot runtime (websocket connection, event loop) +- Discord event handlers (MESSAGE_CREATE, VOICE_STATE_UPDATE, GUILD_MEMBER_ADD) +- Slash commands +- `ModerationPlatformContract` implementation for Discord +- Discord-specific embed formatting +- Listeners for moderation domain events (notification, auto-execution) + +### This module does NOT own: + +- HTTP requests to Discord REST API (belongs to `integration-discord`) +- Moderation domain logic, policies, or classification (belongs to `moderation`) +- User/identity resolution (belongs to `identity`) +- Activity tracking domain logic (belongs to `activity`) + +## Dependencies + +- **Moderation** — domain events (`CaseQueued`, `CaseReadyForEnforcement`), contracts (`ModerationPlatformContract`), models +- **Integration Discord** — `DiscordConnector`, `DiscordRoleResolver`, Saloon requests +- **Identity** — `ExternalIdentity` for resolving Discord users to internal users +- **Activity** — `NewMessage` action for tracking message activity diff --git a/app-modules/integration-discord/CONTEXT.md b/app-modules/integration-discord/CONTEXT.md new file mode 100644 index 000000000..232129af8 --- /dev/null +++ b/app-modules/integration-discord/CONTEXT.md @@ -0,0 +1,57 @@ +# Integration Discord Context + +Transport and integration layer for the Discord platform. Owns all HTTP communication with Discord's REST API (via Saloon), OAuth flows, and ETL for historical data import. + +## Glossary + +| Term | Definition | Not to be confused with | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| **Transport** | The Saloon-based HTTP layer (`Transport/`) that sends requests to Discord's REST API v10. All Discord HTTP goes through here. | The bot runtime (which uses Laracord websocket in `bot-discord`) | +| **DiscordConnector** | Saloon connector authenticated with Bot token. Used for guild operations, messages, moderation actions. | `DiscordOAuthConnector` (which uses client credentials for OAuth) | +| **DiscordOAuthConnector** | Saloon connector for OAuth2 token exchange and user info retrieval. | — | +| **DiscordRoleResolver** | Utility that fetches a member's roles via `GetMember` and determines their protection tier (admin/mod/none) based on configured role IDs. | Authorization (which is about panel access) | +| **ETL** | Historical data import from legacy Discord bots (messages, profiles, voice logs, moderation events). | Real-time bot events (which are in `bot-discord`) | + +## Structure + +``` +src/ +├── Transport/ +│ ├── DiscordConnector.php ← Bot token auth, base URL, timeout +│ ├── DiscordOAuthConnector.php ← OAuth client credentials +│ ├── DiscordRoleResolver.php ← Resolves member protection tier +│ ├── Requests/ +│ │ ├── Members/ ← GetMember, ModifyMember, RemoveMember +│ │ ├── Bans/ ← CreateBan +│ │ ├── Messages/ ← CreateMessage, DeleteMessage +│ │ ├── Channels/ ← CreateDmChannel +│ │ └── OAuth/ ← ExchangeCodeForToken, GetCurrentUser +│ └── DTOs/ +│ └── DiscordMemberDTO.php +├── OAuth/ +│ ├── DiscordOAuthClient.php ← Implements OAuthClientContract (uses Transport) +│ ├── DiscordOAuthAccessDTO.php +│ └── DiscordOAuthUser.php +└── ETL/ ← Historical import (existing, unchanged) +``` + +## Module Boundaries + +### This module owns: + +- All HTTP requests to Discord REST API (Bot + OAuth) +- Discord role resolution (protection tier) +- OAuth token exchange and user profile retrieval +- ETL import from legacy data + +### This module does NOT own: + +- Websocket connection / bot runtime (belongs to `bot-discord`) +- Moderation domain logic (belongs to `moderation`) +- Embed formatting (belongs to `bot-discord`) +- Slash commands and event handlers (belongs to `bot-discord`) + +## Dependencies + +- **Identity** — OAuth user resolution (`OAuthClientContract`, `OAuthUserDTO`) +- **No dependency on** Moderation or Bot Discord diff --git a/app-modules/moderation/CONTEXT.md b/app-modules/moderation/CONTEXT.md new file mode 100644 index 000000000..b213ac155 --- /dev/null +++ b/app-modules/moderation/CONTEXT.md @@ -0,0 +1,112 @@ +# Moderation Context + +Platform-agnostic content moderation domain. Responsible for classifying content, routing decisions, suggesting and enforcing penalties, and managing appeals. + +## Glossary + +| Term | Definition | Not to be confused with | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| **Case** | A record representing a piece of content flagged for review. Has a lifecycle: `screening → pending → resolved/dismissed`. | A "report" (which is user-submitted evidence attached to a case) | +| **Classification** | The process of determining violation type, severity, and confidence scores for a piece of content. Can be rule-based (deterministic) or AI-powered (probabilistic). | — | +| **Pre-screening** | A fast, synchronous check using only rules (regex/keyword). Runs inline before any case is created. Filters out the ~99% of content that is clearly safe. | Full classification (which includes AI) | +| **Routing** | The process of assigning priority and suggesting an action for a classified case, based on severity, AI scores, report count, and the author's offense history. | — | +| **Enforcement** | Executing a moderation action (mute, kick, ban, warn, content removal) on a target platform. | "Routing" (which decides what to do; enforcement does it) | +| **Penalty Escalation** | Increasing the severity of punishment based on prior offenses within a configurable window (default: 30 days). | — | +| **Platform Adapter** | An implementation of `ModerationPlatformContract` that knows how to execute actions on a specific platform (Discord, Web, Twitch, etc.). Registered in the `PlatformRegistry`. | The transport layer (which is just HTTP communication) | +| **Auto-execution** | Automatically enforcing an action without human review. Only allowed when classification is deterministic (rule-based, `classifier_version='rules'`). AI-only classifications always go to human review queue. | — | +| **Protection Tier** | A platform-specific concept where certain users (admins, moderators) are immune to punitive actions or require elevated permissions to punish. Resolved by the platform adapter. | Authorization/RBAC (which is about panel access) | +| **Appeal** | A request by a punished user to review and potentially overturn a moderation action. Has SLA (48h) and submission window (7 days). | — | +| **Case Source** | How the case originated: `auto_detect` (pipeline caught it), `user_report` (community member reported), `manual` (moderator created). | — | +| **Platform Registry** | A singleton that maps `Platform` enum values to their corresponding adapter instances. Allows O(1) lookup instead of iterating all registered adapters. | Container tags | + +## Pipeline Flow + +``` +Content arrives (from any platform) + │ + ▼ +┌─────────────────────┐ +│ SubmitForModeration │ (Action — entry point) +│ │ +│ 1. Pre-screen (sync) │──── RuleBasedClassifier +│ rules only, <5ms │ +└──────────┬───────────┘ + │ + ┌─────┴──────┐ + │ │ + rule match no match + │ │ + ▼ ▼ +┌─────────┐ ┌──────────────┐ +│ Create │ │ ScreenContent │ (Job — async) +��� Case │ │ │ +│ + │ │ Calls AI │ +│ dispatch│ │ If flagged: │ +│ Classify│ │ create case │ +│ AndRoute│ │ + route │ +└────┬────┘ └──────┬───────┘ + │ │ + ▼ ▼ +┌────────────────────────┐ +│ ClassifyAndRoute │ (Job — async, only for rule-match path) +│ │ +│ 1. Classify (AI enrich)│ +│ 2. RouteCaseAction │ +│ - priority calc │ +│ - penalty advisor �� +│ 3. Emit CaseQueued │ +│ 4. If auto-executable: │ +│ emit CaseReady... │ +└────────────────────────┘ + │ + ▼ +┌────────────────────────────┐ +│ CaseReadyForEnforcement ��� (Domain Event) +│ │ +│ Listeners (per platform): │ +│ → AutoExecuteAction │ +│ creates ModerationAction│ +│ dispatches ExecuteAction │ +└────────────────────────────┘ +``` + +## Domain Events + +| Event | Emitted when | Consumers | +| ------------------------- | -------------------------------------------------------------------- | ------------------------------------------------- | +| `CaseCreated` | A new case is persisted | Audit log | +| `CaseQueued` | Case is classified and queued for review | Platform notification (e.g., Discord mod channel) | +| `CaseReadyForEnforcement` | Case passes auto-execution policy (deterministic + suggested action) | Platform listeners that execute the action | +| `CaseResolved` | Case reaches terminal state | Audit log | +| `ActionExecuted` | Enforcement completed on target platform | Audit log | + +## Module Boundaries + +### This module owns: + +- Classification logic (rules, AI, aggregate) +- Penalty escalation policy +- Auto-execution policy ("when is it safe to auto-execute?") +- Case lifecycle (pending → resolved/dismissed) +- Appeal workflow +- Audit logging +- `ModerationPlatformContract` definition +- `PlatformRegistry` +- Domain events + +### This module does NOT own: + +- Platform-specific HTTP communication (belongs to `integration-*`) +- Discord embed formatting (belongs to `bot-discord`) +- Identity resolution (belongs to `identity` — consumed via `FindExternalIdentity`) +- Webhook/websocket runtime (belongs to `bot-discord`) + +## Configuration + +Key config values in `config/moderation.php`: + +- `thresholds.flag` (0.7) — minimum AI score to create a case +- `thresholds.high_priority` (0.9) — score that boosts priority +- `penalties.escalation_window_days` (30) — lookback for prior offenses +- `classifiers.openai.enabled` — toggle AI classification +- `pipeline.queue` — queue name for moderation jobs diff --git a/app-modules/moderation/docs/adr/0001-hybrid-pipeline-with-event-driven-enforcement.md b/app-modules/moderation/docs/adr/0001-hybrid-pipeline-with-event-driven-enforcement.md new file mode 100644 index 000000000..eb1220216 --- /dev/null +++ b/app-modules/moderation/docs/adr/0001-hybrid-pipeline-with-event-driven-enforcement.md @@ -0,0 +1,86 @@ +# ADR-0001: Hybrid Pipeline with Event-Driven Enforcement + +**Status:** Accepted +**Date:** 2026-05-16 +**Deciders:** danielhe4rt + +## Context + +The moderation system was initially built as a single `MessageReceivedEvent` handler in the `bot-discord` module that performed the entire pipeline inline: ingestion, classification (rules + AI), case creation, routing, and enforcement. This approach: + +1. **Blocked the Discord websocket event loop** — AI classification takes 1-3s, stalling all other bot events. +2. **Made the bot-discord module know every internal detail** of the moderation domain (classifiers, rules, penalty advisors). +3. **Was untestable in isolation** — testing classification required mocking the entire Discord message context. +4. **Was not extensible** — adding Twitch or Telegram would require duplicating the entire orchestration. + +We need an architecture that: + +- Keeps the Discord event loop non-blocking +- Respects module boundaries (moderation is platform-agnostic) +- Supports multiple platforms with minimal per-platform code +- Allows retry/backoff for AI calls without duplicating work +- Lets the moderation domain own its policies (when to auto-execute, escalation rules) + +## Decision + +We adopt a **hybrid architecture** with three key properties: + +### 1. Synchronous pre-screening, async classification (C2 pattern) + +`SubmitForModeration` is the single entry point. It runs `RuleBasedClassifier` synchronously (<5ms, regex on DB) to catch obvious violations immediately. For non-matching content, it dispatches `ScreenContent` to the queue for AI evaluation. This avoids creating cases for the ~99% of messages that are safe. + +### 2. Event-driven enforcement + +The moderation module emits `CaseReadyForEnforcement` only when its auto-execution policy is satisfied (deterministic rule match + suggested action exists). Platform modules listen to this event and handle execution. The moderation module never imports platform-specific code. + +### 3. Platform adapters resolve and execute, domain decides policy + +- **Moderation decides**: what action to take, when auto-execution is safe, penalty escalation +- **Platform adapters decide**: how to execute (Discord API calls, role-based protection/immunity) +- **Transport layer handles**: HTTP communication (Saloon connectors/requests in `integration-discord`) + +### Key structural decisions within this ADR: + +| Aspect | Decision | +| ------------------------ | ----------------------------------------------------------------------------------- | +| Pre-screening | Rules-only sync, AI goes to queue (`ScreenContent` job) | +| Case creation | Only when pre-screen flags (rule match) OR async AI exceeds threshold | +| Classification + Routing | Single job (`ClassifyAndRoute`), routing extracted as `RouteCaseAction` | +| Auto-execution policy | Owned by moderation, emits event only when safe | +| Enforcement trigger | Domain event `CaseReadyForEnforcement` → platform listener | +| Adapter registration | `PlatformRegistry` singleton with `Platform` enum lookup | +| Identity resolution | Delegated to `FindExternalIdentity` action from identity module | +| HTTP transport | Saloon connectors in `integration-discord/Transport/` | +| DTO design | `ModerationContentDTO` carries only `authorExternalId` (string), no Eloquent models | +| Protection/immunity | `DiscordRoleResolver` in integration-discord, consumed by adapter | + +## Alternatives Considered + +### A — Synchronous facade (`ModerationPipeline::process()`) + +Single method orchestrates everything. Simple but blocks the event loop (AI takes 1-3s) and provides no retry granularity. + +### B — Full job chain (`Bus::chain([Ingest, Classify, Route, Execute])`) + +Maximum async granularity but `Bus::chain` doesn't pass data between jobs easily, and enforcement decision is coupled inside `RouteDecision`. No event-driven hook for platforms. + +## Consequences + +### Positive + +- Discord event loop stays responsive (only a <5ms regex check runs sync) +- Adding a new platform = implement `ModerationPlatformContract` + register in `PlatformRegistry` + add listener for `CaseReadyForEnforcement` +- AI failures retry without re-running rules or creating duplicate cases +- Each module is testable in isolation (mock `DiscordConnector` with Saloon `MockClient`) +- `moderation_cases` table stays clean — only flagged content gets a case + +### Negative + +- More indirection to follow (event → listener → job) — requires Horizon for debugging +- Two classification paths (rule-match vs. AI-screen) that must converge correctly +- `PlatformRegistry` is an extra moving part vs. simple container tags + +### Risks + +- If `ScreenContent` queue backs up, AI moderation has latency. Mitigated by: rules catch the worst offenders instantly; AI is for borderline cases that can tolerate seconds of delay. +- Stale `FindExternalIdentity` cache (2 days) could miss a recently linked account. Acceptable: user links account → next message is already cached. diff --git a/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/ExternalIdentityResource.php b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/ExternalIdentityResource.php new file mode 100644 index 000000000..231c17ff9 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/ExternalIdentityResource.php @@ -0,0 +1,102 @@ + ListExternalIdentities::route('/'), + 'create' => CreateExternalIdentity::route('/create'), + 'edit' => EditExternalIdentity::route('/{record}/edit'), + ]; + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]) + ->with(['model', 'connectedByUser']); + } + + /** + * @return Builder + */ + public static function getGlobalSearchEloquentQuery(): Builder + { + return parent::getGlobalSearchEloquentQuery()->with(['connectedByUser', 'user']); + } + + public static function getGloballySearchableAttributes(): array + { + return ['email', 'connectedByUser.name', 'user.name']; + } + + /** + * @param ExternalIdentity $record + */ + public static function getGlobalSearchResultDetails(Model $record): array + { + $details = []; + + if ($record->connectedByUser) { + $details['ConnectedByUser'] = $record->connectedByUser->name; + } + + if ($record->user) { + $details['User'] = $record->user->name; + } + + return $details; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Pages/CreateExternalIdentity.php b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Pages/CreateExternalIdentity.php new file mode 100644 index 000000000..856e05398 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Pages/CreateExternalIdentity.php @@ -0,0 +1,20 @@ +components([ + TextInput::make('provider_message_id') + ->label('Provider Message Id'), + + TextInput::make('channel_id') + ->label('Channel Id'), + + MarkdownEditor::make('content') + ->label('Content') + ->required(), + + TextInput::make('obtained_experience') + ->label('Obtained Experience') + ->required() + ->integer(), + + DatePicker::make('sent_at') + ->label('Sent Date'), + + Select::make('tenant_id') + ->label('Tenant Id') + ->relationship('tenant', 'name') + ->searchable() + ->required(), + + TextInput::make('reactions_count') + ->label('Reactions Count') + ->required() + ->integer(), + + TextInput::make('reactions_total') + ->label('Reactions Total') + ->required() + ->integer(), + + TextInput::make('kind') + ->label('Kind'), + + TextInput::make('raw_message_type') + ->label('Raw Message Type') + ->integer(), + + TextInput::make('source_kind') + ->label('Source Kind'), + + Checkbox::make('is_pinned') + ->label('Is Pinned'), + + Checkbox::make('mentions_everyone') + ->label('Mentions Everyone'), + + TextInput::make('mention_role_count') + ->label('Mention Role Count') + ->required() + ->integer(), + + DatePicker::make('edited_at') + ->label('Edited Date'), + + TextInput::make('reply_to_provider_message_id') + ->label('Reply To Provider Message Id'), + + TextInput::make('reply_to_message_id') + ->label('Reply To Message Id'), + + TextEntry::make('created_at') + ->label('Created Date') + ->dateTime(), + + TextEntry::make('updated_at') + ->label('Last Modified Date') + ->dateTime(), + ]); + } + + public function infolist(Schema $schema): Schema + { + return $schema + ->components([ + TextEntry::make('id') + ->label('Id'), + + TextEntry::make('provider_message_id') + ->label('Provider Message Id'), + + TextEntry::make('channel_id') + ->label('Channel Id'), + + TextEntry::make('content') + ->label('Content'), + + TextEntry::make('obtained_experience') + ->label('Obtained Experience'), + + TextEntry::make('sent_at') + ->label('Sent Date') + ->dateTime(), + + TextEntry::make('tenant.name') + ->label('Tenant Id'), + + TextEntry::make('metadata') + ->label('Metadata'), + + TextEntry::make('reactions_count') + ->label('Reactions Count'), + + TextEntry::make('reactions_total') + ->label('Reactions Total'), + + TextEntry::make('kind') + ->label('Kind'), + + TextEntry::make('raw_message_type') + ->label('Raw Message Type'), + + TextEntry::make('source_kind') + ->label('Source Kind'), + + TextEntry::make('is_pinned') + ->label('Is Pinned'), + + TextEntry::make('mentions_everyone') + ->label('Mentions Everyone'), + + TextEntry::make('mention_role_count') + ->label('Mention Role Count'), + + TextEntry::make('edited_at') + ->label('Edited Date') + ->dateTime(), + + TextEntry::make('reply_to_provider_message_id') + ->label('Reply To Provider Message Id'), + + TextEntry::make('reply_to_message_id') + ->label('Reply To Message Id'), + + TextEntry::make('created_at') + ->label('Created Date') + ->dateTime(), + + TextEntry::make('updated_at') + ->label('Last Modified Date') + ->dateTime(), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('id') + ->columns([ + TextColumn::make('provider_message_id') + ->label('Provider Message Id'), + + TextColumn::make('channel_id') + ->label('Channel Id'), + + TextColumn::make('obtained_experience') + ->label('Obtained Experience'), + + TextColumn::make('sent_at') + ->label('Sent Date') + ->date(), + + TextColumn::make('tenant.name') + ->label('Tenant Id') + ->searchable() + ->sortable(), + + TextColumn::make('metadata') + ->label('Metadata'), + + TextColumn::make('reactions_count') + ->label('Reactions Count'), + + TextColumn::make('reactions_total') + ->label('Reactions Total'), + + TextColumn::make('kind') + ->label('Kind'), + + TextColumn::make('raw_message_type') + ->label('Raw Message Type'), + + TextColumn::make('source_kind') + ->label('Source Kind'), + + TextColumn::make('is_pinned') + ->label('Is Pinned'), + + TextColumn::make('mentions_everyone') + ->label('Mentions Everyone'), + + TextColumn::make('mention_role_count') + ->label('Mention Role Count'), + + TextColumn::make('edited_at') + ->label('Edited Date') + ->date(), + + TextColumn::make('reply_to_provider_message_id') + ->label('Reply To Provider Message Id'), + + TextColumn::make('reply_to_message_id') + ->label('Reply To Message Id'), + ]) + ->filters([ + + ]) + ->headerActions([ + CreateAction::make(), + ]) + ->recordActions([ + Action::make('report') + ->label('Report') + ->icon(Heroicon::OutlinedFlag) + ->color('danger') + ->schema([ + Select::make('reason') + ->label('Violation Type') + ->options(ViolationType::class) + ->required(), + Textarea::make('details') + ->label('Details') + ->rows(3), + ]) + ->action(function (Message $record, array $data): void { + $identity = $this->getOwnerRecord(); + + $contentDTO = ModerationContentDTO::fromPlatform(Platform::Discord, [ + 'content_id' => $record->provider_message_id ?? $record->id, + 'content_type' => 'message', + 'author_external_id' => $identity->external_account_id ?? '', + 'text' => $record->content, + 'media_urls' => [], + 'tenant_id' => (string) $record->tenant_id, + 'metadata' => [ + 'channel_id' => $record->channel_id, + ], + ]); + + resolve(SubmitReport::class)->handle( + reporter: auth()->user(), + contentDTO: $contentDTO, + reason: $data['reason'], + details: $data['details'] ?? null, + platform: Platform::Web, + ); + }) + ->successNotificationTitle('Report submitted successfully.'), + EditAction::make(), + DeleteAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Schemas/ExternalIdentityForm.php b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Schemas/ExternalIdentityForm.php new file mode 100644 index 000000000..64698b8d5 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Schemas/ExternalIdentityForm.php @@ -0,0 +1,68 @@ +components([ + TextInput::make('tenant_id') + ->label('Tenant Id') + ->required() + ->integer(), + + TextInput::make('model_type') + ->label('Model Type') + ->required(), + + Select::make('model_id') + ->label('Model Id') + ->relationship('user', 'name') + ->searchable() + ->required(), + + TextInput::make('type') + ->label('Type') + ->required(), + + TextInput::make('provider') + ->label('Provider') + ->required(), + + TextInput::make('external_account_id') + ->label('External Account Id'), + + Select::make('connected_by') + ->label('Connected By') + ->relationship('connectedByUser', 'name') + ->searchable(), + + DatePicker::make('connected_at') + ->label('Connected Date'), + + DatePicker::make('disconnected_at') + ->label('Disconnected Date'), + + TextEntry::make('created_at') + ->label('Created Date') + ->dateTime(), + + TextEntry::make('updated_at') + ->label('Last Modified Date') + ->dateTime(), + + TextInput::make('email') + ->label('Email'), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Schemas/ExternalIdentityInfolist.php b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Schemas/ExternalIdentityInfolist.php new file mode 100644 index 000000000..e992e55ca --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Schemas/ExternalIdentityInfolist.php @@ -0,0 +1,60 @@ +components([ + TextEntry::make('id') + ->label('Id'), + + TextEntry::make('tenant_id') + ->label('Tenant Id'), + + TextEntry::make('model_type') + ->label('Model Type'), + + TextEntry::make('user.name') + ->label('Model Id'), + + TextEntry::make('type') + ->label('Type'), + + TextEntry::make('provider') + ->label('Provider'), + + TextEntry::make('external_account_id') + ->label('External Account Id'), + + TextEntry::make('connectedByUser.name') + ->label('Connected By'), + + TextEntry::make('connected_at') + ->label('Connected Date') + ->dateTime(), + + TextEntry::make('disconnected_at') + ->label('Disconnected Date') + ->dateTime(), + + TextEntry::make('created_at') + ->label('Created Date') + ->dateTime(), + + TextEntry::make('updated_at') + ->label('Last Modified Date') + ->dateTime(), + + TextEntry::make('email') + ->label('Email'), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Tables/ExternalIdentitiesTable.php b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Tables/ExternalIdentitiesTable.php new file mode 100644 index 000000000..3210627ce --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/Tables/ExternalIdentitiesTable.php @@ -0,0 +1,85 @@ +columns([ + TextColumn::make('tenant_id') + ->label('Tenant Id'), + + TextColumn::make('model_type') + ->label('Model Type'), + + TextColumn::make('model.name') + ->label('Owner') + ->searchable(query: fn (Builder $query, string $search): Builder => $query->whereHasMorph('model', '*', function (Builder $q) use ($search): void { + $q->where('name', 'ilike', sprintf('%%%s%%', $search)); + })), + + TextColumn::make('type') + ->label('Type'), + + TextColumn::make('provider') + ->label('Provider'), + + TextColumn::make('external_account_id') + ->label('External Account Id'), + + TextColumn::make('connectedByUser.name') + ->label('Connected By') + ->searchable() + ->sortable(), + + TextColumn::make('connected_at') + ->label('Connected Date') + ->date(), + + TextColumn::make('disconnected_at') + ->label('Disconnected Date') + ->date(), + + TextColumn::make('metadata') + ->label('Metadata'), + + TextColumn::make('email') + ->label('Email') + ->searchable() + ->sortable(), + ]) + ->filters([ + TrashedFilter::make(), + ]) + ->recordActions([ + EditAction::make(), + DeleteAction::make(), + RestoreAction::make(), + ForceDeleteAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + RestoreBulkAction::make(), + ForceDeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 000000000..206319759 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,43 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT-MAP.md`** at the repo root — it points at one `CONTEXT.md` per module. Read each one relevant to the topic. +- **`docs/adr/`** — read ADRs that touch the area you're about to work in. Also check `app-modules//docs/adr/` for module-scoped decisions. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +This is a multi-context repo (modular monorepo via `internachi/modular`): + +``` +/ +├── CONTEXT-MAP.md <- system-wide context map +├── docs/adr/ <- system-wide decisions +└── app-modules/ + ├── moderation/ + │ ├── CONTEXT.md + │ └── docs/adr/ <- module-specific decisions + ├── bot-discord/ + │ ├── CONTEXT.md + │ └── docs/adr/ + ├── identity/ + │ ├── CONTEXT.md + │ └── docs/adr/ + └── ... +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 — but worth reopening because..._ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 000000000..e8569160e --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,22 @@ +# Issue tracker: GitHub + +Issues and PRDs for this repo live as GitHub issues on `he4rt/he4rt-bot-api`. Use the `gh` CLI for all operations. + +## Conventions + +- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --comments`, filtering comments by `jq` and also fetching labels. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Comment on an issue**: `gh issue comment --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --comment "..."` + +Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue. + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view --comments`. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 000000000..a1c862949 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in skills | Label in our tracker | Meaning | +| ----------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +Edit the right-hand column to match whatever vocabulary you actually use. diff --git a/opencode.json b/opencode.json new file mode 100644 index 000000000..4f85b41c6 --- /dev/null +++ b/opencode.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "laravel-boost": { + "type": "local", + "enabled": true, + "command": ["php", "artisan", "boost:mcp"] + } + } +} From e7e5f27128b0f6ea015a32be230a600f09781336 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 16 May 2026 18:31:05 -0300 Subject: [PATCH 02/12] feat(integration-discord): migrate discord http transport to saloon with typed connectors and requests Implements #229. Replaces all ad-hoc Http:: facade usage with Saloon-based typed connectors and request classes for the Discord REST API. - Add saloonphp/saloon dependency - Create DiscordConnector (Bot token auth, timeouts) - Create DiscordOAuthConnector (OAuth2 flows) - Create typed requests: Members (Get/Modify/Remove), Bans (Create), Messages (Create/Delete), Channels (CreateDm), OAuth (ExchangeCode/GetUser) - Extract DiscordRoleResolver (protection tier from member roles) - Migrate DiscordOAuthClient to use Saloon transport - Register connectors as singletons in ServiceProvider - Add unit tests for all requests and resolver (MockClient) - Add feature test for OAuth client migration --- .../src/IntegrationDiscordServiceProvider.php | 15 +- .../src/OAuth/DiscordOAuthClient.php | 34 +- .../src/Transport/DiscordConnector.php | 42 +++ .../src/Transport/DiscordOAuthConnector.php | 38 ++ .../src/Transport/DiscordRoleResolver.php | 44 +++ .../src/Transport/Requests/Bans/CreateBan.php | 38 ++ .../Requests/Channels/CreateDmChannel.php | 36 ++ .../Transport/Requests/Members/GetMember.php | 23 ++ .../Requests/Members/ModifyMember.php | 39 ++ .../Requests/Members/RemoveMember.php | 23 ++ .../Requests/Messages/CreateMessage.php | 38 ++ .../Requests/Messages/DeleteMessage.php | 23 ++ .../Requests/OAuth/ExchangeCodeForToken.php | 43 +++ .../Requests/OAuth/GetCurrentUser.php | 28 ++ .../Feature/OAuth/DiscordOAuthClientTest.php | 81 +++++ .../Unit/Transport/DiscordConnectorTest.php | 34 ++ .../Transport/DiscordRoleResolverTest.php | 87 +++++ .../Unit/Transport/Requests/CreateBanTest.php | 29 ++ .../Requests/CreateDmChannelTest.php | 29 ++ .../Transport/Requests/CreateMessageTest.php | 30 ++ .../Transport/Requests/DeleteMessageTest.php | 18 + .../Requests/ExchangeCodeForTokenTest.php | 35 ++ .../Transport/Requests/GetCurrentUserTest.php | 30 ++ .../Unit/Transport/Requests/GetMemberTest.php | 18 + .../Transport/Requests/ModifyMemberTest.php | 30 ++ .../Transport/Requests/RemoveMemberTest.php | 18 + composer.json | 17 +- composer.lock | 342 +++++++++++------- 28 files changed, 1111 insertions(+), 151 deletions(-) create mode 100644 app-modules/integration-discord/src/Transport/DiscordConnector.php create mode 100644 app-modules/integration-discord/src/Transport/DiscordOAuthConnector.php create mode 100644 app-modules/integration-discord/src/Transport/DiscordRoleResolver.php create mode 100644 app-modules/integration-discord/src/Transport/Requests/Bans/CreateBan.php create mode 100644 app-modules/integration-discord/src/Transport/Requests/Channels/CreateDmChannel.php create mode 100644 app-modules/integration-discord/src/Transport/Requests/Members/GetMember.php create mode 100644 app-modules/integration-discord/src/Transport/Requests/Members/ModifyMember.php create mode 100644 app-modules/integration-discord/src/Transport/Requests/Members/RemoveMember.php create mode 100644 app-modules/integration-discord/src/Transport/Requests/Messages/CreateMessage.php create mode 100644 app-modules/integration-discord/src/Transport/Requests/Messages/DeleteMessage.php create mode 100644 app-modules/integration-discord/src/Transport/Requests/OAuth/ExchangeCodeForToken.php create mode 100644 app-modules/integration-discord/src/Transport/Requests/OAuth/GetCurrentUser.php create mode 100644 app-modules/integration-discord/tests/Feature/OAuth/DiscordOAuthClientTest.php create mode 100644 app-modules/integration-discord/tests/Unit/Transport/DiscordConnectorTest.php create mode 100644 app-modules/integration-discord/tests/Unit/Transport/DiscordRoleResolverTest.php create mode 100644 app-modules/integration-discord/tests/Unit/Transport/Requests/CreateBanTest.php create mode 100644 app-modules/integration-discord/tests/Unit/Transport/Requests/CreateDmChannelTest.php create mode 100644 app-modules/integration-discord/tests/Unit/Transport/Requests/CreateMessageTest.php create mode 100644 app-modules/integration-discord/tests/Unit/Transport/Requests/DeleteMessageTest.php create mode 100644 app-modules/integration-discord/tests/Unit/Transport/Requests/ExchangeCodeForTokenTest.php create mode 100644 app-modules/integration-discord/tests/Unit/Transport/Requests/GetCurrentUserTest.php create mode 100644 app-modules/integration-discord/tests/Unit/Transport/Requests/GetMemberTest.php create mode 100644 app-modules/integration-discord/tests/Unit/Transport/Requests/ModifyMemberTest.php create mode 100644 app-modules/integration-discord/tests/Unit/Transport/Requests/RemoveMemberTest.php diff --git a/app-modules/integration-discord/src/IntegrationDiscordServiceProvider.php b/app-modules/integration-discord/src/IntegrationDiscordServiceProvider.php index 36971780f..db4ee6326 100644 --- a/app-modules/integration-discord/src/IntegrationDiscordServiceProvider.php +++ b/app-modules/integration-discord/src/IntegrationDiscordServiceProvider.php @@ -7,11 +7,24 @@ use He4rt\IntegrationDiscord\ETL\Console\ImportDiscordMessagesCommand; use He4rt\IntegrationDiscord\ETL\Console\ImportDiscordProfilesCommand; use He4rt\IntegrationDiscord\ETL\Console\MergeDuplicateDiscordProfilesCommand; +use He4rt\IntegrationDiscord\Transport\DiscordConnector; +use He4rt\IntegrationDiscord\Transport\DiscordOAuthConnector; use Illuminate\Support\ServiceProvider; class IntegrationDiscordServiceProvider extends ServiceProvider { - public function register(): void {} + public function register(): void + { + $this->app->singleton(DiscordConnector::class, fn (): DiscordConnector => new DiscordConnector( + botToken: config()->string('discord.token'), + )); + + $this->app->singleton(DiscordOAuthConnector::class, fn (): DiscordOAuthConnector => new DiscordOAuthConnector( + clientId: config()->string('services.discord.client_id'), + clientSecret: config()->string('services.discord.client_secret'), + redirectUri: config()->string('services.discord.redirect_uri'), + )); + } public function boot(): void { diff --git a/app-modules/integration-discord/src/OAuth/DiscordOAuthClient.php b/app-modules/integration-discord/src/OAuth/DiscordOAuthClient.php index 3ad255701..962ba2777 100644 --- a/app-modules/integration-discord/src/OAuth/DiscordOAuthClient.php +++ b/app-modules/integration-discord/src/OAuth/DiscordOAuthClient.php @@ -8,16 +8,22 @@ use He4rt\Identity\Auth\DTOs\OAuthAccessDTO; use He4rt\Identity\Auth\DTOs\OAuthStateDTO; use He4rt\Identity\Auth\DTOs\OAuthUserDTO; -use Illuminate\Support\Facades\Http; +use He4rt\IntegrationDiscord\Transport\DiscordOAuthConnector; +use He4rt\IntegrationDiscord\Transport\Requests\OAuth\ExchangeCodeForToken; +use He4rt\IntegrationDiscord\Transport\Requests\OAuth\GetCurrentUser; class DiscordOAuthClient implements OAuthClientContract { + public function __construct( + private readonly DiscordOAuthConnector $connector, + ) {} + public function redirectUrl(?OAuthStateDTO $state = null): string { return 'https://discord.com/oauth2/authorize?'.http_build_query([ - 'client_id' => config('services.discord.client_id'), + 'client_id' => $this->connector->clientId, 'response_type' => 'code', - 'redirect_uri' => config('services.discord.redirect_uri'), + 'redirect_uri' => $this->connector->redirectUri, 'scope' => config('services.discord.scopes'), 'state' => (string) $state, ]); @@ -25,21 +31,21 @@ public function redirectUrl(?OAuthStateDTO $state = null): string public function auth(string $code): OAuthAccessDTO { - $request = Http::asForm()->post('https://discord.com/api/oauth2/token', [ - 'grant_type' => 'authorization_code', - 'code' => $code, - 'redirect_uri' => config('services.discord.redirect_uri'), - 'client_id' => config('services.discord.client_id'), - 'client_secret' => config('services.discord.client_secret'), - ]); - - return DiscordOAuthAccessDTO::make($request->json()); + $response = $this->connector->send(new ExchangeCodeForToken( + code: $code, + clientId: $this->connector->clientId, + clientSecret: $this->connector->clientSecret, + redirectUri: $this->connector->redirectUri, + )); + + return DiscordOAuthAccessDTO::make($response->json()); } public function getAuthenticatedUser(OAuthAccessDTO $credentials): OAuthUserDTO { - $response = Http::withToken($credentials->accessToken) - ->get('https://discord.com/api/v10/users/@me'); + $response = $this->connector->send(new GetCurrentUser( + accessToken: $credentials->accessToken, + )); return DiscordOAuthUser::make($credentials, $response->json()); } diff --git a/app-modules/integration-discord/src/Transport/DiscordConnector.php b/app-modules/integration-discord/src/Transport/DiscordConnector.php new file mode 100644 index 000000000..0e55fecc1 --- /dev/null +++ b/app-modules/integration-discord/src/Transport/DiscordConnector.php @@ -0,0 +1,42 @@ +botToken, 'Bot'); + } + + /** + * @return array + */ + protected function defaultHeaders(): array + { + return [ + 'Accept' => 'application/json', + ]; + } +} diff --git a/app-modules/integration-discord/src/Transport/DiscordOAuthConnector.php b/app-modules/integration-discord/src/Transport/DiscordOAuthConnector.php new file mode 100644 index 000000000..a8c0b4747 --- /dev/null +++ b/app-modules/integration-discord/src/Transport/DiscordOAuthConnector.php @@ -0,0 +1,38 @@ + + */ + protected function defaultHeaders(): array + { + return [ + 'Accept' => 'application/json', + ]; + } +} diff --git a/app-modules/integration-discord/src/Transport/DiscordRoleResolver.php b/app-modules/integration-discord/src/Transport/DiscordRoleResolver.php new file mode 100644 index 000000000..de1007072 --- /dev/null +++ b/app-modules/integration-discord/src/Transport/DiscordRoleResolver.php @@ -0,0 +1,44 @@ +connector->send(new GetMember($guildId, $userId)); + + if ($response->failed()) { + return null; + } + + /** @var array $roles */ + $roles = $response->json('roles', []); + + $adminRoleIds = config('he4rt.discord.moderation.admin_role_ids', []); + $modRoleIds = config('he4rt.discord.moderation.mod_role_ids', []); + + if (array_intersect($roles, $adminRoleIds) !== []) { + return 'admin'; + } + + if (array_intersect($roles, $modRoleIds) !== []) { + return 'mod'; + } + + return null; + } catch (Throwable) { + return null; + } + } +} diff --git a/app-modules/integration-discord/src/Transport/Requests/Bans/CreateBan.php b/app-modules/integration-discord/src/Transport/Requests/Bans/CreateBan.php new file mode 100644 index 000000000..f64765bde --- /dev/null +++ b/app-modules/integration-discord/src/Transport/Requests/Bans/CreateBan.php @@ -0,0 +1,38 @@ +guildId, $this->userId); + } + + /** + * @return array + */ + protected function defaultBody(): array + { + return [ + 'delete_message_seconds' => $this->deleteMessageSeconds, + ]; + } +} diff --git a/app-modules/integration-discord/src/Transport/Requests/Channels/CreateDmChannel.php b/app-modules/integration-discord/src/Transport/Requests/Channels/CreateDmChannel.php new file mode 100644 index 000000000..31afa6c11 --- /dev/null +++ b/app-modules/integration-discord/src/Transport/Requests/Channels/CreateDmChannel.php @@ -0,0 +1,36 @@ + + */ + protected function defaultBody(): array + { + return [ + 'recipient_id' => $this->recipientId, + ]; + } +} diff --git a/app-modules/integration-discord/src/Transport/Requests/Members/GetMember.php b/app-modules/integration-discord/src/Transport/Requests/Members/GetMember.php new file mode 100644 index 000000000..d5536f4dc --- /dev/null +++ b/app-modules/integration-discord/src/Transport/Requests/Members/GetMember.php @@ -0,0 +1,23 @@ +guildId, $this->userId); + } +} diff --git a/app-modules/integration-discord/src/Transport/Requests/Members/ModifyMember.php b/app-modules/integration-discord/src/Transport/Requests/Members/ModifyMember.php new file mode 100644 index 000000000..8f6408eb0 --- /dev/null +++ b/app-modules/integration-discord/src/Transport/Requests/Members/ModifyMember.php @@ -0,0 +1,39 @@ + $payload + */ + public function __construct( + private readonly string $guildId, + private readonly string $userId, + private readonly array $payload, + ) {} + + public function resolveEndpoint(): string + { + return sprintf('/guilds/%s/members/%s', $this->guildId, $this->userId); + } + + /** + * @return array + */ + protected function defaultBody(): array + { + return $this->payload; + } +} diff --git a/app-modules/integration-discord/src/Transport/Requests/Members/RemoveMember.php b/app-modules/integration-discord/src/Transport/Requests/Members/RemoveMember.php new file mode 100644 index 000000000..18794cf9a --- /dev/null +++ b/app-modules/integration-discord/src/Transport/Requests/Members/RemoveMember.php @@ -0,0 +1,23 @@ +guildId, $this->userId); + } +} diff --git a/app-modules/integration-discord/src/Transport/Requests/Messages/CreateMessage.php b/app-modules/integration-discord/src/Transport/Requests/Messages/CreateMessage.php new file mode 100644 index 000000000..57ff9f2a5 --- /dev/null +++ b/app-modules/integration-discord/src/Transport/Requests/Messages/CreateMessage.php @@ -0,0 +1,38 @@ + $payload + */ + public function __construct( + private readonly string $channelId, + private readonly array $payload, + ) {} + + public function resolveEndpoint(): string + { + return sprintf('/channels/%s/messages', $this->channelId); + } + + /** + * @return array + */ + protected function defaultBody(): array + { + return $this->payload; + } +} diff --git a/app-modules/integration-discord/src/Transport/Requests/Messages/DeleteMessage.php b/app-modules/integration-discord/src/Transport/Requests/Messages/DeleteMessage.php new file mode 100644 index 000000000..db261a6c8 --- /dev/null +++ b/app-modules/integration-discord/src/Transport/Requests/Messages/DeleteMessage.php @@ -0,0 +1,23 @@ +channelId, $this->messageId); + } +} diff --git a/app-modules/integration-discord/src/Transport/Requests/OAuth/ExchangeCodeForToken.php b/app-modules/integration-discord/src/Transport/Requests/OAuth/ExchangeCodeForToken.php new file mode 100644 index 000000000..064b42ee3 --- /dev/null +++ b/app-modules/integration-discord/src/Transport/Requests/OAuth/ExchangeCodeForToken.php @@ -0,0 +1,43 @@ + + */ + protected function defaultBody(): array + { + return [ + 'grant_type' => 'authorization_code', + 'code' => $this->code, + 'redirect_uri' => $this->redirectUri, + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + ]; + } +} diff --git a/app-modules/integration-discord/src/Transport/Requests/OAuth/GetCurrentUser.php b/app-modules/integration-discord/src/Transport/Requests/OAuth/GetCurrentUser.php new file mode 100644 index 000000000..448cf73cd --- /dev/null +++ b/app-modules/integration-discord/src/Transport/Requests/OAuth/GetCurrentUser.php @@ -0,0 +1,28 @@ +accessToken); + } +} diff --git a/app-modules/integration-discord/tests/Feature/OAuth/DiscordOAuthClientTest.php b/app-modules/integration-discord/tests/Feature/OAuth/DiscordOAuthClientTest.php new file mode 100644 index 000000000..ac1b7418d --- /dev/null +++ b/app-modules/integration-discord/tests/Feature/OAuth/DiscordOAuthClientTest.php @@ -0,0 +1,81 @@ + MockResponse::make([ + 'access_token' => 'test-access-token', + 'refresh_token' => 'test-refresh-token', + 'expires_in' => 604800, + ]), + ]); + + $connector = new DiscordOAuthConnector('client-id', 'client-secret', 'https://example.com/callback'); + $connector->withMockClient($mockClient); + + $client = new DiscordOAuthClient($connector); + $result = $client->auth('test-code'); + + expect($result) + ->toBeInstanceOf(DiscordOAuthAccessDTO::class) + ->and($result->accessToken)->toBe('test-access-token') + ->and($result->refreshToken)->toBe('test-refresh-token') + ->and($result->expiresIn)->toBe(604800); +}); + +it('gets authenticated user and returns DiscordOAuthUser', function (): void { + $mockClient = new MockClient([ + GetCurrentUser::class => MockResponse::make([ + 'id' => '123456789', + 'username' => 'testuser', + 'global_name' => 'Test User', + 'email' => 'test@example.com', + 'avatar' => 'abc123', + ]), + ]); + + $connector = new DiscordOAuthConnector('client-id', 'client-secret', 'https://example.com/callback'); + $connector->withMockClient($mockClient); + + $credentials = DiscordOAuthAccessDTO::make([ + 'access_token' => 'test-access-token', + 'refresh_token' => 'test-refresh-token', + 'expires_in' => 604800, + ]); + + $client = new DiscordOAuthClient($connector); + $result = $client->getAuthenticatedUser($credentials); + + expect($result) + ->toBeInstanceOf(DiscordOAuthUser::class) + ->and($result->username)->toBe('testuser') + ->and($result->name)->toBe('Test User') + ->and($result->email)->toBe('test@example.com') + ->and($result->providerId)->toBe('123456789'); +}); + +it('generates correct redirect url', function (): void { + config()->set('services.discord.scopes', 'identify email'); + + $connector = new DiscordOAuthConnector('my-client-id', 'client-secret', 'https://example.com/callback'); + $client = new DiscordOAuthClient($connector); + + $url = $client->redirectUrl(); + + expect($url) + ->toContain('https://discord.com/oauth2/authorize') + ->toContain('client_id=my-client-id') + ->toContain('response_type=code') + ->toContain('redirect_uri='.urlencode('https://example.com/callback')) + ->toContain('scope=identify+email'); +}); diff --git a/app-modules/integration-discord/tests/Unit/Transport/DiscordConnectorTest.php b/app-modules/integration-discord/tests/Unit/Transport/DiscordConnectorTest.php new file mode 100644 index 000000000..b2d8181a4 --- /dev/null +++ b/app-modules/integration-discord/tests/Unit/Transport/DiscordConnectorTest.php @@ -0,0 +1,34 @@ +resolveBaseUrl())->toBe('https://discord.com/api/v10'); +}); + +it('uses bot token authentication', function (): void { + $connector = new DiscordConnector('test-bot-token'); + $request = new GetMember('guild', 'user'); + + $pendingRequest = new PendingRequest($connector, $request); + + $authenticator = $pendingRequest->getAuthenticator(); + + expect($authenticator)->toBeInstanceOf(TokenAuthenticator::class); +}); + +it('includes accept json header', function (): void { + $connector = new DiscordConnector('test-bot-token'); + $request = new GetMember('guild', 'user'); + + $pendingRequest = new PendingRequest($connector, $request); + + expect($pendingRequest->headers()->get('Accept'))->toBe('application/json'); +}); diff --git a/app-modules/integration-discord/tests/Unit/Transport/DiscordRoleResolverTest.php b/app-modules/integration-discord/tests/Unit/Transport/DiscordRoleResolverTest.php new file mode 100644 index 000000000..36fa5cd0d --- /dev/null +++ b/app-modules/integration-discord/tests/Unit/Transport/DiscordRoleResolverTest.php @@ -0,0 +1,87 @@ +set('he4rt.discord.moderation.admin_role_ids', ['admin-role-1', 'admin-role-2']); + config()->set('he4rt.discord.moderation.mod_role_ids', ['mod-role-1']); +}); + +it('returns admin when member has an admin role', function (): void { + $mockClient = new MockClient([ + GetMember::class => MockResponse::make([ + 'roles' => ['some-role', 'admin-role-1', 'other-role'], + ]), + ]); + + $connector = new DiscordConnector('test-token'); + $connector->withMockClient($mockClient); + + $resolver = new DiscordRoleResolver($connector); + + expect($resolver->resolveProtectionTier('guild-123', 'user-456'))->toBe('admin'); +}); + +it('returns mod when member has a mod role', function (): void { + $mockClient = new MockClient([ + GetMember::class => MockResponse::make([ + 'roles' => ['some-role', 'mod-role-1'], + ]), + ]); + + $connector = new DiscordConnector('test-token'); + $connector->withMockClient($mockClient); + + $resolver = new DiscordRoleResolver($connector); + + expect($resolver->resolveProtectionTier('guild-123', 'user-456'))->toBe('mod'); +}); + +it('returns null when member has no protected roles', function (): void { + $mockClient = new MockClient([ + GetMember::class => MockResponse::make([ + 'roles' => ['some-role', 'other-role'], + ]), + ]); + + $connector = new DiscordConnector('test-token'); + $connector->withMockClient($mockClient); + + $resolver = new DiscordRoleResolver($connector); + + expect($resolver->resolveProtectionTier('guild-123', 'user-456'))->toBeNull(); +}); + +it('returns null when api fails with non-200 response', function (): void { + $mockClient = new MockClient([ + GetMember::class => MockResponse::make(['message' => 'Unknown Member'], 404), + ]); + + $connector = new DiscordConnector('test-token'); + $connector->withMockClient($mockClient); + + $resolver = new DiscordRoleResolver($connector); + + expect($resolver->resolveProtectionTier('guild-123', 'user-456'))->toBeNull(); +}); + +it('prioritizes admin over mod when member has both roles', function (): void { + $mockClient = new MockClient([ + GetMember::class => MockResponse::make([ + 'roles' => ['admin-role-2', 'mod-role-1'], + ]), + ]); + + $connector = new DiscordConnector('test-token'); + $connector->withMockClient($mockClient); + + $resolver = new DiscordRoleResolver($connector); + + expect($resolver->resolveProtectionTier('guild-123', 'user-456'))->toBe('admin'); +}); diff --git a/app-modules/integration-discord/tests/Unit/Transport/Requests/CreateBanTest.php b/app-modules/integration-discord/tests/Unit/Transport/Requests/CreateBanTest.php new file mode 100644 index 000000000..2337e1979 --- /dev/null +++ b/app-modules/integration-discord/tests/Unit/Transport/Requests/CreateBanTest.php @@ -0,0 +1,29 @@ +getMethod())->toBe(Method::PUT); +}); + +it('resolves the correct endpoint', function (): void { + $request = new CreateBan('123456', '789012', 86400); + + expect($request->resolveEndpoint())->toBe('/guilds/123456/bans/789012'); +}); + +it('includes delete_message_seconds in the body', function (): void { + $request = new CreateBan('123456', '789012', 86400); + + $connector = new DiscordConnector('token'); + $pendingRequest = new PendingRequest($connector, $request); + + expect($pendingRequest->body()->all())->toBe(['delete_message_seconds' => 86400]); +}); diff --git a/app-modules/integration-discord/tests/Unit/Transport/Requests/CreateDmChannelTest.php b/app-modules/integration-discord/tests/Unit/Transport/Requests/CreateDmChannelTest.php new file mode 100644 index 000000000..53f9ebd2a --- /dev/null +++ b/app-modules/integration-discord/tests/Unit/Transport/Requests/CreateDmChannelTest.php @@ -0,0 +1,29 @@ +getMethod())->toBe(Method::POST); +}); + +it('resolves the correct endpoint', function (): void { + $request = new CreateDmChannel('user-123'); + + expect($request->resolveEndpoint())->toBe('/users/@me/channels'); +}); + +it('includes recipient_id in the body', function (): void { + $request = new CreateDmChannel('user-123'); + + $connector = new DiscordConnector('token'); + $pendingRequest = new PendingRequest($connector, $request); + + expect($pendingRequest->body()->all())->toBe(['recipient_id' => 'user-123']); +}); diff --git a/app-modules/integration-discord/tests/Unit/Transport/Requests/CreateMessageTest.php b/app-modules/integration-discord/tests/Unit/Transport/Requests/CreateMessageTest.php new file mode 100644 index 000000000..da9e15813 --- /dev/null +++ b/app-modules/integration-discord/tests/Unit/Transport/Requests/CreateMessageTest.php @@ -0,0 +1,30 @@ + 'Hello']); + + expect($request->getMethod())->toBe(Method::POST); +}); + +it('resolves the correct endpoint', function (): void { + $request = new CreateMessage('channel-123', ['content' => 'Hello']); + + expect($request->resolveEndpoint())->toBe('/channels/channel-123/messages'); +}); + +it('includes the payload as json body', function (): void { + $payload = ['content' => 'Hello', 'embeds' => [['title' => 'Test']]]; + $request = new CreateMessage('channel-123', $payload); + + $connector = new DiscordConnector('token'); + $pendingRequest = new PendingRequest($connector, $request); + + expect($pendingRequest->body()->all())->toBe($payload); +}); diff --git a/app-modules/integration-discord/tests/Unit/Transport/Requests/DeleteMessageTest.php b/app-modules/integration-discord/tests/Unit/Transport/Requests/DeleteMessageTest.php new file mode 100644 index 000000000..e0d5a4975 --- /dev/null +++ b/app-modules/integration-discord/tests/Unit/Transport/Requests/DeleteMessageTest.php @@ -0,0 +1,18 @@ +getMethod())->toBe(Method::DELETE); +}); + +it('resolves the correct endpoint', function (): void { + $request = new DeleteMessage('channel-123', 'message-456'); + + expect($request->resolveEndpoint())->toBe('/channels/channel-123/messages/message-456'); +}); diff --git a/app-modules/integration-discord/tests/Unit/Transport/Requests/ExchangeCodeForTokenTest.php b/app-modules/integration-discord/tests/Unit/Transport/Requests/ExchangeCodeForTokenTest.php new file mode 100644 index 000000000..e8628ed92 --- /dev/null +++ b/app-modules/integration-discord/tests/Unit/Transport/Requests/ExchangeCodeForTokenTest.php @@ -0,0 +1,35 @@ +getMethod())->toBe(Method::POST); +}); + +it('resolves the correct endpoint', function (): void { + $request = new ExchangeCodeForToken('code-123', 'client-id', 'client-secret', 'https://example.com/callback'); + + expect($request->resolveEndpoint())->toBe('/oauth2/token'); +}); + +it('includes oauth parameters as form body', function (): void { + $request = new ExchangeCodeForToken('code-123', 'client-id', 'client-secret', 'https://example.com/callback'); + + $connector = new DiscordOAuthConnector('client-id', 'client-secret', 'https://example.com/callback'); + $pendingRequest = new PendingRequest($connector, $request); + + expect($pendingRequest->body()->all())->toBe([ + 'grant_type' => 'authorization_code', + 'code' => 'code-123', + 'redirect_uri' => 'https://example.com/callback', + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + ]); +}); diff --git a/app-modules/integration-discord/tests/Unit/Transport/Requests/GetCurrentUserTest.php b/app-modules/integration-discord/tests/Unit/Transport/Requests/GetCurrentUserTest.php new file mode 100644 index 000000000..2c1c040ef --- /dev/null +++ b/app-modules/integration-discord/tests/Unit/Transport/Requests/GetCurrentUserTest.php @@ -0,0 +1,30 @@ +getMethod())->toBe(Method::GET); +}); + +it('resolves the correct endpoint', function (): void { + $request = new GetCurrentUser('access-token-123'); + + expect($request->resolveEndpoint())->toBe('/users/@me'); +}); + +it('uses bearer token authentication', function (): void { + $request = new GetCurrentUser('access-token-123'); + + $connector = new DiscordOAuthConnector('client-id', 'client-secret', 'https://example.com/callback'); + $pendingRequest = new PendingRequest($connector, $request); + + expect($pendingRequest->getAuthenticator())->toBeInstanceOf(TokenAuthenticator::class); +}); diff --git a/app-modules/integration-discord/tests/Unit/Transport/Requests/GetMemberTest.php b/app-modules/integration-discord/tests/Unit/Transport/Requests/GetMemberTest.php new file mode 100644 index 000000000..f14e72bfc --- /dev/null +++ b/app-modules/integration-discord/tests/Unit/Transport/Requests/GetMemberTest.php @@ -0,0 +1,18 @@ +getMethod())->toBe(Method::GET); +}); + +it('resolves the correct endpoint', function (): void { + $request = new GetMember('123456', '789012'); + + expect($request->resolveEndpoint())->toBe('/guilds/123456/members/789012'); +}); diff --git a/app-modules/integration-discord/tests/Unit/Transport/Requests/ModifyMemberTest.php b/app-modules/integration-discord/tests/Unit/Transport/Requests/ModifyMemberTest.php new file mode 100644 index 000000000..585e7b5e3 --- /dev/null +++ b/app-modules/integration-discord/tests/Unit/Transport/Requests/ModifyMemberTest.php @@ -0,0 +1,30 @@ + 'new-nick']); + + expect($request->getMethod())->toBe(Method::PATCH); +}); + +it('resolves the correct endpoint', function (): void { + $request = new ModifyMember('123456', '789012', ['nick' => 'new-nick']); + + expect($request->resolveEndpoint())->toBe('/guilds/123456/members/789012'); +}); + +it('includes the payload as json body', function (): void { + $payload = ['nick' => 'new-nick', 'roles' => ['role-1']]; + $request = new ModifyMember('123456', '789012', $payload); + + $connector = new DiscordConnector('token'); + $pendingRequest = new PendingRequest($connector, $request); + + expect($pendingRequest->body()->all())->toBe($payload); +}); diff --git a/app-modules/integration-discord/tests/Unit/Transport/Requests/RemoveMemberTest.php b/app-modules/integration-discord/tests/Unit/Transport/Requests/RemoveMemberTest.php new file mode 100644 index 000000000..e879f67e6 --- /dev/null +++ b/app-modules/integration-discord/tests/Unit/Transport/Requests/RemoveMemberTest.php @@ -0,0 +1,18 @@ +getMethod())->toBe(Method::DELETE); +}); + +it('resolves the correct endpoint', function (): void { + $request = new RemoveMember('123456', '789012'); + + expect($request->resolveEndpoint())->toBe('/guilds/123456/members/789012'); +}); diff --git a/composer.json b/composer.json index 2b501f700..f72c54283 100644 --- a/composer.json +++ b/composer.json @@ -11,8 +11,8 @@ "php": "^8.4", "calebporzio/sushi": "^2.5.4", "dedoc/scramble": "^0.13.22", - "filament/filament": "^5.6.2", - "filament/spatie-laravel-media-library-plugin": "^5.6.2", + "filament/filament": "^5.6.3", + "filament/spatie-laravel-media-library-plugin": "^5.6.3", "guzzlehttp/guzzle": "^7.10.0", "he4rt/activity": ">=1", "he4rt/bot-discord": ">=1.0", @@ -32,9 +32,9 @@ "he4rt/portal": ">=1", "internachi/modular": "^3.0.2", "laracord/framework": "dev-next", - "laravel/framework": "^13.7.0", + "laravel/framework": "^13.9.0", "laravel/nightwatch": "^1.26.1", - "laravel/sanctum": "^4.3.1", + "laravel/sanctum": "^4.3.2", "laravel/telescope": "^5.20.0", "laravel/tinker": "^3.0.2", "league/flysystem-sftp-v3": "^3.33.0", @@ -44,8 +44,9 @@ "owenvoke/blade-fontawesome": "^3.2.2", "predis/predis": "^3.4.2", "ryangjchandler/commonmark-blade-block": "^1.1.1", + "saloonphp/saloon": "^4.0", "spatie/laravel-backup": "^10.2.1", - "spatie/laravel-medialibrary": "^11.22.0", + "spatie/laravel-medialibrary": "^11.22.1", "symfony/dom-crawler": "^8.0.8" }, "require-dev": { @@ -58,16 +59,16 @@ "laravel/pail": "^1.2.6", "laravel/pao": "^1.0.6", "laravel/pint": "^1.29.1", - "laravel/sail": "^1.58.0", + "laravel/sail": "^1.59.0", "mockery/mockery": "^1.6.12", "nunomaduro/collision": "^8.9.4", - "pestphp/pest": "^4.6.3", + "pestphp/pest": "^4.7.0", "pestphp/pest-plugin-drift": "^4.1.0", "pestphp/pest-plugin-faker": "^4.0.0", "pestphp/pest-plugin-laravel": "^4.1.0", "pestphp/pest-plugin-livewire": "^4.1.0", "phpstan/extension-installer": "^1.4.3", - "rector/rector": "^2.4.2" + "rector/rector": "^2.4.3" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 8374d383c..d085f50b1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "60060faa7f911fda3a418f75862a7327", + "content-hash": "bd9ac465244e6f66fcff70f0a50a4425", "packages": [ { "name": "blade-ui-kit/blade-heroicons", @@ -644,16 +644,16 @@ }, { "name": "composer/composer", - "version": "2.9.7", + "version": "2.9.8", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "82a2fbd1372a98d7915cfb092acf05207d9b4113" + "reference": "39ee8baff8e97a1b657bbfcd6a236ff93a5efbb2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/82a2fbd1372a98d7915cfb092acf05207d9b4113", - "reference": "82a2fbd1372a98d7915cfb092acf05207d9b4113", + "url": "https://api.github.com/repos/composer/composer/zipball/39ee8baff8e97a1b657bbfcd6a236ff93a5efbb2", + "reference": "39ee8baff8e97a1b657bbfcd6a236ff93a5efbb2", "shasum": "" }, "require": { @@ -741,7 +741,7 @@ "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/composer/issues", "security": "https://github.com/composer/composer/security/policy", - "source": "https://github.com/composer/composer/tree/2.9.7" + "source": "https://github.com/composer/composer/tree/2.9.8" }, "funding": [ { @@ -753,7 +753,7 @@ "type": "github" } ], - "time": "2026-04-14T11:31:52+00:00" + "time": "2026-05-13T07:28:38+00:00" }, { "name": "composer/metadata-minifier", @@ -2003,16 +2003,16 @@ }, { "name": "filament/actions", - "version": "v5.6.2", + "version": "v5.6.3", "source": { "type": "git", "url": "https://github.com/filamentphp/actions.git", - "reference": "d5b0d6a9311130e34a25cabfa4e91101c2d37e88" + "reference": "38acd89eb7fa90ef97001b1be46742160554b05a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/actions/zipball/d5b0d6a9311130e34a25cabfa4e91101c2d37e88", - "reference": "d5b0d6a9311130e34a25cabfa4e91101c2d37e88", + "url": "https://api.github.com/repos/filamentphp/actions/zipball/38acd89eb7fa90ef97001b1be46742160554b05a", + "reference": "38acd89eb7fa90ef97001b1be46742160554b05a", "shasum": "" }, "require": { @@ -2047,20 +2047,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-05-02T08:33:26+00:00" + "time": "2026-05-11T09:59:40+00:00" }, { "name": "filament/filament", - "version": "v5.6.2", + "version": "v5.6.3", "source": { "type": "git", "url": "https://github.com/filamentphp/panels.git", - "reference": "b3c351850135326c64f00aab802f69aa8c560ad9" + "reference": "01e18f82af33576291ea08d0880553a00e42f131" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/panels/zipball/b3c351850135326c64f00aab802f69aa8c560ad9", - "reference": "b3c351850135326c64f00aab802f69aa8c560ad9", + "url": "https://api.github.com/repos/filamentphp/panels/zipball/01e18f82af33576291ea08d0880553a00e42f131", + "reference": "01e18f82af33576291ea08d0880553a00e42f131", "shasum": "" }, "require": { @@ -2104,20 +2104,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-05-02T08:32:56+00:00" + "time": "2026-05-11T09:58:09+00:00" }, { "name": "filament/forms", - "version": "v5.6.2", + "version": "v5.6.3", "source": { "type": "git", "url": "https://github.com/filamentphp/forms.git", - "reference": "b67e4df2e5e04e2358c507a456882d7fd1313cfb" + "reference": "b140ab0f249d6ea9678fef896910af93d74b50c8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/forms/zipball/b67e4df2e5e04e2358c507a456882d7fd1313cfb", - "reference": "b67e4df2e5e04e2358c507a456882d7fd1313cfb", + "url": "https://api.github.com/repos/filamentphp/forms/zipball/b140ab0f249d6ea9678fef896910af93d74b50c8", + "reference": "b140ab0f249d6ea9678fef896910af93d74b50c8", "shasum": "" }, "require": { @@ -2154,20 +2154,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-05-02T08:38:31+00:00" + "time": "2026-05-11T09:57:47+00:00" }, { "name": "filament/infolists", - "version": "v5.6.2", + "version": "v5.6.3", "source": { "type": "git", "url": "https://github.com/filamentphp/infolists.git", - "reference": "688892eaacde483f7707af54d51699f7922d611e" + "reference": "f7fbfe6d705e930f0a5f2e01286e4ec76b12cb7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/infolists/zipball/688892eaacde483f7707af54d51699f7922d611e", - "reference": "688892eaacde483f7707af54d51699f7922d611e", + "url": "https://api.github.com/repos/filamentphp/infolists/zipball/f7fbfe6d705e930f0a5f2e01286e4ec76b12cb7a", + "reference": "f7fbfe6d705e930f0a5f2e01286e4ec76b12cb7a", "shasum": "" }, "require": { @@ -2199,11 +2199,11 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-05-02T08:32:48+00:00" + "time": "2026-05-11T09:57:44+00:00" }, { "name": "filament/notifications", - "version": "v5.6.2", + "version": "v5.6.3", "source": { "type": "git", "url": "https://github.com/filamentphp/notifications.git", @@ -2250,16 +2250,16 @@ }, { "name": "filament/query-builder", - "version": "v5.6.2", + "version": "v5.6.3", "source": { "type": "git", "url": "https://github.com/filamentphp/query-builder.git", - "reference": "3f1fc2c03649552922954389f81a702180a5166c" + "reference": "870972a57682c32dae18c9708241eb2c0f92a626" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/query-builder/zipball/3f1fc2c03649552922954389f81a702180a5166c", - "reference": "3f1fc2c03649552922954389f81a702180a5166c", + "url": "https://api.github.com/repos/filamentphp/query-builder/zipball/870972a57682c32dae18c9708241eb2c0f92a626", + "reference": "870972a57682c32dae18c9708241eb2c0f92a626", "shasum": "" }, "require": { @@ -2292,20 +2292,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-04-21T15:37:23+00:00" + "time": "2026-05-11T09:58:01+00:00" }, { "name": "filament/schemas", - "version": "v5.6.2", + "version": "v5.6.3", "source": { "type": "git", "url": "https://github.com/filamentphp/schemas.git", - "reference": "6f9c39e3f3ad127a0a086c7d6ea577b7e7dc8553" + "reference": "d2f79fe2dc22b0fa41dc24a35a8fdeb09a8a6765" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/schemas/zipball/6f9c39e3f3ad127a0a086c7d6ea577b7e7dc8553", - "reference": "6f9c39e3f3ad127a0a086c7d6ea577b7e7dc8553", + "url": "https://api.github.com/repos/filamentphp/schemas/zipball/d2f79fe2dc22b0fa41dc24a35a8fdeb09a8a6765", + "reference": "d2f79fe2dc22b0fa41dc24a35a8fdeb09a8a6765", "shasum": "" }, "require": { @@ -2337,11 +2337,11 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-05-02T08:33:42+00:00" + "time": "2026-05-11T09:57:37+00:00" }, { "name": "filament/spatie-laravel-media-library-plugin", - "version": "v5.6.2", + "version": "v5.6.3", "source": { "type": "git", "url": "https://github.com/filamentphp/spatie-laravel-media-library-plugin.git", @@ -2378,16 +2378,16 @@ }, { "name": "filament/support", - "version": "v5.6.2", + "version": "v5.6.3", "source": { "type": "git", "url": "https://github.com/filamentphp/support.git", - "reference": "ca7a16ef9145b3000a5493efe8b905272a9b11cb" + "reference": "c407ad2841d80866cc029bbad7eda0aa931c792c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/support/zipball/ca7a16ef9145b3000a5493efe8b905272a9b11cb", - "reference": "ca7a16ef9145b3000a5493efe8b905272a9b11cb", + "url": "https://api.github.com/repos/filamentphp/support/zipball/c407ad2841d80866cc029bbad7eda0aa931c792c", + "reference": "c407ad2841d80866cc029bbad7eda0aa931c792c", "shasum": "" }, "require": { @@ -2432,20 +2432,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-05-02T08:37:14+00:00" + "time": "2026-05-11T09:58:44+00:00" }, { "name": "filament/tables", - "version": "v5.6.2", + "version": "v5.6.3", "source": { "type": "git", "url": "https://github.com/filamentphp/tables.git", - "reference": "916a01c20d485841d5b3b745711ec4cc4b030e4b" + "reference": "ca6356a486f608c5b5d2a64575388dde0d1b6023" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/tables/zipball/916a01c20d485841d5b3b745711ec4cc4b030e4b", - "reference": "916a01c20d485841d5b3b745711ec4cc4b030e4b", + "url": "https://api.github.com/repos/filamentphp/tables/zipball/ca6356a486f608c5b5d2a64575388dde0d1b6023", + "reference": "ca6356a486f608c5b5d2a64575388dde0d1b6023", "shasum": "" }, "require": { @@ -2478,20 +2478,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-05-02T08:33:35+00:00" + "time": "2026-05-11T09:57:57+00:00" }, { "name": "filament/widgets", - "version": "v5.6.2", + "version": "v5.6.3", "source": { "type": "git", "url": "https://github.com/filamentphp/widgets.git", - "reference": "6a05b754cc23c1776e68bebbf2cf2cce29972f5d" + "reference": "d395af226cdd63aeae7848ab52619201ee582bfd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/widgets/zipball/6a05b754cc23c1776e68bebbf2cf2cce29972f5d", - "reference": "6a05b754cc23c1776e68bebbf2cf2cce29972f5d", + "url": "https://api.github.com/repos/filamentphp/widgets/zipball/d395af226cdd63aeae7848ab52619201ee582bfd", + "reference": "d395af226cdd63aeae7848ab52619201ee582bfd", "shasum": "" }, "require": { @@ -2522,7 +2522,7 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-05-02T08:39:09+00:00" + "time": "2026-05-11T09:59:57+00:00" }, { "name": "fruitcake/php-cors", @@ -3569,7 +3569,12 @@ "dist": { "type": "path", "url": "app-modules/panel-hub", - "reference": "d1f9af23deb139f2c40967d3a7885ce0823df0e1" + "reference": "1624d5602cc0f8b850c03469bb6db921fef67e3a" + }, + "require": { + "filament/filament": "^5.6", + "illuminate/support": "^13.0", + "php": "^8.4" }, "type": "library", "extra": { @@ -4046,16 +4051,16 @@ }, { "name": "laravel/framework", - "version": "v13.8.0", + "version": "v13.9.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "e7db333a025a1e93ebca7744953069d7719f4bcf" + "reference": "a0c6ad03b380287015287d8d5a0fa2459e2332fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/e7db333a025a1e93ebca7744953069d7719f4bcf", - "reference": "e7db333a025a1e93ebca7744953069d7719f4bcf", + "url": "https://api.github.com/repos/laravel/framework/zipball/a0c6ad03b380287015287d8d5a0fa2459e2332fd", + "reference": "a0c6ad03b380287015287d8d5a0fa2459e2332fd", "shasum": "" }, "require": { @@ -4159,7 +4164,7 @@ "aws/aws-sdk-php": "^3.322.9", "ext-gmp": "*", "fakerphp/faker": "^1.24", - "guzzlehttp/psr7": "^2.4", + "guzzlehttp/psr7": "^2.9", "laravel/pint": "^1.18", "league/flysystem-aws-s3-v3": "^3.25.1", "league/flysystem-ftp": "^3.25.1", @@ -4266,7 +4271,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-05-05T21:01:14+00:00" + "time": "2026-05-13T15:38:40+00:00" }, { "name": "laravel/nightwatch", @@ -5021,16 +5026,16 @@ }, { "name": "league/flysystem", - "version": "3.33.0", + "version": "3.34.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "570b8871e0ce693764434b29154c54b434905350" + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350", - "reference": "570b8871e0ce693764434b29154c54b434905350", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", "shasum": "" }, "require": { @@ -5098,9 +5103,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.33.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.34.0" }, - "time": "2026-03-25T07:59:30+00:00" + "time": "2026-05-14T10:28:08+00:00" }, { "name": "league/flysystem-local", @@ -6383,16 +6388,16 @@ }, { "name": "nette/utils", - "version": "v4.1.3", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", - "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", "shasum": "" }, "require": { @@ -6468,9 +6473,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.3" + "source": "https://github.com/nette/utils/tree/v4.1.4" }, - "time": "2026-02-13T03:05:33+00:00" + "time": "2026-05-11T20:49:54+00:00" }, { "name": "nikic/php-parser", @@ -9178,6 +9183,87 @@ ], "time": "2026-03-19T10:05:33+00:00" }, + { + "name": "saloonphp/saloon", + "version": "v4.0.0", + "source": { + "type": "git", + "url": "https://github.com/saloonphp/saloon.git", + "reference": "1307b1d72cacdd2c9c20978cdf7a0b720b4bf3bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/saloonphp/saloon/zipball/1307b1d72cacdd2c9c20978cdf7a0b720b4bf3bb", + "reference": "1307b1d72cacdd2c9c20978cdf7a0b720b4bf3bb", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.6", + "guzzlehttp/promises": "^1.5 || ^2.0", + "guzzlehttp/psr7": "^2.0", + "php": "^8.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "conflict": { + "sammyjo20/saloon": "*" + }, + "require-dev": { + "ext-simplexml": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "illuminate/collections": "^10.0 || ^11.0 || ^12.0", + "league/flysystem": "^3.0", + "pestphp/pest": "^2.36.0 || ^3.8.2 || ^4.1.4", + "phpstan/phpstan": "^2.1.13", + "saloonphp/xml-wrangler": "^1.1", + "spatie/invade": "^2.1", + "symfony/dom-crawler": "^6.0 || ^7.0", + "symfony/var-dumper": "^6.3 || ^7.0" + }, + "suggest": { + "illuminate/collections": "Required for the response collect() method.", + "saloonphp/xml-wrangler": "Required for the response xmlReader() method.", + "symfony/dom-crawler": "Required for the response dom() method.", + "symfony/var-dumper": "Required for default debugging drivers." + }, + "type": "library", + "autoload": { + "psr-4": { + "Saloon\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sam Carré", + "email": "29132017+Sammyjo20@users.noreply.github.com", + "role": "Developer" + } + ], + "description": "Build beautiful API integrations and SDKs with Saloon", + "homepage": "https://github.com/saloonphp/saloon", + "keywords": [ + "api", + "api-integrations", + "saloon", + "sammyjo20", + "sdk" + ], + "support": { + "issues": "https://github.com/saloonphp/saloon/issues", + "source": "https://github.com/saloonphp/saloon/tree/v4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sammyjo20", + "type": "github" + } + ], + "time": "2026-03-17T22:58:33+00:00" + }, { "name": "scrivo/highlight.php", "version": "v9.18.1.10", @@ -10307,16 +10393,16 @@ }, { "name": "symfony/console", - "version": "v8.0.9", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d" + "reference": "3156577f46a38aa1b9323aad223de7a9cd426782" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/7113778e2e91f4709cb3194a75dfa9c0d028d94d", - "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d", + "url": "https://api.github.com/repos/symfony/console/zipball/3156577f46a38aa1b9323aad223de7a9cd426782", + "reference": "3156577f46a38aa1b9323aad223de7a9cd426782", "shasum": "" }, "require": { @@ -10373,7 +10459,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.9" + "source": "https://github.com/symfony/console/tree/v8.0.11" }, "funding": [ { @@ -10393,7 +10479,7 @@ "type": "tidelift" } ], - "time": "2026-04-29T15:02:55+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/css-selector", @@ -10853,16 +10939,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.0.9", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "d1ec4543d5c6c2dac78503c2fae5ea0b3608ce40" + "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d1ec4543d5c6c2dac78503c2fae5ea0b3608ce40", - "reference": "d1ec4543d5c6c2dac78503c2fae5ea0b3608ce40", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/224db910898ce1317b892a9a1338f1f8f17eb7c7", + "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7", "shasum": "" }, "require": { @@ -10899,7 +10985,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.9" + "source": "https://github.com/symfony/filesystem/tree/v8.0.11" }, "funding": [ { @@ -10919,7 +11005,7 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:51:42+00:00" + "time": "2026-05-11T16:39:47+00:00" }, { "name": "symfony/finder", @@ -11143,16 +11229,16 @@ }, { "name": "symfony/http-kernel", - "version": "v8.0.10", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "fb3f65b3d4ca2dad31c80d323819a762ca31d6ac" + "reference": "20d3680373f4b791903c09e74b45402b4aeda71c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/fb3f65b3d4ca2dad31c80d323819a762ca31d6ac", - "reference": "fb3f65b3d4ca2dad31c80d323819a762ca31d6ac", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/20d3680373f4b791903c09e74b45402b4aeda71c", + "reference": "20d3680373f4b791903c09e74b45402b4aeda71c", "shasum": "" }, "require": { @@ -11223,7 +11309,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.0.10" + "source": "https://github.com/symfony/http-kernel/tree/v8.0.11" }, "funding": [ { @@ -11243,7 +11329,7 @@ "type": "tidelift" } ], - "time": "2026-05-06T12:27:31+00:00" + "time": "2026-05-13T18:07:14+00:00" }, { "name": "symfony/mailer", @@ -12473,16 +12559,16 @@ }, { "name": "symfony/process", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc" + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", - "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "url": "https://api.github.com/repos/symfony/process/zipball/26d89e459f037d2873300605d0a07e7a8ef84db0", + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0", "shasum": "" }, "require": { @@ -12514,7 +12600,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.0.8" + "source": "https://github.com/symfony/process/tree/v8.0.11" }, "funding": [ { @@ -12534,7 +12620,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-11T16:56:32+00:00" }, { "name": "symfony/psr-http-message-bridge", @@ -12793,16 +12879,16 @@ }, { "name": "symfony/string", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", - "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", + "url": "https://api.github.com/repos/symfony/string/zipball/39be2ad058a3c0bd558edca23e65f009865d75ff", + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff", "shasum": "" }, "require": { @@ -12859,7 +12945,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.8" + "source": "https://github.com/symfony/string/tree/v8.0.11" }, "funding": [ { @@ -12879,7 +12965,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/translation", @@ -13223,16 +13309,16 @@ }, { "name": "team-reflex/discord-php", - "version": "v10.48.6", + "version": "v10.48.7", "source": { "type": "git", "url": "https://github.com/discord-php/DiscordPHP.git", - "reference": "83fcc81e29bf15095debdaa05f0195d67b3cb03b" + "reference": "9a1e85a58e59d6ddb7e8b0c84c88911160cb64bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/discord-php/DiscordPHP/zipball/83fcc81e29bf15095debdaa05f0195d67b3cb03b", - "reference": "83fcc81e29bf15095debdaa05f0195d67b3cb03b", + "url": "https://api.github.com/repos/discord-php/DiscordPHP/zipball/9a1e85a58e59d6ddb7e8b0c84c88911160cb64bc", + "reference": "9a1e85a58e59d6ddb7e8b0c84c88911160cb64bc", "shasum": "" }, "require": { @@ -13302,7 +13388,7 @@ "chat": "https://discord.gg/dphp", "docs": "https://discord-php.github.io/DiscordPHP/", "issues": "https://github.com/discord-php/DiscordPHP/issues", - "source": "https://github.com/discord-php/DiscordPHP/tree/v10.48.6", + "source": "https://github.com/discord-php/DiscordPHP/tree/v10.48.7", "wiki": "https://github.com/discord-php/DiscordPHP/wiki" }, "funding": [ @@ -13323,7 +13409,7 @@ "type": "patreon" } ], - "time": "2026-04-25T20:56:58+00:00" + "time": "2026-05-11T18:38:43+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -15016,16 +15102,16 @@ }, { "name": "laravel/sail", - "version": "v1.58.0", + "version": "v1.59.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "2e5e968138ca52ed87d712449697a8364d73b466" + "reference": "a41abad557e487eaefde6c9873085ed086fdf47a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/2e5e968138ca52ed87d712449697a8364d73b466", - "reference": "2e5e968138ca52ed87d712449697a8364d73b466", + "url": "https://api.github.com/repos/laravel/sail/zipball/a41abad557e487eaefde6c9873085ed086fdf47a", + "reference": "a41abad557e487eaefde6c9873085ed086fdf47a", "shasum": "" }, "require": { @@ -15075,7 +15161,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-04-27T13:38:34+00:00" + "time": "2026-05-13T14:02:20+00:00" }, { "name": "mockery/mockery", @@ -16921,16 +17007,16 @@ }, { "name": "rector/rector", - "version": "2.4.2", + "version": "2.4.3", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "e645b6463c6a88ea5b44b17d3387d35a912c7946" + "reference": "891824c6c59f02a56a5dd58ea8edc44e6c0ece29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/e645b6463c6a88ea5b44b17d3387d35a912c7946", - "reference": "e645b6463c6a88ea5b44b17d3387d35a912c7946", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/891824c6c59f02a56a5dd58ea8edc44e6c0ece29", + "reference": "891824c6c59f02a56a5dd58ea8edc44e6c0ece29", "shasum": "" }, "require": { @@ -16969,7 +17055,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.4.2" + "source": "https://github.com/rectorphp/rector/tree/2.4.3" }, "funding": [ { @@ -16977,7 +17063,7 @@ "type": "github" } ], - "time": "2026-04-16T13:07:34+00:00" + "time": "2026-05-12T11:17:24+00:00" }, { "name": "sebastian/cli-parser", @@ -17854,16 +17940,16 @@ }, { "name": "symfony/yaml", - "version": "v8.0.10", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "aa9ee60c41d9b20a2468c41ff0a32e2a7405ac05" + "reference": "48046fbd5567bd1717f278eaa2cfc3131f489984" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/aa9ee60c41d9b20a2468c41ff0a32e2a7405ac05", - "reference": "aa9ee60c41d9b20a2468c41ff0a32e2a7405ac05", + "url": "https://api.github.com/repos/symfony/yaml/zipball/48046fbd5567bd1717f278eaa2cfc3131f489984", + "reference": "48046fbd5567bd1717f278eaa2cfc3131f489984", "shasum": "" }, "require": { @@ -17905,7 +17991,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.0.10" + "source": "https://github.com/symfony/yaml/tree/v8.0.11" }, "funding": [ { @@ -17925,7 +18011,7 @@ "type": "tidelift" } ], - "time": "2026-05-05T08:10:04+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", @@ -18109,6 +18195,6 @@ "platform": { "php": "^8.4" }, - "platform-dev": [], - "plugin-api-version": "2.6.0" + "platform-dev": {}, + "plugin-api-version": "2.9.0" } From 1cb2a366bdd2929806b4a19138ec9af6a44492e0 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 16 May 2026 18:35:59 -0300 Subject: [PATCH 03/12] feat(moderation): implement hybrid pipeline with submit, screen, classify-and-route, and platform registry Implements #230. Creates the complete moderation pipeline core that replaces the inline orchestration in MessageReceivedEvent. - Create PlatformRegistry with Platform enum-based O(1) lookup - Create SubmitForModeration action (single entry point, sync rule pre-screen) - Create ScreenContent job (async AI classification, self-contained) - Create ClassifyAndRoute job (enriches rule-matched cases with AI) - Extract RouteCaseAction from RouteDecision (testable in isolation) - Create CaseReadyForEnforcement event (auto-execution policy) - Update ExecuteAction to use PlatformRegistry instead of container tags - Register PlatformRegistry as singleton in ModerationServiceProvider --- .../Cases/Events/CaseReadyForEnforcement.php | 15 ++ .../Actions/RouteCaseAction.php | 50 ++++++ .../Classification/Jobs/ClassifyAndRoute.php | 77 ++++++++++ .../src/Classification/Jobs/ScreenContent.php | 103 +++++++++++++ .../src/Enforcement/ExecuteAction.php | 17 ++- .../src/ModerationServiceProvider.php | 9 ++ .../src/Pipeline/SubmitForModeration.php | 76 ++++++++++ .../src/Platform/PlatformRegistry.php | 35 +++++ .../Feature/Pipeline/ClassifyAndRouteTest.php | 121 +++++++++++++++ .../Feature/Pipeline/ScreenContentTest.php | 142 ++++++++++++++++++ .../Pipeline/SubmitForModerationTest.php | 120 +++++++++++++++ .../tests/Unit/PlatformRegistryTest.php | 37 +++++ .../tests/Unit/RouteCaseActionTest.php | 119 +++++++++++++++ 13 files changed, 914 insertions(+), 7 deletions(-) create mode 100644 app-modules/moderation/src/Cases/Events/CaseReadyForEnforcement.php create mode 100644 app-modules/moderation/src/Classification/Actions/RouteCaseAction.php create mode 100644 app-modules/moderation/src/Classification/Jobs/ClassifyAndRoute.php create mode 100644 app-modules/moderation/src/Classification/Jobs/ScreenContent.php create mode 100644 app-modules/moderation/src/Pipeline/SubmitForModeration.php create mode 100644 app-modules/moderation/src/Platform/PlatformRegistry.php create mode 100644 app-modules/moderation/tests/Feature/Pipeline/ClassifyAndRouteTest.php create mode 100644 app-modules/moderation/tests/Feature/Pipeline/ScreenContentTest.php create mode 100644 app-modules/moderation/tests/Feature/Pipeline/SubmitForModerationTest.php create mode 100644 app-modules/moderation/tests/Unit/PlatformRegistryTest.php create mode 100644 app-modules/moderation/tests/Unit/RouteCaseActionTest.php diff --git a/app-modules/moderation/src/Cases/Events/CaseReadyForEnforcement.php b/app-modules/moderation/src/Cases/Events/CaseReadyForEnforcement.php new file mode 100644 index 000000000..c06f94020 --- /dev/null +++ b/app-modules/moderation/src/Cases/Events/CaseReadyForEnforcement.php @@ -0,0 +1,15 @@ +ai_scores ?? []; + $maxScore = blank($scores) ? 0 : max($scores); + + $highPriorityThreshold = config('moderation.thresholds.high_priority', 0.9); + + $priority = (int) ($maxScore * 100); + + if ($maxScore >= $highPriorityThreshold) { + $priority = min($priority + 10, 100); + } + + $reportBoost = $case->reports()->count() * 10; + $priority = min($priority + $reportBoost, 100); + + $suggestedAction = null; + + if ($case->suggested_action === null && $case->author_id && $case->violation_type && $case->severity) { + $suggestion = $this->advisor->suggest( + $case->author, + $case->violation_type, + $case->severity, + ); + $suggestedAction = $suggestion->action; + } + + $case->update([ + 'status' => CaseStatus::Pending, + 'priority' => $priority, + 'suggested_action' => $suggestedAction ?? $case->suggested_action, + ]); + } +} diff --git a/app-modules/moderation/src/Classification/Jobs/ClassifyAndRoute.php b/app-modules/moderation/src/Classification/Jobs/ClassifyAndRoute.php new file mode 100644 index 000000000..1a0afd919 --- /dev/null +++ b/app-modules/moderation/src/Classification/Jobs/ClassifyAndRoute.php @@ -0,0 +1,77 @@ + */ + public array $backoff = [5, 15, 30]; + + public function __construct(private readonly ModerationCase $case) {} + + public function handle(RouteCaseAction $routeAction): void + { + $content = ModerationContentDTO::fromCase($this->case); + + $result = AggregateClassifier::make() + ->addClassifier(OpenAiClassifier::make()) + ->classify($content); + + $this->case->update([ + 'ai_scores' => $this->mergeScores($this->case->ai_scores ?? [], $result->scores), + 'violation_type' => $result->primary ?? $this->case->violation_type, + 'severity' => $result->severity ?? $this->case->severity, + 'classifier_version' => $this->case->classifier_version, + ]); + + $routeAction->execute($this->case); + + event(new CaseQueued($this->case)); + + if ($this->case->classifier_version === 'rules' && $this->case->suggested_action !== null && $this->case->author_id !== null) { + event(new CaseReadyForEnforcement($this->case)); + } + } + + public function failed(Throwable $exception): void + { + Log::error('ClassifyAndRoute job failed', [ + 'case_id' => $this->case->id, + 'error' => $exception->getMessage(), + ]); + } + + /** + * @param array $existing + * @param array $incoming + * @return array + */ + private function mergeScores(array $existing, array $incoming): array + { + foreach ($incoming as $type => $score) { + $existing[$type] = max($existing[$type] ?? 0, $score); + } + + return $existing; + } +} diff --git a/app-modules/moderation/src/Classification/Jobs/ScreenContent.php b/app-modules/moderation/src/Classification/Jobs/ScreenContent.php new file mode 100644 index 000000000..d761f6e99 --- /dev/null +++ b/app-modules/moderation/src/Classification/Jobs/ScreenContent.php @@ -0,0 +1,103 @@ + */ + public array $backoff = [5, 15, 30]; + + public function __construct( + private readonly ModerationContentDTO $content, + private readonly CaseSource $source, + ) {} + + public function handle(RouteCaseAction $routeAction): void + { + $result = AggregateClassifier::make() + ->addClassifier(OpenAiClassifier::make()) + ->classify($this->content); + + $maxScore = blank($result->scores) ? 0 : max($result->scores); + $flagThreshold = config('moderation.thresholds.flag', 0.7); + + if ($maxScore < $flagThreshold) { + return; + } + + $authorId = $this->resolveAuthorId(); + + $case = ModerationCase::query()->create([ + 'content_type' => $this->content->contentType, + 'content_id' => $this->content->contentId, + 'content_snapshot' => $this->content->snapshot, + 'source_platform' => $this->content->sourcePlatform, + 'source' => $this->source, + 'status' => CaseStatus::Pending, + 'priority' => 50, + 'author_id' => $authorId, + 'tenant_id' => $this->content->tenantId, + 'ai_scores' => $result->scores, + 'violation_type' => $result->primary, + 'severity' => $result->severity, + 'classifier_version' => $result->classifierName, + ]); + + event(new CaseCreated($case)); + + $routeAction->execute($case); + + event(new CaseQueued($case)); + + if ($case->classifier_version === 'rules' && $case->suggested_action !== null && $case->author_id !== null) { + event(new CaseReadyForEnforcement($case)); + } + } + + public function failed(Throwable $exception): void + { + Log::error('ScreenContent job failed', [ + 'content_id' => $this->content->contentId, + 'content_type' => $this->content->contentType, + 'source' => $this->source->value, + 'error' => $exception->getMessage(), + ]); + } + + private function resolveAuthorId(): ?string + { + if ($this->content->authorExternalId === '') { + return null; + } + + return ExternalIdentity::query() + ->where('provider', IdentityProvider::Discord) + ->where('external_account_id', $this->content->authorExternalId) + ->value('model_id'); + } +} diff --git a/app-modules/moderation/src/Enforcement/ExecuteAction.php b/app-modules/moderation/src/Enforcement/ExecuteAction.php index 2e18c74b0..ccedd4f47 100644 --- a/app-modules/moderation/src/Enforcement/ExecuteAction.php +++ b/app-modules/moderation/src/Enforcement/ExecuteAction.php @@ -7,7 +7,8 @@ use He4rt\Identity\User\Models\User; use He4rt\Moderation\DTOs\ExecutionResultDTO; use He4rt\Moderation\Enums\CaseStatus; -use He4rt\Moderation\Platform\ModerationPlatformContract; +use He4rt\Moderation\Enums\Platform; +use He4rt\Moderation\Platform\PlatformRegistry; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; @@ -22,16 +23,18 @@ public function __construct( private readonly User $target, ) {} - public function handle(): void + public function handle(PlatformRegistry $registry): void { - $platforms = app()->tagged('moderation.platforms'); $results = []; - foreach ($platforms as $adapter) { - /** @var ModerationPlatformContract $adapter */ - if (in_array($adapter->platform()->value, $this->action->target_platforms, true)) { - $results[] = $adapter->execute($this->action, $this->target); + foreach ($this->action->target_platforms as $platformValue) { + $platform = Platform::tryFrom($platformValue); + + if ($platform === null || !$registry->has($platform)) { + continue; } + + $results[] = $registry->resolve($platform)->execute($this->action, $this->target); } $this->action->update([ diff --git a/app-modules/moderation/src/ModerationServiceProvider.php b/app-modules/moderation/src/ModerationServiceProvider.php index c792ac6e0..284745218 100644 --- a/app-modules/moderation/src/ModerationServiceProvider.php +++ b/app-modules/moderation/src/ModerationServiceProvider.php @@ -8,6 +8,8 @@ use He4rt\Moderation\Cases\Events\CaseCreated; use He4rt\Moderation\Cases\Events\CaseResolved; use He4rt\Moderation\Enforcement\ActionExecuted; +use He4rt\Moderation\Enums\Platform; +use He4rt\Moderation\Platform\PlatformRegistry; use He4rt\Moderation\Platform\WebModerationAdapter; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; @@ -18,6 +20,13 @@ public function register(): void { $this->mergeConfigFrom(__DIR__.'/../config/moderation.php', 'moderation'); + $this->app->singleton(PlatformRegistry::class, function (): PlatformRegistry { + $registry = new PlatformRegistry(); + $registry->register(Platform::Web, WebModerationAdapter::class); + + return $registry; + }); + $this->app->singleton(WebModerationAdapter::class); $this->app->tag([WebModerationAdapter::class], 'moderation.platforms'); } diff --git a/app-modules/moderation/src/Pipeline/SubmitForModeration.php b/app-modules/moderation/src/Pipeline/SubmitForModeration.php new file mode 100644 index 000000000..8bfcc3568 --- /dev/null +++ b/app-modules/moderation/src/Pipeline/SubmitForModeration.php @@ -0,0 +1,76 @@ +ruleClassifier->classify($content); + $hasRuleMatch = $ruleResult->matchedRules !== []; + + if ($hasRuleMatch) { + $ruleAction = ModerationRule::query() + ->whereIn('id', $ruleResult->matchedRules) + ->get() + ->sortByDesc(fn (ModerationRule $rule): int => $rule->severity->weight()) + ->first()?->action_on_match; + + $case = ModerationCase::query()->create([ + 'content_type' => $content->contentType, + 'content_id' => $content->contentId, + 'content_snapshot' => $content->snapshot, + 'source_platform' => $content->sourcePlatform, + 'source' => $source, + 'status' => CaseStatus::Pending, + 'priority' => 50, + 'author_id' => $this->resolveAuthorId($content), + 'tenant_id' => $content->tenantId, + 'ai_scores' => $ruleResult->scores, + 'violation_type' => $ruleResult->primary, + 'severity' => $ruleResult->severity, + 'classifier_version' => $ruleResult->classifierName, + 'suggested_action' => $ruleAction, + ]); + + event(new CaseCreated($case)); + dispatch(new ClassifyAndRoute($case)); + + return $case; + } + + dispatch(new ScreenContent($content, $source)); + + return null; + } + + private function resolveAuthorId(ModerationContentDTO $content): ?string + { + if ($content->authorExternalId === '') { + return null; + } + + return ExternalIdentity::query() + ->where('provider', IdentityProvider::Discord) + ->where('external_account_id', $content->authorExternalId) + ->value('model_id'); + } +} diff --git a/app-modules/moderation/src/Platform/PlatformRegistry.php b/app-modules/moderation/src/Platform/PlatformRegistry.php new file mode 100644 index 000000000..14920a2b0 --- /dev/null +++ b/app-modules/moderation/src/Platform/PlatformRegistry.php @@ -0,0 +1,35 @@ +> */ + private array $adapters = []; + + public function register(Platform $platform, string $adapterClass): void + { + $this->adapters[$platform->value] = $adapterClass; + } + + public function resolve(Platform $platform): ModerationPlatformContract + { + $class = $this->adapters[$platform->value] ?? null; + + if ($class === null) { + throw new RuntimeException('No adapter registered for platform: '.$platform->value); + } + + return resolve($class); + } + + public function has(Platform $platform): bool + { + return isset($this->adapters[$platform->value]); + } +} diff --git a/app-modules/moderation/tests/Feature/Pipeline/ClassifyAndRouteTest.php b/app-modules/moderation/tests/Feature/Pipeline/ClassifyAndRouteTest.php new file mode 100644 index 000000000..56a7cd2a4 --- /dev/null +++ b/app-modules/moderation/tests/Feature/Pipeline/ClassifyAndRouteTest.php @@ -0,0 +1,121 @@ + Http::response([ + 'results' => [[ + 'flagged' => true, + 'categories' => [], + 'category_scores' => [ + 'harassment' => 0.8, + 'hate' => 0.3, + ], + ]], + ]), + ]); + + $user = User::factory()->create(); + $case = ModerationCase::factory()->create([ + 'content_snapshot' => ['text' => 'some toxic content here'], + 'ai_scores' => ['spam' => 0.95], + 'violation_type' => 'spam', + 'severity' => 'high', + 'classifier_version' => 'rules', + 'suggested_action' => 'ban', + 'author_id' => $user->id, + 'status' => 'pending', + 'priority' => 50, + ]); + + $job = new ClassifyAndRoute($case); + $job->handle(resolve(RouteCaseAction::class)); + + $case->refresh(); + + // AI scores should be merged (harassment from OpenAI added) + expect($case->ai_scores)->toHaveKey('harassment') + ->and($case->ai_scores)->toHaveKey('spam') + ->and($case->priority)->toBeGreaterThan(50); + + Event::assertDispatched(CaseQueued::class); +}); + +test('emits CaseReadyForEnforcement when classifier_version is rules and suggested_action is set', function (): void { + Event::fake([CaseQueued::class, CaseReadyForEnforcement::class]); + + Http::fake([ + 'api.openai.com/*' => Http::response([ + 'results' => [[ + 'flagged' => false, + 'categories' => [], + 'category_scores' => ['harassment' => 0.1], + ]], + ]), + ]); + + $user = User::factory()->create(); + $case = ModerationCase::factory()->create([ + 'content_snapshot' => ['text' => 'buy followers now'], + 'ai_scores' => ['spam' => 0.95], + 'violation_type' => 'spam', + 'severity' => 'high', + 'classifier_version' => 'rules', + 'suggested_action' => 'ban', + 'author_id' => $user->id, + 'status' => 'pending', + 'priority' => 50, + ]); + + $job = new ClassifyAndRoute($case); + $job->handle(resolve(RouteCaseAction::class)); + + Event::assertDispatched(CaseReadyForEnforcement::class); +}); + +test('does NOT emit CaseReadyForEnforcement when classifier_version is aggregate', function (): void { + Event::fake([CaseQueued::class, CaseReadyForEnforcement::class]); + + Http::fake([ + 'api.openai.com/*' => Http::response([ + 'results' => [[ + 'flagged' => true, + 'categories' => [], + 'category_scores' => ['harassment' => 0.9], + ]], + ]), + ]); + + $user = User::factory()->create(); + $case = ModerationCase::factory()->create([ + 'content_snapshot' => ['text' => 'toxic content here'], + 'ai_scores' => ['harassment' => 0.9], + 'violation_type' => 'harassment', + 'severity' => 'high', + 'classifier_version' => 'aggregate', + 'suggested_action' => 'ban', + 'author_id' => $user->id, + 'status' => 'pending', + 'priority' => 50, + ]); + + $job = new ClassifyAndRoute($case); + $job->handle(resolve(RouteCaseAction::class)); + + Event::assertNotDispatched(CaseReadyForEnforcement::class); +}); diff --git a/app-modules/moderation/tests/Feature/Pipeline/ScreenContentTest.php b/app-modules/moderation/tests/Feature/Pipeline/ScreenContentTest.php new file mode 100644 index 000000000..97099edbb --- /dev/null +++ b/app-modules/moderation/tests/Feature/Pipeline/ScreenContentTest.php @@ -0,0 +1,142 @@ + Http::response([ + 'results' => [[ + 'flagged' => true, + 'categories' => [], + 'category_scores' => [ + 'harassment' => 0.85, + 'hate' => 0.1, + ], + ]], + ]), + ]); + + $user = User::factory()->create(); + ExternalIdentity::factory()->create([ + 'model_type' => (new User)->getMorphClass(), + 'model_id' => $user->id, + 'provider' => IdentityProvider::Discord, + 'external_account_id' => 'discord-ext-456', + ]); + + $dto = new ModerationContentDTO( + contentId: 'msg-ai-flag', + contentType: 'message', + sourcePlatform: Platform::Discord, + authorExternalId: 'discord-ext-456', + textContent: 'extremely toxic content here', + mediaUrls: [], + metadata: [], + snapshot: ['text' => 'extremely toxic content here'], + tenantId: null, + ); + + $job = new ScreenContent($dto, CaseSource::AutoDetect); + $job->handle(resolve(RouteCaseAction::class)); + + expect(ModerationCase::query()->count())->toBe(1); + + $case = ModerationCase::query()->first(); + expect($case->content_id)->toBe('msg-ai-flag') + ->and($case->ai_scores)->toHaveKey('harassment') + ->and($case->author_id)->toBe($user->id); + + Event::assertDispatched(CaseCreated::class); + Event::assertDispatched(CaseQueued::class); +}); + +test('AI clears content below threshold and no case is created', function (): void { + Event::fake([CaseCreated::class, CaseQueued::class, CaseReadyForEnforcement::class]); + + Http::fake([ + 'api.openai.com/*' => Http::response([ + 'results' => [[ + 'flagged' => false, + 'categories' => [], + 'category_scores' => [ + 'harassment' => 0.1, + 'hate' => 0.05, + ], + ]], + ]), + ]); + + $dto = new ModerationContentDTO( + contentId: 'msg-ai-clear', + contentType: 'message', + sourcePlatform: Platform::Discord, + authorExternalId: '', + textContent: 'this is a normal message', + mediaUrls: [], + metadata: [], + snapshot: ['text' => 'this is a normal message'], + tenantId: null, + ); + + $job = new ScreenContent($dto, CaseSource::AutoDetect); + $job->handle(resolve(RouteCaseAction::class)); + + expect(ModerationCase::query()->count())->toBe(0); + + Event::assertNotDispatched(CaseCreated::class); + Event::assertNotDispatched(CaseQueued::class); +}); + +test('CaseReadyForEnforcement is NOT emitted since classifier is aggregate/openai', function (): void { + Event::fake([CaseCreated::class, CaseQueued::class, CaseReadyForEnforcement::class]); + + Http::fake([ + 'api.openai.com/*' => Http::response([ + 'results' => [[ + 'flagged' => true, + 'categories' => [], + 'category_scores' => [ + 'harassment' => 0.9, + ], + ]], + ]), + ]); + + $dto = new ModerationContentDTO( + contentId: 'msg-no-enforce', + contentType: 'message', + sourcePlatform: Platform::Discord, + authorExternalId: '', + textContent: 'very harassing content', + mediaUrls: [], + metadata: [], + snapshot: ['text' => 'very harassing content'], + tenantId: null, + ); + + $job = new ScreenContent($dto, CaseSource::AutoDetect); + $job->handle(resolve(RouteCaseAction::class)); + + // classifier_version will be 'aggregate' or 'openai', never 'rules' + Event::assertNotDispatched(CaseReadyForEnforcement::class); +}); diff --git a/app-modules/moderation/tests/Feature/Pipeline/SubmitForModerationTest.php b/app-modules/moderation/tests/Feature/Pipeline/SubmitForModerationTest.php new file mode 100644 index 000000000..162e417ca --- /dev/null +++ b/app-modules/moderation/tests/Feature/Pipeline/SubmitForModerationTest.php @@ -0,0 +1,120 @@ +create([ + 'name' => 'Spam keywords', + 'type' => 'keyword', + 'pattern' => 'buy followers', + 'violation_type' => 'spam', + 'severity' => 'high', + 'action_on_match' => 'ban', + 'is_active' => true, + ]); + + $user = User::factory()->create(); + ExternalIdentity::factory()->create([ + 'model_type' => (new User)->getMorphClass(), + 'model_id' => $user->id, + 'provider' => IdentityProvider::Discord, + 'external_account_id' => 'discord-ext-123', + ]); + + $dto = new ModerationContentDTO( + contentId: 'msg-100', + contentType: 'message', + sourcePlatform: Platform::Discord, + authorExternalId: 'discord-ext-123', + textContent: 'buy followers now at spam.com', + mediaUrls: [], + metadata: [], + snapshot: ['text' => 'buy followers now at spam.com'], + tenantId: null, + ); + + $action = resolve(SubmitForModeration::class); + $case = $action->execute($dto, CaseSource::AutoDetect); + + expect($case)->toBeInstanceOf(ModerationCase::class) + ->and($case->content_id)->toBe('msg-100') + ->and($case->status)->toBe(CaseStatus::Pending) + ->and($case->classifier_version)->toBe('rules') + ->and($case->ai_scores)->toHaveKey('spam') + ->and($case->suggested_action)->not->toBeNull() + ->and($case->author_id)->toBe($user->id); + + Event::assertDispatched(CaseCreated::class); + Queue::assertPushed(ClassifyAndRoute::class); + Queue::assertNotPushed(ScreenContent::class); +}); + +test('no rule match dispatches ScreenContent and returns null', function (): void { + Queue::fake(); + + $dto = new ModerationContentDTO( + contentId: 'msg-200', + contentType: 'message', + sourcePlatform: Platform::Discord, + authorExternalId: '', + textContent: 'hello everyone, how are you?', + mediaUrls: [], + metadata: [], + snapshot: ['text' => 'hello everyone, how are you?'], + tenantId: null, + ); + + $action = resolve(SubmitForModeration::class); + $result = $action->execute($dto, CaseSource::AutoDetect); + + expect($result)->toBeNull(); + expect(ModerationCase::query()->count())->toBe(0); + + Queue::assertPushed(ScreenContent::class); + Queue::assertNotPushed(ClassifyAndRoute::class); +}); + +test('empty text with no matching rules dispatches ScreenContent', function (): void { + Queue::fake(); + + $dto = new ModerationContentDTO( + contentId: 'msg-300', + contentType: 'message', + sourcePlatform: Platform::Web, + authorExternalId: '', + textContent: '', + mediaUrls: [], + metadata: [], + snapshot: ['text' => ''], + tenantId: null, + ); + + $action = resolve(SubmitForModeration::class); + $result = $action->execute($dto, CaseSource::UserReport); + + expect($result)->toBeNull(); + + Queue::assertPushed(ScreenContent::class); +}); diff --git a/app-modules/moderation/tests/Unit/PlatformRegistryTest.php b/app-modules/moderation/tests/Unit/PlatformRegistryTest.php new file mode 100644 index 000000000..206763b1b --- /dev/null +++ b/app-modules/moderation/tests/Unit/PlatformRegistryTest.php @@ -0,0 +1,37 @@ +register(Platform::Web, WebModerationAdapter::class); + + $adapter = $registry->resolve(Platform::Web); + + expect($adapter)->toBeInstanceOf(ModerationPlatformContract::class) + ->and($adapter)->toBeInstanceOf(WebModerationAdapter::class); +}); + +test('resolve throws RuntimeException for unregistered platform', function (): void { + $registry = new PlatformRegistry(); + + $registry->resolve(Platform::Discord); +})->throws(RuntimeException::class, 'No adapter registered for platform: discord'); + +test('has returns true for registered platform', function (): void { + $registry = new PlatformRegistry(); + $registry->register(Platform::Web, WebModerationAdapter::class); + + expect($registry->has(Platform::Web))->toBeTrue(); +}); + +test('has returns false for unregistered platform', function (): void { + $registry = new PlatformRegistry(); + + expect($registry->has(Platform::Discord))->toBeFalse(); +}); diff --git a/app-modules/moderation/tests/Unit/RouteCaseActionTest.php b/app-modules/moderation/tests/Unit/RouteCaseActionTest.php new file mode 100644 index 000000000..2f50b4446 --- /dev/null +++ b/app-modules/moderation/tests/Unit/RouteCaseActionTest.php @@ -0,0 +1,119 @@ +create(); + $case = ModerationCase::factory()->create([ + 'ai_scores' => ['spam' => 0.75, 'toxicity' => 0.3], + 'violation_type' => 'spam', + 'severity' => 'high', + 'status' => 'pending', + 'priority' => 50, + 'author_id' => $user->id, + 'suggested_action' => 'ban', + ]); + + $action = resolve(RouteCaseAction::class); + $action->execute($case); + + $case->refresh(); + + expect($case->priority)->toBe(75) + ->and($case->status)->toBe(CaseStatus::Pending); +}); + +test('high priority boost is applied when score exceeds threshold', function (): void { + $user = User::factory()->create(); + $case = ModerationCase::factory()->create([ + 'ai_scores' => ['spam' => 0.95], + 'violation_type' => 'spam', + 'severity' => 'critical', + 'status' => 'pending', + 'priority' => 50, + 'author_id' => $user->id, + 'suggested_action' => 'ban', + ]); + + $action = resolve(RouteCaseAction::class); + $action->execute($case); + + $case->refresh(); + + // 95 + 10 = 105, capped at 100 + expect($case->priority)->toBe(100); +}); + +test('report count boosts priority', function (): void { + $user = User::factory()->create(); + $case = ModerationCase::factory()->create([ + 'ai_scores' => ['spam' => 0.75], + 'violation_type' => 'spam', + 'severity' => 'high', + 'status' => 'pending', + 'priority' => 50, + 'author_id' => $user->id, + 'suggested_action' => 'ban', + ]); + + // Create 2 reports for boost + $case->reports()->create(['reporter_id' => User::factory()->create()->id, 'reason' => 'spam', 'platform' => 'discord']); + $case->reports()->create(['reporter_id' => User::factory()->create()->id, 'reason' => 'spam', 'platform' => 'discord']); + + $action = resolve(RouteCaseAction::class); + $action->execute($case); + + $case->refresh(); + + // 75 (base) + 20 (2 reports * 10) = 95 + expect($case->priority)->toBe(95); +}); + +test('penalty advisor is consulted when suggested_action is null and conditions are met', function (): void { + $user = User::factory()->create(); + $case = ModerationCase::factory()->create([ + 'ai_scores' => ['spam' => 0.8], + 'violation_type' => 'spam', + 'severity' => 'high', + 'status' => 'pending', + 'priority' => 50, + 'author_id' => $user->id, + 'suggested_action' => null, + ]); + + $action = resolve(RouteCaseAction::class); + $action->execute($case); + + $case->refresh(); + + expect($case->suggested_action)->not->toBeNull(); +}); + +test('penalty advisor is NOT consulted when suggested_action already set', function (): void { + $user = User::factory()->create(); + $case = ModerationCase::factory()->create([ + 'ai_scores' => ['spam' => 0.8], + 'violation_type' => 'spam', + 'severity' => 'high', + 'status' => 'pending', + 'priority' => 50, + 'author_id' => $user->id, + 'suggested_action' => 'warn', + ]); + + $action = resolve(RouteCaseAction::class); + $action->execute($case); + + $case->refresh(); + + // suggested_action should remain 'warn' (advisor was not consulted) + expect($case->suggested_action->value)->toBe('warn'); +}); From 868a556fb9bb883c84c3de6bda4446a7a29eeb48 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 16 May 2026 18:36:18 -0300 Subject: [PATCH 04/12] refactor(moderation/identity): lightweight moderation content dto and find-external-identity tenant parameter Implements #231. Makes ModerationContentDTO queue-serializable by removing the Eloquent User model, and adjusts FindExternalIdentity for non-HTTP contexts. - Remove ?User $author from ModerationContentDTO (only authorExternalId remains) - Add optional ?string $tenantId to FindExternalIdentity::handle() (backward compatible) - Refactor IngestContent to use FindExternalIdentity action instead of raw query - Remove 'author' key from DiscordModerationAdapter ingest payload - Update all tests constructing ModerationContentDTO to remove author parameter - Add DTO serialization tests and FindExternalIdentity tenantId tests --- .../src/Events/MessageReceivedEvent.php | 1 - .../Actions/FindExternalIdentity.php | 14 +- .../FindExternalIdentityTest.php | 122 ++++++++++++++++++ .../src/Classification/Jobs/IngestContent.php | 25 ++-- .../src/DTOs/ModerationContentDTO.php | 5 - .../tests/Feature/CaseWorkflowTest.php | 2 - .../tests/Feature/Cases/SubmitReportTest.php | 7 +- .../RuleBasedClassifierTest.php | 1 - .../moderation/tests/Feature/PipelineTest.php | 5 - .../moderation/tests/Unit/ClassifierTest.php | 1 - app-modules/moderation/tests/Unit/DTOTest.php | 66 +++++++++- 11 files changed, 213 insertions(+), 36 deletions(-) create mode 100644 app-modules/identity/tests/Unit/ExternalIdentity/FindExternalIdentityTest.php diff --git a/app-modules/bot-discord/src/Events/MessageReceivedEvent.php b/app-modules/bot-discord/src/Events/MessageReceivedEvent.php index c2c4b4944..9077055cf 100644 --- a/app-modules/bot-discord/src/Events/MessageReceivedEvent.php +++ b/app-modules/bot-discord/src/Events/MessageReceivedEvent.php @@ -72,7 +72,6 @@ public function handle(Message $message): void 'username' => $message->author->username, 'attachments' => [], 'tenant_id' => (string) $tenantProvider->tenant_id, - 'author' => $authorIdentity?->user, ]); $this->logger()->info('[Moderation] Pre-screening message: '.$message->id); diff --git a/app-modules/identity/src/ExternalIdentity/Actions/FindExternalIdentity.php b/app-modules/identity/src/ExternalIdentity/Actions/FindExternalIdentity.php index 29771dfbd..6d52ac895 100644 --- a/app-modules/identity/src/ExternalIdentity/Actions/FindExternalIdentity.php +++ b/app-modules/identity/src/ExternalIdentity/Actions/FindExternalIdentity.php @@ -10,21 +10,25 @@ class FindExternalIdentity { - public function handle(string $provider, string $providerId): ExternalIdentity + public function handle(string $provider, string $providerId, ?string $tenantId = null): ExternalIdentity { - $cacheKey = sprintf('provider-%s-%s', $provider, $providerId); + $cacheKey = $tenantId !== null + ? sprintf('provider-%s-%s-%s', $provider, $providerId, $tenantId) + : sprintf('provider-%s-%s', $provider, $providerId); return Cache::remember( $cacheKey, 2 * 86400, - fn () => $this->find($provider, $providerId) + fn () => $this->find($provider, $providerId, $tenantId) ); } - private function find(string $provider, string $providerId): ExternalIdentity + private function find(string $provider, string $providerId, ?string $tenantId = null): ExternalIdentity { + $resolvedTenantId = $tenantId ?? request()->input('tenant_id'); + $model = ExternalIdentity::query() - ->where('tenant_id', request()->input('tenant_id')) + ->where('tenant_id', $resolvedTenantId) ->where('provider', $provider) ->where('external_account_id', $providerId) ->first(); diff --git a/app-modules/identity/tests/Unit/ExternalIdentity/FindExternalIdentityTest.php b/app-modules/identity/tests/Unit/ExternalIdentity/FindExternalIdentityTest.php new file mode 100644 index 000000000..5001319a2 --- /dev/null +++ b/app-modules/identity/tests/Unit/ExternalIdentity/FindExternalIdentityTest.php @@ -0,0 +1,122 @@ +create(); + $user = User::factory()->create(); + + $identity = ExternalIdentity::factory()->create([ + 'tenant_id' => $tenant->id, + 'model_type' => (new User)->getMorphClass(), + 'model_id' => $user->id, + 'provider' => IdentityProvider::Discord, + 'external_account_id' => 'discord-999', + ]); + + $action = new FindExternalIdentity(); + $result = $action->handle( + provider: IdentityProvider::Discord->value, + providerId: 'discord-999', + tenantId: (string) $tenant->id, + ); + + expect($result->id)->toBe($identity->id) + ->and($result->model_id)->toBe($user->id); +}); + +test('cache key includes tenantId when provided', function (): void { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + ExternalIdentity::factory()->create([ + 'tenant_id' => $tenant->id, + 'model_type' => (new User)->getMorphClass(), + 'model_id' => $user->id, + 'provider' => IdentityProvider::Discord, + 'external_account_id' => 'discord-cache-test', + ]); + + Cache::flush(); + + $action = new FindExternalIdentity(); + $action->handle( + provider: IdentityProvider::Discord->value, + providerId: 'discord-cache-test', + tenantId: (string) $tenant->id, + ); + + $expectedKey = sprintf('provider-%s-%s-%s', IdentityProvider::Discord->value, 'discord-cache-test', $tenant->id); + expect(Cache::has($expectedKey))->toBeTrue(); +}); + +test('cache key excludes tenantId when null', function (): void { + Cache::flush(); + + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + ExternalIdentity::factory()->create([ + 'tenant_id' => $tenant->id, + 'model_type' => (new User)->getMorphClass(), + 'model_id' => $user->id, + 'provider' => IdentityProvider::Discord, + 'external_account_id' => 'discord-no-tenant', + ]); + + // Simulate request with tenant_id + request()->merge(['tenant_id' => (string) $tenant->id]); + + $action = new FindExternalIdentity(); + $action->handle( + provider: IdentityProvider::Discord->value, + providerId: 'discord-no-tenant', + ); + + $expectedKey = sprintf('provider-%s-%s', IdentityProvider::Discord->value, 'discord-no-tenant'); + expect(Cache::has($expectedKey))->toBeTrue(); +}); + +test('null tenantId falls back to request input', function (): void { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + ExternalIdentity::factory()->create([ + 'tenant_id' => $tenant->id, + 'model_type' => (new User)->getMorphClass(), + 'model_id' => $user->id, + 'provider' => IdentityProvider::Discord, + 'external_account_id' => 'discord-fallback', + ]); + + request()->merge(['tenant_id' => (string) $tenant->id]); + + $action = new FindExternalIdentity(); + $result = $action->handle( + provider: IdentityProvider::Discord->value, + providerId: 'discord-fallback', + ); + + expect($result->model_id)->toBe($user->id); +}); + +test('throws exception when identity not found', function (): void { + $action = new FindExternalIdentity(); + + $action->handle( + provider: IdentityProvider::Discord->value, + providerId: 'nonexistent-id', + tenantId: '1', + ); +})->throws(ExternalIdentityException::class); diff --git a/app-modules/moderation/src/Classification/Jobs/IngestContent.php b/app-modules/moderation/src/Classification/Jobs/IngestContent.php index cf058e841..28ac2cea9 100644 --- a/app-modules/moderation/src/Classification/Jobs/IngestContent.php +++ b/app-modules/moderation/src/Classification/Jobs/IngestContent.php @@ -4,9 +4,8 @@ namespace He4rt\Moderation\Classification\Jobs; +use He4rt\Identity\ExternalIdentity\Actions\FindExternalIdentity; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; -use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\User\Models\User; use He4rt\Moderation\Cases\Events\CaseCreated; use He4rt\Moderation\Cases\Models\ModerationCase; use He4rt\Moderation\DTOs\ModerationContentDTO; @@ -15,6 +14,7 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; +use Throwable; final class IngestContent implements ShouldQueue { @@ -28,14 +28,19 @@ public function __construct( public function handle(): ModerationCase { - $authorId = $this->content->author?->id; - - if ($authorId === null && $this->content->authorExternalId !== '') { - $authorId = ExternalIdentity::query() - ->where('model_type', (new User)->getMorphClass()) - ->where('provider', IdentityProvider::Discord) - ->where('external_account_id', $this->content->authorExternalId) - ->value('model_id'); + $authorId = null; + + if ($this->content->authorExternalId !== '') { + try { + $identity = resolve(FindExternalIdentity::class)->handle( + provider: IdentityProvider::Discord->value, + providerId: $this->content->authorExternalId, + tenantId: $this->content->tenantId, + ); + $authorId = $identity->model_id; + } catch (Throwable) { + // Author not found in system — proceed with null author_id + } } $case = ModerationCase::query()->create([ diff --git a/app-modules/moderation/src/DTOs/ModerationContentDTO.php b/app-modules/moderation/src/DTOs/ModerationContentDTO.php index 930782505..95c7cc657 100644 --- a/app-modules/moderation/src/DTOs/ModerationContentDTO.php +++ b/app-modules/moderation/src/DTOs/ModerationContentDTO.php @@ -4,7 +4,6 @@ namespace He4rt\Moderation\DTOs; -use He4rt\Identity\User\Models\User; use He4rt\Moderation\Cases\Models\ModerationCase; use He4rt\Moderation\Enums\Platform; use JsonSerializable; @@ -21,7 +20,6 @@ public function __construct( public string $contentType, public Platform $sourcePlatform, public string $authorExternalId, - public ?User $author, public string $textContent, public array $mediaUrls, public array $metadata, @@ -36,7 +34,6 @@ public static function fromCase(ModerationCase $case): self contentType: $case->content_type, sourcePlatform: $case->source_platform ?? Platform::Web, authorExternalId: '', - author: $case->author, textContent: $case->content_snapshot['text'] ?? '', mediaUrls: $case->content_snapshot['media_urls'] ?? [], metadata: $case->content_snapshot['metadata'] ?? [], @@ -55,7 +52,6 @@ public static function fromPlatform(Platform $platform, array $rawPayload): self contentType: $rawPayload['content_type'] ?? 'message', sourcePlatform: $platform, authorExternalId: $rawPayload['author_external_id'] ?? '', - author: ($rawPayload['author'] ?? null) instanceof User ? $rawPayload['author'] : null, textContent: $rawPayload['text_content'] ?? $rawPayload['text'] ?? '', mediaUrls: $rawPayload['media_urls'] ?? [], metadata: $rawPayload['metadata'] ?? [], @@ -74,7 +70,6 @@ public function jsonSerialize(): array 'content_type' => $this->contentType, 'source_platform' => $this->sourcePlatform->value, 'author_external_id' => $this->authorExternalId, - 'author' => $this->author?->toArray(), 'text_content' => $this->textContent, 'media_urls' => $this->mediaUrls, 'metadata' => $this->metadata, diff --git a/app-modules/moderation/tests/Feature/CaseWorkflowTest.php b/app-modules/moderation/tests/Feature/CaseWorkflowTest.php index adaac67b8..01f970118 100644 --- a/app-modules/moderation/tests/Feature/CaseWorkflowTest.php +++ b/app-modules/moderation/tests/Feature/CaseWorkflowTest.php @@ -31,7 +31,6 @@ contentType: 'message', sourcePlatform: Platform::Discord, authorExternalId: 'ext-id', - author: $author, textContent: 'some bad content', mediaUrls: [], metadata: [], @@ -70,7 +69,6 @@ contentType: 'message', sourcePlatform: Platform::Discord, authorExternalId: 'ext-id', - author: $author, textContent: 'bad stuff', mediaUrls: [], metadata: [], diff --git a/app-modules/moderation/tests/Feature/Cases/SubmitReportTest.php b/app-modules/moderation/tests/Feature/Cases/SubmitReportTest.php index 5790b382c..70e58cd47 100644 --- a/app-modules/moderation/tests/Feature/Cases/SubmitReportTest.php +++ b/app-modules/moderation/tests/Feature/Cases/SubmitReportTest.php @@ -23,14 +23,13 @@ ])]); }); -function makeReportDTO(string $contentId = 'msg-1', string $text = 'test', ?User $author = null): ModerationContentDTO +function makeReportDTO(string $contentId = 'msg-1', string $text = 'test'): ModerationContentDTO { return new ModerationContentDTO( contentId: $contentId, contentType: 'message', sourcePlatform: Platform::Discord, authorExternalId: 'ext-1', - author: $author ?? User::factory()->create(), textContent: $text, mediaUrls: [], metadata: [], @@ -94,7 +93,7 @@ function makeReportDTO(string $contentId = 'msg-1', string $text = 'test', ?User 'author_id' => $author->id, ]); - $dto = makeReportDTO('msg-resolved', 'same content', $author); + $dto = makeReportDTO('msg-resolved', 'same content'); $newCase = resolve(SubmitReport::class)->handle($reporter, $dto, ViolationType::Toxicity, null, Platform::Discord); expect($newCase->id)->not->toBe($resolvedCase->id); @@ -111,7 +110,7 @@ function makeReportDTO(string $contentId = 'msg-1', string $text = 'test', ?User 'author_id' => $author->id, ]); - $dto = makeReportDTO('msg-dismissed', 'content', $author); + $dto = makeReportDTO('msg-dismissed', 'content'); $newCase = resolve(SubmitReport::class)->handle($reporter, $dto, ViolationType::Spam, null, Platform::Web); expect($newCase->id)->not->toBe($dismissedCase->id); diff --git a/app-modules/moderation/tests/Feature/Classification/RuleBasedClassifierTest.php b/app-modules/moderation/tests/Feature/Classification/RuleBasedClassifierTest.php index 4aec8b402..c5f0db481 100644 --- a/app-modules/moderation/tests/Feature/Classification/RuleBasedClassifierTest.php +++ b/app-modules/moderation/tests/Feature/Classification/RuleBasedClassifierTest.php @@ -20,7 +20,6 @@ function contentDTO(string $text, ?string $tenantId = null): ModerationContentDT contentType: 'message', sourcePlatform: Platform::Discord, authorExternalId: '123', - author: null, textContent: $text, mediaUrls: [], metadata: [], diff --git a/app-modules/moderation/tests/Feature/PipelineTest.php b/app-modules/moderation/tests/Feature/PipelineTest.php index cc7b2e418..44b54711c 100644 --- a/app-modules/moderation/tests/Feature/PipelineTest.php +++ b/app-modules/moderation/tests/Feature/PipelineTest.php @@ -18,13 +18,11 @@ uses(RefreshDatabase::class); test('IngestContent creates a ModerationCase from DTO', function (): void { - $user = User::factory()->create(); $dto = new ModerationContentDTO( contentId: 'msg-999', contentType: 'message', sourcePlatform: Platform::Discord, authorExternalId: '12345', - author: $user, textContent: 'some spam content', mediaUrls: [], metadata: ['channel_id' => 'ch-1'], @@ -41,7 +39,6 @@ ->and($case->source_platform)->toBe(Platform::Discord) ->and($case->source)->toBe(CaseSource::UserReport) ->and($case->status)->toBe(CaseStatus::Pending) - ->and($case->author_id)->toBe($user->id) ->and($case->content_snapshot)->toBe(['text' => 'some spam content']); }); @@ -126,13 +123,11 @@ ]), ]); - $user = User::factory()->create(); $dto = new ModerationContentDTO( contentId: 'msg-full-test', contentType: 'message', sourcePlatform: Platform::Discord, authorExternalId: 'ext-1', - author: $user, textContent: 'Get free followers at spam.com', mediaUrls: [], metadata: [], diff --git a/app-modules/moderation/tests/Unit/ClassifierTest.php b/app-modules/moderation/tests/Unit/ClassifierTest.php index 25bdd5b0a..d82d608e3 100644 --- a/app-modules/moderation/tests/Unit/ClassifierTest.php +++ b/app-modules/moderation/tests/Unit/ClassifierTest.php @@ -24,7 +24,6 @@ function makeContentDTO(string $text = 'hello world'): ModerationContentDTO contentType: 'message', sourcePlatform: Platform::Discord, authorExternalId: '123', - author: null, textContent: $text, mediaUrls: [], metadata: [], diff --git a/app-modules/moderation/tests/Unit/DTOTest.php b/app-modules/moderation/tests/Unit/DTOTest.php index a2ad3b9d9..368188e70 100644 --- a/app-modules/moderation/tests/Unit/DTOTest.php +++ b/app-modules/moderation/tests/Unit/DTOTest.php @@ -17,7 +17,6 @@ contentType: 'message', sourcePlatform: Platform::Discord, authorExternalId: '999888777', - author: null, textContent: 'spam content here', mediaUrls: ['https://example.com/img.png'], metadata: ['channel_id' => '123', 'guild_id' => '456'], @@ -29,7 +28,6 @@ ->and($dto->contentType)->toBe('message') ->and($dto->sourcePlatform)->toBe(Platform::Discord) ->and($dto->authorExternalId)->toBe('999888777') - ->and($dto->author)->toBeNull() ->and($dto->textContent)->toBe('spam content here') ->and($dto->mediaUrls)->toBe(['https://example.com/img.png']) ->and($dto->metadata)->toBe(['channel_id' => '123', 'guild_id' => '456']) @@ -94,3 +92,67 @@ classifierName: 'openai', expect($dto->success)->toBeFalse() ->and($dto->error)->toBe('User not found on platform'); }); + +test('ModerationContentDTO can be json_encoded', function (): void { + $dto = new ModerationContentDTO( + contentId: 'msg-ser-1', + contentType: 'message', + sourcePlatform: Platform::Discord, + authorExternalId: 'ext-123', + textContent: 'test serialization', + mediaUrls: ['https://example.com/file.png'], + metadata: ['channel_id' => 'ch-1'], + snapshot: ['text' => 'test serialization'], + tenantId: 'tenant-1', + ); + + $json = json_encode($dto); + + expect($json)->toBeString(); + + $decoded = json_decode($json, true); + expect($decoded['content_id'])->toBe('msg-ser-1') + ->and($decoded['source_platform'])->toBe('discord') + ->and($decoded['author_external_id'])->toBe('ext-123') + ->and($decoded['text_content'])->toBe('test serialization') + ->and($decoded['tenant_id'])->toBe('tenant-1') + ->and($decoded)->not->toHaveKey('author'); +}); + +test('ModerationContentDTO serialized output has no author key', function (): void { + $dto = new ModerationContentDTO( + contentId: 'msg-no-author', + contentType: 'message', + sourcePlatform: Platform::Web, + authorExternalId: 'user-456', + textContent: 'content without author', + mediaUrls: [], + metadata: [], + snapshot: ['text' => 'content without author'], + tenantId: null, + ); + + $serialized = $dto->jsonSerialize(); + + expect($serialized)->not->toHaveKey('author') + ->and($serialized)->toHaveKey('author_external_id') + ->and($serialized['author_external_id'])->toBe('user-456'); +}); + +test('ModerationContentDTO fromPlatform produces correct DTO without author', function (): void { + $dto = ModerationContentDTO::fromPlatform(Platform::Discord, [ + 'content_id' => 'msg-fp-1', + 'content_type' => 'message', + 'author_external_id' => 'ext-789', + 'text' => 'platform content', + 'media_urls' => [], + 'metadata' => ['guild_id' => 'g-1'], + 'tenant_id' => 'tenant-2', + ]); + + expect($dto->contentId)->toBe('msg-fp-1') + ->and($dto->sourcePlatform)->toBe(Platform::Discord) + ->and($dto->authorExternalId)->toBe('ext-789') + ->and($dto->textContent)->toBe('platform content') + ->and($dto->tenantId)->toBe('tenant-2'); +}); From 33be12b5e8565c1dd3045417d5f76e4b06030645 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 16 May 2026 18:36:42 -0300 Subject: [PATCH 05/12] test(moderation): update execute-action tests to use platform registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to 1cb2a366 — updates ExecuteAction tests to pass PlatformRegistry via method injection after the ExecuteAction refactor. --- .../tests/Feature/Enforcement/ExecuteActionTest.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/app-modules/moderation/tests/Feature/Enforcement/ExecuteActionTest.php b/app-modules/moderation/tests/Feature/Enforcement/ExecuteActionTest.php index d9af24352..a8db098da 100644 --- a/app-modules/moderation/tests/Feature/Enforcement/ExecuteActionTest.php +++ b/app-modules/moderation/tests/Feature/Enforcement/ExecuteActionTest.php @@ -8,6 +8,7 @@ use He4rt\Moderation\Enforcement\ExecuteAction; use He4rt\Moderation\Enforcement\ModerationAction; use He4rt\Moderation\Enums\CaseStatus; +use He4rt\Moderation\Platform\PlatformRegistry; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Notification; @@ -27,7 +28,7 @@ 'duration' => '7d', ]); - new ExecuteAction($action, $user)->handle(); + new ExecuteAction($action, $user)->handle(resolve(PlatformRegistry::class)); $action->refresh(); expect($action->execution_results)->not->toBeNull() @@ -47,7 +48,7 @@ 'target_platforms' => ['web'], ]); - new ExecuteAction($action, $user)->handle(); + new ExecuteAction($action, $user)->handle(resolve(PlatformRegistry::class)); $case->refresh(); expect($case->status)->toBe(CaseStatus::Resolved) @@ -65,7 +66,7 @@ 'moderator_id' => User::factory()->create()->id, ]); - new ExecuteAction($action, $user)->handle(); + new ExecuteAction($action, $user)->handle(resolve(PlatformRegistry::class)); expect(ModerationAuditLog::query()->where('event_type', 'action_executed')->count())->toBe(1); @@ -83,7 +84,7 @@ 'target_platforms' => ['skype'], ]); - new ExecuteAction($action, $user)->handle(); + new ExecuteAction($action, $user)->handle(resolve(PlatformRegistry::class)); $action->refresh(); expect($action->execution_results)->toBeEmpty(); @@ -101,7 +102,7 @@ 'target_platforms' => ['web', 'discord'], ]); - new ExecuteAction($action, $user)->handle(); + new ExecuteAction($action, $user)->handle(resolve(PlatformRegistry::class)); $action->refresh(); $webResult = collect($action->execution_results)->firstWhere('platform', 'web'); @@ -118,7 +119,7 @@ 'target_platforms' => [], ]); - new ExecuteAction($action, $user)->handle(); + new ExecuteAction($action, $user)->handle(resolve(PlatformRegistry::class)); $action->refresh(); expect($action->execution_results)->toBeEmpty(); From 2aeb80e4ddcebec54d515912e2eb202b4221920c Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 16 May 2026 18:45:33 -0300 Subject: [PATCH 06/12] docs(moderation): add inline comments explaining pipeline flow and domain decisions Adds class-level docblocks and key inline comments to the pipeline classes to make the flow navigable without reading the ADR first. --- .../Cases/Events/CaseReadyForEnforcement.php | 6 ++++++ .../Classification/Actions/RouteCaseAction.php | 12 ++++++++++++ .../Classification/Jobs/ClassifyAndRoute.php | 17 +++++++++++++++++ .../src/Classification/Jobs/ScreenContent.php | 17 +++++++++++++++++ .../src/Pipeline/SubmitForModeration.php | 18 ++++++++++++++++++ .../src/Platform/PlatformRegistry.php | 8 ++++++++ 6 files changed, 78 insertions(+) diff --git a/app-modules/moderation/src/Cases/Events/CaseReadyForEnforcement.php b/app-modules/moderation/src/Cases/Events/CaseReadyForEnforcement.php index c06f94020..3a0cb2eb9 100644 --- a/app-modules/moderation/src/Cases/Events/CaseReadyForEnforcement.php +++ b/app-modules/moderation/src/Cases/Events/CaseReadyForEnforcement.php @@ -7,6 +7,12 @@ use He4rt\Moderation\Cases\Models\ModerationCase; use Illuminate\Foundation\Events\Dispatchable; +/** + * Emitted when the auto-execution policy is satisfied: deterministic rule match + suggested action + known author. + * + * Platform listeners (e.g., AutoExecuteAction in bot-discord) react to this by creating a ModerationAction + * and dispatching enforcement. The moderation module decides WHEN to emit — platforms decide HOW to enforce. + */ final readonly class CaseReadyForEnforcement { use Dispatchable; diff --git a/app-modules/moderation/src/Classification/Actions/RouteCaseAction.php b/app-modules/moderation/src/Classification/Actions/RouteCaseAction.php index e7cd9fa6c..4beb02e11 100644 --- a/app-modules/moderation/src/Classification/Actions/RouteCaseAction.php +++ b/app-modules/moderation/src/Classification/Actions/RouteCaseAction.php @@ -8,6 +8,14 @@ use He4rt\Moderation\Classification\Actions\Advisors\HistoryBasedPenaltyAdvisor; use He4rt\Moderation\Enums\CaseStatus; +/** + * Assigns priority and suggests a penalty for a classified case. + * + * Priority formula: base (AI score * 100) + high-priority boost (+10) + report boost (reports * 10), capped at 100. + * Penalty suggestion: only consulted when no action was already set by a rule, uses the author's offense history. + * + * Called by both ClassifyAndRoute (rule-match path) and ScreenContent (AI-only path). + */ final readonly class RouteCaseAction { public function __construct( @@ -21,17 +29,21 @@ public function execute(ModerationCase $case): void $highPriorityThreshold = config('moderation.thresholds.high_priority', 0.9); + // Base priority: AI confidence mapped to 0-100 scale. $priority = (int) ($maxScore * 100); if ($maxScore >= $highPriorityThreshold) { $priority = min($priority + 10, 100); } + // Community signal: more reports = higher priority. $reportBoost = $case->reports()->count() * 10; $priority = min($priority + $reportBoost, 100); $suggestedAction = null; + // Only consult the penalty advisor if no rule already set the action. + // Rules are authoritative — the advisor only fills gaps for AI-flagged cases. if ($case->suggested_action === null && $case->author_id && $case->violation_type && $case->severity) { $suggestion = $this->advisor->suggest( $case->author, diff --git a/app-modules/moderation/src/Classification/Jobs/ClassifyAndRoute.php b/app-modules/moderation/src/Classification/Jobs/ClassifyAndRoute.php index 1a0afd919..70b54a543 100644 --- a/app-modules/moderation/src/Classification/Jobs/ClassifyAndRoute.php +++ b/app-modules/moderation/src/Classification/Jobs/ClassifyAndRoute.php @@ -17,6 +17,17 @@ use Illuminate\Support\Facades\Log; use Throwable; +/** + * Async AI enrichment for cases that already matched a deterministic rule. + * + * This is the "rule-match" path from SubmitForModeration. The case already exists + * with rule-based scores. This job adds AI scores for moderator context (not for + * changing the decision — the rule already decided). + * + * After enrichment, it routes the case (priority + penalty suggestion) and emits + * CaseReadyForEnforcement if the auto-execution policy passes. Since the case was + * created by rules (classifier_version='rules'), auto-execution IS allowed here. + */ final class ClassifyAndRoute implements ShouldQueue { use InteractsWithQueue; @@ -33,10 +44,13 @@ public function handle(RouteCaseAction $routeAction): void { $content = ModerationContentDTO::fromCase($this->case); + // AI enrichment: adds granular scores alongside the rule match (e.g., toxicity: 0.92). + // This gives moderators context even on auto-executed cases. $result = AggregateClassifier::make() ->addClassifier(OpenAiClassifier::make()) ->classify($content); + // Merge scores (take the highest per category) but preserve the rule-based classifier_version. $this->case->update([ 'ai_scores' => $this->mergeScores($this->case->ai_scores ?? [], $result->scores), 'violation_type' => $result->primary ?? $this->case->violation_type, @@ -44,10 +58,13 @@ public function handle(RouteCaseAction $routeAction): void 'classifier_version' => $this->case->classifier_version, ]); + // Route: calculate priority and suggest penalty based on history. $routeAction->execute($this->case); + // Notify moderators that a new case is queued for review. event(new CaseQueued($this->case)); + // Auto-execution policy: only deterministic rule matches auto-execute. if ($this->case->classifier_version === 'rules' && $this->case->suggested_action !== null && $this->case->author_id !== null) { event(new CaseReadyForEnforcement($this->case)); } diff --git a/app-modules/moderation/src/Classification/Jobs/ScreenContent.php b/app-modules/moderation/src/Classification/Jobs/ScreenContent.php index d761f6e99..d2616ecc4 100644 --- a/app-modules/moderation/src/Classification/Jobs/ScreenContent.php +++ b/app-modules/moderation/src/Classification/Jobs/ScreenContent.php @@ -22,6 +22,19 @@ use Illuminate\Support\Facades\Log; use Throwable; +/** + * Async AI screening for content that didn't match any deterministic rule. + * + * This is the "no-match" path from SubmitForModeration. It calls the AI classifier + * and only creates a case if the score exceeds the flag threshold — keeping the + * moderation_cases table clean from false positives. + * + * Self-contained: if AI flags the content, this job creates the case AND routes it + * in one pass (no second job needed). This avoids a redundant AI call. + * + * Note: Since classifier_version will always be 'aggregate'/'openai' here (never 'rules'), + * CaseReadyForEnforcement is never emitted — AI-only results always go to human review. + */ final class ScreenContent implements ShouldQueue { use InteractsWithQueue; @@ -46,12 +59,14 @@ public function handle(RouteCaseAction $routeAction): void $maxScore = blank($result->scores) ? 0 : max($result->scores); $flagThreshold = config('moderation.thresholds.flag', 0.7); + // Below threshold = content is safe. No case created, no DB noise. if ($maxScore < $flagThreshold) { return; } $authorId = $this->resolveAuthorId(); + // AI flagged this content — create case with scores already populated. $case = ModerationCase::query()->create([ 'content_type' => $this->content->contentType, 'content_id' => $this->content->contentId, @@ -70,10 +85,12 @@ public function handle(RouteCaseAction $routeAction): void event(new CaseCreated($case)); + // Route inline — no need for a separate job since we already have all the data. $routeAction->execute($case); event(new CaseQueued($case)); + // Auto-execution guard: AI-only classifications never auto-execute (classifier_version != 'rules'). if ($case->classifier_version === 'rules' && $case->suggested_action !== null && $case->author_id !== null) { event(new CaseReadyForEnforcement($case)); } diff --git a/app-modules/moderation/src/Pipeline/SubmitForModeration.php b/app-modules/moderation/src/Pipeline/SubmitForModeration.php index 8bfcc3568..1c3389cde 100644 --- a/app-modules/moderation/src/Pipeline/SubmitForModeration.php +++ b/app-modules/moderation/src/Pipeline/SubmitForModeration.php @@ -16,18 +16,33 @@ use He4rt\Moderation\Enums\CaseStatus; use He4rt\Moderation\Rules\ModerationRule; +/** + * Single entry point for the moderation pipeline. All platforms submit content here. + * + * Flow (C2 hybrid pattern): + * 1. Pre-screen sync with rules only (<5ms, regex/keyword match) + * 2a. Rule match → create case immediately + dispatch ClassifyAndRoute (AI enrichment) + * 2b. No match → dispatch ScreenContent to queue (AI decides if it's worth a case) + * + * This avoids creating cases for ~99% of safe messages while keeping the caller non-blocking. + */ final readonly class SubmitForModeration { public function __construct( private RuleBasedClassifier $ruleClassifier, ) {} + /** + * @return ModerationCase|null The case if a rule matched (immediate), null if dispatched to AI queue. + */ public function execute(ModerationContentDTO $content, CaseSource $source): ?ModerationCase { + // Sync pre-screen: rules are deterministic and fast (DB regex), safe to run inline. $ruleResult = $this->ruleClassifier->classify($content); $hasRuleMatch = $ruleResult->matchedRules !== []; if ($hasRuleMatch) { + // Pick the highest-severity rule's action as the suggested enforcement. $ruleAction = ModerationRule::query() ->whereIn('id', $ruleResult->matchedRules) ->get() @@ -52,11 +67,14 @@ public function execute(ModerationContentDTO $content, CaseSource $source): ?Mod ]); event(new CaseCreated($case)); + + // Enrich with AI scores async (won't block the caller, adds context for moderators). dispatch(new ClassifyAndRoute($case)); return $case; } + // No rule match — let the AI evaluate async. No case created yet to avoid DB noise. dispatch(new ScreenContent($content, $source)); return null; diff --git a/app-modules/moderation/src/Platform/PlatformRegistry.php b/app-modules/moderation/src/Platform/PlatformRegistry.php index 14920a2b0..acbfaec08 100644 --- a/app-modules/moderation/src/Platform/PlatformRegistry.php +++ b/app-modules/moderation/src/Platform/PlatformRegistry.php @@ -7,6 +7,14 @@ use He4rt\Moderation\Enums\Platform; use RuntimeException; +/** + * Maps Platform enum → adapter instance. Replaces service container tags for O(1) lookup. + * + * Each module registers its adapter in its ServiceProvider: + * $registry->register(Platform::Discord, DiscordModerationAdapter::class); + * + * Lazy: adapters are only instantiated when resolve() is called (via the container). + */ final class PlatformRegistry { /** @var array> */ From d0e35bc5b03183b1c6a6faf8c3709154464511fd Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 16 May 2026 18:45:54 -0300 Subject: [PATCH 07/12] refactor(bot-discord): event-driven enforcement with saloon adapter, auto-execute listener, and embed builder Implements #232. Refactors bot-discord to use event-driven enforcement pattern where the moderation domain decides WHEN to act and the platform decides HOW. - Refactor DiscordModerationAdapter to use DiscordConnector (Saloon) + DiscordRoleResolver - Create AutoExecuteAction listener for CaseReadyForEnforcement event - Extract ModerationEmbedBuilder (pure class, testable without HTTP) - Refactor NotifyModerationChannel to use Saloon transport - Register adapter via PlatformRegistry instead of container tags - Migrate all 30+ adapter tests from Http::fake to Saloon MockClient - Add tests for AutoExecuteAction and ModerationEmbedBuilder --- .../src/BotDiscordServiceProvider.php | 10 +- .../src/Listeners/AutoExecuteAction.php | 49 +++ .../src/Listeners/NotifyModerationChannel.php | 75 +---- .../Moderation/DiscordModerationAdapter.php | 315 +++++------------- .../src/Moderation/ModerationEmbedBuilder.php | 57 ++++ .../Listeners/AutoExecuteActionTest.php | 92 +++++ .../Listeners/NotifyModerationChannelTest.php | 75 +++-- .../DiscordModerationAdapterTest.php | 263 ++++++++++----- .../Moderation/ModerationEmbedBuilderTest.php | 99 ++++++ 9 files changed, 620 insertions(+), 415 deletions(-) create mode 100644 app-modules/bot-discord/src/Listeners/AutoExecuteAction.php create mode 100644 app-modules/bot-discord/src/Moderation/ModerationEmbedBuilder.php create mode 100644 app-modules/bot-discord/tests/Feature/Listeners/AutoExecuteActionTest.php create mode 100644 app-modules/bot-discord/tests/Unit/Moderation/ModerationEmbedBuilderTest.php diff --git a/app-modules/bot-discord/src/BotDiscordServiceProvider.php b/app-modules/bot-discord/src/BotDiscordServiceProvider.php index 3db127460..31349c510 100644 --- a/app-modules/bot-discord/src/BotDiscordServiceProvider.php +++ b/app-modules/bot-discord/src/BotDiscordServiceProvider.php @@ -4,9 +4,13 @@ namespace He4rt\BotDiscord; +use He4rt\BotDiscord\Listeners\AutoExecuteAction; use He4rt\BotDiscord\Listeners\NotifyModerationChannel; use He4rt\BotDiscord\Moderation\DiscordModerationAdapter; use He4rt\Moderation\Cases\Events\CaseQueued; +use He4rt\Moderation\Cases\Events\CaseReadyForEnforcement; +use He4rt\Moderation\Enums\Platform; +use He4rt\Moderation\Platform\PlatformRegistry; use Illuminate\Support\Facades\Event; use Laracord\Laracord; use Laracord\LaracordServiceProvider; @@ -18,7 +22,10 @@ public function register(): void $this->mergeConfigFrom(__DIR__.'/../config/bot-discord.php', 'bot-discord'); $this->app->singleton(DiscordModerationAdapter::class); - $this->app->tag([DiscordModerationAdapter::class], 'moderation.platforms'); + + $this->app->afterResolving(PlatformRegistry::class, function (PlatformRegistry $registry): void { + $registry->register(Platform::Discord, DiscordModerationAdapter::class); + }); parent::register(); } @@ -28,6 +35,7 @@ public function boot(): void parent::boot(); Event::listen(CaseQueued::class, [NotifyModerationChannel::class, 'handle']); + Event::listen(CaseReadyForEnforcement::class, [AutoExecuteAction::class, 'handle']); if ($this->app->runningInConsole()) { $this->publishes([ diff --git a/app-modules/bot-discord/src/Listeners/AutoExecuteAction.php b/app-modules/bot-discord/src/Listeners/AutoExecuteAction.php new file mode 100644 index 000000000..9bbca5aac --- /dev/null +++ b/app-modules/bot-discord/src/Listeners/AutoExecuteAction.php @@ -0,0 +1,49 @@ +case; + + if ($case->source_platform !== Platform::Discord) { + return; + } + + if ($case->author_id === null) { + return; + } + + $action = ModerationAction::query()->create([ + 'case_id' => $case->id, + 'moderator_id' => null, + 'action_type' => $case->suggested_action, + 'target_platforms' => [Platform::Discord->value], + 'duration' => $this->resolveDuration($case->suggested_action), + 'reason' => 'Auto-moderation triggered by rule-based classification.', + 'automated' => true, + 'tenant_id' => $case->tenant_id, + ]); + + dispatch(new ExecuteAction($action, $case->author)); + } + + private function resolveDuration(?ActionType $type): ?string + { + return match ($type) { + ActionType::Ban => 'permanent', + ActionType::Mute, ActionType::Suspend => '24h', + default => null, + }; + } +} diff --git a/app-modules/bot-discord/src/Listeners/NotifyModerationChannel.php b/app-modules/bot-discord/src/Listeners/NotifyModerationChannel.php index 68c8369aa..9d0f05873 100644 --- a/app-modules/bot-discord/src/Listeners/NotifyModerationChannel.php +++ b/app-modules/bot-discord/src/Listeners/NotifyModerationChannel.php @@ -4,86 +4,39 @@ namespace He4rt\BotDiscord\Listeners; +use He4rt\BotDiscord\Moderation\ModerationEmbedBuilder; +use He4rt\IntegrationDiscord\Transport\DiscordConnector; +use He4rt\IntegrationDiscord\Transport\Requests\Messages\CreateMessage; use He4rt\Moderation\Cases\Events\CaseQueued; -use He4rt\Moderation\Cases\Models\ModerationCase; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; -final class NotifyModerationChannel +final readonly class NotifyModerationChannel { + public function __construct( + private DiscordConnector $connector, + private ModerationEmbedBuilder $embedBuilder, + ) {} + public function handle(CaseQueued $event): void { - $token = config('discord.token', config('he4rt.discord.token')); $channelId = config()->string('he4rt.discord.moderation.mod_channel_id'); - if (!is_string($token) || blank($token) || blank($channelId)) { + if (blank($channelId)) { return; } $case = $event->case; - $response = Http::withHeaders(['Authorization' => 'Bot '.$token]) - ->post( - sprintf('https://discord.com/api/v10/channels/%s/messages', $channelId), - [ - 'content' => $this->buildMentions(), - 'embeds' => [$this->buildEmbed($case)], - ], - ); + $response = $this->connector->send(new CreateMessage($channelId, [ + 'content' => $this->embedBuilder->buildRoleMentions(), + 'embeds' => [$this->embedBuilder->buildCaseEmbed($case)], + ])); if ($response->failed()) { Log::warning('Failed to notify mod channel about new moderation case.', [ 'case_id' => $case->id, 'status' => $response->status(), - 'response' => $response->json() ?: $response->body(), ]); } } - - private function buildMentions(): string - { - /** @var array $adminRoles */ - $adminRoles = config('he4rt.discord.moderation.admin_role_ids', []); - - /** @var array $modRoles */ - $modRoles = config('he4rt.discord.moderation.mod_role_ids', []); - - $mentions = array_map( - fn (string $id): string => sprintf('<@&%s>', $id), - array_merge($adminRoles, $modRoles), - ); - - return implode(' ', $mentions); - } - - /** @return array */ - private function buildEmbed(ModerationCase $case): array - { - $author = $case->content_snapshot['metadata']['username'] ?? 'Unknown'; - $text = $case->content_snapshot['text'] ?? null; - $platform = $case->source_platform->value; - - $fields = [ - ['name' => 'Platform', 'value' => ucfirst($platform), 'inline' => true], - ['name' => 'Source', 'value' => $case->source->value, 'inline' => true], - ['name' => 'Priority', 'value' => (string) $case->priority, 'inline' => true], - ]; - - if ($text !== null) { - $fields[] = [ - 'name' => 'Content', - 'value' => mb_substr($text, 0, 1024), - 'inline' => false, - ]; - } - - return [ - 'title' => sprintf(':warning: New moderation case — %s', $author), - 'description' => sprintf('Case ID: `%s`', $case->id), - 'color' => 0xFFA500, - 'fields' => $fields, - 'timestamp' => $case->created_at->toIso8601String(), - 'footer' => ['text' => 'He4rt Moderation System'], - ]; - } } diff --git a/app-modules/bot-discord/src/Moderation/DiscordModerationAdapter.php b/app-modules/bot-discord/src/Moderation/DiscordModerationAdapter.php index 16f32be15..db65794f4 100644 --- a/app-modules/bot-discord/src/Moderation/DiscordModerationAdapter.php +++ b/app-modules/bot-discord/src/Moderation/DiscordModerationAdapter.php @@ -9,22 +9,34 @@ use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; use He4rt\Identity\User\Models\User; +use He4rt\IntegrationDiscord\Transport\DiscordConnector; +use He4rt\IntegrationDiscord\Transport\DiscordRoleResolver; +use He4rt\IntegrationDiscord\Transport\Requests\Bans\CreateBan; +use He4rt\IntegrationDiscord\Transport\Requests\Channels\CreateDmChannel; +use He4rt\IntegrationDiscord\Transport\Requests\Members\ModifyMember; +use He4rt\IntegrationDiscord\Transport\Requests\Members\RemoveMember; +use He4rt\IntegrationDiscord\Transport\Requests\Messages\CreateMessage; +use He4rt\IntegrationDiscord\Transport\Requests\Messages\DeleteMessage; use He4rt\Moderation\DTOs\ExecutionResultDTO; use He4rt\Moderation\DTOs\ModerationContentDTO; use He4rt\Moderation\Enforcement\ModerationAction; use He4rt\Moderation\Enums\ActionType; use He4rt\Moderation\Enums\Platform; use He4rt\Moderation\Platform\ModerationPlatformContract; -use Illuminate\Http\Client\Response; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Saloon\Http\Response; use Throwable; -final class DiscordModerationAdapter implements ModerationPlatformContract +final readonly class DiscordModerationAdapter implements ModerationPlatformContract { + public function __construct( + private DiscordConnector $connector, + private DiscordRoleResolver $roleResolver, + ) {} + public static function make(): self { - return new self(); + return resolve(self::class); } public function platform(): Platform @@ -63,9 +75,8 @@ public function execute(ModerationAction $action, User $target): ExecutionResult } $guildId = config()->string('he4rt.discord.guild_id'); - $token = config('discord.token', config('he4rt.discord.token')); - if (blank($guildId) || !is_string($token) || blank($token)) { + if (blank($guildId)) { return ExecutionResultDTO::failure( Platform::Discord, 'Discord bot token or guild id is not configured.', @@ -73,7 +84,7 @@ public function execute(ModerationAction $action, User $target): ExecutionResult } if ($this->isPunitiveAction($action->action_type)) { - $tier = $this->resolveTargetProtectionTier($token, $guildId, $discordId); + $tier = $this->roleResolver->resolveProtectionTier($guildId, $discordId); if ($tier === 'admin') { return ExecutionResultDTO::failure( @@ -82,7 +93,7 @@ public function execute(ModerationAction $action, User $target): ExecutionResult ); } - if ($tier === 'mod' && !$this->actorIsAdmin($token, $guildId, $action)) { + if ($tier === 'mod' && !$this->actorIsAdmin($guildId, $action)) { return ExecutionResultDTO::failure( Platform::Discord, 'Only administrators can apply punitive actions to moderators.', @@ -90,26 +101,17 @@ public function execute(ModerationAction $action, User $target): ExecutionResult } } - $response = $this->executeAction( - $action, - $token, - $guildId, - $discordId, - ); + $response = $this->executeAction($action, $guildId, $discordId); try { - $this->sendDmNotification( - $token, - $discordId, - $action, - ); + $this->sendDmNotification($discordId, $action); } catch (Throwable) { // ignore dm failures } if ($this->shouldDeleteContent($action->action_type)) { try { - $this->deleteOriginalMessage($token, $action); + $this->deleteOriginalMessage($action); } catch (Throwable) { // ignore delete failures } @@ -145,36 +147,22 @@ public function execute(ModerationAction $action, User $target): ExecutionResult public function notify(User $user, string $message, array $context = []): void { $discordId = $this->resolveDiscordId($user); - $token = config('discord.token', config('he4rt.discord.token')); - if ($discordId === null || !is_string($token) || blank($token)) { + if ($discordId === null) { return; } - $dmResponse = $this->discordRequest( - $token, - 'post', - 'https://discord.com/api/v10/users/@me/channels', - [ - 'recipient_id' => $discordId, - ], - ); + $dmResponse = $this->connector->send(new CreateDmChannel($discordId)); if ($dmResponse->failed()) { return; } - $this->discordRequest( - $token, - 'post', - sprintf( - 'https://discord.com/api/v10/channels/%s/messages', - $dmResponse->json('id'), - ), - [ - 'content' => $message, - ], - ); + $channelId = (string) $dmResponse->json('id'); + + $this->connector->send(new CreateMessage($channelId, [ + 'content' => $message, + ])); } /** @return array */ @@ -202,30 +190,19 @@ public function resolveUser(string $externalId): ?User private function executeAction( ModerationAction $action, - string $token, string $guildId, string $discordId, ): ?Response { return match ($action->action_type) { ActionType::Mute, ActionType::Suspend => $this->suspendMember( - $token, $guildId, $discordId, $action->duration, ), - ActionType::Kick => $this->kickMember( - $token, - $guildId, - $discordId, - ), + ActionType::Kick => $this->kickMember($guildId, $discordId), - ActionType::Ban => $this->banMember( - $token, - $guildId, - $discordId, - $action->duration, - ), + ActionType::Ban => $this->banMember($guildId, $discordId, $action->duration), ActionType::Warn, ActionType::ContentRemove => null, @@ -233,29 +210,18 @@ private function executeAction( } private function suspendMember( - string $token, string $guildId, string $discordId, ?string $duration, ): Response { $until = $this->parseDuration($duration) ?? now()->addHours(24)->toDateTimeImmutable(); - return $this->discordRequest( - $token, - 'patch', - sprintf( - 'https://discord.com/api/v10/guilds/%s/members/%s', - $guildId, - $discordId, - ), - [ - 'communication_disabled_until' => $until->format(DateTimeInterface::ATOM), - ], - ); + return $this->connector->send(new ModifyMember($guildId, $discordId, [ + 'communication_disabled_until' => $until->format(DateTimeInterface::ATOM), + ])); } private function banMember( - string $token, string $guildId, string $discordId, ?string $duration, @@ -266,34 +232,12 @@ private function banMember( default => 0, }; - return $this->discordRequest( - $token, - 'put', - sprintf( - 'https://discord.com/api/v10/guilds/%s/bans/%s', - $guildId, - $discordId, - ), - [ - 'delete_message_seconds' => $deleteSeconds, - ], - ); + return $this->connector->send(new CreateBan($guildId, $discordId, $deleteSeconds)); } - private function kickMember( - string $token, - string $guildId, - string $discordId, - ): Response { - return $this->discordRequest( - $token, - 'delete', - sprintf( - 'https://discord.com/api/v10/guilds/%s/members/%s', - $guildId, - $discordId, - ), - ); + private function kickMember(string $guildId, string $discordId): Response + { + return $this->connector->send(new RemoveMember($guildId, $discordId)); } private function shouldDeleteContent(ActionType $type): bool @@ -325,10 +269,8 @@ private function parseDuration(?string $duration): ?DateTimeImmutable }; } - private function deleteOriginalMessage( - string $token, - ModerationAction $action, - ): void { + private function deleteOriginalMessage(ModerationAction $action): void + { $messageId = $action->case?->content_id; $channelId = $action->case?->content_snapshot['metadata']['channel_id'] ?? null; @@ -336,30 +278,22 @@ private function deleteOriginalMessage( return; } - $this->discordRequest( - $token, - 'delete', - sprintf( - 'https://discord.com/api/v10/channels/%s/messages/%s', - $channelId, - $messageId, - ), - ); + $response = $this->connector->send(new DeleteMessage($channelId, $messageId)); + + if ($response->failed()) { + Log::warning('Failed to delete original message.', [ + 'channel_id' => $channelId, + 'message_id' => $messageId, + 'status' => $response->status(), + ]); + } } private function sendDmNotification( - string $token, string $discordId, ModerationAction $action, ): void { - $dmResponse = $this->discordRequest( - $token, - 'post', - 'https://discord.com/api/v10/users/@me/channels', - [ - 'recipient_id' => $discordId, - ], - ); + $dmResponse = $this->connector->send(new CreateDmChannel($discordId)); if ($dmResponse->failed()) { return; @@ -368,45 +302,34 @@ private function sendDmNotification( $channelId = (string) $dmResponse->json('id'); $originalText = $action->case?->content_snapshot['text'] ?? null; - $this->discordRequest( - $token, - 'post', - sprintf( - 'https://discord.com/api/v10/channels/%s/messages', - $channelId, - ), - [ - 'embeds' => [[ - 'title' => __('moderation::notifications.discord_dm.title'), - 'description' => $this->buildDmDescription( - $action, - $originalText, - ), - 'color' => 0xFF4444, - 'fields' => [ - [ - 'name' => __('moderation::notifications.discord_dm.field_type'), - 'value' => $action->action_type->getLabel(), - 'inline' => true, - ], - [ - 'name' => __('moderation::notifications.discord_dm.field_duration'), - 'value' => $action->duration ?? 'N/A', - 'inline' => true, - ], - [ - 'name' => __('moderation::notifications.discord_dm.field_reason'), - 'value' => $action->reason - ?? __('moderation::notifications.discord_dm.default_reason'), - 'inline' => false, - ], + $this->connector->send(new CreateMessage($channelId, [ + 'embeds' => [[ + 'title' => __('moderation::notifications.discord_dm.title'), + 'description' => $this->buildDmDescription($action, $originalText), + 'color' => 0xFF4444, + 'fields' => [ + [ + 'name' => __('moderation::notifications.discord_dm.field_type'), + 'value' => $action->action_type->getLabel(), + 'inline' => true, ], - 'footer' => [ - 'text' => __('moderation::notifications.discord_dm.footer'), + [ + 'name' => __('moderation::notifications.discord_dm.field_duration'), + 'value' => $action->duration ?? 'N/A', + 'inline' => true, ], - ]], - ], - ); + [ + 'name' => __('moderation::notifications.discord_dm.field_reason'), + 'value' => $action->reason + ?? __('moderation::notifications.discord_dm.default_reason'), + 'inline' => false, + ], + ], + 'footer' => [ + 'text' => __('moderation::notifications.discord_dm.footer'), + ], + ]], + ])); } private function buildDmDescription( @@ -447,43 +370,7 @@ private function isPunitiveAction(ActionType $type): bool return in_array($type, [ActionType::Ban, ActionType::Kick, ActionType::Mute, ActionType::Suspend], true); } - /** - * Returns 'admin', 'mod', or null based on the target's Discord roles. - * Admins can never be punished. Mods can only be punished by admins. - */ - private function resolveTargetProtectionTier(string $token, string $guildId, string $discordId): ?string - { - $response = $this->discordRequest( - $token, - 'get', - sprintf('https://discord.com/api/v10/guilds/%s/members/%s', $guildId, $discordId), - ); - - if ($response->failed()) { - return null; - } - - /** @var array $memberRoles */ - $memberRoles = $response->json('roles') ?? []; - - /** @var array $adminRoles */ - $adminRoles = config('he4rt.discord.moderation.admin_role_ids', []); - - /** @var array $modRoles */ - $modRoles = config('he4rt.discord.moderation.mod_role_ids', []); - - if (array_intersect($memberRoles, $adminRoles) !== []) { - return 'admin'; - } - - if (array_intersect($memberRoles, $modRoles) !== []) { - return 'mod'; - } - - return null; - } - - private function actorIsAdmin(string $token, string $guildId, ModerationAction $action): bool + private function actorIsAdmin(string $guildId, ModerationAction $action): bool { $moderator = $action->moderator; @@ -497,52 +384,8 @@ private function actorIsAdmin(string $token, string $guildId, ModerationAction $ return false; } - $response = $this->discordRequest( - $token, - 'get', - sprintf('https://discord.com/api/v10/guilds/%s/members/%s', $guildId, $actorDiscordId), - ); - - if ($response->failed()) { - return false; - } - - /** @var array $actorRoles */ - $actorRoles = $response->json('roles') ?? []; - - /** @var array $adminRoles */ - $adminRoles = config('he4rt.discord.moderation.admin_role_ids', []); - - return array_intersect($actorRoles, $adminRoles) !== []; - } - - /** @param array $payload */ - private function discordRequest( - string $token, - string $method, - string $url, - array $payload = [], - ): Response { - $options = []; - - if ($payload !== []) { - $options['json'] = $payload; - } - - $response = Http::withHeaders([ - 'Authorization' => 'Bot '.$token, - ])->send($method, $url, $options); - - if ($response->failed()) { - Log::warning('Discord API request failed.', [ - 'method' => mb_strtoupper($method), - 'url' => $url, - 'payload' => $payload, - 'status' => $response->status(), - 'response' => $response->json() ?: $response->body(), - ]); - } + $tier = $this->roleResolver->resolveProtectionTier($guildId, $actorDiscordId); - return $response; + return $tier === 'admin'; } } diff --git a/app-modules/bot-discord/src/Moderation/ModerationEmbedBuilder.php b/app-modules/bot-discord/src/Moderation/ModerationEmbedBuilder.php new file mode 100644 index 000000000..eb6df1536 --- /dev/null +++ b/app-modules/bot-discord/src/Moderation/ModerationEmbedBuilder.php @@ -0,0 +1,57 @@ + */ + public function buildCaseEmbed(ModerationCase $case): array + { + $author = $case->content_snapshot['metadata']['username'] ?? 'Unknown'; + $text = $case->content_snapshot['text'] ?? null; + $platform = $case->source_platform->value; + + $fields = [ + ['name' => 'Platform', 'value' => ucfirst($platform), 'inline' => true], + ['name' => 'Source', 'value' => $case->source->value, 'inline' => true], + ['name' => 'Priority', 'value' => (string) $case->priority, 'inline' => true], + ]; + + if ($text !== null) { + $fields[] = [ + 'name' => 'Content', + 'value' => mb_substr($text, 0, 1024), + 'inline' => false, + ]; + } + + return [ + 'title' => sprintf(':warning: New moderation case — %s', $author), + 'description' => sprintf('Case ID: `%s`', $case->id), + 'color' => 0xFFA500, + 'fields' => $fields, + 'timestamp' => $case->created_at->toIso8601String(), + 'footer' => ['text' => 'He4rt Moderation System'], + ]; + } + + public function buildRoleMentions(): string + { + /** @var array $adminRoles */ + $adminRoles = config('he4rt.discord.moderation.admin_role_ids', []); + + /** @var array $modRoles */ + $modRoles = config('he4rt.discord.moderation.mod_role_ids', []); + + $mentions = array_map( + fn (string $id): string => sprintf('<@&%s>', $id), + array_merge($adminRoles, $modRoles), + ); + + return implode(' ', $mentions); + } +} diff --git a/app-modules/bot-discord/tests/Feature/Listeners/AutoExecuteActionTest.php b/app-modules/bot-discord/tests/Feature/Listeners/AutoExecuteActionTest.php new file mode 100644 index 000000000..0f12a6657 --- /dev/null +++ b/app-modules/bot-discord/tests/Feature/Listeners/AutoExecuteActionTest.php @@ -0,0 +1,92 @@ +create(); + $case = ModerationCase::factory()->create([ + 'source_platform' => Platform::Discord, + 'suggested_action' => ActionType::Mute, + 'author_id' => $user->id, + ]); + + $listener = new AutoExecuteAction(); + $listener->handle(new CaseReadyForEnforcement($case)); + + $action = ModerationAction::query()->where('case_id', $case->id)->first(); + + expect($action)->not->toBeNull() + ->and($action->action_type)->toBe(ActionType::Mute) + ->and($action->automated)->toBeTrue() + ->and($action->moderator_id)->toBeNull() + ->and($action->duration)->toBe('24h') + ->and($action->target_platforms)->toBe([Platform::Discord->value]); + + Bus::assertDispatched(ExecuteAction::class); +}); + +test('resolves permanent duration for ban action', function (): void { + Bus::fake([ExecuteAction::class]); + + $user = User::factory()->create(); + $case = ModerationCase::factory()->create([ + 'source_platform' => Platform::Discord, + 'suggested_action' => ActionType::Ban, + 'author_id' => $user->id, + ]); + + $listener = new AutoExecuteAction(); + $listener->handle(new CaseReadyForEnforcement($case)); + + $action = ModerationAction::query()->where('case_id', $case->id)->first(); + + expect($action->duration)->toBe('permanent'); +}); + +test('does nothing when platform is not discord', function (): void { + Bus::fake([ExecuteAction::class]); + + $case = ModerationCase::factory()->create([ + 'source_platform' => Platform::Twitch, + 'suggested_action' => ActionType::Ban, + ]); + + $listener = new AutoExecuteAction(); + $listener->handle(new CaseReadyForEnforcement($case)); + + expect(ModerationAction::query()->where('case_id', $case->id)->exists())->toBeFalse(); + + Bus::assertNotDispatched(ExecuteAction::class); +}); + +test('does nothing when author_id is null', function (): void { + Bus::fake([ExecuteAction::class]); + + $case = ModerationCase::factory()->create([ + 'source_platform' => Platform::Discord, + 'suggested_action' => ActionType::Mute, + 'author_id' => null, + ]); + + $listener = new AutoExecuteAction(); + $listener->handle(new CaseReadyForEnforcement($case)); + + expect(ModerationAction::query()->where('case_id', $case->id)->exists())->toBeFalse(); + + Bus::assertNotDispatched(ExecuteAction::class); +}); diff --git a/app-modules/bot-discord/tests/Feature/Listeners/NotifyModerationChannelTest.php b/app-modules/bot-discord/tests/Feature/Listeners/NotifyModerationChannelTest.php index 23e0fe25b..ce11cc386 100644 --- a/app-modules/bot-discord/tests/Feature/Listeners/NotifyModerationChannelTest.php +++ b/app-modules/bot-discord/tests/Feature/Listeners/NotifyModerationChannelTest.php @@ -3,10 +3,13 @@ declare(strict_types=1); use He4rt\BotDiscord\Listeners\NotifyModerationChannel; +use He4rt\IntegrationDiscord\Transport\DiscordConnector; +use He4rt\IntegrationDiscord\Transport\Requests\Messages\CreateMessage; use He4rt\Moderation\Cases\Events\CaseQueued; use He4rt\Moderation\Cases\Models\ModerationCase; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Http; +use Saloon\Http\Faking\MockClient; +use Saloon\Http\Faking\MockResponse; uses(RefreshDatabase::class); @@ -18,39 +21,63 @@ }); test('sends embed to mod channel when a new case is created', function (): void { - Http::fake(['discord.com/*' => Http::response([], 200)]); + $mockClient = new MockClient([ + CreateMessage::class => MockResponse::make([], 200), + ]); + + $connector = resolve(DiscordConnector::class); + $connector->withMockClient($mockClient); $case = ModerationCase::factory()->create(); - new NotifyModerationChannel()->handle(new CaseQueued($case)); + resolve(NotifyModerationChannel::class)->handle(new CaseQueued($case)); - Http::assertSent(fn ($req) => str_contains((string) $req->url(), '/channels/1095115912820043829/messages') - && isset($req->data()['embeds'])); + $mockClient->assertSent(fn ($request) => $request instanceof CreateMessage + && str_contains($request->resolveEndpoint(), '/channels/1095115912820043829/messages') + && isset($request->body()->all()['embeds'])); }); test('embed contains the case id', function (): void { - Http::fake(['discord.com/*' => Http::response([], 200)]); + $mockClient = new MockClient([ + CreateMessage::class => MockResponse::make([], 200), + ]); + + $connector = resolve(DiscordConnector::class); + $connector->withMockClient($mockClient); $case = ModerationCase::factory()->create(); - new NotifyModerationChannel()->handle(new CaseQueued($case)); + resolve(NotifyModerationChannel::class)->handle(new CaseQueued($case)); - Http::assertSent(function ($req) use ($case): bool { - $embeds = $req->data()['embeds'] ?? []; + $mockClient->assertSent(function ($request) use ($case): bool { + if (!$request instanceof CreateMessage) { + return false; + } + + $embeds = $request->body()->all()['embeds'] ?? []; return isset($embeds[0]['description']) && str_contains((string) $embeds[0]['description'], $case->id); }); }); test('message content includes role mentions for admins and mods', function (): void { - Http::fake(['discord.com/*' => Http::response([], 200)]); + $mockClient = new MockClient([ + CreateMessage::class => MockResponse::make([], 200), + ]); + + $connector = resolve(DiscordConnector::class); + $connector->withMockClient($mockClient); $case = ModerationCase::factory()->create(); - new NotifyModerationChannel()->handle(new CaseQueued($case)); + resolve(NotifyModerationChannel::class)->handle(new CaseQueued($case)); - Http::assertSent(function ($req): bool { - $content = $req->data()['content'] ?? ''; + $mockClient->assertSent(function ($request): bool { + if (!$request instanceof CreateMessage) { + return false; + } + + $content = $request->body()->all()['content'] ?? ''; return str_contains($content, '<@&111111111111111111>') && str_contains($content, '<@&222222222222222222>'); @@ -58,26 +85,16 @@ }); test('does nothing when mod channel is not configured', function (): void { - Http::fake(); + $mockClient = new MockClient([]); - config()->set('he4rt.discord.moderation.mod_channel_id', ''); + $connector = resolve(DiscordConnector::class); + $connector->withMockClient($mockClient); - $case = ModerationCase::factory()->create(); - - new NotifyModerationChannel()->handle(new CaseQueued($case)); - - Http::assertNothingSent(); -}); - -test('does nothing when bot token is not configured', function (): void { - Http::fake(); - - config()->set('discord.token'); - config()->set('he4rt.discord.token'); + config()->set('he4rt.discord.moderation.mod_channel_id', ''); $case = ModerationCase::factory()->create(); - new NotifyModerationChannel()->handle(new CaseQueued($case)); + resolve(NotifyModerationChannel::class)->handle(new CaseQueued($case)); - Http::assertNothingSent(); + $mockClient->assertNothingSent(); }); diff --git a/app-modules/bot-discord/tests/Feature/Moderation/DiscordModerationAdapterTest.php b/app-modules/bot-discord/tests/Feature/Moderation/DiscordModerationAdapterTest.php index d5ac9691e..e63eac2b9 100644 --- a/app-modules/bot-discord/tests/Feature/Moderation/DiscordModerationAdapterTest.php +++ b/app-modules/bot-discord/tests/Feature/Moderation/DiscordModerationAdapterTest.php @@ -7,13 +7,22 @@ use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; +use He4rt\IntegrationDiscord\Transport\DiscordConnector; +use He4rt\IntegrationDiscord\Transport\Requests\Bans\CreateBan; +use He4rt\IntegrationDiscord\Transport\Requests\Channels\CreateDmChannel; +use He4rt\IntegrationDiscord\Transport\Requests\Members\GetMember; +use He4rt\IntegrationDiscord\Transport\Requests\Members\ModifyMember; +use He4rt\IntegrationDiscord\Transport\Requests\Members\RemoveMember; +use He4rt\IntegrationDiscord\Transport\Requests\Messages\CreateMessage; +use He4rt\IntegrationDiscord\Transport\Requests\Messages\DeleteMessage; use He4rt\Moderation\Cases\Models\ModerationCase; use He4rt\Moderation\DTOs\ModerationContentDTO; use He4rt\Moderation\Enforcement\ModerationAction; use He4rt\Moderation\Enums\ActionType; use He4rt\Moderation\Enums\Platform; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Http; +use Saloon\Http\Faking\MockClient; +use Saloon\Http\Faking\MockResponse; uses(RefreshDatabase::class); @@ -46,13 +55,29 @@ function makeAction(User $user, ActionType $type, ?string $duration = null): Mod ]); } +function mockConnector(array $responses = []): MockClient +{ + $mockClient = new MockClient($responses); + + $connector = resolve(DiscordConnector::class); + $connector->withMockClient($mockClient); + + return $mockClient; +} + beforeEach(function (): void { config()->set('he4rt.discord.guild_id', '123456789'); config()->set('discord.token', 'bot-token'); }); test('mute sends PATCH with communication_disabled_until', function (): void { - Http::fake(['discord.com/*' => Http::response([], 200)]); + $mockClient = mockConnector([ + GetMember::class => MockResponse::make(['roles' => []], 200), + ModifyMember::class => MockResponse::make([], 200), + CreateDmChannel::class => MockResponse::make(['id' => 'dm-chan-1'], 200), + CreateMessage::class => MockResponse::make([], 200), + DeleteMessage::class => MockResponse::make([], 204), + ]); $user = makeUserWithDiscord('111'); $action = makeAction($user, ActionType::Mute, '24h'); @@ -61,28 +86,37 @@ function makeAction(User $user, ActionType $type, ?string $duration = null): Mod expect($result->success)->toBeTrue(); - Http::assertSent(fn ($req) => $req->method() === 'PATCH' - && str_contains((string) $req->url(), '/guilds/123456789/members/111') - && isset($req->data()['communication_disabled_until'])); + $mockClient->assertSent(ModifyMember::class); + $mockClient->assertSent(fn ($request) => $request instanceof ModifyMember + && str_contains($request->resolveEndpoint(), '/guilds/123456789/members/111')); }); test('mute 7d sends correct timeout duration', function (): void { - Http::fake(['discord.com/*' => Http::response([], 200)]); + $mockClient = mockConnector([ + GetMember::class => MockResponse::make(['roles' => []], 200), + ModifyMember::class => MockResponse::make([], 200), + CreateDmChannel::class => MockResponse::make(['id' => 'dm-chan-1'], 200), + CreateMessage::class => MockResponse::make([], 200), + DeleteMessage::class => MockResponse::make([], 204), + ]); $user = makeUserWithDiscord('222'); $action = makeAction($user, ActionType::Mute, '7d'); DiscordModerationAdapter::make()->execute($action, $user); - Http::assertSent(function ($req): bool { - $until = $req->data()['communication_disabled_until'] ?? null; - - return $until !== null && str_contains($until, now()->addDays(7)->format('Y-m-d')); - }); + $mockClient->assertSent(fn ($request) => $request instanceof ModifyMember + && str_contains($request->body()->all()['communication_disabled_until'] ?? '', now()->addDays(7)->format('Y-m-d'))); }); test('mute 28d sends timeout capped at 28 days', function (): void { - Http::fake(['discord.com/*' => Http::response([], 200)]); + $mockClient = mockConnector([ + GetMember::class => MockResponse::make(['roles' => []], 200), + ModifyMember::class => MockResponse::make([], 200), + CreateDmChannel::class => MockResponse::make(['id' => 'dm-chan-1'], 200), + CreateMessage::class => MockResponse::make([], 200), + DeleteMessage::class => MockResponse::make([], 204), + ]); $user = makeUserWithDiscord('333'); $action = makeAction($user, ActionType::Mute, '28d'); @@ -91,15 +125,18 @@ function makeAction(User $user, ActionType $type, ?string $duration = null): Mod expect($result->success)->toBeTrue(); - Http::assertSent(function ($req): bool { - $until = $req->data()['communication_disabled_until'] ?? null; - - return $until !== null && str_contains($until, now()->addDays(28)->format('Y-m-d')); - }); + $mockClient->assertSent(fn ($request) => $request instanceof ModifyMember + && str_contains($request->body()->all()['communication_disabled_until'] ?? '', now()->addDays(28)->format('Y-m-d'))); }); test('kick sends DELETE to members endpoint', function (): void { - Http::fake(['discord.com/*' => Http::response([], 204)]); + $mockClient = mockConnector([ + GetMember::class => MockResponse::make(['roles' => []], 200), + RemoveMember::class => MockResponse::make([], 204), + CreateDmChannel::class => MockResponse::make(['id' => 'dm-chan-1'], 200), + CreateMessage::class => MockResponse::make([], 200), + DeleteMessage::class => MockResponse::make([], 204), + ]); $user = makeUserWithDiscord('444'); $action = makeAction($user, ActionType::Kick); @@ -108,12 +145,18 @@ function makeAction(User $user, ActionType $type, ?string $duration = null): Mod expect($result->success)->toBeTrue(); - Http::assertSent(fn ($req) => $req->method() === 'DELETE' - && str_contains((string) $req->url(), '/guilds/123456789/members/444')); + $mockClient->assertSent(fn ($request) => $request instanceof RemoveMember + && str_contains($request->resolveEndpoint(), '/guilds/123456789/members/444')); }); test('ban 24h sends PUT to bans endpoint with delete_message_seconds 86400', function (): void { - Http::fake(['discord.com/*' => Http::response([], 200)]); + $mockClient = mockConnector([ + GetMember::class => MockResponse::make(['roles' => []], 200), + CreateBan::class => MockResponse::make([], 200), + CreateDmChannel::class => MockResponse::make(['id' => 'dm-chan-1'], 200), + CreateMessage::class => MockResponse::make([], 200), + DeleteMessage::class => MockResponse::make([], 204), + ]); $user = makeUserWithDiscord('555'); $action = makeAction($user, ActionType::Ban, '24h'); @@ -122,35 +165,53 @@ function makeAction(User $user, ActionType $type, ?string $duration = null): Mod expect($result->success)->toBeTrue(); - Http::assertSent(fn ($req) => $req->method() === 'PUT' - && str_contains((string) $req->url(), '/guilds/123456789/bans/555') - && ($req->data()['delete_message_seconds'] ?? null) === 86400); + $mockClient->assertSent(fn ($request) => $request instanceof CreateBan + && str_contains($request->resolveEndpoint(), '/guilds/123456789/bans/555') + && ($request->body()->all()['delete_message_seconds'] ?? null) === 86400); }); test('ban 7d sends delete_message_seconds 604800', function (): void { - Http::fake(['discord.com/*' => Http::response([], 200)]); + $mockClient = mockConnector([ + GetMember::class => MockResponse::make(['roles' => []], 200), + CreateBan::class => MockResponse::make([], 200), + CreateDmChannel::class => MockResponse::make(['id' => 'dm-chan-1'], 200), + CreateMessage::class => MockResponse::make([], 200), + DeleteMessage::class => MockResponse::make([], 204), + ]); $user = makeUserWithDiscord('666'); $action = makeAction($user, ActionType::Ban, '7d'); DiscordModerationAdapter::make()->execute($action, $user); - Http::assertSent(fn ($req) => ($req->data()['delete_message_seconds'] ?? null) === 604800); + $mockClient->assertSent(fn ($request) => $request instanceof CreateBan + && ($request->body()->all()['delete_message_seconds'] ?? null) === 604800); }); test('permanent ban sends delete_message_seconds 0', function (): void { - Http::fake(['discord.com/*' => Http::response([], 200)]); + $mockClient = mockConnector([ + GetMember::class => MockResponse::make(['roles' => []], 200), + CreateBan::class => MockResponse::make([], 200), + CreateDmChannel::class => MockResponse::make(['id' => 'dm-chan-1'], 200), + CreateMessage::class => MockResponse::make([], 200), + DeleteMessage::class => MockResponse::make([], 204), + ]); $user = makeUserWithDiscord('777'); $action = makeAction($user, ActionType::Ban, 'permanent'); DiscordModerationAdapter::make()->execute($action, $user); - Http::assertSent(fn ($req) => ($req->data()['delete_message_seconds'] ?? -1) === 0); + $mockClient->assertSent(fn ($request) => $request instanceof CreateBan + && ($request->body()->all()['delete_message_seconds'] ?? -1) === 0); }); test('warn sends dm and returns success without calling guild api', function (): void { - Http::fake(['discord.com/*' => Http::response(['id' => 'dm-channel-1'], 200)]); + $mockClient = mockConnector([ + CreateDmChannel::class => MockResponse::make(['id' => 'dm-channel-1'], 200), + CreateMessage::class => MockResponse::make([], 200), + DeleteMessage::class => MockResponse::make([], 204), + ]); $user = makeUserWithDiscord('888'); $action = makeAction($user, ActionType::Warn); @@ -160,16 +221,18 @@ function makeAction(User $user, ActionType $type, ?string $duration = null): Mod expect($result->success)->toBeTrue() ->and($result->platformResponse['action'])->toBe('warn'); - Http::assertSent(fn ($req) => str_contains((string) $req->url(), '/users/@me/channels')); - - $guildCalls = collect(Http::recorded())->filter( - fn ($recorded) => str_contains((string) $recorded[0]->url(), '/guilds/') - ); - expect($guildCalls)->toBeEmpty(); + $mockClient->assertSent(CreateDmChannel::class); + $mockClient->assertNotSent(ModifyMember::class); + $mockClient->assertNotSent(CreateBan::class); + $mockClient->assertNotSent(RemoveMember::class); }); test('content_remove sends dm and returns success without calling guild api', function (): void { - Http::fake(['discord.com/*' => Http::response(['id' => 'dm-channel-2'], 200)]); + $mockClient = mockConnector([ + CreateDmChannel::class => MockResponse::make(['id' => 'dm-channel-2'], 200), + CreateMessage::class => MockResponse::make([], 200), + DeleteMessage::class => MockResponse::make([], 204), + ]); $user = makeUserWithDiscord('889'); $action = makeAction($user, ActionType::ContentRemove); @@ -178,35 +241,37 @@ function makeAction(User $user, ActionType $type, ?string $duration = null): Mod expect($result->success)->toBeTrue(); - Http::assertSent(fn ($req) => str_contains((string) $req->url(), '/users/@me/channels')); - - $guildCalls = collect(Http::recorded())->filter( - fn ($recorded) => str_contains((string) $recorded[0]->url(), '/guilds/') - ); - expect($guildCalls)->toBeEmpty(); + $mockClient->assertSent(CreateDmChannel::class); + $mockClient->assertNotSent(ModifyMember::class); + $mockClient->assertNotSent(CreateBan::class); + $mockClient->assertNotSent(RemoveMember::class); }); test('notify sends dm to user with discord identity', function (): void { - Http::fake(['discord.com/*' => Http::response(['id' => 'dm-chan-notify'], 200)]); + $mockClient = mockConnector([ + CreateDmChannel::class => MockResponse::make(['id' => 'dm-chan-notify'], 200), + CreateMessage::class => MockResponse::make([], 200), + ]); $user = makeUserWithDiscord('notify-111'); DiscordModerationAdapter::make()->notify($user, 'You have been warned.'); - Http::assertSent(fn ($req) => str_contains((string) $req->url(), '/users/@me/channels') - && ($req->data()['recipient_id'] ?? null) === 'notify-111'); + $mockClient->assertSent(fn ($request) => $request instanceof CreateDmChannel + && $request->body()->all()['recipient_id'] === 'notify-111'); - Http::assertSent(fn ($req) => str_contains((string) $req->url(), '/channels/dm-chan-notify/messages')); + $mockClient->assertSent(fn ($request) => $request instanceof CreateMessage + && str_contains($request->resolveEndpoint(), '/channels/dm-chan-notify/messages')); }); test('notify does nothing when user has no discord identity', function (): void { - Http::fake(); + $mockClient = mockConnector([]); $user = User::factory()->create(); DiscordModerationAdapter::make()->notify($user, 'You have been warned.'); - Http::assertNothingSent(); + $mockClient->assertNothingSent(); }); test('resolve user finds user by discord external id', function (): void { @@ -248,7 +313,12 @@ function makeAction(User $user, ActionType $type, ?string $duration = null): Mod }); test('returns failure when api responds with 403 forbidden', function (): void { - Http::fake(['discord.com/*' => Http::response(['message' => 'Missing Permissions'], 403)]); + mockConnector([ + GetMember::class => MockResponse::make(['roles' => []], 200), + CreateBan::class => MockResponse::make(['message' => 'Missing Permissions'], 403), + CreateDmChannel::class => MockResponse::make(['id' => 'dm-chan'], 200), + CreateMessage::class => MockResponse::make([], 200), + ]); $user = makeUserWithDiscord('aaaaa'); $action = makeAction($user, ActionType::Ban, 'permanent'); @@ -260,7 +330,12 @@ function makeAction(User $user, ActionType $type, ?string $duration = null): Mod }); test('returns failure when api responds with 429 rate limit', function (): void { - Http::fake(['discord.com/*' => Http::response(['message' => 'You are being rate limited.'], 429)]); + mockConnector([ + GetMember::class => MockResponse::make(['roles' => []], 200), + ModifyMember::class => MockResponse::make(['message' => 'You are being rate limited.'], 429), + CreateDmChannel::class => MockResponse::make(['id' => 'dm-chan'], 200), + CreateMessage::class => MockResponse::make([], 200), + ]); $user = makeUserWithDiscord('bbbbb'); $action = makeAction($user, ActionType::Mute, '24h'); @@ -271,7 +346,7 @@ function makeAction(User $user, ActionType $type, ?string $duration = null): Mod }); test('returns failure when user has no discord identity', function (): void { - Http::fake(); + $mockClient = mockConnector([]); $user = User::factory()->create(); $action = makeAction($user, ActionType::Ban, 'permanent'); @@ -281,11 +356,11 @@ function makeAction(User $user, ActionType $type, ?string $duration = null): Mod expect($result->success)->toBeFalse() ->and($result->error)->toContain('Discord identity not found'); - Http::assertNothingSent(); + $mockClient->assertNothingSent(); }); test('returns failure when guild id is not configured', function (): void { - Http::fake(); + $mockClient = mockConnector([]); config()->set('he4rt.discord.guild_id', ''); @@ -299,18 +374,24 @@ function makeAction(User $user, ActionType $type, ?string $duration = null): Mod }); test('returns failure when bot token is not configured', function (): void { - Http::fake(); + config()->set('discord.token', ''); + config()->set('he4rt.discord.token', ''); - config()->set('discord.token'); - config()->set('he4rt.discord.token'); + // Rebind with empty token so the connector itself is recreated + app()->singleton(DiscordConnector::class, fn () => new DiscordConnector(botToken: '')); + + $mockClient = mockConnector([ + GetMember::class => MockResponse::make([], 500), + ]); $user = makeUserWithDiscord('ddddd'); $action = makeAction($user, ActionType::Kick); + // Since guild_id is still set, the adapter will try to call the role resolver + // which will fail. The adapter catches the Throwable and returns failure. $result = DiscordModerationAdapter::make()->execute($action, $user); - expect($result->success)->toBeFalse() - ->and($result->error)->toContain('not configured'); + expect($result->success)->toBeFalse(); }); // --- Protection hierarchy tests --- @@ -319,11 +400,8 @@ function makeAction(User $user, ActionType $type, ?string $duration = null): Mod config()->set('he4rt.discord.moderation.admin_role_ids', ['547549573959385098']); config()->set('he4rt.discord.moderation.mod_role_ids', []); - Http::fake([ - 'discord.com/api/v10/guilds/*/members/*' => Http::response( - ['roles' => ['547549573959385098']], - 200, - ), + mockConnector([ + GetMember::class => MockResponse::make(['roles' => ['547549573959385098']], 200), ]); $user = makeUserWithDiscord('eeeee'); @@ -339,11 +417,8 @@ function makeAction(User $user, ActionType $type, ?string $duration = null): Mod config()->set('he4rt.discord.moderation.admin_role_ids', ['547549573959385098']); config()->set('he4rt.discord.moderation.mod_role_ids', []); - Http::fake([ - 'discord.com/api/v10/guilds/*/members/*' => Http::response( - ['roles' => ['547549573959385098']], - 200, - ), + mockConnector([ + GetMember::class => MockResponse::make(['roles' => ['547549573959385098']], 200), ]); $user = makeUserWithDiscord('fffff'); @@ -359,11 +434,8 @@ function makeAction(User $user, ActionType $type, ?string $duration = null): Mod config()->set('he4rt.discord.moderation.admin_role_ids', ['547549573959385098']); config()->set('he4rt.discord.moderation.mod_role_ids', []); - Http::fake([ - 'discord.com/api/v10/guilds/*/members/*' => Http::response( - ['roles' => ['547549573959385098']], - 200, - ), + mockConnector([ + GetMember::class => MockResponse::make(['roles' => ['547549573959385098']], 200), ]); $user = makeUserWithDiscord('ggggg'); @@ -379,7 +451,6 @@ function makeAction(User $user, ActionType $type, ?string $duration = null): Mod config()->set('he4rt.discord.moderation.admin_role_ids', ['admin-role']); config()->set('he4rt.discord.moderation.mod_role_ids', ['mod-role']); - // target is a mod; actor (moderator) is also a mod $actor = makeUserWithDiscord('actor-mod'); $target = makeUserWithDiscord('target-mod'); @@ -391,9 +462,9 @@ function makeAction(User $user, ActionType $type, ?string $duration = null): Mod 'moderator_id' => $actor->id, ]); - Http::fake([ - 'discord.com/api/v10/guilds/*/members/target-mod' => Http::response(['roles' => ['mod-role']], 200), - 'discord.com/api/v10/guilds/*/members/actor-mod' => Http::response(['roles' => ['mod-role']], 200), + // Both target and actor have mod-role + mockConnector([ + GetMember::class => MockResponse::make(['roles' => ['mod-role']], 200), ]); $result = DiscordModerationAdapter::make()->execute($action, $target); @@ -417,11 +488,20 @@ function makeAction(User $user, ActionType $type, ?string $duration = null): Mod 'moderator_id' => $actor->id, ]); - Http::fake([ - 'discord.com/api/v10/guilds/*/members/target-mod2' => Http::response(['roles' => ['mod-role']], 200), - 'discord.com/api/v10/guilds/*/members/actor-admin' => Http::response(['roles' => ['admin-role']], 200), - 'discord.com/api/v10/guilds/*/bans/*' => Http::response([], 200), - 'discord.com/*' => Http::response(['id' => 'dm-chan'], 200), + $getMemberCalls = 0; + mockConnector([ + GetMember::class => function () use (&$getMemberCalls): MockResponse { + $getMemberCalls++; + + // First call is for target (mod-role), second is for actor (admin-role) + return $getMemberCalls === 1 + ? MockResponse::make(['roles' => ['mod-role']], 200) + : MockResponse::make(['roles' => ['admin-role']], 200); + }, + CreateBan::class => MockResponse::make([], 200), + CreateDmChannel::class => MockResponse::make(['id' => 'dm-chan'], 200), + CreateMessage::class => MockResponse::make([], 200), + DeleteMessage::class => MockResponse::make([], 204), ]); $result = DiscordModerationAdapter::make()->execute($action, $target); @@ -444,8 +524,8 @@ function makeAction(User $user, ActionType $type, ?string $duration = null): Mod 'automated' => true, ]); - Http::fake([ - 'discord.com/api/v10/guilds/*/members/target-mod3' => Http::response(['roles' => ['mod-role']], 200), + mockConnector([ + GetMember::class => MockResponse::make(['roles' => ['mod-role']], 200), ]); $result = DiscordModerationAdapter::make()->execute($action, $target); @@ -458,9 +538,12 @@ function makeAction(User $user, ActionType $type, ?string $duration = null): Mod config()->set('he4rt.discord.moderation.admin_role_ids', ['admin-role']); config()->set('he4rt.discord.moderation.mod_role_ids', ['mod-role']); - Http::fake([ - 'discord.com/api/v10/guilds/*/members/*' => Http::response(['roles' => ['123456789']], 200), - 'discord.com/*' => Http::response([], 200), + mockConnector([ + GetMember::class => MockResponse::make(['roles' => ['123456789']], 200), + CreateBan::class => MockResponse::make([], 200), + CreateDmChannel::class => MockResponse::make(['id' => 'dm-chan'], 200), + CreateMessage::class => MockResponse::make([], 200), + DeleteMessage::class => MockResponse::make([], 204), ]); $user = makeUserWithDiscord('hhhhh'); @@ -474,7 +557,11 @@ function makeAction(User $user, ActionType $type, ?string $duration = null): Mod test('warn is not blocked even when target is an admin', function (): void { config()->set('he4rt.discord.moderation.admin_role_ids', ['admin-role']); - Http::fake(['discord.com/*' => Http::response(['id' => 'dm-channel-warn'], 200)]); + mockConnector([ + CreateDmChannel::class => MockResponse::make(['id' => 'dm-channel-warn'], 200), + CreateMessage::class => MockResponse::make([], 200), + DeleteMessage::class => MockResponse::make([], 204), + ]); $user = makeUserWithDiscord('iiiii'); $action = makeAction($user, ActionType::Warn); diff --git a/app-modules/bot-discord/tests/Unit/Moderation/ModerationEmbedBuilderTest.php b/app-modules/bot-discord/tests/Unit/Moderation/ModerationEmbedBuilderTest.php new file mode 100644 index 000000000..aba4bb378 --- /dev/null +++ b/app-modules/bot-discord/tests/Unit/Moderation/ModerationEmbedBuilderTest.php @@ -0,0 +1,99 @@ +create([ + 'content_snapshot' => [ + 'text' => 'some offensive content', + 'metadata' => ['username' => 'baduser'], + ], + ]); + + $builder = new ModerationEmbedBuilder(); + $embed = $builder->buildCaseEmbed($case); + + expect($embed) + ->toHaveKeys(['title', 'description', 'color', 'fields', 'timestamp', 'footer']) + ->and($embed['title'])->toContain('baduser') + ->and($embed['description'])->toContain($case->id) + ->and($embed['color'])->toBe(0xFFA500) + ->and($embed['footer']['text'])->toBe('He4rt Moderation System') + ->and($embed['fields'])->toHaveCount(4) + ->and($embed['fields'][0]['name'])->toBe('Platform') + ->and($embed['fields'][1]['name'])->toBe('Source') + ->and($embed['fields'][2]['name'])->toBe('Priority') + ->and($embed['fields'][3]['name'])->toBe('Content') + ->and($embed['fields'][3]['value'])->toBe('some offensive content'); +}); + +test('buildCaseEmbed truncates content to 1024 chars', function (): void { + $longText = str_repeat('a', 2000); + + $case = ModerationCase::factory()->create([ + 'content_snapshot' => [ + 'text' => $longText, + 'metadata' => ['username' => 'user'], + ], + ]); + + $builder = new ModerationEmbedBuilder(); + $embed = $builder->buildCaseEmbed($case); + + $contentField = collect($embed['fields'])->firstWhere('name', 'Content'); + + expect(mb_strlen((string) $contentField['value']))->toBe(1024); +}); + +test('buildCaseEmbed handles null text gracefully', function (): void { + $case = ModerationCase::factory()->create([ + 'content_snapshot' => [ + 'metadata' => ['username' => 'user'], + ], + ]); + + $builder = new ModerationEmbedBuilder(); + $embed = $builder->buildCaseEmbed($case); + + expect($embed['fields'])->toHaveCount(3); + + $fieldNames = array_column($embed['fields'], 'name'); + expect($fieldNames)->not->toContain('Content'); +}); + +test('buildCaseEmbed uses Unknown when username is missing', function (): void { + $case = ModerationCase::factory()->create([ + 'content_snapshot' => ['text' => 'test', 'metadata' => []], + ]); + + $builder = new ModerationEmbedBuilder(); + $embed = $builder->buildCaseEmbed($case); + + expect($embed['title'])->toContain('Unknown'); +}); + +test('buildRoleMentions formats role IDs correctly', function (): void { + config()->set('he4rt.discord.moderation.admin_role_ids', ['111111111111111111']); + config()->set('he4rt.discord.moderation.mod_role_ids', ['222222222222222222']); + + $builder = new ModerationEmbedBuilder(); + $mentions = $builder->buildRoleMentions(); + + expect($mentions)->toBe('<@&111111111111111111> <@&222222222222222222>'); +}); + +test('buildRoleMentions returns empty string when no roles configured', function (): void { + config()->set('he4rt.discord.moderation.admin_role_ids', []); + config()->set('he4rt.discord.moderation.mod_role_ids', []); + + $builder = new ModerationEmbedBuilder(); + $mentions = $builder->buildRoleMentions(); + + expect($mentions)->toBe(''); +}); From 8049ede3c1baaa6f9a06a3d6702a71f0d566647e Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 16 May 2026 18:48:03 -0300 Subject: [PATCH 08/12] refactor(bot-discord): thin message-received-event with final pipeline integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #233. Strips MessageReceivedEvent down to a thin orchestrator that delegates entirely to SubmitForModeration. - Remove all inline classification, routing, and enforcement logic - Remove 10 unused imports (classifiers, jobs, rules, actions) - Handler now: resolve tenant → track activity → submit to moderation pipeline - Simplify error logging (remove full stack trace) --- .../src/Events/MessageReceivedEvent.php | 85 +------------------ 1 file changed, 3 insertions(+), 82 deletions(-) diff --git a/app-modules/bot-discord/src/Events/MessageReceivedEvent.php b/app-modules/bot-discord/src/Events/MessageReceivedEvent.php index 9077055cf..c413592ef 100644 --- a/app-modules/bot-discord/src/Events/MessageReceivedEvent.php +++ b/app-modules/bot-discord/src/Events/MessageReceivedEvent.php @@ -12,17 +12,8 @@ use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; use He4rt\Identity\Tenant\Models\Tenant; -use He4rt\Moderation\Classification\Actions\Classifiers\AggregateClassifier; -use He4rt\Moderation\Classification\Actions\Classifiers\OpenAiClassifier; -use He4rt\Moderation\Classification\Actions\Classifiers\RuleBasedClassifier; -use He4rt\Moderation\Classification\Jobs\IngestContent; -use He4rt\Moderation\Classification\Jobs\RouteDecision; -use He4rt\Moderation\Enforcement\ExecuteAction; -use He4rt\Moderation\Enforcement\ModerationAction; -use He4rt\Moderation\Enums\ActionType; use He4rt\Moderation\Enums\CaseSource; -use He4rt\Moderation\Enums\Platform; -use He4rt\Moderation\Rules\ModerationRule; +use He4rt\Moderation\Pipeline\SubmitForModeration; use Laracord\Events\Event; use Throwable; @@ -74,82 +65,12 @@ public function handle(Message $message): void 'tenant_id' => (string) $tenantProvider->tenant_id, ]); - $this->logger()->info('[Moderation] Pre-screening message: '.$message->id); - - $ruleResult = RuleBasedClassifier::make()->classify($content); - $hasRuleMatch = $ruleResult->matchedRules !== []; - - $ruleAction = null; - if ($hasRuleMatch) { - $ruleAction = ModerationRule::query() - ->whereIn('id', $ruleResult->matchedRules) - ->get() - ->sortByDesc(fn (ModerationRule $rule): int => $rule->severity->weight()) - ->first()?->action_on_match; - } - - $aiResult = $hasRuleMatch - ? $ruleResult - : AggregateClassifier::make() - ->addClassifier(OpenAiClassifier::make()) - ->classify($content); - - $maxScore = blank($aiResult->scores) ? 0.0 : max($aiResult->scores); - $flagThreshold = config('moderation.thresholds.flag', 0.7); - - $shouldCreateCase = $hasRuleMatch || $maxScore >= $flagThreshold; - - $this->logger()->info( - '[Moderation] Pre-screening result: rule_match='.($hasRuleMatch ? 'yes' : 'no') - .' max_score='.$maxScore.' threshold='.$flagThreshold - .' create_case='.($shouldCreateCase ? 'yes' : 'no') - ); - - if (!$shouldCreateCase) { - return; - } - - $case = new IngestContent($content, CaseSource::AutoDetect)->handle(); - $this->logger()->info('[Moderation] Case created: '.$case->id.' status='.$case->status->value); - - $case->update([ - 'ai_scores' => $aiResult->scores, - 'violation_type' => $aiResult->primary, - 'severity' => $aiResult->severity, - 'classifier_version' => $aiResult->classifierName, - 'suggested_action' => $ruleAction, - ]); - - new RouteDecision($case)->handle(); - $case->refresh(); - $this->logger()->info('[Moderation] After route: status='.$case->status->value.' priority='.$case->priority); - - // Only auto-execute when the action was set by a deterministic rule (classifier_version='rules'). - // AI-only suggestions stay pending for human moderator review. - if ($case->suggested_action && $case->author && $case->classifier_version === 'rules') { - $action = ModerationAction::query()->create([ - 'case_id' => $case->id, - 'moderator_id' => null, - 'action_type' => $case->suggested_action, - 'target_platforms' => [Platform::Discord->value], - 'duration' => match ($case->suggested_action) { - ActionType::Ban => 'permanent', - ActionType::Mute, ActionType::Suspend => '24h', - default => null, - }, - 'reason' => 'Auto-moderation triggered by Discord message classification.', - 'automated' => true, - 'tenant_id' => $case->tenant_id, - ]); - - dispatch(new ExecuteAction($action, $case->author)); - } + resolve(SubmitForModeration::class)->execute($content, CaseSource::AutoDetect); } catch (Throwable $throwable) { $this->logger()->error( - sprintf('%s | File: %s | Line: %s | Trace: %s', $throwable->getMessage(), $throwable->getFile(), $throwable->getLine(), $throwable->getTraceAsString()), + sprintf('%s | File: %s | Line: %s', $throwable->getMessage(), $throwable->getFile(), $throwable->getLine()), ); } - } } From 1b8deaa6c4e09229c6df366af16180d072d17b16 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 16 May 2026 18:55:39 -0300 Subject: [PATCH 09/12] docs(bot-discord/moderation): add inline comments to adapter, listeners, and event handler Adds class-level docblocks and key inline comments to bot-discord and enforcement classes explaining the flow, responsibilities, and design rationale. --- .../src/BotDiscordServiceProvider.php | 2 ++ .../src/Events/MessageReceivedEvent.php | 20 +++++++++---------- .../src/Listeners/AutoExecuteAction.php | 11 ++++++++++ .../src/Listeners/NotifyModerationChannel.php | 4 ++++ .../Moderation/DiscordModerationAdapter.php | 17 ++++++++++++++-- .../src/Enforcement/ExecuteAction.php | 7 +++++++ 6 files changed, 49 insertions(+), 12 deletions(-) diff --git a/app-modules/bot-discord/src/BotDiscordServiceProvider.php b/app-modules/bot-discord/src/BotDiscordServiceProvider.php index 31349c510..7550f2a68 100644 --- a/app-modules/bot-discord/src/BotDiscordServiceProvider.php +++ b/app-modules/bot-discord/src/BotDiscordServiceProvider.php @@ -23,6 +23,7 @@ public function register(): void $this->app->singleton(DiscordModerationAdapter::class); + // Register Discord adapter in the moderation PlatformRegistry for O(1) lookup during enforcement. $this->app->afterResolving(PlatformRegistry::class, function (PlatformRegistry $registry): void { $registry->register(Platform::Discord, DiscordModerationAdapter::class); }); @@ -34,6 +35,7 @@ public function boot(): void { parent::boot(); + // Domain event listeners: moderation module emits events, bot-discord reacts with Discord-specific behavior. Event::listen(CaseQueued::class, [NotifyModerationChannel::class, 'handle']); Event::listen(CaseReadyForEnforcement::class, [AutoExecuteAction::class, 'handle']); diff --git a/app-modules/bot-discord/src/Events/MessageReceivedEvent.php b/app-modules/bot-discord/src/Events/MessageReceivedEvent.php index c413592ef..83cf5aeb0 100644 --- a/app-modules/bot-discord/src/Events/MessageReceivedEvent.php +++ b/app-modules/bot-discord/src/Events/MessageReceivedEvent.php @@ -17,13 +17,14 @@ use Laracord\Events\Event; use Throwable; +/** + * Thin event handler for Discord MESSAGE_CREATE. + * + * Responsibilities: resolve tenant, track activity, submit to moderation pipeline. + * All classification, routing, and enforcement logic lives in the moderation module. + */ class MessageReceivedEvent extends Event { - /** - * The event handler. - * - * @var string - */ protected $handler = Events::MESSAGE_CREATE; public function handle(Message $message): void @@ -33,16 +34,13 @@ public function handle(Message $message): void } try { + // Resolve which tenant (Discord guild) this message belongs to. $tenantProvider = ExternalIdentity::query() ->where('model_type', (new Tenant)->getMorphClass()) ->where('external_account_id', (string) $message->guild_id) ->firstOrFail(); - $authorIdentity = ExternalIdentity::query() - ->where('provider', IdentityProvider::Discord) - ->where('external_account_id', (string) $message->user_id) - ->first(); - + // Activity tracking — records message for XP/gamification regardless of moderation outcome. resolve(NewMessage::class)->persist(new NewMessageDTO( tenantId: $tenantProvider->tenant_id, provider: IdentityProvider::Discord, @@ -54,6 +52,8 @@ public function handle(Message $message): void sentAt: $message->timestamp->toDateTimeImmutable() )); + // Moderation pipeline — SubmitForModeration handles pre-screen (sync) + async AI. + // See ADR-0001 for architecture details. $content = DiscordModerationAdapter::make()->ingest([ 'message_id' => $message->id, 'author_id' => $message->user_id, diff --git a/app-modules/bot-discord/src/Listeners/AutoExecuteAction.php b/app-modules/bot-discord/src/Listeners/AutoExecuteAction.php index 9bbca5aac..15f649f65 100644 --- a/app-modules/bot-discord/src/Listeners/AutoExecuteAction.php +++ b/app-modules/bot-discord/src/Listeners/AutoExecuteAction.php @@ -10,12 +10,22 @@ use He4rt\Moderation\Enums\ActionType; use He4rt\Moderation\Enums\Platform; +/** + * Listens to CaseReadyForEnforcement and auto-executes the suggested action on Discord. + * + * This is the "how" side of enforcement for Discord. The moderation module already decided + * "this case is safe to auto-execute" (deterministic rule match). This listener just creates + * the action record and dispatches execution. + * + * Only acts on Discord-sourced cases. Other platforms register their own listeners. + */ final class AutoExecuteAction { public function handle(CaseReadyForEnforcement $event): void { $case = $event->case; + // Each platform handles its own cases — skip if not Discord. if ($case->source_platform !== Platform::Discord) { return; } @@ -35,6 +45,7 @@ public function handle(CaseReadyForEnforcement $event): void 'tenant_id' => $case->tenant_id, ]); + // Async: enforcement runs in the queue (API calls, DM, delete — may fail/retry). dispatch(new ExecuteAction($action, $case->author)); } diff --git a/app-modules/bot-discord/src/Listeners/NotifyModerationChannel.php b/app-modules/bot-discord/src/Listeners/NotifyModerationChannel.php index 9d0f05873..b4459bc03 100644 --- a/app-modules/bot-discord/src/Listeners/NotifyModerationChannel.php +++ b/app-modules/bot-discord/src/Listeners/NotifyModerationChannel.php @@ -10,6 +10,10 @@ use He4rt\Moderation\Cases\Events\CaseQueued; use Illuminate\Support\Facades\Log; +/** + * Sends an embed notification to the moderation channel when a new case is queued for review. + * Mentions admin and mod roles so the team is alerted immediately. + */ final readonly class NotifyModerationChannel { public function __construct( diff --git a/app-modules/bot-discord/src/Moderation/DiscordModerationAdapter.php b/app-modules/bot-discord/src/Moderation/DiscordModerationAdapter.php index db65794f4..63175c9ea 100644 --- a/app-modules/bot-discord/src/Moderation/DiscordModerationAdapter.php +++ b/app-modules/bot-discord/src/Moderation/DiscordModerationAdapter.php @@ -27,6 +27,13 @@ use Saloon\Http\Response; use Throwable; +/** + * Discord implementation of ModerationPlatformContract. + * + * Handles the "how" of enforcement on Discord: mute (timeout), kick, ban, warn (DM), content removal. + * All HTTP goes through DiscordConnector (Saloon) in integration-discord. + * Protection hierarchy (admin/mod immunity) is resolved by DiscordRoleResolver. + */ final readonly class DiscordModerationAdapter implements ModerationPlatformContract { public function __construct( @@ -62,6 +69,12 @@ public function ingest(array $rawPayload): ModerationContentDTO ]); } + /** + * Execute a moderation action against a user on Discord. + * + * Flow: check protection tier → apply action → DM user → delete content. + * DM and delete failures are non-fatal (best-effort). + */ public function execute(ModerationAction $action, User $target): ExecutionResultDTO { try { @@ -83,6 +96,7 @@ public function execute(ModerationAction $action, User $target): ExecutionResult ); } + // Protection hierarchy: admins are immune, mods can only be punished by admins. if ($this->isPunitiveAction($action->action_type)) { $tier = $this->roleResolver->resolveProtectionTier($guildId, $discordId); @@ -103,17 +117,16 @@ public function execute(ModerationAction $action, User $target): ExecutionResult $response = $this->executeAction($action, $guildId, $discordId); + // Best-effort: DM notification and content deletion are non-fatal. try { $this->sendDmNotification($discordId, $action); } catch (Throwable) { - // ignore dm failures } if ($this->shouldDeleteContent($action->action_type)) { try { $this->deleteOriginalMessage($action); } catch (Throwable) { - // ignore delete failures } } diff --git a/app-modules/moderation/src/Enforcement/ExecuteAction.php b/app-modules/moderation/src/Enforcement/ExecuteAction.php index ccedd4f47..b4029e006 100644 --- a/app-modules/moderation/src/Enforcement/ExecuteAction.php +++ b/app-modules/moderation/src/Enforcement/ExecuteAction.php @@ -13,6 +13,12 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; +/** + * Dispatches a moderation action to all target platforms via their registered adapters. + * + * Resolves adapters through PlatformRegistry (O(1) per platform, lazy instantiation). + * After execution, marks the case as resolved and emits ActionExecuted for audit logging. + */ final class ExecuteAction implements ShouldQueue { use InteractsWithQueue; @@ -27,6 +33,7 @@ public function handle(PlatformRegistry $registry): void { $results = []; + // An action can target multiple platforms (e.g., Discord + Web ban simultaneously). foreach ($this->action->target_platforms as $platformValue) { $platform = Platform::tryFrom($platformValue); From 3318f3420c1b9914f375836316adb770cbe5a014 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 16 May 2026 19:22:50 -0300 Subject: [PATCH 10/12] feat(identity/panel-admin): add wireable support to ClientAccessManager and external identity admin resource - Make ClientAccessManager implement Wireable (fromLivewire/toLivewire) - Add tenant() relationship to ExternalIdentity model - Register ExternalIdentityResource in admin panel --- .../Data/ClientAccessManager.php | 33 ++++++++++++++++++- .../Models/ExternalIdentity.php | 9 +++++ .../ExternalIdentityResource.php | 5 ++- .../src/PanelAdminServiceProvider.php | 5 +++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/app-modules/identity/src/ExternalIdentity/Data/ClientAccessManager.php b/app-modules/identity/src/ExternalIdentity/Data/ClientAccessManager.php index 6ab748b3d..f77961557 100644 --- a/app-modules/identity/src/ExternalIdentity/Data/ClientAccessManager.php +++ b/app-modules/identity/src/ExternalIdentity/Data/ClientAccessManager.php @@ -5,8 +5,9 @@ namespace He4rt\Identity\ExternalIdentity\Data; use Illuminate\Support\Facades\Crypt; +use Livewire\Wireable; -class ClientAccessManager +final class ClientAccessManager implements Wireable { public function __construct( public ?string $clientId = null, @@ -49,6 +50,21 @@ public static function makeFromPayload(array $payload): self ); } + /** @param array $value */ + public static function fromLivewire(mixed $value): static + { + return new self( + clientId: $value['clientId'] ?? null, + clientSecret: $value['clientSecret'] ?? null, + accessToken: $value['accessToken'] ?? null, + refreshToken: $value['refreshToken'] ?? null, + expiresIn: $value['expiresIn'] ?? null, + username: $value['username'] ?? null, + password: $value['password'] ?? null, + apiKey: $value['apiKey'] ?? null, + ); + } + public function getClientId(): ?string { return $this->clientId !== null ? Crypt::decrypt($this->clientId) : null; @@ -88,4 +104,19 @@ public function getApiKey(): ?string { return $this->apiKey !== null ? Crypt::decrypt($this->apiKey) : null; } + + /** @return array */ + public function toLivewire(): array + { + return [ + 'clientId' => $this->clientId, + 'clientSecret' => $this->clientSecret, + 'accessToken' => $this->accessToken, + 'refreshToken' => $this->refreshToken, + 'expiresIn' => $this->expiresIn, + 'username' => $this->username, + 'password' => $this->password, + 'apiKey' => $this->apiKey, + ]; + } } diff --git a/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php b/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php index 5036f888e..c0894f967 100644 --- a/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php +++ b/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php @@ -11,6 +11,7 @@ use He4rt\Identity\ExternalIdentity\Enums\CredentialsType; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Enums\IdentityType; +use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -100,6 +101,14 @@ public function messages(): HasMany return $this->hasMany(Message::class, 'external_identity_id'); } + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_id', 'id'); + } + public function isConnected(): bool { return $this->connected_at !== null && $this->disconnected_at === null; diff --git a/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/ExternalIdentityResource.php b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/ExternalIdentityResource.php index 231c17ff9..8319bd5bb 100644 --- a/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/ExternalIdentityResource.php +++ b/app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/ExternalIdentityResource.php @@ -74,7 +74,10 @@ public static function getEloquentQuery(): Builder */ public static function getGlobalSearchEloquentQuery(): Builder { - return parent::getGlobalSearchEloquentQuery()->with(['connectedByUser', 'user']); + /** @var Builder $query */ + $query = parent::getGlobalSearchEloquentQuery()->with(['connectedByUser', 'user']); + + return $query; } public static function getGloballySearchableAttributes(): array diff --git a/app-modules/panel-admin/src/PanelAdminServiceProvider.php b/app-modules/panel-admin/src/PanelAdminServiceProvider.php index f340d174e..c446b9e1b 100644 --- a/app-modules/panel-admin/src/PanelAdminServiceProvider.php +++ b/app-modules/panel-admin/src/PanelAdminServiceProvider.php @@ -7,6 +7,7 @@ use Filament\Navigation\NavigationBuilder; use Filament\Navigation\NavigationItem; use Filament\Panel; +use He4rt\PanelAdmin\Filament\Resources\ExternalIdentities\ExternalIdentityResource; use He4rt\PanelAdmin\Moderation\Livewire\AppealQueue; use He4rt\PanelAdmin\Moderation\Livewire\ModerationDashboardLivewire; use He4rt\PanelAdmin\Moderation\Livewire\ModerationQueue; @@ -29,6 +30,9 @@ public function register(): void $panel ->pages([ModerationCluster::class]) ->navigation($this->buildNavigation(...)) + ->resources([ + ExternalIdentityResource::class, + ]) ->discoverResources( in: __DIR__.'/Moderation/Resources', for: 'He4rt\\PanelAdmin\\Moderation\\Resources', @@ -81,6 +85,7 @@ private function defaultNavigation(NavigationBuilder $builder): NavigationBuilde return $builder->items([ ...Dashboard::getNavigationItems(), ...ModerationCluster::getNavigationItems(), + ...ExternalIdentityResource::getNavigationItems(), ]); } From 524af8bf3bd14488d91eb3c0cb1e43447febb1fa Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 16 May 2026 19:23:45 -0300 Subject: [PATCH 11/12] chore: remove deprecated guideline files replaced by 99-knowledge-base --- .ai/guidelines/filament.blade.php | 34 ------------------------- .ai/guidelines/knowledge-base.blade.php | 31 ---------------------- 2 files changed, 65 deletions(-) delete mode 100644 .ai/guidelines/filament.blade.php delete mode 100644 .ai/guidelines/knowledge-base.blade.php diff --git a/.ai/guidelines/filament.blade.php b/.ai/guidelines/filament.blade.php deleted file mode 100644 index 12d7c16ff..000000000 --- a/.ai/guidelines/filament.blade.php +++ /dev/null @@ -1,34 +0,0 @@ -# Filament Conventions These rules are project-specific and override the generic Filament guidelines from Boost when -they conflict. ## Action notifications: never send manually inside `action()` When an action (`Action`, `BulkAction`, -`DeleteAction`, etc.) needs a custom success notification with dynamic data — like a record count, a generated ID, or a -translated message — **declare it via `successNotification()` with a closure**, not by calling -`Notification::make()->send()` inside the `action()` callback. ### Why Filament built-in actions (`DeleteAction`, -`DeleteBulkAction`, `RestoreAction`, `ForceDeleteAction`, `EditAction`, `CreateAction`, etc.) **already dispatch a -default success notification** after `action()` returns. If you also call `Notification::make()->send()` inside -`action()`, the user sees **two notifications stacked**: the default one ("Deleted") and the custom one. The default -cannot be suppressed by the closure — it must be customized or disabled at configuration time. -`successNotification(Closure)` is the right hook because: 1. It **replaces** the default notification instead of adding -to it — no duplicates. 2. The closure runs **after** `action()` completes, so it can reflect the final state. 3. -Filament **injects utilities** into the closure (`$record`, `$records`, `$data`, etc.), so the dynamic count/title is -computed from the actual processed records, not from a stale variable captured at action-call time. 4. It separates -concerns: `action()` does the work, `successNotification()` declares the feedback. ### Wrong ```php -DeleteBulkAction::make() ->action(function (Collection $records): void { Proxy::query()->whereIn('id', -$records->pluck('id'))->delete(); // ❌ This sends a SECOND notification on top of Filament's default "Deleted" one. -Notification::make() ->success() ->title(__('panel-admin::proxies.bulk_delete.success', ['count' => $records->count()])) -->send(); }) ``` ### Right ```php DeleteBulkAction::make() ->action(function (Collection $records): void { // ✅ -action() only does the work — no notification logic. Proxy::query()->whereIn('id', $records->pluck('id'))->delete(); }) -->successNotification(fn (Collection $records): Notification => Notification::make() ->success() -->title(__('panel-admin::proxies.bulk_delete.success', [ 'count' => $records->count(), ]))) -->deselectRecordsAfterCompletion() ``` ### When `Notification::make()->send()` *is* acceptable Custom actions that -**inherit directly from `Filament\Actions\Action`** (not from a built-in subclass like `DeleteAction`) do not have a -default success notification. For these, you may either: - Send a notification manually inside `action()` — fine because -there is no default to conflict with. - Or still prefer `successNotification()` for consistency, especially if other -actions in the same resource use that pattern. Reference example: -`app-modules/panel-admin/src/Filament/Resources/Proxies/Actions/BulkDeleteProxiesAction.php` is a custom `Action` -subclass and sends manually — that's fine. The table-level `DeleteBulkAction::make()` in `ProxiesTable.php` uses -`successNotification()` because it extends a built-in. ### Migration checklist When auditing existing actions in this -codebase, the smell is: a built-in action subclass (`DeleteAction`, `DeleteBulkAction`, `RestoreAction`, -`RestoreBulkAction`, `ForceDeleteAction`, `EditAction`, `CreateAction`, `ReplicateAction`) with -`Notification::make()->send()` inside its `action()` callback. If you find one, refactor it to use -`successNotification(Closure)` and remove the manual send. If the goal is just to **disable** the default notification -(no replacement needed), use `->successNotification(null)` instead of leaving `Notification::make()->send()` unmoved. diff --git a/.ai/guidelines/knowledge-base.blade.php b/.ai/guidelines/knowledge-base.blade.php deleted file mode 100644 index f3b01ce20..000000000 --- a/.ai/guidelines/knowledge-base.blade.php +++ /dev/null @@ -1,31 +0,0 @@ -# Knowledge Base Documentation This project uses `guava/filament-knowledge-base` to provide an embedded documentation -system inside the Filament admin panel. Documentation is written as Markdown files and rendered in the sidebar. ## -Documentation Structure All documentation files live in `docs/admin/{lang}/` following this structure: ``` -docs/admin/{lang}/ ├── introduction.md # Top-level page (ungrouped) ├── getting-started.md # Group definition (type: -group) │ └── getting-started/ │ ├── navigating-the-panel.md │ ├── dashboard.md │ └── profile.md ├── users.md # Group -definition (type: group) │ └── users/ │ ├── managing-users.md │ ├── roles.md │ ├── teams.md │ └── authentication.md └── -system.md # Group definition (type: group) └── system/ ├── activity-logs.md ├── emails.md └── configuration.md ``` ### -Rules - Maximum **3 levels** of nesting. - Group files (directories) require a matching `.md` file at the same level -with `type: group` in the front matter. - All documentation files require YAML front matter with at least `title`, -`icon`, and `order`. - Use `heroicon-o-*` icons from the Heroicons outlined set. ### Front Matter Format ```yaml --- -title: Page Title icon: heroicon-o-document order: 1 --- ``` For group files, add `type: group`: ```yaml --- title: -Group Name icon: heroicon-o-folder order: 2 type: group --- ``` ## Keeping Documentation in Sync When making changes to -the codebase that affect user-facing behavior, you MUST update the related documentation in `docs/admin/en/`. This -includes: - **Adding a new resource or page** — Create a corresponding doc file under the appropriate group directory. - -**Changing navigation groups or labels** — Update `docs/admin/en/{group}.md` and its children to match. - **Adding, -removing, or renaming form fields** — Update the relevant doc page that describes the resource. - **Changing -authentication or authorization behavior** — Update `docs/admin/en/users/authentication.md` and -`docs/admin/en/users/roles.md`. - **Modifying system features** (activity logs, emails, config) — Update the -corresponding file under `docs/admin/en/system/`. - **Adding or changing translations** — Ensure doc content reflects -the new labels. If no existing doc page covers the changed feature, create a new one under the appropriate group and add -it with the correct front matter (title, icon, order). ## Key Files - `app/Filament/Plugins/BetterKnowledgeBase.php` — -Plugin that builds the documentation sidebar navigation. Adds a "Documentation" nav item to the admin panel and -overrides the sidebar with the doc tree when viewing docs. - `config/filament-knowledge-base.php` — KB plugin -configuration (cache TTL, icons, model). - `resources/views/vendor/filament-knowledge-base/livewire/help-menu.blade.php` -— Custom help menu popover for contextual per-resource documentation. - `lang/en/knowledge_base.php` and -`lang/pt_BR/knowledge_base.php` — Translations for the KB UI. ## Contextual Help (HasKnowledgeBase) Resources can -implement `Guava\FilamentKnowledgeBase\Contracts\HasKnowledgeBase` to link contextual documentation that appears in the -sidebar help popover: ```php use Guava\FilamentKnowledgeBase\Contracts\HasKnowledgeBase; class UserResource extends -Resource implements HasKnowledgeBase { public static function getDocumentation(): array { return [ -'users.managing-users', 'users.roles', ]; } } ``` When adding `HasKnowledgeBase` to a resource, the documentation IDs -follow the pattern `{group}.{file-slug}` matching the file path under `docs/admin/en/`. From 50a1238eaa35753a0a51cbc547ae01743e58b325 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 16 May 2026 19:32:06 -0300 Subject: [PATCH 12/12] style(guidelines): format blade guideline files for readability Prettier had collapsed all markdown into single lines. Restores proper heading hierarchy, list formatting, code blocks, and table alignment. --- .agents/skills/configure-nightwatch/SKILL.md | 405 ------------------ .../skills/configure-nightwatch/reference.md | 102 ----- .agents/skills/fluxui-development/SKILL.md | 85 ---- .agents/skills/laravel-attributes | 1 - .../skills/laravel-best-practices/SKILL.md | 190 -------- .../rules/advanced-queries.md | 106 ----- .../rules/architecture.md | 211 --------- .../rules/blade-views.md | 41 -- .../laravel-best-practices/rules/caching.md | 72 ---- .../rules/collections.md | 45 -- .../laravel-best-practices/rules/config.md | 79 ---- .../rules/db-performance.md | 205 --------- .../laravel-best-practices/rules/eloquent.md | 164 ------- .../rules/error-handling.md | 75 ---- .../rules/events-notifications.md | 52 --- .../rules/http-client.md | 168 -------- .../laravel-best-practices/rules/mail.md | 27 -- .../rules/migrations.md | 129 ------ .../rules/queue-jobs.md | 145 ------- .../laravel-best-practices/rules/routing.md | 105 ----- .../rules/scheduling.md | 41 -- .../laravel-best-practices/rules/security.md | 208 --------- .../laravel-best-practices/rules/style.md | 133 ------ .../laravel-best-practices/rules/testing.md | 43 -- .../rules/validation.md | 79 ---- .agents/skills/modular/SKILL.md | 188 -------- .agents/skills/pest-testing/SKILL.md | 171 -------- .../skills/tailwindcss-development/SKILL.md | 123 ------ .ai/guidelines/01-domain.blade.php | 59 ++- .ai/guidelines/02-issue-tracker.blade.php | 38 +- .ai/guidelines/03-triage-labels.blade.php | 20 +- .ai/guidelines/99-knowledge-base.blade.php | 112 ++++- .gitignore | 1 + .prettierignore | 1 + PR.md | 17 - 35 files changed, 171 insertions(+), 3470 deletions(-) delete mode 100644 .agents/skills/configure-nightwatch/SKILL.md delete mode 100644 .agents/skills/configure-nightwatch/reference.md delete mode 100644 .agents/skills/fluxui-development/SKILL.md delete mode 120000 .agents/skills/laravel-attributes delete mode 100644 .agents/skills/laravel-best-practices/SKILL.md delete mode 100644 .agents/skills/laravel-best-practices/rules/advanced-queries.md delete mode 100644 .agents/skills/laravel-best-practices/rules/architecture.md delete mode 100644 .agents/skills/laravel-best-practices/rules/blade-views.md delete mode 100644 .agents/skills/laravel-best-practices/rules/caching.md delete mode 100644 .agents/skills/laravel-best-practices/rules/collections.md delete mode 100644 .agents/skills/laravel-best-practices/rules/config.md delete mode 100644 .agents/skills/laravel-best-practices/rules/db-performance.md delete mode 100644 .agents/skills/laravel-best-practices/rules/eloquent.md delete mode 100644 .agents/skills/laravel-best-practices/rules/error-handling.md delete mode 100644 .agents/skills/laravel-best-practices/rules/events-notifications.md delete mode 100644 .agents/skills/laravel-best-practices/rules/http-client.md delete mode 100644 .agents/skills/laravel-best-practices/rules/mail.md delete mode 100644 .agents/skills/laravel-best-practices/rules/migrations.md delete mode 100644 .agents/skills/laravel-best-practices/rules/queue-jobs.md delete mode 100644 .agents/skills/laravel-best-practices/rules/routing.md delete mode 100644 .agents/skills/laravel-best-practices/rules/scheduling.md delete mode 100644 .agents/skills/laravel-best-practices/rules/security.md delete mode 100644 .agents/skills/laravel-best-practices/rules/style.md delete mode 100644 .agents/skills/laravel-best-practices/rules/testing.md delete mode 100644 .agents/skills/laravel-best-practices/rules/validation.md delete mode 100644 .agents/skills/modular/SKILL.md delete mode 100644 .agents/skills/pest-testing/SKILL.md delete mode 100644 .agents/skills/tailwindcss-development/SKILL.md delete mode 100644 PR.md diff --git a/.agents/skills/configure-nightwatch/SKILL.md b/.agents/skills/configure-nightwatch/SKILL.md deleted file mode 100644 index 4438533d0..000000000 --- a/.agents/skills/configure-nightwatch/SKILL.md +++ /dev/null @@ -1,405 +0,0 @@ ---- -name: configure-nightwatch -description: Configures Laravel Nightwatch data collection, sampling rates, filtering rules, and redaction policies. Use when setting up Nightwatch, managing data volume, protecting sensitive data (PII), or optimizing event collection for production workloads. -license: MIT -metadata: - author: laravel ---- - -# Nightwatch Configuration Guide - -This skill helps configure Laravel Nightwatch data collection to balance observability, performance, and privacy. Covers sampling strategies, filtering rules, and redaction methods across all event types. - -## Documentation Reference - -The [Nightwatch Documentation](https://nightwatch.laravel.com/docs) is the definitive and up-to-date source of information for all Nightwatch configuration options. This skill provides practical guidance and common patterns, but always consult the official documentation as the primary source of truth for specific details, environment variables, and API behavior. The documentation includes comprehensive coverage of: - -- [Filtering and Configuration](https://nightwatch.laravel.com/docs/filtering) - Core concepts for sampling, filtering, and redaction -- Individual event type pages with specific configuration options: - - [Requests](https://nightwatch.laravel.com/docs/requests) - Request sampling, header handling, payload capture - - [Commands](https://nightwatch.laravel.com/docs/commands) - Command sampling and redaction - - [Queries](https://nightwatch.laravel.com/docs/queries) - Query filtering and redaction - - [Cache](https://nightwatch.laravel.com/docs/cache) - Cache event filtering by key or pattern - - [Jobs](https://nightwatch.laravel.com/docs/jobs) - Job filtering and sampling decoupling - - [Mail](https://nightwatch.laravel.com/docs/mail) - Mail event filtering - - [Notifications](https://nightwatch.laravel.com/docs/notifications) - Notification filtering by channel - - [Exceptions](https://nightwatch.laravel.com/docs/exceptions) - Exception sampling and throttling - - [Outgoing Requests](https://nightwatch.laravel.com/docs/outgoing-requests) - HTTP request filtering -- [reference.md](reference.md) - Quick lookup table by event type, production presets, and verification checklist - -## Data Collection Flow - -Nightwatch processes events through three stages: - -1. **Sampling** - Controls which entry points are captured (requests, commands, scheduled tasks) -2. **Filtering** - Excludes specific events after sampling (queries, cache, mail, etc.) -3. **Redaction** - Modifies captured data to remove/obfuscate sensitive information - -``` -Request/Command/Scheduled Task - | - v - [Sampling?] ----NO----> Drop entire trace - | YES - v - Events generated - | - v - [Filtering?] ----YES---> Drop specific event - | NO - v - [Redaction] ----------> Store modified data -``` - ---- - -## Sampling Configuration - -Sampling determines which entry points (requests, commands, scheduled tasks) trigger full trace collection. When an entry point is sampled, all related events are captured. - -### Global Sample Rates - -Configure via environment variables: - -```bash - -# Default: 100% sampling (all requests/commands captured) - -NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1 # Recommended: 10% of requests - -NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0 # Capture all commands - -NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions - -``` - -**Recommendation**: Start with `0.1` (10%) for requests in production, adjust based on volume and needs. - -### Route-Based Sampling - -Apply different rates to specific routes using the `Sample` middleware: - -```php routes/web.php -use Illuminate\Support\Facades\Route; -use Laravel\Nightwatch\Http\Middleware\Sample; - -// Sample admin routes at 100% -Route::middleware(Sample::rate(1.0)) - ->prefix('admin') - ->group(function () { - // All admin routes sampled fully - }); - -// Sample API routes at 5% -Route::middleware(Sample::rate(0.05)) - ->prefix('api') - ->group(function () { - // API routes sampled sparingly - }); - -// Always sample critical endpoints -Route::post('/checkout', [CheckoutController::class, 'process'])->middleware(Sample::always()); - -// Never sample health checks -Route::get('/health', [HealthController::class, 'check'])->middleware(Sample::never()); -``` - -### Unmatched Route Sampling - -Handle 404/bot traffic with reduced sampling: - -```php routes/web.php -Route::fallback(fn() => abort(404))->middleware(Sample::rate(0.01)); // 1% sampling for unmatched routes -``` - -### Dynamic Sampling - -Sample based on runtime conditions (user role, request attributes): - -```php app/Http/Middleware/SampleAdminRequests.php -use Closure; -use Illuminate\Http\Request; -use Laravel\Nightwatch\Facades\Nightwatch; - -class SampleAdminRequests -{ - public function handle(Request $request, Closure $next) - { - if ($request->user()?->isAdmin()) { - Nightwatch::sample(); // Always sample admin requests - } - return $next($request); - } -} -``` - -### Command Sampling - -Exclude specific commands from sampling: - -```php AppServiceProvider.php -use Illuminate\Console\Events\CommandStarting; -use Illuminate\Support\Facades\Event; -use Laravel\Nightwatch\Facades\Nightwatch; - -public function boot(): void -{ - Event::listen(function (CommandStarting $event) { - if (in_array($event->command, ['schedule:finish', 'horizon:snapshot'])) { - Nightwatch::dontSample(); - } - }); -} -``` - -### Vendor Commands - -Nightwatch automatically ignores framework/internal commands. Opt-in to capture them: - -```php -Nightwatch::captureDefaultVendorCommands(); -``` - ---- - -## Filtering Configuration - -Filtering excludes specific events from collection after sampling. Use filtering to reduce noise and quota usage. - -### Database Queries - -**Filter all queries** (disable query collection): - -```bash -NIGHTWATCH_IGNORE_QUERIES=true -``` - -**Filter specific queries** by SQL pattern: - -```php AppServiceProvider.php -use Laravel\Nightwatch\Facades\Nightwatch; -use Laravel\Nightwatch\Records\Query; - -public function boot(): void -{ - // Filter job table queries (PostgreSQL) - Nightwatch::rejectQueries(function (Query $query) { - return str_contains($query->sql, 'into "jobs"'); - }); - - // Filter cache table queries (MySQL) - Nightwatch::rejectQueries(function (Query $query) { - return str_contains($query->sql, 'from `cache`') - || str_contains($query->sql, 'into `cache`'); - }); -} -``` - -### Cache Events - -**Filter all cache events**: - -```bash -NIGHTWATCH_IGNORE_CACHE_EVENTS=true -``` - -**Filter by cache key patterns**: - -```php -Nightwatch::rejectCacheKeys([ - 'my-app:users', // Exact match - '/^my-app:posts:/', // Regex: starts with my-app:posts: - '/^[a-zA-Z0-9]{40}$/', // Regex: session IDs -]); -``` - -**Filter with callback**: - -```php -use Laravel\Nightwatch\Records\CacheEvent; - -Nightwatch::rejectCacheEvents(function (CacheEvent $cacheEvent) { - return str_starts_with($cacheEvent->key, 'temp:'); -}); -``` - -### Mail Events - -**Filter all mail**: - -```bash -NIGHTWATCH_IGNORE_MAIL=true -``` - -**Filter specific mail**: - -```php -use Laravel\Nightwatch\Records\Mail; - -Nightwatch::rejectMail(function (Mail $mail) { - return str_contains($mail->subject, 'Newsletter'); -}); -``` - -### Notification Events - -**Filter all notifications**: - -```bash -NIGHTWATCH_IGNORE_NOTIFICATIONS=true -``` - -**Filter by channel**: - -```php -use Laravel\Nightwatch\Records\Notification; - -Nightwatch::rejectNotifications(function (Notification $notification) { - return $notification->channel === 'database'; -}); -``` - -### Outgoing HTTP Requests - -**Filter all outgoing requests**: - -```bash -NIGHTWATCH_IGNORE_OUTGOING_REQUESTS=true -``` - -**Filter by URL**: - -```php -use Laravel\Nightwatch\Records\OutgoingRequest; - -Nightwatch::rejectOutgoingRequests(function (OutgoingRequest $request) { - return str_contains($request->url, 'analytics.example.com'); -}); -``` - -### Queued Jobs - -**Filter specific jobs**: - -```php -use Laravel\Nightwatch\Records\QueuedJob; - -Nightwatch::rejectQueuedJobs(function (QueuedJob $job) { - return $job->name === 'App\Jobs\LowPriorityJob'; -}); -``` - -### Decoupling Job Sampling - -Sample jobs independently from parent contexts: - -```php -use Illuminate\Support\Facades\Queue; - -public function boot(): void -{ - Queue::before(fn () => Nightwatch::sample(rate: 0.5)); -} -``` - ---- - -## Redaction Configuration - -Redaction modifies captured data to remove or obfuscate sensitive information. Unlike filtering, redaction keeps the event but sanitizes its content. - -### Request Redaction - -**Redact sensitive headers** (automatically redacts: Authorization, Cookie, X-XSRF-TOKEN): - -```bash - -# Customize redacted headers - -NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-API-Key -``` - -**Redact request payloads** (disabled by default): - -```bash - -# Enable payload capture - -NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=true - -# Customize redacted fields - -NIGHTWATCH_REDACT_PAYLOAD_FIELDS=password,password_confirmation,ssn,credit_card -``` - -**Programmatic redaction**: - -```php -use Laravel\Nightwatch\Facades\Nightwatch; -use Laravel\Nightwatch\Records\Request; - -Nightwatch::redactRequests(function (Request $request) { - $request->url = str_replace('secret', '***', $request->url); - $request->ip = preg_replace('/\d+$/', '***', $request->ip); -}); -``` - -### Query Redaction - -```php -use Laravel\Nightwatch\Records\Query; - -Nightwatch::redactQueries(function (Query $query) { - $query->sql = str_replace('secret_token', '***', $query->sql); -}); -``` - -### Cache Redaction - -```php -use Laravel\Nightwatch\Records\CacheEvent; - -Nightwatch::redactCacheEvents(function (CacheEvent $cacheEvent) { - $cacheEvent->key = str_replace('user:', 'user:***:', $cacheEvent->key); -}); -``` - -### Command Redaction - -```php -use Laravel\Nightwatch\Records\Command; - -Nightwatch::redactCommands(function (Command $command) { - $command->command = preg_replace('/--password=\S+/', '--password=***', $command->command); -}); -``` - -### Exception Redaction - -```php -use Laravel\Nightwatch\Records\Exception; - -Nightwatch::redactExceptions(function (Exception $exception) { - $exception->message = str_replace('secret', '***', $exception->message); -}); -``` - -### Mail Redaction - -```php -use Laravel\Nightwatch\Records\Mail; - -Nightwatch::redactMail(function (Mail $mail) { - $mail->subject = str_replace('Invoice #', 'Invoice ***', $mail->subject); -}); -``` - -### Outgoing Request Redaction - -```php -use Laravel\Nightwatch\Records\OutgoingRequest; - -Nightwatch::redactOutgoingRequests(function (OutgoingRequest $outgoingRequest) { - $outgoingRequest->url = preg_replace('/api_key=\w+/', 'api_key=***', $outgoingRequest->url); -}); -``` diff --git a/.agents/skills/configure-nightwatch/reference.md b/.agents/skills/configure-nightwatch/reference.md deleted file mode 100644 index d9995dd4d..000000000 --- a/.agents/skills/configure-nightwatch/reference.md +++ /dev/null @@ -1,102 +0,0 @@ -# Nightwatch Configuration Reference - -## Configuration Summary by Event Type - -| Event Type | Sampling | Filtering | Redaction | -| --------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------- | -| **Requests** | `NIGHTWATCH_REQUEST_SAMPLE_RATE`, Route middleware | Not applicable | Headers, payload, URL, IP | -| **Commands** | `NIGHTWATCH_COMMAND_SAMPLE_RATE`, Event listener | Not applicable | Command arguments | -| **Queries** | Parent context | `rejectQueries()`, `NIGHTWATCH_IGNORE_QUERIES` | SQL statement | -| **Cache** | Parent context | `rejectCacheKeys()`, `rejectCacheEvents()`, `NIGHTWATCH_IGNORE_CACHE_EVENTS` | Cache key | -| **Jobs** | Parent context, Queue::before | `rejectQueuedJobs()` | Not applicable | -| **Mail** | Parent context | `rejectMail()`, `NIGHTWATCH_IGNORE_MAIL` | Subject | -| **Notifications** | Parent context | `rejectNotifications()`, `NIGHTWATCH_IGNORE_NOTIFICATIONS` | Not applicable | -| **Outgoing Requests** | Parent context | `rejectOutgoingRequests()`, `NIGHTWATCH_IGNORE_OUTGOING_REQUESTS` | URL | -| **Exceptions** | `NIGHTWATCH_EXCEPTION_SAMPLE_RATE` | Not applicable | Exception message | - ---- - -## Production Recommendations - -### High-Traffic Applications - -```bash - -# Conservative sampling - -NIGHTWATCH_REQUEST_SAMPLE_RATE=0.01 # 1% of requests - -NIGHTWATCH_COMMAND_SAMPLE_RATE=0.1 # 10% of commands - -NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions - -# Filter noisy events - -NIGHTWATCH_IGNORE_CACHE_EVENTS=true -NIGHTWATCH_IGNORE_QUERIES=true # Or filter specific queries programmatically - -``` - -### Privacy-Conscious Applications - -```bash - -# Disable sensitive data collection - -NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=false -NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-XSRF-TOKEN - -# Or use redaction in AppServiceProvider - -``` - -### Balanced Configuration (Recommended Start) - -```bash - -# Sample rates - -NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1 -NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0 -NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 - -# Filter obvious noise programmatically - -# Redact PII as needed - -``` - ---- - -## Verification Checklist - -After configuration: - -- [ ] Sampling rates appropriate for traffic volume -- [ ] Noisy events filtered (cache, certain queries) -- [ ] Sensitive data redacted (PII, tokens, credentials) -- [ ] Exceptions always captured for debugging -- [ ] Test in development with `NIGHTWATCH_REQUEST_SAMPLE_RATE=1.0` -- [ ] Monitor event quota usage in Nightwatch dashboard - ---- - -## Common Patterns - -### Filter Health Checks + Reduce Sampling - -```php -Route::get('/health', fn() => ['status' => 'ok'])->middleware(Sample::never()); -``` - -### Exclude Internal/Vendor Queries - -```php -Nightwatch::rejectQueries(fn($q) => str_contains($q->sql, 'telescope') || str_contains($q->sql, 'pulse')); -``` - -### Protect User Data in Cache Keys - -```php -Nightwatch::redactCacheEvents(fn($e) => ($e->key = preg_replace('/user:\d+/', 'user:***', $e->key))); -``` diff --git a/.agents/skills/fluxui-development/SKILL.md b/.agents/skills/fluxui-development/SKILL.md deleted file mode 100644 index 6e5b06719..000000000 --- a/.agents/skills/fluxui-development/SKILL.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -name: fluxui-development -description: 'Use this skill for Flux UI development in Livewire applications only. Trigger when working with components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling.' -license: MIT -metadata: - author: laravel ---- - -# Flux UI Development - -## Documentation - -Use `search-docs` for detailed Flux UI patterns and documentation. - -## Basic Usage - -This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components. - -Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize. - -Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs. - - - -```blade -Click me -``` - -## Available Components (Free Edition) - -Available: avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, profile, radio, select, separator, skeleton, switch, text, textarea, tooltip - -## Icons - -Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names. - - - -```blade -Export -``` - -For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command: - -```bash -php artisan flux:icon crown grip-vertical github -``` - -## Common Patterns - -### Form Fields - - - -```blade - - Email - - - -``` - -### Modals - - - -```blade - - Title -

Content

-
-``` - -## Verification - -1. Check component renders correctly -2. Test interactive states -3. Verify mobile responsiveness - -## Common Pitfalls - -- Trying to use Pro-only components in the free edition -- Not checking if a Flux component exists before creating custom implementations -- Forgetting to use the `search-docs` tool for component-specific documentation -- Not following existing project patterns for Flux usage diff --git a/.agents/skills/laravel-attributes b/.agents/skills/laravel-attributes deleted file mode 120000 index caca720bd..000000000 --- a/.agents/skills/laravel-attributes +++ /dev/null @@ -1 +0,0 @@ -../../.ai/skills/laravel-attributes \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/SKILL.md b/.agents/skills/laravel-best-practices/SKILL.md deleted file mode 100644 index d2d1147b1..000000000 --- a/.agents/skills/laravel-best-practices/SKILL.md +++ /dev/null @@ -1,190 +0,0 @@ ---- -name: laravel-best-practices -description: 'Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.' -license: MIT -metadata: - author: laravel ---- - -# Laravel Best Practices - -Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`. - -## Consistency First - -Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. - -Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides. - -## Quick Reference - -### 1. Database Performance → `rules/db-performance.md` - -- Eager load with `with()` to prevent N+1 queries -- Enable `Model::preventLazyLoading()` in development -- Select only needed columns, avoid `SELECT *` -- `chunk()` / `chunkById()` for large datasets -- Index columns used in `WHERE`, `ORDER BY`, `JOIN` -- `withCount()` instead of loading relations to count -- `cursor()` for memory-efficient read-only iteration -- Never query in Blade templates - -### 2. Advanced Query Patterns → `rules/advanced-queries.md` - -- `addSelect()` subqueries over eager-loading entire has-many for a single value -- Dynamic relationships via subquery FK + `belongsTo` -- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries -- `setRelation()` to prevent circular N+1 queries -- `whereIn` + `pluck()` over `whereHas` for better index usage -- Two simple queries can beat one complex query -- Compound indexes matching `orderBy` column order -- Correlated subqueries in `orderBy` for has-many sorting (avoid joins) - -### 3. Security → `rules/security.md` - -- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates -- No raw SQL with user input — use Eloquent or query builder -- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes -- Validate MIME type, extension, and size for file uploads -- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields - -### 4. Caching → `rules/caching.md` - -- `Cache::remember()` over manual get/put -- `Cache::flexible()` for stale-while-revalidate on high-traffic data -- `Cache::memo()` to avoid redundant cache hits within a request -- Cache tags to invalidate related groups -- `Cache::add()` for atomic conditional writes -- `once()` to memoize per-request or per-object lifetime -- `Cache::lock()` / `lockForUpdate()` for race conditions -- Failover cache stores in production - -### 5. Eloquent Patterns → `rules/eloquent.md` - -- Correct relationship types with return type hints -- Local scopes for reusable query constraints -- Global scopes sparingly — document their existence -- Attribute casts in the `casts()` method -- Cast date columns, use Carbon instances in templates -- `whereBelongsTo($model)` for cleaner queries -- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries - -### 6. Validation & Forms → `rules/validation.md` - -- Form Request classes, not inline validation -- Array notation `['required', 'email']` for new code; follow existing convention -- `$request->validated()` only — never `$request->all()` -- `Rule::when()` for conditional validation -- `after()` instead of `withValidator()` - -### 7. Configuration → `rules/config.md` - -- `env()` only inside config files -- `App::environment()` or `app()->isProduction()` -- Config, lang files, and constants over hardcoded text - -### 8. Testing Patterns → `rules/testing.md` - -- `LazilyRefreshDatabase` over `RefreshDatabase` for speed -- `assertModelExists()` over raw `assertDatabaseHas()` -- Factory states and sequences over manual overrides -- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before -- `recycle()` to share relationship instances across factories - -### 9. Queue & Job Patterns → `rules/queue-jobs.md` - -- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]` -- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release -- Always implement `failed()`; with `retryUntil()`, set `$tries = 0` -- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs -- Horizon for complex multi-queue scenarios - -### 10. Routing & Controllers → `rules/routing.md` - -- Implicit route model binding -- Scoped bindings for nested resources -- `Route::resource()` or `apiResource()` -- Methods under 10 lines — extract to actions/services -- Type-hint Form Requests for auto-validation - -### 11. HTTP Client → `rules/http-client.md` - -- Explicit `timeout` and `connectTimeout` on every request -- `retry()` with exponential backoff for external APIs -- Check response status or use `throw()` -- `Http::pool()` for concurrent independent requests -- `Http::fake()` and `preventStrayRequests()` in tests - -### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md` - -- Event discovery over manual registration; `event:cache` in production -- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions -- Queue notifications and mailables with `ShouldQueue` -- On-demand notifications for non-user recipients -- `HasLocalePreference` on notifiable models -- `assertQueued()` not `assertSent()` for queued mailables -- Markdown mailables for transactional emails - -### 13. Error Handling → `rules/error-handling.md` - -- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern -- `ShouldntReport` for exceptions that should never log -- Throttle high-volume exceptions to protect log sinks -- `dontReportDuplicates()` for multi-catch scenarios -- Force JSON rendering for API routes -- Structured context via `context()` on exception classes - -### 14. Task Scheduling → `rules/scheduling.md` - -- `withoutOverlapping()` on variable-duration tasks -- `onOneServer()` on multi-server deployments -- `runInBackground()` for concurrent long tasks -- `environments()` to restrict to appropriate environments -- `takeUntilTimeout()` for time-bounded processing -- Schedule groups for shared configuration - -### 15. Architecture → `rules/architecture.md` - -- Single-purpose Action classes; dependency injection over `app()` helper -- Prefer official Laravel packages and follow conventions, don't override defaults -- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety -- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution - -### 16. Migrations → `rules/migrations.md` - -- Generate migrations with `php artisan make:migration` -- `constrained()` for foreign keys -- Never modify migrations that have run in production -- Add indexes in the migration, not as an afterthought -- Mirror column defaults in model `$attributes` -- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes -- One concern per migration — never mix DDL and DML - -### 17. Collections → `rules/collections.md` - -- Higher-order messages for simple collection operations -- `cursor()` vs. `lazy()` — choose based on relationship needs -- `lazyById()` when updating records while iterating -- `toQuery()` for bulk operations on collections - -### 18. Blade & Views → `rules/blade-views.md` - -- `$attributes->merge()` in component templates -- Blade components over `@include`; `@pushOnce` for per-component scripts -- View Composers for shared view data -- `@aware` for deeply nested component props - -### 19. Conventions & Style → `rules/style.md` - -- Follow Laravel naming conventions for all entities -- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions -- No JS/CSS in Blade, no HTML in PHP classes -- Code should be readable; comments only for config files - -## How to Apply - -Always use a sub-agent to read rule files and explore this skill's content. - -1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10) -2. Check sibling files for existing patterns — follow those first per Consistency First -3. Verify API syntax with `search-docs` for the installed Laravel version diff --git a/.agents/skills/laravel-best-practices/rules/advanced-queries.md b/.agents/skills/laravel-best-practices/rules/advanced-queries.md deleted file mode 100644 index 24bb3f307..000000000 --- a/.agents/skills/laravel-best-practices/rules/advanced-queries.md +++ /dev/null @@ -1,106 +0,0 @@ -# Advanced Query Patterns - -## Use `addSelect()` Subqueries for Single Values from Has-Many - -Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries. - -```php -public function scopeWithLastLoginAt($query): void -{ - $query->addSelect([ - 'last_login_at' => Login::select('created_at') - ->whereColumn('user_id', 'users.id') - ->latest() - ->take(1), - ])->withCasts(['last_login_at' => 'datetime']); -} -``` - -## Create Dynamic Relationships via Subquery FK - -Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection. - -```php -public function lastLogin(): BelongsTo -{ - return $this->belongsTo(Login::class); -} - -public function scopeWithLastLogin($query): void -{ - $query->addSelect([ - 'last_login_id' => Login::select('id') - ->whereColumn('user_id', 'users.id') - ->latest() - ->take(1), - ])->with('lastLogin'); -} -``` - -## Use Conditional Aggregates Instead of Multiple Count Queries - -Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values. - -```php -$statuses = Feature::toBase() - ->selectRaw("count(case when status = 'Requested' then 1 end) as requested") - ->selectRaw("count(case when status = 'Planned' then 1 end) as planned") - ->selectRaw("count(case when status = 'Completed' then 1 end) as completed") - ->first(); -``` - -## Use `setRelation()` to Prevent Circular N+1 - -When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries. - -```php -$feature->load('comments.user'); -$feature->comments->each->setRelation('feature', $feature); -``` - -## Prefer `whereIn` + Subquery Over `whereHas` - -`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory. - -Incorrect (correlated EXISTS re-executes per row): - -```php -$query->whereHas('company', fn($q) => $q->where('name', 'like', $term)); -``` - -Correct (index-friendly subquery, no PHP memory overhead): - -```php -$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id')); -``` - -## Sometimes Two Simple Queries Beat One Complex Query - -Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index. - -## Use Compound Indexes Matching `orderBy` Column Order - -When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index. - -```php -// Migration -$table->index(['last_name', 'first_name']); - -// Query — column order must match the index -User::query()->orderBy('last_name')->orderBy('first_name')->paginate(); -``` - -## Use Correlated Subqueries for Has-Many Ordering - -When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading. - -```php -public function scopeOrderByLastLogin($query): void -{ - $query->orderByDesc(Login::select('created_at') - ->whereColumn('user_id', 'users.id') - ->latest() - ->take(1) - ); -} -``` diff --git a/.agents/skills/laravel-best-practices/rules/architecture.md b/.agents/skills/laravel-best-practices/rules/architecture.md deleted file mode 100644 index 1ef4186ea..000000000 --- a/.agents/skills/laravel-best-practices/rules/architecture.md +++ /dev/null @@ -1,211 +0,0 @@ -# Architecture Best Practices - -## Single-Purpose Action Classes - -Extract discrete business operations into invokable Action classes. - -```php -class CreateOrderAction -{ - public function __construct(private InventoryService $inventory) {} - - public function execute(array $data): Order - { - $order = Order::create($data); - $this->inventory->reserve($order); - - return $order; - } -} -``` - -## Use Dependency Injection - -Always use constructor injection. Avoid `app()` or `resolve()` inside classes. - -Incorrect: - -```php -class OrderController extends Controller -{ - public function store(StoreOrderRequest $request) - { - $service = app(OrderService::class); - - return $service->create($request->validated()); - } -} -``` - -Correct: - -```php -class OrderController extends Controller -{ - public function __construct(private OrderService $service) {} - - public function store(StoreOrderRequest $request) - { - return $this->service->create($request->validated()); - } -} -``` - -## Code to Interfaces - -Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability. - -Incorrect (concrete dependency): - -```php -class OrderService -{ - public function __construct(private StripeGateway $gateway) {} -} -``` - -Correct (interface dependency): - -```php -interface PaymentGateway -{ - public function charge(int $amount, string $customerId): PaymentResult; -} - -class OrderService -{ - public function __construct(private PaymentGateway $gateway) {} -} -``` - -Bind in a service provider: - -```php -$this->app->bind(PaymentGateway::class, StripeGateway::class); -``` - -## Default Sort by Descending - -When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined. - -Incorrect: - -```php -$posts = Post::paginate(); -``` - -Correct: - -```php -$posts = Post::latest()->paginate(); -``` - -## Use Atomic Locks for Race Conditions - -Prevent race conditions with `Cache::lock()` or `lockForUpdate()`. - -```php -Cache::lock('order-processing-' . $order->id, 10)->block(5, function () use ($order) { - $order->process(); -}); - -// Or at query level -$product = Product::where('id', $id)->lockForUpdate()->first(); -``` - -## Use `mb_*` String Functions - -When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters. - -Incorrect: - -```php -strlen('José'); // 5 (bytes, not characters) -strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte -``` - -Correct: - -```php -mb_strlen('José'); // 4 (characters) -mb_strtolower('MÜNCHEN'); // 'münchen' - -// Prefer Laravel's Str helpers when available -Str::length('José'); // 4 -Str::lower('MÜNCHEN'); // 'münchen' -``` - -## Use `defer()` for Post-Response Work - -For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead. - -Incorrect (job overhead for trivial work): - -```php -dispatch(new LogPageView($page)); -``` - -Correct (runs after response, same process): - -```php -defer(fn() => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()])); -``` - -Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work. - -## Use `Context` for Request-Scoped Data - -The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually. - -```php -// In middleware -Context::add('tenant_id', $request->header('X-Tenant-ID')); - -// Anywhere later — controllers, jobs, log context -$tenantId = Context::get('tenant_id'); -``` - -Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`. - -## Use `Concurrency::run()` for Parallel Execution - -Run independent operations in parallel using child processes — no async libraries needed. - -```php -use Illuminate\Support\Facades\Concurrency; - -[$users, $orders] = Concurrency::run([fn() => User::count(), fn() => Order::where('status', 'pending')->count()]); -``` - -Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially. - -## Convention Over Configuration - -Follow Laravel conventions. Don't override defaults unnecessarily. - -Incorrect: - -```php -class Customer extends Model -{ - protected $table = 'Customer'; - protected $primaryKey = 'customer_id'; - - public function roles(): BelongsToMany - { - return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id'); - } -} -``` - -Correct: - -```php -class Customer extends Model -{ - public function roles(): BelongsToMany - { - return $this->belongsToMany(Role::class); - } -} -``` diff --git a/.agents/skills/laravel-best-practices/rules/blade-views.md b/.agents/skills/laravel-best-practices/rules/blade-views.md deleted file mode 100644 index f66e0d448..000000000 --- a/.agents/skills/laravel-best-practices/rules/blade-views.md +++ /dev/null @@ -1,41 +0,0 @@ -# Blade & Views Best Practices - -## Use `$attributes->merge()` in Component Templates - -Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly. - -```blade -
merge([ - 'class' => 'alert alert-' . $type, - ]) - }} -> - {{ $message }} -
-``` - -## Use `@pushOnce` for Per-Component Scripts - -If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once. - -## Prefer Blade Components Over `@include` - -`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots. - -## Use View Composers for Shared View Data - -If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it. - -## Use Blade Fragments for Partial Re-Renders (htmx/Turbo) - -A single view can return either the full page or just a fragment, keeping routing clean. - -```php -return view('dashboard', compact('users'))->fragmentIf($request->hasHeader('HX-Request'), 'user-list'); -``` - -## Use `@aware` for Deeply Nested Component Props - -Avoids re-passing parent props through every level of nested components. diff --git a/.agents/skills/laravel-best-practices/rules/caching.md b/.agents/skills/laravel-best-practices/rules/caching.md deleted file mode 100644 index 5f201316f..000000000 --- a/.agents/skills/laravel-best-practices/rules/caching.md +++ /dev/null @@ -1,72 +0,0 @@ -# Caching Best Practices - -## Use `Cache::remember()` Instead of Manual Get/Put - -Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions. - -Incorrect: - -```php -$val = Cache::get('stats'); -if (!$val) { - $val = $this->computeStats(); - Cache::put('stats', $val, 60); -} -``` - -Correct: - -```php -$val = Cache::remember('stats', 60, fn() => $this->computeStats()); -``` - -## Use `Cache::flexible()` for Stale-While-Revalidate - -On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background. - -Incorrect: `Cache::remember('users', 300, fn () => User::all());` - -Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function. - -## Use `Cache::memo()` to Avoid Redundant Hits Within a Request - -If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory. - -`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5. - -## Use Cache Tags to Invalidate Related Groups - -Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`. - -```php -Cache::tags(['user-1'])->flush(); -``` - -## Use `Cache::add()` for Atomic Conditional Writes - -`add()` only writes if the key does not exist — atomic, no race condition between checking and writing. - -Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }` - -Correct: `Cache::add('lock', true, 10);` - -## Use `once()` for Per-Request Memoization - -`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory. - -```php -public function roles(): Collection -{ - return once(fn () => $this->loadRoles()); -} -``` - -Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching. - -## Configure Failover Cache Stores in Production - -If Redis goes down, the app falls back to a secondary store automatically. - -```php -'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], -``` diff --git a/.agents/skills/laravel-best-practices/rules/collections.md b/.agents/skills/laravel-best-practices/rules/collections.md deleted file mode 100644 index 6e8af3e85..000000000 --- a/.agents/skills/laravel-best-practices/rules/collections.md +++ /dev/null @@ -1,45 +0,0 @@ -# Collection Best Practices - -## Use Higher-Order Messages for Simple Operations - -Incorrect: - -```php -$users->each(function (User $user) { - $user->markAsVip(); -}); -``` - -Correct: `$users->each->markAsVip();` - -Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc. - -## Choose `cursor()` vs. `lazy()` Correctly - -- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk). -- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading. - -Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored. - -Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work. - -## Use `lazyById()` When Updating Records While Iterating - -`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation. - -## Use `toQuery()` for Bulk Operations on Collections - -Avoids manual `whereIn` construction. - -Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);` - -Correct: `$users->toQuery()->update([...]);` - -## Use `#[CollectedBy]` for Custom Collection Classes - -More declarative than overriding `newCollection()`. - -```php -#[CollectedBy(UserCollection::class)] -class User extends Model {} -``` diff --git a/.agents/skills/laravel-best-practices/rules/config.md b/.agents/skills/laravel-best-practices/rules/config.md deleted file mode 100644 index eb1d9d778..000000000 --- a/.agents/skills/laravel-best-practices/rules/config.md +++ /dev/null @@ -1,79 +0,0 @@ -# Configuration Best Practices - -## `env()` Only in Config Files - -Direct `env()` calls may return `null` when config is cached. - -Incorrect: - -```php -$key = env('API_KEY'); -``` - -Correct: - -```php -// config/services.php -'key' => env('API_KEY'), - -// Application code -$key = config('services.key'); -``` - -## Use Encrypted Env or External Secrets - -Never store production secrets in plain `.env` files in version control. - -Incorrect: - -```bash - -# .env committed to repo or shared in Slack - -STRIPE_SECRET=sk_live_abc123 -AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI -``` - -Correct: - -```bash -php artisan env:encrypt --env=production --readable -php artisan env:decrypt --env=production -``` - -For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime. - -## Use `App::environment()` for Environment Checks - -Incorrect: - -```php -if (env('APP_ENV') === 'production') { -``` - -Correct: - -```php -if (app()->isProduction()) { -// or -if (App::environment('production')) { -``` - -## Use Constants and Language Files - -Use class constants instead of hardcoded magic strings for model states, types, and statuses. - -```php -// Incorrect -return $this->type === 'normal'; - -// Correct -return $this->type === self::TYPE_NORMAL; -``` - -If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there. - -```php -// Only when lang files already exist in the project -return back()->with('message', __('app.article_added')); -``` diff --git a/.agents/skills/laravel-best-practices/rules/db-performance.md b/.agents/skills/laravel-best-practices/rules/db-performance.md deleted file mode 100644 index ec444381f..000000000 --- a/.agents/skills/laravel-best-practices/rules/db-performance.md +++ /dev/null @@ -1,205 +0,0 @@ -# Database Performance Best Practices - -## Always Eager Load Relationships - -Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront. - -Incorrect (N+1 — executes 1 + N queries): - -```php -$posts = Post::all(); -foreach ($posts as $post) { - echo $post->author->name; -} -``` - -Correct (2 queries total): - -```php -$posts = Post::with('author')->get(); -foreach ($posts as $post) { - echo $post->author->name; -} -``` - -Constrain eager loads to select only needed columns (always include the foreign key): - -```php -$users = User::with([ - 'posts' => function ($query) { - $query->select('id', 'user_id', 'title')->where('published', true)->latest()->limit(10); - }, -])->get(); -``` - -## Prevent Lazy Loading in Development - -Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development. - -```php -public function boot(): void -{ - Model::preventLazyLoading(! app()->isProduction()); -} -``` - -Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded. - -## Select Only Needed Columns - -Avoid `SELECT *` — especially when tables have large text or JSON columns. - -Incorrect: - -```php -$posts = Post::with('author')->get(); -``` - -Correct: - -```php -$posts = Post::select('id', 'title', 'user_id', 'created_at') - ->with(['author:id,name,avatar']) - ->get(); -``` - -When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match. - -## Chunk Large Datasets - -Never load thousands of records at once. Use chunking for batch processing. - -Incorrect: - -```php -$users = User::all(); -foreach ($users as $user) { - $user->notify(new WeeklyDigest()); -} -``` - -Correct: - -```php -User::where('subscribed', true)->chunk(200, function ($users) { - foreach ($users as $user) { - $user->notify(new WeeklyDigest()); - } -}); -``` - -Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change: - -```php -User::where('active', false)->chunkById(200, function ($users) { - $users->each->delete(); -}); -``` - -## Add Database Indexes - -Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses. - -Incorrect: - -```php -Schema::create('orders', function (Blueprint $table) { - $table->id(); - $table->foreignId('user_id')->constrained(); - $table->string('status'); - $table->timestamps(); -}); -``` - -Correct: - -```php -Schema::create('orders', function (Blueprint $table) { - $table->id(); - $table->foreignId('user_id')->index()->constrained(); - $table->string('status')->index(); - $table->timestamps(); - $table->index(['status', 'created_at']); -}); -``` - -Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`). - -## Use `withCount()` for Counting Relations - -Never load entire collections just to count them. - -Incorrect: - -```php -$posts = Post::all(); -foreach ($posts as $post) { - echo $post->comments->count(); -} -``` - -Correct: - -```php -$posts = Post::withCount('comments')->get(); -foreach ($posts as $post) { - echo $post->comments_count; -} -``` - -Conditional counting: - -```php -$posts = Post::withCount([ - 'comments', - 'comments as approved_comments_count' => function ($query) { - $query->where('approved', true); - }, -])->get(); -``` - -## Use `cursor()` for Memory-Efficient Iteration - -For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator. - -Incorrect: - -```php -$users = User::where('active', true)->get(); -``` - -Correct: - -```php -foreach (User::where('active', true)->cursor() as $user) { - ProcessUser::dispatch($user->id); -} -``` - -Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records. - -## No Queries in Blade Templates - -Never execute queries in Blade templates. Pass data from controllers. - -Incorrect: - -```blade -@foreach (User::all() as $user) - {{ $user->profile->name }} -@endforeach -``` - -Correct: - -```php -// Controller -$users = User::with('profile')->get(); -return view('users.index', compact('users')); -``` - -```blade -@foreach ($users as $user) - {{ $user->profile->name }} -@endforeach -``` diff --git a/.agents/skills/laravel-best-practices/rules/eloquent.md b/.agents/skills/laravel-best-practices/rules/eloquent.md deleted file mode 100644 index 50c1a486e..000000000 --- a/.agents/skills/laravel-best-practices/rules/eloquent.md +++ /dev/null @@ -1,164 +0,0 @@ -# Eloquent Best Practices - -## Use Correct Relationship Types - -Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints. - -```php -public function comments(): HasMany -{ - return $this->hasMany(Comment::class); -} - -public function author(): BelongsTo -{ - return $this->belongsTo(User::class, 'user_id'); -} -``` - -## Use Local Scopes for Reusable Queries - -Extract reusable query constraints into local scopes to avoid duplication. - -Incorrect: - -```php -$active = User::where('verified', true)->whereNotNull('activated_at')->get(); -$articles = Article::whereHas('user', function ($q) { - $q->where('verified', true)->whereNotNull('activated_at'); -})->get(); -``` - -Correct: - -```php -public function scopeActive(Builder $query): Builder -{ - return $query->where('verified', true)->whereNotNull('activated_at'); -} - -// Usage -$active = User::active()->get(); -$articles = Article::whereHas('user', fn ($q) => $q->active())->get(); -``` - -## Apply Global Scopes Sparingly - -Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy. - -Incorrect (global scope for a conditional filter): - -```php -class PublishedScope implements Scope -{ - public function apply(Builder $builder, Model $model): void - { - $builder->where('published', true); - } -} -// Now admin panels, reports, and background jobs all silently skip drafts -``` - -Correct (local scope you opt into): - -```php -public function scopePublished(Builder $query): Builder -{ - return $query->where('published', true); -} - -Post::published()->paginate(); // Explicit -Post::paginate(); // Admin sees all -``` - -## Define Attribute Casts - -Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. - -```php -protected function casts(): array -{ - return [ - 'is_active' => 'boolean', - 'metadata' => 'array', - 'total' => 'decimal:2', - ]; -} -``` - -## Cast Date Columns Properly - -Always cast date columns. Use Carbon instances in templates instead of formatting strings manually. - -Incorrect: - -```blade -{{ - Carbon::createFromFormat( - 'Y-d-m H-i', - $order->ordered_at, - )->toDateString() -}} -``` - -Correct: - -```php -protected function casts(): array -{ - return [ - 'ordered_at' => 'datetime', - ]; -} -``` - -```blade -{{ $order->ordered_at->toDateString() }} {{ $order->ordered_at->format('m-d') }} -``` - -## Use `whereBelongsTo()` for Relationship Queries - -Cleaner than manually specifying foreign keys. - -Incorrect: - -```php -Post::where('user_id', $user->id)->get(); -``` - -Correct: - -```php -Post::whereBelongsTo($user)->get(); -Post::whereBelongsTo($user, 'author')->get(); -``` - -## Avoid Hardcoded Table Names in Queries - -Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string). - -Incorrect: - -```php -DB::table('users')->where('active', true)->get(); - -$query->join('companies', 'companies.id', '=', 'users.company_id'); - -DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); -``` - -Correct — reference the model's table: - -```php -DB::table(new User()->getTable()) - ->where('active', true) - ->get(); - -// Even better — use Eloquent or the query builder instead of raw SQL -User::where('active', true)->get(); -Order::where('status', 'pending')->get(); -``` - -Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable. - -**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration. diff --git a/.agents/skills/laravel-best-practices/rules/error-handling.md b/.agents/skills/laravel-best-practices/rules/error-handling.md deleted file mode 100644 index f30917a50..000000000 --- a/.agents/skills/laravel-best-practices/rules/error-handling.md +++ /dev/null @@ -1,75 +0,0 @@ -# Error Handling Best Practices - -## Exception Reporting and Rendering - -There are two valid approaches — choose one and apply it consistently across the project. - -**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find: - -```php -class InvalidOrderException extends Exception -{ - public function report(): void - { - /* custom reporting */ - } - - public function render(Request $request): Response - { - return response()->view('errors.invalid-order', status: 422); - } -} -``` - -**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture: - -```php -->withExceptions(function (Exceptions $exceptions) { - $exceptions->report(function (InvalidOrderException $e) { /* ... */ }); - $exceptions->render(function (InvalidOrderException $e, Request $request) { - return response()->view('errors.invalid-order', status: 422); - }); -}) -``` - -Check the existing codebase and follow whichever pattern is already established. - -## Use `ShouldntReport` for Exceptions That Should Never Log - -More discoverable than listing classes in `dontReport()`. - -```php -class PodcastProcessingException extends Exception implements ShouldntReport {} -``` - -## Throttle High-Volume Exceptions - -A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type. - -## Enable `dontReportDuplicates()` - -Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks. - -## Force JSON Error Rendering for API Routes - -Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes. - -```php -$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { - return $request->is('api/*') || $request->expectsJson(); -}); -``` - -## Add Context to Exception Classes - -Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry. - -```php -class InvalidOrderException extends Exception -{ - public function context(): array - { - return ['order_id' => $this->orderId]; - } -} -``` diff --git a/.agents/skills/laravel-best-practices/rules/events-notifications.md b/.agents/skills/laravel-best-practices/rules/events-notifications.md deleted file mode 100644 index eeab72096..000000000 --- a/.agents/skills/laravel-best-practices/rules/events-notifications.md +++ /dev/null @@ -1,52 +0,0 @@ -# Events & Notifications Best Practices - -## Rely on Event Discovery - -Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`. - -## Run `event:cache` in Production Deploy - -Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`. - -## Use `ShouldDispatchAfterCommit` Inside Transactions - -Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet. - -```php -class OrderShipped implements ShouldDispatchAfterCommit {} -``` - -## Always Queue Notifications - -Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response. - -```php -class InvoicePaid extends Notification implements ShouldQueue -{ - use Queueable; -} -``` - -## Use `afterCommit()` on Notifications in Transactions - -Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits. - -```php -$user->notify(new InvoicePaid($invoice)->afterCommit()); -``` - -## Route Notification Channels to Dedicated Queues - -Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues. - -## Use On-Demand Notifications for Non-User Recipients - -Avoid creating dummy models to send notifications to arbitrary addresses. - -```php -Notification::route('mail', 'admin@example.com')->notify(new SystemAlert()); -``` - -## Implement `HasLocalePreference` on Notifiable Models - -Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. diff --git a/.agents/skills/laravel-best-practices/rules/http-client.md b/.agents/skills/laravel-best-practices/rules/http-client.md deleted file mode 100644 index a47720df9..000000000 --- a/.agents/skills/laravel-best-practices/rules/http-client.md +++ /dev/null @@ -1,168 +0,0 @@ -# HTTP Client Best Practices - -## Always Set Explicit Timeouts - -The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast. - -Incorrect: - -```php -$response = Http::get('https://api.example.com/users'); -``` - -Correct: - -```php -$response = Http::timeout(5)->connectTimeout(3)->get('https://api.example.com/users'); -``` - -For service-specific clients, define timeouts in a macro: - -```php -Http::macro('github', function () { - return Http::baseUrl('https://api.github.com') - ->timeout(10) - ->connectTimeout(3) - ->withToken(config('services.github.token')); -}); - -$response = Http::github()->get('/repos/laravel/framework'); -``` - -## Use Retry with Backoff for External APIs - -External APIs have transient failures. Use `retry()` with increasing delays. - -Incorrect: - -```php -$response = Http::post('https://api.stripe.com/v1/charges', $data); - -if ($response->failed()) { - throw new PaymentFailedException('Charge failed'); -} -``` - -Correct: - -```php -$response = Http::retry([100, 500, 1000]) - ->timeout(10) - ->post('https://api.stripe.com/v1/charges', $data); -``` - -Only retry on specific errors: - -```php -$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) { - return $exception instanceof ConnectionException || - ($exception instanceof RequestException && $exception->response->serverError()); -})->post('https://api.example.com/data'); -``` - -## Handle Errors Explicitly - -The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`. - -Incorrect: - -```php -$response = Http::get('https://api.example.com/users/1'); -$user = $response->json(); // Could be an error body -``` - -Correct: - -```php -$response = Http::timeout(5)->get('https://api.example.com/users/1')->throw(); - -$user = $response->json(); -``` - -For graceful degradation: - -```php -$response = Http::get('https://api.example.com/users/1'); - -if ($response->successful()) { - return $response->json(); -} - -if ($response->notFound()) { - return null; -} - -$response->throw(); -``` - -## Use Request Pooling for Concurrent Requests - -When making multiple independent API calls, use `Http::pool()` instead of sequential calls. - -Incorrect: - -```php -$users = Http::get('https://api.example.com/users')->json(); -$posts = Http::get('https://api.example.com/posts')->json(); -$comments = Http::get('https://api.example.com/comments')->json(); -``` - -Correct: - -```php -use Illuminate\Http\Client\Pool; - -$responses = Http::pool( - fn(Pool $pool) => [ - $pool->as('users')->get('https://api.example.com/users'), - $pool->as('posts')->get('https://api.example.com/posts'), - $pool->as('comments')->get('https://api.example.com/comments'), - ], -); - -$users = $responses['users']->json(); -$posts = $responses['posts']->json(); -``` - -## Fake HTTP Calls in Tests - -Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`. - -Incorrect: - -```php -it('syncs user from API', function () { - $service = new UserSyncService(); - $service->sync(1); // Hits the real API -}); -``` - -Correct: - -```php -it('syncs user from API', function () { - Http::preventStrayRequests(); - - Http::fake([ - 'api.example.com/users/1' => Http::response([ - 'name' => 'John Doe', - 'email' => 'john@example.com', - ]), - ]); - - $service = new UserSyncService(); - $service->sync(1); - - Http::assertSent(function (Request $request) { - return $request->url() === 'https://api.example.com/users/1'; - }); -}); -``` - -Test failure scenarios too: - -```php -Http::fake([ - 'api.example.com/*' => Http::failedConnection(), -]); -``` diff --git a/.agents/skills/laravel-best-practices/rules/mail.md b/.agents/skills/laravel-best-practices/rules/mail.md deleted file mode 100644 index 7c717336d..000000000 --- a/.agents/skills/laravel-best-practices/rules/mail.md +++ /dev/null @@ -1,27 +0,0 @@ -# Mail Best Practices - -## Implement `ShouldQueue` on the Mailable Class - -Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it. - -## Use `afterCommit()` on Mailables Inside Transactions - -A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor. - -## Use `assertQueued()` Not `assertSent()` for Queued Mailables - -`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint. - -Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. - -Correct: `Mail::assertQueued(OrderShipped::class);` - -## Use Markdown Mailables for Transactional Emails - -Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag. - -## Separate Content Tests from Sending Tests - -Content tests: instantiate the mailable directly, call `assertSeeInHtml()`. -Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`. -Don't mix them — it conflates concerns and makes tests brittle. diff --git a/.agents/skills/laravel-best-practices/rules/migrations.md b/.agents/skills/laravel-best-practices/rules/migrations.md deleted file mode 100644 index c295476ee..000000000 --- a/.agents/skills/laravel-best-practices/rules/migrations.md +++ /dev/null @@ -1,129 +0,0 @@ -# Migration Best Practices - -## Generate Migrations with Artisan - -Always use `php artisan make:migration` for consistent naming and timestamps. - -Incorrect (manually created file): - -```php -// database/migrations/posts_migration.php ← wrong naming, no timestamp -``` - -Correct (Artisan-generated): - -```bash -php artisan make:migration create_posts_table -php artisan make:migration add_slug_to_posts_table -``` - -## Use `constrained()` for Foreign Keys - -Automatic naming and referential integrity. - -```php -$table->foreignId('user_id')->constrained()->cascadeOnDelete(); - -// Non-standard names -$table->foreignId('author_id')->constrained('users'); -``` - -## Never Modify Deployed Migrations - -Once a migration has run in production, treat it as immutable. Create a new migration to change the table. - -Incorrect (editing a deployed migration): - -```php -// 2024_01_01_create_posts_table.php — already in production -$table->string('slug')->unique(); // ← added after deployment -``` - -Correct (new migration to alter): - -```php -// 2024_03_15_add_slug_to_posts_table.php -Schema::table('posts', function (Blueprint $table) { - $table->string('slug')->unique()->after('title'); -}); -``` - -## Add Indexes in the Migration - -Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes. - -Incorrect: - -```php -Schema::create('orders', function (Blueprint $table) { - $table->id(); - $table->foreignId('user_id')->constrained(); - $table->string('status'); - $table->timestamps(); -}); -``` - -Correct: - -```php -Schema::create('orders', function (Blueprint $table) { - $table->id(); - $table->foreignId('user_id')->constrained()->index(); - $table->string('status')->index(); - $table->timestamp('shipped_at')->nullable()->index(); - $table->timestamps(); -}); -``` - -## Mirror Defaults in Model `$attributes` - -When a column has a database default, mirror it in the model so new instances have correct values before saving. - -```php -// Migration -$table->string('status')->default('pending'); - -// Model -protected $attributes = [ - 'status' => 'pending', -]; -``` - -## Write Reversible `down()` Methods by Default - -Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments. - -```php -public function down(): void -{ - Schema::table('posts', function (Blueprint $table) { - $table->dropColumn('slug'); - }); -} -``` - -For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported. - -## Keep Migrations Focused - -One concern per migration. Never mix DDL (schema changes) and DML (data manipulation). - -Incorrect (partial failure creates unrecoverable state): - -```php -public function up(): void -{ - Schema::create('settings', function (Blueprint $table) { ... }); - DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); -} -``` - -Correct (separate migrations): - -```php -// Migration 1: create_settings_table -Schema::create('settings', function (Blueprint $table) { ... }); - -// Migration 2: seed_default_settings -DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); -``` diff --git a/.agents/skills/laravel-best-practices/rules/queue-jobs.md b/.agents/skills/laravel-best-practices/rules/queue-jobs.md deleted file mode 100644 index db67e9e17..000000000 --- a/.agents/skills/laravel-best-practices/rules/queue-jobs.md +++ /dev/null @@ -1,145 +0,0 @@ -# Queue & Job Best Practices - -## Set `retry_after` Greater Than `timeout` - -If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution. - -Incorrect (`retry_after` ≤ `timeout`): - -```php -class ProcessReport implements ShouldQueue -{ - public $timeout = 120; -} - -// config/queue.php — retry_after: 90 ← job retried while still running! -``` - -Correct (`retry_after` > `timeout`): - -```php -class ProcessReport implements ShouldQueue -{ - public $timeout = 120; -} - -// config/queue.php — retry_after: 180 ← safely longer than any job timeout -``` - -## Use Exponential Backoff - -Use progressively longer delays between retries to avoid hammering failing services. - -Incorrect (fixed retry interval): - -```php -class SyncWithStripe implements ShouldQueue -{ - public $tries = 3; - // Default: retries immediately, overwhelming the API -} -``` - -Correct (exponential backoff): - -```php -class SyncWithStripe implements ShouldQueue -{ - public $tries = 3; - public $backoff = [1, 5, 10]; -} -``` - -## Implement `ShouldBeUnique` - -Prevent duplicate job processing. - -```php -class GenerateInvoice implements ShouldQueue, ShouldBeUnique -{ - public function uniqueId(): string - { - return $this->order->id; - } - - public $uniqueFor = 3600; -} -``` - -## Always Implement `failed()` - -Handle errors explicitly — don't rely on silent failure. - -```php -public function failed(?Throwable $exception): void -{ - $this->podcast->update(['status' => 'failed']); - Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]); -} -``` - -## Rate Limit External API Calls in Jobs - -Use `RateLimited` middleware to throttle jobs calling third-party APIs. - -```php -public function middleware(): array -{ - return [new RateLimited('external-api')]; -} -``` - -## Batch Related Jobs - -Use `Bus::batch()` when jobs should succeed or fail together. - -```php -Bus::batch([new ImportCsvChunk($chunk1), new ImportCsvChunk($chunk2)]) - ->then(fn(Batch $batch) => Notification::send($user, new ImportComplete())) - ->catch(fn(Batch $batch, Throwable $e) => Log::error('Batch failed')) - ->dispatch(); -``` - -## `retryUntil()` Needs `$tries = 0` - -When using time-based retry limits, set `$tries = 0` to avoid premature failure. - -```php -public $tries = 0; - -public function retryUntil(): \DateTimeInterface -{ - return now()->addHours(4); -} -``` - -## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release - -`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue. - -```php -class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing -{ - // Lock releases when processing begins, not when it finishes -} -``` - -## Use Horizon for Complex Queue Scenarios - -Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities. - -```php -// config/horizon.php -'environments' => [ - 'production' => [ - 'supervisor-1' => [ - 'connection' => 'redis', - 'queue' => ['high', 'default', 'low'], - 'balance' => 'auto', - 'minProcesses' => 1, - 'maxProcesses' => 10, - 'tries' => 3, - ], - ], -], -``` diff --git a/.agents/skills/laravel-best-practices/rules/routing.md b/.agents/skills/laravel-best-practices/rules/routing.md deleted file mode 100644 index 2ce6d7f28..000000000 --- a/.agents/skills/laravel-best-practices/rules/routing.md +++ /dev/null @@ -1,105 +0,0 @@ -# Routing & Controllers Best Practices - -## Use Implicit Route Model Binding - -Let Laravel resolve models automatically from route parameters. - -Incorrect: - -```php -public function show(int $id) -{ - $post = Post::findOrFail($id); -} -``` - -Correct: - -```php -public function show(Post $post) -{ - return view('posts.show', ['post' => $post]); -} -``` - -## Use Scoped Bindings for Nested Resources - -Enforce parent-child relationships automatically. - -```php -Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { - // $post is automatically scoped to $user -})->scopeBindings(); -``` - -## Use Resource Controllers - -Use `Route::resource()` or `apiResource()` for RESTful endpoints. - -```php -Route::resource('posts', PostController::class); -// In routes/api.php — the /api prefix is applied automatically -Route::apiResource('posts', Api\PostController::class); -``` - -## Keep Controllers Thin - -Aim for under 10 lines per method. Extract business logic to action or service classes. - -Incorrect: - -```php -public function store(Request $request) -{ - $validated = $request->validate([...]); - if ($request->hasFile('image')) { - $request->file('image')->move(public_path('images')); - } - $post = Post::create($validated); - $post->tags()->sync($validated['tags']); - event(new PostCreated($post)); - return redirect()->route('posts.show', $post); -} -``` - -Correct: - -```php -public function store(StorePostRequest $request, CreatePostAction $create) -{ - $post = $create->execute($request->validated()); - - return redirect()->route('posts.show', $post); -} -``` - -## Type-Hint Form Requests - -Type-hinting Form Requests triggers automatic validation and authorization before the method executes. - -Incorrect: - -```php -public function store(Request $request): RedirectResponse -{ - $validated = $request->validate([ - 'title' => ['required', 'max:255'], - 'body' => ['required'], - ]); - - Post::create($validated); - - return redirect()->route('posts.index'); -} -``` - -Correct: - -```php -public function store(StorePostRequest $request): RedirectResponse -{ - Post::create($request->validated()); - - return redirect()->route('posts.index'); -} -``` diff --git a/.agents/skills/laravel-best-practices/rules/scheduling.md b/.agents/skills/laravel-best-practices/rules/scheduling.md deleted file mode 100644 index 4752d59b9..000000000 --- a/.agents/skills/laravel-best-practices/rules/scheduling.md +++ /dev/null @@ -1,41 +0,0 @@ -# Task Scheduling Best Practices - -## Use `withoutOverlapping()` on Variable-Duration Tasks - -Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion. - -## Use `onOneServer()` on Multi-Server Deployments - -Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached). - -## Use `runInBackground()` for Concurrent Long Tasks - -By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes. - -## Use `environments()` to Restrict Tasks - -Prevent accidental execution of production-only tasks (billing, reporting) on staging. - -```php -Schedule::command('billing:charge') - ->monthly() - ->environments(['production']); -``` - -## Use `takeUntilTimeout()` for Time-Bounded Processing - -A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time. - -## Use Schedule Groups for Shared Configuration - -Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks. - -```php -Schedule::daily() - ->onOneServer() - ->timezone('America/New_York') - ->group(function () { - Schedule::command('emails:send --force'); - Schedule::command('emails:prune'); - }); -``` diff --git a/.agents/skills/laravel-best-practices/rules/security.md b/.agents/skills/laravel-best-practices/rules/security.md deleted file mode 100644 index 2afd8d3d1..000000000 --- a/.agents/skills/laravel-best-practices/rules/security.md +++ /dev/null @@ -1,208 +0,0 @@ -# Security Best Practices - -## Mass Assignment Protection - -Every model must define `$fillable` (whitelist) or `$guarded` (blacklist). - -Incorrect: - -```php -class User extends Model -{ - protected $guarded = []; // All fields are mass assignable -} -``` - -Correct: - -```php -class User extends Model -{ - protected $fillable = ['name', 'email', 'password']; -} -``` - -Never use `$guarded = []` on models that accept user input. - -## Authorize Every Action - -Use policies or gates in controllers. Never skip authorization. - -Incorrect: - -```php -public function update(UpdatePostRequest $request, Post $post) -{ - $post->update($request->validated()); -} -``` - -Correct: - -```php -public function update(UpdatePostRequest $request, Post $post) -{ - Gate::authorize('update', $post); - - $post->update($request->validated()); -} -``` - -Or via Form Request: - -```php -public function authorize(): bool -{ - return $this->user()->can('update', $this->route('post')); -} -``` - -## Prevent SQL Injection - -Always use parameter binding. Never interpolate user input into queries. - -Incorrect: - -```php -DB::select("SELECT * FROM users WHERE name = '{$request->name}'"); -``` - -Correct: - -```php -User::where('name', $request->name)->get(); - -// Raw expressions with bindings -User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get(); -``` - -## Escape Output to Prevent XSS - -Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content. - -Incorrect: - -```blade -{!! $user->bio !!} -``` - -Correct: - -```blade -{{ $user->bio }} -``` - -## CSRF Protection - -Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied. - -Incorrect: - -```blade -
- -
-``` - -Correct: - -```blade -
- @csrf - -
-``` - -## Rate Limit Auth and API Routes - -Apply `throttle` middleware to authentication and API routes. - -```php -RateLimiter::for('login', function (Request $request) { - return Limit::perMinute(5)->by($request->ip()); -}); - -Route::post('/login', LoginController::class)->middleware('throttle:login'); -``` - -## Validate File Uploads - -Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames. - -```php -public function rules(): array -{ - return [ - 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], - ]; -} -``` - -Store with generated filenames: - -```php -$path = $request->file('avatar')->store('avatars', 'public'); -``` - -## Keep Secrets Out of Code - -Never commit `.env`. Access secrets via `config()` only. - -Incorrect: - -```php -$key = env('API_KEY'); -``` - -Correct: - -```php -// config/services.php -'api_key' => env('API_KEY'), - -// In application code -$key = config('services.api_key'); -``` - -## Audit Dependencies - -Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment. - -```bash -composer audit -``` - -## Encrypt Sensitive Database Fields - -Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`. - -Incorrect: - -```php -class Integration extends Model -{ - protected function casts(): array - { - return [ - 'api_key' => 'string', - ]; - } -} -``` - -Correct: - -```php -class Integration extends Model -{ - protected $hidden = ['api_key', 'api_secret']; - - protected function casts(): array - { - return [ - 'api_key' => 'encrypted', - 'api_secret' => 'encrypted', - ]; - } -} -``` diff --git a/.agents/skills/laravel-best-practices/rules/style.md b/.agents/skills/laravel-best-practices/rules/style.md deleted file mode 100644 index 6e975df17..000000000 --- a/.agents/skills/laravel-best-practices/rules/style.md +++ /dev/null @@ -1,133 +0,0 @@ -# Conventions & Style - -## Follow Laravel Naming Conventions - -| What | Convention | Good | Bad | -| ----------- | ------------------------- | ------------------------- | ------------------------ | -| Controller | singular | `ArticleController` | `ArticlesController` | -| Model | singular | `User` | `Users` | -| Table | plural, snake_case | `article_comments` | `articleComments` | -| Pivot table | singular alphabetical | `article_user` | `user_article` | -| Column | snake_case, no model name | `meta_title` | `article_meta_title` | -| Foreign key | singular model + `_id` | `article_id` | `articles_id` | -| Route | plural | `articles/1` | `article/1` | -| Route name | snake_case with dots | `users.show_active` | `users.show-active` | -| Method | camelCase | `getAll` | `get_all` | -| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` | -| Collection | descriptive, plural | `$activeUsers` | `$data` | -| Object | descriptive, singular | `$activeUser` | `$users` | -| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` | -| Config | snake_case | `google_calendar.php` | `googleCalendar.php` | -| Enum | singular | `UserType` | `UserTypes` | - -## Prefer Shorter Readable Syntax - -| Verbose | Shorter | -| ---------------------------------- | ---------------------- | -| `Session::get('cart')` | `session('cart')` | -| `$request->session()->get('cart')` | `session('cart')` | -| `$request->input('name')` | `$request->name` | -| `return Redirect::back()` | `return back()` | -| `Carbon::now()` | `now()` | -| `App::make('Class')` | `app('Class')` | -| `->where('column', '=', 1)` | `->where('column', 1)` | -| `->orderBy('created_at', 'desc')` | `->latest()` | -| `->orderBy('created_at', 'asc')` | `->oldest()` | -| `->first()->name` | `->value('name')` | - -## Use Laravel String & Array Helpers - -Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them. - -Strings — use `Str` and fluent `Str::of()` over raw PHP: - -```php -// Incorrect -$slug = strtolower(str_replace(' ', '-', $title)); -$short = substr($text, 0, 100) . '...'; -$class = substr(strrchr('App\Models\User', '\'), 1); - -// Correct -$slug = Str::slug($title); -$short = Str::limit($text, 100); -$class = class_basename('App\Models\User'); -``` - -Fluent strings — chain operations for complex transformations: - -```php -// Incorrect -$result = strtolower(trim(str_replace('_', '-', $input))); - -// Correct -$result = Str::of($input)->trim()->replace('_', '-')->lower(); -``` - -Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`. - -Arrays — use `Arr` over raw PHP: - -```php -// Incorrect -$name = isset($array['user']['name']) ? $array['user']['name'] : 'default'; - -// Correct -$name = Arr::get($array, 'user.name', 'default'); -``` - -Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`. - -Numbers — use `Number` for display formatting: - -```php -Number::format(1000000); // "1,000,000" -Number::currency(1500, 'USD'); // "$1,500.00" -Number::abbreviate(1000000); // "1M" -Number::fileSize(1024 * 1024); // "1 MB" -Number::percentage(75.5); // "75.5%" -``` - -URIs — use `Uri` for URL manipulation: - -```php -$uri = Uri::of('https://example.com/search')->withQuery(['q' => 'laravel', 'page' => 1]); -``` - -Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining. - -Use `search-docs` for the full list of available methods — these helpers are extensive. - -## No Inline JS/CSS in Blade - -Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes. - -Incorrect: - -```blade -let article = `{{ json_encode($article) }}`; -``` - -Correct: - -```blade - -``` - -Pass data to JS via data attributes or use a dedicated PHP-to-JS package. - -## No Unnecessary Comments - -Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected. - -Incorrect: - -```php -// Check if there are any joins -if (count((array) $builder->getQuery()->joins) > 0) -``` - -Correct: - -```php -if ($this->hasJoins()) -``` diff --git a/.agents/skills/laravel-best-practices/rules/testing.md b/.agents/skills/laravel-best-practices/rules/testing.md deleted file mode 100644 index 4fbf12f8a..000000000 --- a/.agents/skills/laravel-best-practices/rules/testing.md +++ /dev/null @@ -1,43 +0,0 @@ -# Testing Best Practices - -## Use `LazilyRefreshDatabase` Over `RefreshDatabase` - -`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date. - -## Use Model Assertions Over Raw Database Assertions - -Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);` - -Correct: `$this->assertModelExists($user);` - -More expressive, type-safe, and fails with clearer messages. - -## Use Factory States and Sequences - -Named states make tests self-documenting. Sequences eliminate repetitive setup. - -Incorrect: `User::factory()->create(['email_verified_at' => null]);` - -Correct: `User::factory()->unverified()->create();` - -## Use `Exceptions::fake()` to Assert Exception Reporting - -Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally. - -## Call `Event::fake()` After Factory Setup - -Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models. - -Incorrect: `Event::fake(); $user = User::factory()->create();` - -Correct: `$user = User::factory()->create(); Event::fake();` - -## Use `recycle()` to Share Relationship Instances Across Factories - -Without `recycle()`, nested factories create separate instances of the same conceptual entity. - -```php -Ticket::factory() - ->recycle(Airline::factory()->create()) - ->create(); -``` diff --git a/.agents/skills/laravel-best-practices/rules/validation.md b/.agents/skills/laravel-best-practices/rules/validation.md deleted file mode 100644 index 0484f905c..000000000 --- a/.agents/skills/laravel-best-practices/rules/validation.md +++ /dev/null @@ -1,79 +0,0 @@ -# Validation & Forms Best Practices - -## Use Form Request Classes - -Extract validation from controllers into dedicated Form Request classes. - -Incorrect: - -```php -public function store(Request $request) -{ - $request->validate([ - 'title' => 'required|max:255', - 'body' => 'required', - ]); -} -``` - -Correct: - -```php -public function store(StorePostRequest $request) -{ - Post::create($request->validated()); -} -``` - -## Array vs. String Notation for Rules - -Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses. - -```php -// Preferred for new code -'email' => ['required', 'email', Rule::unique('users')], - -// Follow existing convention if the project uses string notation -'email' => 'required|email|unique:users', -``` - -## Always Use `validated()` - -Get only validated data. Never use `$request->all()` for mass operations. - -Incorrect: - -```php -Post::create($request->all()); -``` - -Correct: - -```php -Post::create($request->validated()); -``` - -## Use `Rule::when()` for Conditional Validation - -```php -'company_name' => [ - Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']), -], -``` - -## Use the `after()` Method for Custom Validation - -Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields. - -```php -public function after(): array -{ - return [ - function (Validator $validator) { - if ($this->quantity > Product::find($this->product_id)?->stock) { - $validator->errors()->add('quantity', 'Not enough stock.'); - } - }, - ]; -} -``` diff --git a/.agents/skills/modular/SKILL.md b/.agents/skills/modular/SKILL.md deleted file mode 100644 index 408ff8a32..000000000 --- a/.agents/skills/modular/SKILL.md +++ /dev/null @@ -1,188 +0,0 @@ ---- -name: modular -description: Create or modify Laravel modules using `internachi/modular`. Use when the user asks to create a module, add components to a module, scaffold module structure, or work on files in an `app-modules` directory. -argument-hint: [component-type] ---- - -# Laravel Modular Development - -You are helping with a Laravel application that uses `internachi/modular` for modular architecture. Modules live in `app-modules/` and follow Laravel package conventions. - -## Module Structure - -The structure of `app-modules` mimics a standard Laravel application, where what typically would be found in `app` is found in `src`: - -``` -app-modules/ - {module-name}/ - composer.json # PSR-4 autoload, Laravel provider discovery - - src/ - Providers/ - Models/ - Http/ - tests/ - Feature/ - Unit/ - routes/ - {module-name}-routes.php - resources/ - database/ - migrations/ - factories/ - seeders/ -``` - -## Creating a New Module - -When asked to create a new module: - -1. **Check if `internachi/modular` is installed:** - - ```bash - composer show internachi/modular - ``` - - If not installed, install it first: - - ```bash - composer require internachi/modular - ``` - -2. **Check the modular namespace:** - - Check for `config/app-modules.php` - - If present, get the `modules_vendor` value from that file - - If not, assume the vendor name is "modules" - -3. **Create the module:** - - ```bash - php artisan make:module {module-name} --no-interaction - ``` - -4. **Register with Composer:** - ```bash - composer update {module-vendor}/{module-name} - ``` -5. **Sync modules** - ```bash - php artisan modules:sync - ``` - -## Adding Components to a Module - -Use the `--module` flag with Laravel's make commands: - -| Component | Command | -| ---------- | ------------------------------------------------------------------------------------ | -| Model | `php artisan make:model {Name} --module={module} --no-interaction` | -| Controller | `php artisan make:controller {Name}Controller --module={module} --no-interaction` | -| Migration | `php artisan make:migration create_{table}_table --module={module} --no-interaction` | -| Factory | `php artisan make:factory {Name}Factory --module={module} --no-interaction` | -| Seeder | `php artisan make:seeder {Name}Seeder --module={module} --no-interaction` | -| Request | `php artisan make:request {Name}Request --module={module} --no-interaction` | -| Test | `php artisan make:test {Name}Test --module={module} --no-interaction` | -| Policy | `php artisan make:policy {Name}Policy --module={module} --no-interaction` | -| Event | `php artisan make:event {Name} --module={module} --no-interaction` | -| Listener | `php artisan make:listener {Name} --module={module} --no-interaction` | -| Job | `php artisan make:job {Name} --module={module} --no-interaction` | -| Middleware | `php artisan make:middleware {Name} --module={module} --no-interaction` | -| Resource | `php artisan make:resource {Name}Resource --module={module} --no-interaction` | -| Rule | `php artisan make:rule {Name} --module={module} --no-interaction` | -| Observer | `php artisan make:observer {Name}Observer --module={module} --no-interaction` | - -## Module Conventions - -### Namespacing - -- Default namespace: `{ModuleVendor}\{ModuleName}\` -- Example: `Modules\Billing\Models\Invoice` - -### composer.json Format - -```json -{ - "name": "{module-vendor}/{module-name}", - "require": {}, - "autoload": { - "psr-4": { - "{ModuleVendor}\\{ModuleName}\\": "src/" - } - }, - "extra": { - "laravel": { - "providers": ["{ModuleVendor}\\{ModuleName}\\Providers\\{ModuleName}ServiceProvider"] - } - } -} -``` - -### Routes - -- By convention, module routes are in `routes/{module-name}-routes.php` -- By convention, module route names are prefixed with the module name (eg. `billing::dashboards.index`) -- If necessary, break into separate files as needed (eg. `routes/{module-name}-api.php`) -- Routes are auto-discovered—no need to register them - -### Migrations - -- Place in `database/migrations/` -- Auto-discovered by Laravel's migrator -- Run with `php artisan migrate` - -### Factories - -- Place in `database/factories/` -- Auto-loaded for `factory()` calls -- Namespace: `{ModuleVendor}\{ModuleName}\Database\Factories` - -### Tests - -- Place in `tests/Feature/` and `tests/Unit/` -- Run module tests: `php artisan test app-modules/{module-name}/tests` -- All module tests can be run using the `Modules` testsuite configuration that is auto-generated by the `modules:sync` command - -### Cross-Module Dependencies - -- Import models/services from other modules directly -- Example: `use Modules\Billing\Models\Invoice;` -- Keep dependencies minimal and unidirectional when possible - -## Available Commands - -```bash - -# List all modules - -php artisan modules:list - -# Sync phpunit.xml and IDE configs - -php artisan modules:sync - -# Cache module configs - -php artisan modules:cache - -# Clear module cache - -php artisan modules:clear - -# Run module seeders - -php artisan db:seed --module={module-name} -``` - -## Best Practices - -1. **One domain per module** - Group related functionality together -2. **Minimal cross-dependencies** - Modules should be loosely coupled -3. **Follow Laravel conventions** - Use standard directory structure within modules (treat `src` like `app`) - -## When Processing Arguments - -If `$ARGUMENTS` contains: - -- Just a module name (e.g., "billing"): Create the module or list what can be added -- Module + component (e.g., "billing model Invoice"): Create that specific component -- "list": Show existing modules with `php artisan modules:list` diff --git a/.agents/skills/pest-testing/SKILL.md b/.agents/skills/pest-testing/SKILL.md deleted file mode 100644 index 21f8ccb67..000000000 --- a/.agents/skills/pest-testing/SKILL.md +++ /dev/null @@ -1,171 +0,0 @@ ---- -name: pest-testing -description: 'Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code.' -license: MIT -metadata: - author: laravel ---- - -# Pest Testing 4 - -## Documentation - -Use `search-docs` for detailed Pest 4 patterns and documentation. - -## Basic Usage - -### Creating Tests - -All tests must be written using Pest. Use `php artisan make:test --pest {name}`. - -The `{name}` argument should include only the path and test name, but should not include the test suite. - -- Incorrect: `php artisan make:test --pest Feature/SomeFeatureTest` will generate `tests/Feature/Feature/SomeFeatureTest.php` -- Correct: `php artisan make:test --pest SomeControllerTest` will generate `tests/Feature/SomeControllerTest.php` -- Incorrect: `php artisan make:test --pest --unit Unit/SomeServiceTest` will generate `tests/Unit/Unit/SomeServiceTest.php` -- Correct: `php artisan make:test --pest --unit SomeServiceTest` will generate `tests/Unit/SomeServiceTest.php` - -### Test Organization - -- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. -- Browser tests: `tests/Browser/` directory. -- Do NOT remove tests without approval - these are core application code. - -### Basic Test Structure - -Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`. - - - -```php -it('is true', function () { - expect(true)->toBeTrue(); -}); -``` - -### Running Tests - -- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`. -- Run all tests: `php artisan test --compact`. -- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`. - -## Assertions - -Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: - - - -```php -it('returns all', function () { - $this->postJson('/api/docs', [])->assertSuccessful(); -}); -``` - -| Use | Instead of | -| -------------------- | ------------------- | -| `assertSuccessful()` | `assertStatus(200)` | -| `assertNotFound()` | `assertStatus(404)` | -| `assertForbidden()` | `assertStatus(403)` | - -## Mocking - -Import mock function before use: `use function Pest\Laravel\mock;` - -## Datasets - -Use datasets for repetitive tests (validation rules, etc.): - - - -```php -it('has emails', function (string $email) { - expect($email)->not->toBeEmpty(); -})->with([ - 'james' => 'james@laravel.com', - 'taylor' => 'taylor@laravel.com', -]); -``` - -## Pest 4 Features - -| Feature | Purpose | -| -------------------- | --------------------------------------- | -| Browser Testing | Full integration tests in real browsers | -| Smoke Testing | Validate multiple pages quickly | -| Visual Regression | Compare screenshots for visual changes | -| Test Sharding | Parallel CI runs | -| Architecture Testing | Enforce code conventions | - -### Browser Test Example - -Browser tests run in real browsers for full integration testing: - -- Browser tests live in `tests/Browser/`. -- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories. -- Use `RefreshDatabase` for clean state per test. -- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures. -- Test on multiple browsers (Chrome, Firefox, Safari) if requested. -- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested. -- Switch color schemes (light/dark mode) when appropriate. -- Take screenshots or pause tests for debugging. - - - -```php -it('may reset the password', function () { - Notification::fake(); - - $this->actingAs(User::factory()->create()); - - $page = visit('/sign-in'); - - $page - ->assertSee('Sign In') - ->assertNoJavaScriptErrors() - ->click('Forgot Password?') - ->fill('email', 'nuno@laravel.com') - ->click('Send Reset Link') - ->assertSee('We have emailed your password reset link!'); - - Notification::assertSent(ResetPassword::class); -}); -``` - -### Smoke Testing - -Quickly validate multiple pages have no JavaScript errors: - - - -```php -$pages = visit(['/', '/about', '/contact']); - -$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs(); -``` - -### Visual Regression Testing - -Capture and compare screenshots to detect visual changes. - -### Test Sharding - -Split tests across parallel processes for faster CI runs. - -### Architecture Testing - -Pest 4 includes architecture testing (from Pest 3): - - - -```php -arch('controllers')->expect('App\Http\Controllers')->toExtendNothing()->toHaveSuffix('Controller'); -``` - -## Common Pitfalls - -- Not importing `use function Pest\Laravel\mock;` before using mock -- Using `assertStatus(200)` instead of `assertSuccessful()` -- Forgetting datasets for repetitive validation tests -- Deleting tests without approval -- Forgetting `assertNoJavaScriptErrors()` in browser tests -- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test` diff --git a/.agents/skills/tailwindcss-development/SKILL.md b/.agents/skills/tailwindcss-development/SKILL.md deleted file mode 100644 index dba5867e7..000000000 --- a/.agents/skills/tailwindcss-development/SKILL.md +++ /dev/null @@ -1,123 +0,0 @@ ---- -name: tailwindcss-development -description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS." -license: MIT -metadata: - author: laravel ---- - -# Tailwind CSS Development - -## Documentation - -Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. - -## Basic Usage - -- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. -- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). -- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. - -## Tailwind CSS v4 Specifics - -- Always use Tailwind CSS v4 and avoid deprecated utilities. -- `corePlugins` is not supported in Tailwind v4. - -### CSS-First Configuration - -In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: - - - -```css -@theme { - --color-brand: oklch(0.72 0.11 178); -} -``` - -### Import Syntax - -In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: - - - -```diff -- @tailwind base; -- @tailwind components; -- @tailwind utilities; -+ @import "tailwindcss"; -``` - -### Replaced Utilities - -Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. - -| Deprecated | Replacement | -| ---------------------- | -------------------- | -| bg-opacity-\* | bg-black/\* | -| text-opacity-\* | text-black/\* | -| border-opacity-\* | border-black/\* | -| divide-opacity-\* | divide-black/\* | -| ring-opacity-\* | ring-black/\* | -| placeholder-opacity-\* | placeholder-black/\* | -| flex-shrink-\* | shrink-\* | -| flex-grow-\* | grow-\* | -| overflow-ellipsis | text-ellipsis | -| decoration-slice | box-decoration-slice | -| decoration-clone | box-decoration-clone | - -## Spacing - -Use `gap` utilities instead of margins for spacing between siblings: - - - -```html -
-
Item 1
-
Item 2
-
-``` - -## Dark Mode - -If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: - - - -```html -
Content adapts to color scheme
-``` - -## Common Patterns - -### Flexbox Layout - - - -```html -
-
Left content
-
Right content
-
-``` - -### Grid Layout - - - -```html -
-
Card 1
-
Card 2
-
Card 3
-
-``` - -## Common Pitfalls - -- Using deprecated v3 utilities (bg-opacity-_, flex-shrink-_, etc.) -- Using `@tailwind` directives instead of `@import "tailwindcss"` -- Trying to use `tailwind.config.js` instead of CSS `@theme` directive -- Using margins for spacing between siblings instead of gap utilities -- Forgetting to add dark mode variants when the project uses dark mode diff --git a/.ai/guidelines/01-domain.blade.php b/.ai/guidelines/01-domain.blade.php index 0a6705407..206319759 100644 --- a/.ai/guidelines/01-domain.blade.php +++ b/.ai/guidelines/01-domain.blade.php @@ -1,16 +1,43 @@ -# Domain Docs How the engineering skills should consume this repo's domain documentation when exploring the codebase. ## -Before exploring, read these - **`CONTEXT-MAP.md`** at the repo root — it points at one `CONTEXT.md` per module. Read -each one relevant to the topic. - **`docs/adr/`** — read ADRs that touch the area you're about to work in. Also check -`app-modules//docs/adr/` for module-scoped decisions. If any of these files don't exist, **proceed silently**. Don't flag their - absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms - or decisions actually get resolved. ## File structure This is a multi-context repo (modular monorepo via - `internachi/modular`): ``` / ├── CONTEXT-MAP.md <- system-wide context map ├── docs/adr/ <- system-wide decisions - └── app-modules/ ├── moderation/ │ ├── CONTEXT.md │ └── docs/adr/ <- module-specific decisions ├── bot-discord/ │ - ├── CONTEXT.md │ └── docs/adr/ ├── identity/ │ ├── CONTEXT.md │ └── docs/adr/ └── ... ``` ## Use the glossary's - vocabulary When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test - name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. If the - concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't - use (reconsider) or there's a real gap (note it for `/grill-with-docs`). ## Flag ADR conflicts If your output - contradicts an existing ADR, surface it explicitly rather than silently overriding: > _Contradicts ADR-0007 — but - worth reopening because..._ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT-MAP.md`** at the repo root — it points at one `CONTEXT.md` per module. Read each one relevant to the topic. +- **`docs/adr/`** — read ADRs that touch the area you're about to work in. Also check `app-modules//docs/adr/` for module-scoped decisions. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +This is a multi-context repo (modular monorepo via `internachi/modular`): + +``` +/ +├── CONTEXT-MAP.md <- system-wide context map +├── docs/adr/ <- system-wide decisions +└── app-modules/ + ├── moderation/ + │ ├── CONTEXT.md + │ └── docs/adr/ <- module-specific decisions + ├── bot-discord/ + │ ├── CONTEXT.md + │ └── docs/adr/ + ├── identity/ + │ ├── CONTEXT.md + │ └── docs/adr/ + └── ... +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 — but worth reopening because..._ diff --git a/.ai/guidelines/02-issue-tracker.blade.php b/.ai/guidelines/02-issue-tracker.blade.php index cd080e616..e8569160e 100644 --- a/.ai/guidelines/02-issue-tracker.blade.php +++ b/.ai/guidelines/02-issue-tracker.blade.php @@ -1,16 +1,22 @@ -# Issue tracker: GitHub Issues and PRDs for this repo live as GitHub issues on `he4rt/he4rt-bot-api`. Use the `gh` CLI -for all operations. ## Conventions - **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc -for multi-line bodies. - **Read an issue**: `gh issue view - - --comments`, filtering comments by `jq` and also fetching labels. - **List issues**: `gh issue list --state open - --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: - [.comments[].body]}]'` with appropriate `--label` and `--state` filters. - **Comment on an issue**: `gh issue - comment - - --body "..."` - **Apply / remove labels**: `gh issue edit - - --add-label "..."` / `--remove-label "..."` - **Close**: `gh issue close - - --comment "..."` Infer the repo from `git remote -v` — `gh` does this automatically when run inside a - clone. ## When a skill says "publish to the issue tracker" Create a GitHub issue. ## When a skill says - "fetch the relevant ticket" Run `gh issue view --comments`. +# Issue tracker: GitHub + +Issues and PRDs for this repo live as GitHub issues on `he4rt/he4rt-bot-api`. Use the `gh` CLI for all operations. + +## Conventions + +- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --comments`, filtering comments by `jq` and also fetching labels. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Comment on an issue**: `gh issue comment --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --comment "..."` + +Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue. + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view --comments`. diff --git a/.ai/guidelines/03-triage-labels.blade.php b/.ai/guidelines/03-triage-labels.blade.php index 56df1b815..ff5d0e638 100644 --- a/.ai/guidelines/03-triage-labels.blade.php +++ b/.ai/guidelines/03-triage-labels.blade.php @@ -1,7 +1,13 @@ -# Triage Labels The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label -strings used in this repo's issue tracker. | Label in skills | Label in our tracker | Meaning | | --------------------------- | -------------------- | ---------------------------------------- | | `needs-triage` | -`needs-triage` | Maintainer needs to evaluate this issue | | `needs-info` | `needs-info` | Waiting on reporter for more -information | | `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | | `ready-for-human` | -`ready-for-human` | Requires human implementation | | `wontfix` | `wontfix` | Will not be actioned | When a skill -mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. diff --git a/.ai/guidelines/99-knowledge-base.blade.php b/.ai/guidelines/99-knowledge-base.blade.php index 66960b326..aef8af121 100644 --- a/.ai/guidelines/99-knowledge-base.blade.php +++ b/.ai/guidelines/99-knowledge-base.blade.php @@ -1,21 +1,91 @@ -# Knowledge Base Documentation This project uses `guava/filament-knowledge-base` for embedded docs inside the Filament -admin panel. Docs are Markdown files rendered in the sidebar. ## Structure All files live in `docs/admin/{lang}/`: ``` -docs/admin/{lang}/ ├── introduction.md ├── getting-started.md (type: group) │ └── getting-started/ │ ├── -navigating-the-panel.md │ ├── dashboard.md │ └── profile.md ├── users.md (type: group) │ └── users/ │ ├── -managing-users.md │ ├── roles.md │ ├── teams.md │ └── authentication.md └── system.md (type: group) └── system/ ├── -activity-logs.md ├── emails.md └── configuration.md ``` ### Rules - Maximum **3 levels** of nesting. - Group directories -require a matching `.md` file at the same level with `type: group` in front matter. - All files require YAML front -matter: `title`, `icon`, `order`. - Use `heroicon-o-*` icons (Heroicons outlined set). ### Front Matter ```yaml --- -title: Page Title icon: heroicon-o-document order: 1 --- ``` For groups, add `type: group`: ```yaml --- title: Group -Name icon: heroicon-o-folder order: 2 type: group --- ``` ## Keeping Docs in Sync When changes affect user-facing -behavior, update `docs/admin/en/`: - **New resource/page** — add a doc file under the appropriate group. - **Changed nav -groups/labels** — update the group `.md` and children. - **Added/removed/renamed form fields** — update the resource's -doc page. - **Auth/authorization changes** — update `users/authentication.md` and `users/roles.md`. - **System -features** (logs, emails, config) — update under `system/`. ## Key Files - -`app/Filament/Plugins/BetterKnowledgeBase.php` — sidebar navigation builder - `config/filament-knowledge-base.php` — -plugin config (cache TTL, icons, model) - `resources/views/vendor/filament-knowledge-base/livewire/help-menu.blade.php` -— contextual help popover - `lang/{en,pt_BR}/knowledge_base.php` — KB UI translations ## Contextual Help -(HasKnowledgeBase) Resources can implement `HasKnowledgeBase` for per-resource sidebar help: ```php use -Guava\FilamentKnowledgeBase\Contracts\HasKnowledgeBase; class UserResource extends Resource implements HasKnowledgeBase -{ public static function getDocumentation(): array { return ['users.managing-users', 'users.roles']; } } ``` Doc IDs -follow `{group}.{file-slug}` matching paths under `docs/admin/en/`. +# Knowledge Base Documentation + +This project uses `guava/filament-knowledge-base` for embedded docs inside the Filament admin panel. Docs are Markdown files rendered in the sidebar. + +## Structure + +All files live in `docs/admin/{lang}/`: + +``` +docs/admin/{lang}/ +├── introduction.md +├── getting-started.md (type: group) +│ └── getting-started/ +│ ├── navigating-the-panel.md +│ ├── dashboard.md +│ └── profile.md +├── users.md (type: group) +│ └── users/ +│ ├── managing-users.md +│ ├── roles.md +│ ├── teams.md +│ └── authentication.md +└── system.md (type: group) + └── system/ + ├── activity-logs.md + ├── emails.md + └── configuration.md +``` + +### Rules + +- Maximum **3 levels** of nesting. +- Group directories require a matching `.md` file at the same level with `type: group` in front matter. +- All files require YAML front matter: `title`, `icon`, `order`. +- Use `heroicon-o-*` icons (Heroicons outlined set). + +### Front Matter + +```yaml +--- +title: Page Title +icon: heroicon-o-document +order: 1 +--- +``` + +For groups, add `type: group`: + +```yaml +--- +title: Group Name +icon: heroicon-o-folder +order: 2 +type: group +--- +``` + +## Keeping Docs in Sync + +When changes affect user-facing behavior, update `docs/admin/en/`: + +- **New resource/page** — add a doc file under the appropriate group. +- **Changed nav groups/labels** — update the group `.md` and children. +- **Added/removed/renamed form fields** — update the resource's doc page. +- **Auth/authorization changes** — update `users/authentication.md` and `users/roles.md`. +- **System features** (logs, emails, config) — update under `system/`. + +## Key Files + +- `app/Filament/Plugins/BetterKnowledgeBase.php` — sidebar navigation builder +- `config/filament-knowledge-base.php` — plugin config (cache TTL, icons, model) +- `resources/views/vendor/filament-knowledge-base/livewire/help-menu.blade.php` — contextual help popover +- `lang/{en,pt_BR}/knowledge_base.php` — KB UI translations + +## Contextual Help (HasKnowledgeBase) + +Resources can implement `HasKnowledgeBase` for per-resource sidebar help: + +```php +use Guava\FilamentKnowledgeBase\Contracts\HasKnowledgeBase; + +class UserResource extends Resource implements HasKnowledgeBase +{ + public static function getDocumentation(): array + { + return ['users.managing-users', 'users.roles']; + } +} +``` + +Doc IDs follow `{group}.{file-slug}` matching paths under `docs/admin/en/`. diff --git a/.gitignore b/.gitignore index 227efa8d7..7b356a0c4 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ /.fleet /.idea /.kiro +/.agents /.laracord /.nova /.phpunit.cache diff --git a/.prettierignore b/.prettierignore index d008048e7..f48f4f542 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,4 @@ public/ package-lock.json composer.lock storage/ +.ai diff --git a/PR.md b/PR.md deleted file mode 100644 index f2c8222c2..000000000 --- a/PR.md +++ /dev/null @@ -1,17 +0,0 @@ -## Summary - -- Ajusta a escalada de penalidades para alinhar com as regras esperadas pelos testes. -- Corrige o teste de execucao para validar plataforma realmente nao suportada. - -## Changes - -- `app-modules/moderation/src/Classification/Actions/Advisors/HistoryBasedPenaltyAdvisor.php` -- `app-modules/moderation/tests/Feature/Enforcement/ExecuteActionTest.php` - -## Testing - -- `vendor/bin/pest --ci --parallel` - -## Notes - -- O teste "skips platforms not in target list" foi ajustado porque o Discord agora e uma plataforma registrada; antes, nao existia adapter Discord e o teste passava mesmo com `discord` na lista. Agora a lista usa `skype` (plataforma inexistente) para garantir que a execucao seja realmente ignorada.