Compare commits
175 Commits
feat/024-t
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c3daae405 | |||
| a4f2629493 | |||
| b1e1e06861 | |||
| a74ab12f04 | |||
| 5ec62cd117 | |||
| ec71c2d4e7 | |||
| 1f3619bd16 | |||
| 5bcb4f6ab8 | |||
| ede4cc363d | |||
| 417df4f9aa | |||
| 73a879d061 | |||
| 6ca496233b | |||
| 440e63edff | |||
| b0a724acef | |||
| 641bb4afde | |||
| 3f6f80f7af | |||
| 0b5cadc234 | |||
| d2f2c55ead | |||
| b182f55562 | |||
| 98e2b5acd9 | |||
| bab01f07a9 | |||
| 45a804970e | |||
| cc93329672 | |||
| 28cfe38ba4 | |||
| d4fb886de0 | |||
| 8ee1174c8d | |||
| b15d1950b4 | |||
| 4b3113498c | |||
| 3c445709af | |||
| 0c709df54e | |||
| ef41c9193a | |||
| c6e7591d19 | |||
| a490261eca | |||
| 02028be7e4 | |||
| a4f5c4f122 | |||
| 3971c315d8 | |||
| bfc483e9b8 | |||
| 4db73e872e | |||
| 73a3a62451 | |||
| 891f177311 | |||
| cd811cff4f | |||
| da1adbdeb5 | |||
| 92704a2f7e | |||
| f08924525d | |||
| 7620144ab6 | |||
| fdfb781144 | |||
| 0cf612826f | |||
| 200498fa8e | |||
| 32c3a64147 | |||
| 7ac53f4cc4 | |||
| f13a4ce409 | |||
| 9f5c99317b | |||
| 0dc79520a4 | |||
| e15eee8f26 | |||
| 8bee824966 | |||
| 33a2b1a242 | |||
| 6a15fe978a | |||
| ef380b67d1 | |||
| d32b2115a8 | |||
| 558b5d3807 | |||
| 8f8bc24d1d | |||
| a30be84084 | |||
| d49d33ac27 | |||
| 3ed275cef3 | |||
| c57f680f39 | |||
| e241e27853 | |||
| 521fb6baaf | |||
| ef5c223172 | |||
| 9d0c884251 | |||
| 03127a670b | |||
| eec93b510a | |||
| bda1d90fc4 | |||
| 92a36ab89e | |||
| 3ddf8c3fd6 | |||
| 5770c7b76b | |||
| 1c098441aa | |||
| 90bfe1516e | |||
| fb4de17c63 | |||
| d6e7de597a | |||
| 1acbf8cc54 | |||
| 57f3e3934c | |||
| 2bf5de4663 | |||
| 0e2adeab71 | |||
| 55166cf9b8 | |||
| a770b32e87 | |||
| 11c73abd1d | |||
| 4db8030f2a | |||
| 3f09fd50f6 | |||
| ff671d8d4a | |||
| d56ba85755 | |||
| fb1046c97a | |||
| 05a604cfb6 | |||
| 53dc89e6ef | |||
| 8e34b6084f | |||
| 439248ba15 | |||
| b6343d5c3a | |||
| 5f9e6fb04a | |||
| 38d9826f5e | |||
| a989ef1a23 | |||
| 3490fb9e2c | |||
| d1a9989037 | |||
| 7217559e5a | |||
| 6a86c5901a | |||
| cfbc74c035 | |||
| d90fb0f963 | |||
| 3a3de045ba | |||
| 210cf5ce8b | |||
| c5fbcaa692 | |||
| 81c010fa00 | |||
| eef85af990 | |||
| a0ed9e24c5 | |||
| 1bc6600fcc | |||
| 0b6600b926 | |||
| e1ed7ae232 | |||
| ec9f28ccbd | |||
| abda751296 | |||
| 5745461654 | |||
|
|
c41f264231 | ||
|
|
6a2fe91547 | ||
|
|
8b17bbe9be | ||
|
|
283daeab33 | ||
|
|
9870f5d102 | ||
| 971105daa9 | |||
|
|
9a8b283b1d | ||
|
|
aa8d132f3c | ||
|
|
a6ab36aca4 | ||
|
|
e442a865d6 | ||
|
|
eac19118a2 | ||
| a97beefda3 | |||
|
|
ec99c6519c | ||
|
|
1ed3b953da | ||
|
|
6737ba7d85 | ||
|
|
b807a7bb96 | ||
|
|
cb3da561ef | ||
|
|
c352bc9a17 | ||
|
|
7b96ef8dd8 | ||
|
|
5118497da9 | ||
|
|
bcdeeb5525 | ||
| bd6df1f343 | |||
|
|
af17875b9d | ||
|
|
45f40d0a08 | ||
|
|
37873829fd | ||
|
|
b62d7c2ca5 | ||
|
|
8b9ab52138 | ||
| 3030dd9af2 | |||
|
|
39287b250b | ||
|
|
94f8719e09 | ||
|
|
9f980ce80e | ||
|
|
48b558db93 | ||
| 30ad57baab | |||
| c60d16ffba | |||
| a449ecec5b | |||
| bc846d7c5c | |||
| bcf4996a1e | |||
| 060a82a1ed | |||
| b35e3a6518 | |||
| bbb1cb0982 | |||
| 9c56a2349a | |||
| da18d3cb14 | |||
| 361e301f67 | |||
| cf5b0027e3 | |||
| 16c9c7ee80 | |||
| 93dbd3b13d | |||
| 1340c47f54 | |||
| 8ae7a7234e | |||
| dedca3c612 | |||
| 3465076a04 | |||
| d63bce7b54 | |||
| 78467a76ac | |||
| a62c855851 | |||
| 4d3fcd28a9 | |||
| beffbfca4c | |||
| 2ca989c00f | |||
| 817ad208da | |||
| 83f1814254 |
167
.agents/skills/pest-testing/SKILL.md
Normal file
167
.agents/skills/pest-testing/SKILL.md
Normal file
@ -0,0 +1,167 @@
|
||||
---
|
||||
name: pest-testing
|
||||
description: "Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Pest Testing 4
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate this skill when:
|
||||
|
||||
- Creating new tests (unit, feature, or browser)
|
||||
- Modifying existing tests
|
||||
- Debugging test failures
|
||||
- Working with browser testing or smoke testing
|
||||
- Writing architecture tests or visual regression tests
|
||||
|
||||
## 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}`.
|
||||
|
||||
### 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
|
||||
|
||||
<!-- Basic Pest Test Example -->
|
||||
```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()`:
|
||||
|
||||
<!-- Pest Response Assertion -->
|
||||
```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.):
|
||||
|
||||
<!-- Pest Dataset Example -->
|
||||
```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.
|
||||
|
||||
<!-- Pest Browser Test Example -->
|
||||
```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:
|
||||
|
||||
<!-- Pest Smoke Testing Example -->
|
||||
```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):
|
||||
|
||||
<!-- Architecture Test Example -->
|
||||
```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
|
||||
129
.agents/skills/tailwindcss-development/SKILL.md
Normal file
129
.agents/skills/tailwindcss-development/SKILL.md
Normal file
@ -0,0 +1,129 @@
|
||||
---
|
||||
name: tailwindcss-development
|
||||
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Tailwind CSS Development
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate this skill when:
|
||||
|
||||
- Adding styles to components or pages
|
||||
- Working with responsive design
|
||||
- Implementing dark mode
|
||||
- Extracting repeated patterns into components
|
||||
- Debugging spacing or layout issues
|
||||
|
||||
## 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-First Config -->
|
||||
```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:
|
||||
|
||||
<!-- v4 Import Syntax -->
|
||||
```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:
|
||||
|
||||
<!-- Gap Utilities -->
|
||||
```html
|
||||
<div class="flex gap-8">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 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:
|
||||
|
||||
<!-- Dark Mode -->
|
||||
```html
|
||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
Content adapts to color scheme
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Flexbox Layout
|
||||
|
||||
<!-- Flexbox Layout -->
|
||||
```html
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>Left content</div>
|
||||
<div>Right content</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Grid Layout
|
||||
|
||||
<!-- Grid Layout -->
|
||||
```html
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>Card 1</div>
|
||||
<div>Card 2</div>
|
||||
<div>Card 3</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 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
|
||||
173
.ai/guidelines/filament-v5-blueprint.md
Normal file
173
.ai/guidelines/filament-v5-blueprint.md
Normal file
@ -0,0 +1,173 @@
|
||||
## Source of Truth
|
||||
If any Filament behavior is uncertain, lookup the exact section in:
|
||||
- docs/research/filament-v5-notes.md
|
||||
and prefer that over guesses.
|
||||
|
||||
# SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES)
|
||||
|
||||
# Filament Blueprint (v5)
|
||||
|
||||
## 1) Non-negotiables
|
||||
- Filament v5 requires Livewire v4.0+.
|
||||
- Laravel 11+: register panel providers in `bootstrap/providers.php` (never `bootstrap/app.php`).
|
||||
- Global search hard rule: If a Resource should appear in Global Search, it must have an Edit or View page; otherwise it will return no results.
|
||||
- Destructive actions must execute via `Action::make(...)->action(...)` and include `->requiresConfirmation()` (no exceptions).
|
||||
- Prefer render hooks + CSS hook classes over publishing Filament internal views.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||
- https://filamentphp.com/docs/5.x/actions/modals
|
||||
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
||||
|
||||
## 2) Directory & naming conventions
|
||||
- Default to Filament discovery conventions for Resources/Pages/Widgets unless you adopt modular architecture.
|
||||
- Clusters: directory layout is recommended, not mandatory; functional behavior depends on `$cluster`.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/navigation/clusters
|
||||
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
|
||||
|
||||
## 3) Panel setup defaults
|
||||
- Default to a single `/admin` panel unless multiple audiences/configs demand multiple panels.
|
||||
- Verify provider registration (Laravel 11+: `bootstrap/providers.php`) when adding a panel.
|
||||
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
|
||||
- Assets policy:
|
||||
- Panel-only assets: register via panel config.
|
||||
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||
- Deployment must include `php artisan filament:assets`.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||
|
||||
## 4) Navigation & information architecture
|
||||
- Use nav groups + sort order intentionally; apply conditional visibility for clarity, but enforce authorization separately.
|
||||
- Use clusters to introduce hierarchy and sub-navigation when sidebar complexity grows.
|
||||
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
|
||||
- User menu:
|
||||
- Configure via `userMenuItems()` with Action objects.
|
||||
- Never put destructive actions there without confirmation + authorization.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/navigation/overview
|
||||
- https://filamentphp.com/docs/5.x/navigation/clusters
|
||||
- https://filamentphp.com/docs/5.x/navigation/user-menu
|
||||
|
||||
## 5) Resource patterns
|
||||
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
|
||||
- Global search:
|
||||
- If a resource is intended for global search: ensure Edit/View page exists.
|
||||
- Otherwise disable global search for that resource (don’t “expect it to work”).
|
||||
- If global search renders relationship-backed details: eager-load via global search query override.
|
||||
- For very large datasets: consider disabling term splitting (only when needed).
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/resources/overview
|
||||
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||
|
||||
## 6) Page lifecycle & query rules
|
||||
- Treat relationship-backed rendering in aggregate contexts (global search details, list summaries) as requiring eager loading.
|
||||
- Prefer render hooks for layout injection; avoid publishing internal views.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||
|
||||
## 7) Infolists vs RelationManagers (decision tree)
|
||||
- Interactive CRUD / attach / detach under owner record → RelationManager.
|
||||
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
|
||||
- Inline CRUD inside owner form → Repeater.
|
||||
- Default performance stance: RelationManagers stay lazy-loaded unless explicit UX justification exists.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/resources/managing-relationships
|
||||
- https://filamentphp.com/docs/5.x/infolists/overview
|
||||
|
||||
## 8) Form patterns (validation, reactivity, state)
|
||||
- Default: minimize server-driven reactivity; only use it when schema/visibility/requirements must change server-side.
|
||||
- Prefer “on blur” semantics for chatty inputs when using reactive behavior (per docs patterns).
|
||||
- Custom field views must obey state binding modifiers.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/forms/overview
|
||||
- https://filamentphp.com/docs/5.x/forms/custom-fields
|
||||
|
||||
## 9) Table & action patterns
|
||||
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
|
||||
- Actions:
|
||||
- Execution actions use `->action(...)`.
|
||||
- Destructive actions add `->requiresConfirmation()`.
|
||||
- Navigation-only actions should use `->url(...)`.
|
||||
- UNVERIFIED: do not assert modal/confirmation behavior for URL-only actions unless verified.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/tables/empty-state
|
||||
- https://filamentphp.com/docs/5.x/actions/modals
|
||||
|
||||
## 10) Authorization & security
|
||||
- Enforce panel access in non-local environments as documented.
|
||||
- UI visibility is not security; enforce policies/access checks in addition to hiding UI.
|
||||
- Bulk operations: explicitly decide between “Any” policy methods vs per-record authorization.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/users/overview
|
||||
- https://filamentphp.com/docs/5.x/resources/deleting-records
|
||||
|
||||
## 11) Notifications & UX feedback
|
||||
- Default to explicit success/error notifications for user-triggered mutations that aren’t instantly obvious.
|
||||
- Treat polling as a cost; set intervals intentionally where polling is used.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/notifications/overview
|
||||
- https://filamentphp.com/docs/5.x/widgets/stats-overview
|
||||
|
||||
## 12) Performance defaults
|
||||
- Heavy assets: prefer on-demand loading (`loadedOnRequest()` + `x-load-css` / `x-load-js`) for heavy dependencies.
|
||||
- Styling overrides use CSS hook classes; layout injection uses render hooks; avoid view publishing.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
||||
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||
|
||||
## 13) Testing requirements
|
||||
- Test pages/relation managers/widgets as Livewire components.
|
||||
- Test actions using Filament’s action testing guidance.
|
||||
- Do not mount non-Livewire classes in Livewire tests.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/testing/overview
|
||||
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
||||
|
||||
## 14) Forbidden patterns
|
||||
- Mixing Filament v3/v4 APIs into v5 code.
|
||||
- Any mention of Livewire v3 for Filament v5.
|
||||
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
|
||||
- Destructive actions without `->requiresConfirmation()`.
|
||||
- Shipping heavy assets globally when on-demand loading fits.
|
||||
- Publishing Filament internal views as a default customization technique.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||
- https://filamentphp.com/docs/5.x/actions/modals
|
||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||
|
||||
## 15) Agent output contract
|
||||
For any implementation request, the agent must explicitly state:
|
||||
1) Livewire v4.0+ compliance.
|
||||
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
|
||||
3) For each globally searchable resource: whether it has Edit/View page (or global search is disabled).
|
||||
4) Which actions are destructive and how confirmation + authorization is handled.
|
||||
5) Asset strategy: global vs on-demand and where `filament:assets` runs in deploy.
|
||||
6) Testing plan: which pages/widgets/relation managers/actions are covered.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
||||
79
.ai/guidelines/filament-v5-checklist.md
Normal file
79
.ai/guidelines/filament-v5-checklist.md
Normal file
@ -0,0 +1,79 @@
|
||||
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
|
||||
|
||||
## Version Safety
|
||||
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
|
||||
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “Upgrading Livewire”
|
||||
- [ ] All references are Filament `/docs/5.x/` only (no v3/v4 docs, no legacy APIs).
|
||||
- [ ] Upgrade assumptions match the v5 upgrade guide requirements and steps.
|
||||
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
|
||||
|
||||
## Panel & Navigation
|
||||
- [ ] Laravel 11+: panel providers are registered in `bootstrap/providers.php` (not `bootstrap/app.php`).
|
||||
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Creating a new panel”
|
||||
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
|
||||
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Changing the path”
|
||||
- [ ] Cluster usage is correctly configured (discovery + `$cluster` assignments).
|
||||
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Creating a cluster”
|
||||
- [ ] Cluster semantics (sub-navigation + grouped navigation behavior) are understood and verified against the clusters docs.
|
||||
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Introduction”
|
||||
- [ ] Cluster directory structure is treated as recommended, not mandatory.
|
||||
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Code structure recommendations for panels using clusters”
|
||||
- [ ] User menu items are registered via `userMenuItems()` and permission-gated where needed.
|
||||
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
|
||||
|
||||
## Resource Structure
|
||||
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/overview — “Record titles”
|
||||
- [ ] Hard rule enforced: every globally searchable resource has an Edit or View page; otherwise global search is disabled for it.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Setting global search result titles”
|
||||
- [ ] Relationship-backed global search details are eager-loaded via the global search query override.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
|
||||
|
||||
## Infolists & Relations
|
||||
- [ ] Each relationship uses the correct tool (RelationManager vs Select/CheckboxList vs Repeater) based on required interaction.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Choosing the right tool for the job”
|
||||
- [ ] RelationManagers remain lazy-loaded by default unless there’s an explicit UX justification.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading”
|
||||
|
||||
## Forms
|
||||
- [ ] Server-driven reactivity is minimal; chatty inputs do not trigger network requests unnecessarily.
|
||||
- Source: https://filamentphp.com/docs/5.x/forms/overview — “Reactive fields on blur”
|
||||
- [ ] Custom field views obey state binding modifiers (no hardcoded `wire:model` without modifiers).
|
||||
- Source: https://filamentphp.com/docs/5.x/forms/custom-fields — “Obeying state binding modifiers”
|
||||
|
||||
## Tables & Actions
|
||||
- [ ] Tables define a meaningful empty state (and empty-state actions where appropriate).
|
||||
- Source: https://filamentphp.com/docs/5.x/tables/empty-state — “Adding empty state actions”
|
||||
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
|
||||
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
||||
- [ ] No checklist rule assumes confirmation/modals for `->url(...)` actions unless verified in docs (UNVERIFIED behavior must not be asserted as fact).
|
||||
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
||||
|
||||
## Authorization & Security
|
||||
- [ ] Panel access is enforced for non-local environments as documented.
|
||||
- Source: https://filamentphp.com/docs/5.x/users/overview — “Authorizing access to the panel”
|
||||
- [ ] UI visibility is not treated as authorization; policies/access checks still enforce boundaries.
|
||||
- [ ] Bulk operations intentionally choose between “Any” policy methods vs per-record authorization where required.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
|
||||
|
||||
## UX & Notifications
|
||||
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes aren’t instantly obvious.
|
||||
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
|
||||
- [ ] Polling (widgets/notifications) is configured intentionally (interval set or disabled) to control load.
|
||||
- Source: https://filamentphp.com/docs/5.x/widgets/stats-overview — “Live updating stats (polling)”
|
||||
|
||||
## Performance
|
||||
- [ ] Heavy frontend assets are loaded on-demand using `loadedOnRequest()` + `x-load-css` / `x-load-js` where appropriate.
|
||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “Lazy loading CSS” / “Lazy loading JavaScript”
|
||||
- [ ] Styling overrides use CSS hook classes discovered via DevTools (no brittle selectors by default).
|
||||
- Source: https://filamentphp.com/docs/5.x/styling/css-hooks — “Discovering hook classes”
|
||||
|
||||
## Testing
|
||||
- [ ] Livewire tests mount Filament pages/relation managers/widgets (Livewire components), not static resource classes.
|
||||
- Source: https://filamentphp.com/docs/5.x/testing/overview — “What is a Livewire component when using Filament?”
|
||||
- [ ] Actions that mutate data are covered using Filament’s action testing guidance.
|
||||
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
||||
|
||||
## Deployment / Ops
|
||||
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
||||
4
.codex/config.toml
Normal file
4
.codex/config.toml
Normal file
@ -0,0 +1,4 @@
|
||||
[mcp_servers.laravel-boost]
|
||||
command = "vendor/bin/sail"
|
||||
args = ["artisan", "boost:mcp"]
|
||||
cwd = "/Users/ahmeddarrazi/Documents/projects/TenantAtlas"
|
||||
76
.codex/prompts/tenantpilot.audit.md
Normal file
76
.codex/prompts/tenantpilot.audit.md
Normal file
@ -0,0 +1,76 @@
|
||||
---
|
||||
description: Scan the TenantPilot repository for architecture and safety violations against the workspace-first, RBAC-first, audit-first governance model.
|
||||
---
|
||||
|
||||
You are a Senior Staff Engineer and Enterprise SaaS Architecture Auditor reviewing TenantPilot / TenantAtlas.
|
||||
|
||||
This is not a generic code review. Audit the repository against the TenantPilot audit constitution at `docs/audits/tenantpilot-architecture-audit-constitution.md`.
|
||||
|
||||
## Audit focus
|
||||
|
||||
Prioritize:
|
||||
|
||||
- workspace and tenant isolation
|
||||
- route model binding safety
|
||||
- Filament resources, pages, relation managers, widgets, and actions
|
||||
- Livewire public properties and serialized state risks
|
||||
- jobs, queue boundaries, and backend authorization rechecks
|
||||
- provider access boundaries
|
||||
- `OperationRun` consistency
|
||||
- findings, exceptions, review, drift, and baseline workflow integrity
|
||||
- audit trail completeness
|
||||
- wrong-tenant regression coverage
|
||||
- unauthorized action coverage
|
||||
- workflow misuse and invalid transition coverage
|
||||
|
||||
## Output rules
|
||||
|
||||
Classify every finding as exactly one of:
|
||||
|
||||
- Constitutional Violation
|
||||
- Architectural Drift
|
||||
- Workflow Trust Gap
|
||||
- Test Blind Spot
|
||||
|
||||
Assign one severity:
|
||||
|
||||
- Severity 1: Critical
|
||||
- Severity 2: High
|
||||
- Severity 3: Medium
|
||||
- Severity 4: Low
|
||||
|
||||
Anything directly touching isolation, RBAC, secrets, or auditability must not be rated Low.
|
||||
|
||||
For each finding provide:
|
||||
|
||||
1. Title
|
||||
2. Classification
|
||||
3. Severity
|
||||
4. Affected Area
|
||||
5. Evidence with specific files, classes, methods, routes, or test gaps
|
||||
6. Why this matters in TenantPilot
|
||||
7. Recommended structural correction
|
||||
8. Delivery recommendation: `hotfix`, `follow-up refactor`, or `dedicated spec required`
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not praise the codebase.
|
||||
- Do not focus on style unless it affects architecture or safety.
|
||||
- Do not suggest random patterns without proving fit.
|
||||
- Group multiple symptoms under one deeper diagnosis when appropriate.
|
||||
- Be explicit when a local fix is insufficient and a dedicated spec is required.
|
||||
|
||||
## Repository context
|
||||
|
||||
TenantPilot is an enterprise SaaS product for Intune and Microsoft 365 governance, backup, restore, inventory, drift detection, findings, exceptions, operations, and auditability.
|
||||
|
||||
The strategic priorities are:
|
||||
|
||||
- workspace-first context modeling
|
||||
- capability-first RBAC
|
||||
- strong auditability
|
||||
- deterministic workflow semantics
|
||||
- provider access through canonical boundaries
|
||||
- minimal duplication of domain logic across UI surfaces
|
||||
|
||||
Return the audit as a concise but substantive findings report.
|
||||
104
.codex/prompts/tenantpilot.spec-candidates.md
Normal file
104
.codex/prompts/tenantpilot.spec-candidates.md
Normal file
@ -0,0 +1,104 @@
|
||||
---
|
||||
description: Turn TenantPilot architecture audit findings into bounded spec candidates without colliding with active spec numbering.
|
||||
---
|
||||
|
||||
You are a Senior Staff Engineer and Enterprise SaaS Architect working on TenantPilot / TenantAtlas.
|
||||
|
||||
Your task is to produce spec candidates, not implementation code.
|
||||
|
||||
Before writing anything, read and use these repository files as binding context:
|
||||
|
||||
- `docs/audits/tenantpilot-architecture-audit-constitution.md`
|
||||
- `docs/audits/2026-03-15-audit-spec-candidates.md`
|
||||
- `specs/110-ops-ux-enforcement/spec.md`
|
||||
- `specs/111-findings-workflow-sla/spec.md`
|
||||
- `specs/134-audit-log-foundation/spec.md`
|
||||
- `specs/138-managed-tenant-onboarding-draft-identity/spec.md`
|
||||
|
||||
## Goal
|
||||
|
||||
Turn the existing audit-derived problem clusters into exactly four proposed follow-up spec candidates.
|
||||
|
||||
The four candidate themes are:
|
||||
|
||||
1. queued execution reauthorization and scope continuity
|
||||
2. tenant-owned query canon and wrong-tenant guards
|
||||
3. findings workflow enforcement and audit backstop
|
||||
4. Livewire context locking and trusted-state reduction
|
||||
|
||||
## Numbering rule
|
||||
|
||||
- Do not invent or reserve fixed spec numbers unless the current repository state proves they are available.
|
||||
- If numbering is uncertain, use `Candidate A`, `Candidate B`, `Candidate C`, and `Candidate D`.
|
||||
- Only recommend a numbering strategy; do not force numbering in the output when collisions are possible.
|
||||
|
||||
## Output requirements
|
||||
|
||||
Create exactly four spec candidates, one per problem class.
|
||||
|
||||
For each candidate provide:
|
||||
|
||||
1. Candidate label or confirmed spec number
|
||||
2. Working title
|
||||
3. Status: `Proposed`
|
||||
4. Summary
|
||||
5. Why this is needed now
|
||||
6. Boundary to existing specs
|
||||
7. Problem statement
|
||||
8. Goals
|
||||
9. Non-goals
|
||||
10. Scope
|
||||
11. Target model
|
||||
12. Key requirements
|
||||
13. Risks if not implemented
|
||||
14. Dependencies and sequencing notes
|
||||
15. Delivery recommendation: `hotfix`, `dedicated spec`, or `phased spec`
|
||||
16. Suggested implementation priority: `Critical`, `High`, or `Medium`
|
||||
17. Suggested slug
|
||||
|
||||
At the end provide:
|
||||
|
||||
A. Recommended implementation order
|
||||
B. Which candidates can run in parallel
|
||||
C. Which candidate should start first and why
|
||||
D. A numbering strategy recommendation if active spec numbers are not yet known
|
||||
|
||||
## Writing rules
|
||||
|
||||
- Write in English.
|
||||
- Use formal enterprise spec language.
|
||||
- Be concrete and opinionated.
|
||||
- Focus on structural integrity, not patch-level fixes.
|
||||
- Treat the audit constitution as binding.
|
||||
- Explicitly say when UI-only authorization is insufficient.
|
||||
- Explicitly say when Livewire public state must be treated as untrusted input.
|
||||
- Explicitly say when negative-path regression tests are required.
|
||||
- Explicitly say when `OperationRun` or audit semantics must be extended or hardened.
|
||||
- Do not duplicate adjacent specs; state the boundary clearly.
|
||||
- Do not collapse all four themes into one umbrella spec.
|
||||
|
||||
## Candidate-specific direction
|
||||
|
||||
### Candidate A — queued execution reauthorization and scope continuity
|
||||
|
||||
- Treat this as an execution trust problem, not a simple `authorize()` omission.
|
||||
- Cover dispatch-time actor and context capture, handle-time scope revalidation, capability reauthorization, execution denial semantics, and audit visibility.
|
||||
- Define what happens when authorization or tenant operability changes between dispatch and execution.
|
||||
|
||||
### Candidate B — tenant-owned query canon and wrong-tenant guards
|
||||
|
||||
- Treat this as canonical data-access hardening.
|
||||
- Cover tenant-owned and workspace-owned query rules, route model binding safety, canonical query paths, anti-pattern elimination, and required wrong-tenant regression tests.
|
||||
- Focus on ownership enforcement, not generic repository-pattern advice.
|
||||
|
||||
### Candidate C — findings workflow enforcement and audit backstop
|
||||
|
||||
- Treat this as a workflow-truth problem.
|
||||
- Cover formal lifecycle enforcement, invalid transition prevention, reopen and recurrence semantics, and audit backstop requirements.
|
||||
- Make clear how this extends but does not duplicate Spec 111.
|
||||
|
||||
### Candidate D — Livewire context locking and trusted-state reduction
|
||||
|
||||
- Treat this as a UI/server trust-boundary hardening problem.
|
||||
- Cover locked identifiers, untrusted public state, server-side reconstruction of workflow truth, sensitive-state reduction, and misuse regression tests.
|
||||
- Make clear how this complements but does not duplicate Spec 138.
|
||||
@ -1,14 +1,27 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
vendor/
|
||||
coverage/
|
||||
.git/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
*.log*
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
Dockerfile*
|
||||
.dockerignore
|
||||
*.tmp
|
||||
*.swp
|
||||
public/build/
|
||||
public/hot/
|
||||
public/storage/
|
||||
storage/framework/
|
||||
storage/logs/
|
||||
storage/debugbar/
|
||||
storage/*.key
|
||||
/references/
|
||||
|
||||
17
.env.example
17
.env.example
@ -63,3 +63,20 @@ AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# Entra ID (OIDC) - Tenant Admin (/admin) sign-in
|
||||
ENTRA_CLIENT_ID=
|
||||
ENTRA_CLIENT_SECRET=
|
||||
ENTRA_REDIRECT_URI="${APP_URL}/auth/entra/callback"
|
||||
ENTRA_AUTHORITY_TENANT=organizations
|
||||
|
||||
# System panel break-glass (Platform Operators)
|
||||
BREAK_GLASS_ENABLED=false
|
||||
BREAK_GLASS_TTL_MINUTES=60
|
||||
|
||||
# Baselines (Spec 118: full-content drift detection)
|
||||
TENANTPILOT_BASELINE_FULL_CONTENT_CAPTURE_ENABLED=false
|
||||
TENANTPILOT_BASELINE_EVIDENCE_MAX_ITEMS_PER_RUN=200
|
||||
TENANTPILOT_BASELINE_EVIDENCE_MAX_CONCURRENCY=5
|
||||
TENANTPILOT_BASELINE_EVIDENCE_MAX_RETRIES=3
|
||||
TENANTPILOT_BASELINE_EVIDENCE_RETENTION_DAYS=90
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
{
|
||||
"general": {
|
||||
"previewFeatures": false
|
||||
}
|
||||
"general": {
|
||||
"previewFeatures": false
|
||||
},
|
||||
"mcpServers": {
|
||||
"laravel-boost": {
|
||||
"command": "vendor/bin/sail",
|
||||
"args": [
|
||||
"artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
99
.github/agents/copilot-instructions.md
vendored
99
.github/agents/copilot-instructions.md
vendored
@ -5,6 +5,98 @@ # TenantAtlas Development Guidelines
|
||||
## Active Technologies
|
||||
- PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations)
|
||||
- PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations)
|
||||
- PostgreSQL (Sail locally) (feat/032-backup-scheduling-mvp)
|
||||
- PHP 8.4.x + Laravel 12, Filament v4, Livewire v3 (feat/042-inventory-dependencies-graph)
|
||||
- PostgreSQL (JSONB) (feat/042-inventory-dependencies-graph)
|
||||
- PHP 8.4.x (Laravel 12) + Laravel 12, Filament v4, Livewire v3 (feat/047-inventory-foundations-nodes)
|
||||
- PostgreSQL (JSONB for `InventoryItem.meta_jsonb`) (feat/047-inventory-foundations-nodes)
|
||||
- PostgreSQL (JSONB in `operation_runs.context`, `operation_runs.summary_counts`) (056-remove-legacy-bulkops)
|
||||
- PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish)
|
||||
- PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (067-rbac-troubleshooting)
|
||||
- PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting)
|
||||
- PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x (073-unified-managed-tenant-onboarding-wizard)
|
||||
- PostgreSQL (Sail) + SQLite in tests where applicable (073-unified-managed-tenant-onboarding-wizard)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Filament Infolists (schema-based) (078-operations-tenantless-canonical)
|
||||
- PostgreSQL (no new migrations — read-only model changes) (078-operations-tenantless-canonical)
|
||||
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4 (080-workspace-managed-tenant-admin)
|
||||
- PostgreSQL (via Sail) (080-workspace-managed-tenant-admin)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5 (081-provider-connection-cutover)
|
||||
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 (082-action-surface-contract)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface` (084-verification-surfaces-unification)
|
||||
- PostgreSQL (JSONB-backed `OperationRun.context`) (084-verification-surfaces-unification)
|
||||
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail (085-tenant-operate-hub)
|
||||
- PostgreSQL (primary) + session (workspace context + last-tenant memory) (085-tenant-operate-hub)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 (085-tenant-operate-hub)
|
||||
- PostgreSQL (Sail), SQLite in tests (087-legacy-runs-removal)
|
||||
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface` (095-graph-contracts-registry-completeness)
|
||||
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Laravel Queue, Laravel Notifications (100-alert-target-test-actions)
|
||||
- PostgreSQL (Sail locally); SQLite is used in some tests (101-golden-master-baseline-governance-v1)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, `OperateHubShell` support class (103-ia-scope-filter-semantics)
|
||||
- PostgreSQL — no schema changes (103-ia-scope-filter-semantics)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Pest v4 (104-provider-permission-posture)
|
||||
- PostgreSQL (via Sail), JSONB for stored report payloads and finding evidence (104-provider-permission-posture)
|
||||
- PHP 8.4 / Laravel 12 + Filament v5, Livewire v4, Tailwind CSS v4 (107-workspace-chooser)
|
||||
- PostgreSQL (existing tables: `workspaces`, `workspace_memberships`, `users`, `audit_logs`) (107-workspace-chooser)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Framework v12 (109-review-pack-export)
|
||||
- PostgreSQL (jsonb columns for summary/options), local filesystem (`exports` disk) for ZIP artifacts (109-review-pack-export)
|
||||
- PHP 8.4 + Laravel 12, Filament v5, Livewire v4 (116-baseline-drift-engine)
|
||||
- PHP 8.4, Laravel 12, Filament v5, Livewire v4 + Laravel framework, Filament admin panels, Livewire, PostgreSQL JSONB persistence, Laravel Sail (120-secret-redaction-integrity)
|
||||
- PostgreSQL (`policy_versions`, `operation_runs`, `audit_logs`, related evidence tables) (120-secret-redaction-integrity)
|
||||
- PHP 8.4.15 / Laravel 12 + Filament v5 + Livewire v4.0+ + Tailwind CSS v4 (121-workspace-switch-fix)
|
||||
- PostgreSQL + session-backed workspace context; no schema changes (121-workspace-switch-fix)
|
||||
- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4 (122-empty-state-consistency)
|
||||
- PostgreSQL + existing workspace/tenant session context; no schema changes (122-empty-state-consistency)
|
||||
- PHP 8.4 runtime target on Laravel 12 code conventions; Composer constraint `php:^8.2` + Laravel 12, Filament v5.2.1, Livewire v4, Pest v4, Laravel Sail (123-operations-auto-refresh)
|
||||
- PostgreSQL primary app database (123-operations-auto-refresh)
|
||||
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, existing `CoverageCapabilitiesResolver`, `InventoryPolicyTypeMeta`, `BadgeCatalog`, and `TagBadgeCatalog` (124-inventory-coverage-table)
|
||||
- N/A for this feature; page remains read-only and uses registry/config-derived runtime data while PostgreSQL remains unchanged (124-inventory-coverage-table)
|
||||
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, existing `BadgeCatalog` / `BadgeRenderer`, existing UI enforcement helpers, existing Filament resources, relation managers, widgets, and Livewire table components (125-table-ux-standardization)
|
||||
- PostgreSQL remains unchanged; this feature is presentation-layer and behavior-layer only (125-table-ux-standardization)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer`, existing `TagBadgeCatalog` / `TagBadgeRenderer`, existing Filament resource tables (126-filter-ux-standardization)
|
||||
- PostgreSQL remains unchanged; session persistence uses Filament-native session keys and existing workspace/tenant contex (126-filter-ux-standardization)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Microsoft Graph provider stack (127-rbac-inventory-backup)
|
||||
- PostgreSQL for tenant-owned inventory, backup items, versions, verification outcomes, and operation runs (127-rbac-inventory-backup)
|
||||
- PostgreSQL via Laravel Sail (128-rbac-baseline-compare)
|
||||
- PostgreSQL via Laravel Sail plus session-backed workspace and tenant contex (129-workspace-admin-home)
|
||||
- PostgreSQL via Laravel Sail using existing `baseline_snapshots`, `baseline_snapshot_items`, and JSONB presentation source fields (130-structured-snapshot-rendering)
|
||||
- PostgreSQL via Laravel Sail, plus existing session-backed workspace and tenant contex (131-cross-resource-navigation)
|
||||
- PostgreSQL via Laravel Sail plus existing workspace and tenant context, existing Eloquent relations, and provider-derived identifiers already stored in domain records (132-guid-context-resolver)
|
||||
- PostgreSQL via Laravel Sail; no schema change expected (133-detail-page-template)
|
||||
- PostgreSQL via Laravel Sail; existing `audit_logs` table expanded in place; JSON context payload remains application-shaped rather than raw archival payloads (134-audit-log-foundation)
|
||||
- PHP 8.4 on Laravel 12 + Filament v5, Livewire v4, Pest v4, Laravel Sail (135-canonical-tenant-context-resolution)
|
||||
- PostgreSQL application database (135-canonical-tenant-context-resolution)
|
||||
- PostgreSQL application database and session-backed Filament table state (136-admin-canonical-tenant)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Laravel Sail, Pest v4, PHPUnit v12 (137-platform-provider-identity)
|
||||
- PostgreSQL via Laravel migrations and encrypted model casts (137-platform-provider-identity)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Laravel Blade, existing onboarding/verification support classes (139-verify-access-permissions-assist)
|
||||
- PostgreSQL; existing JSON-backed onboarding draft state and `OperationRun.context.verification_report` (139-verify-access-permissions-assist)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4, existing `OperationRunService`, `ProviderOperationStartGate`, onboarding services, workspace audit logging (140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp)
|
||||
- PostgreSQL tables including `managed_tenant_onboarding_sessions`, `operation_runs`, `tenants`, and provider-connection-backed tenant records (140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp)
|
||||
- PostgreSQL via Laravel Sail for existing source records and JSON payloads; no new persistence introduced (141-shared-diff-presentation-foundation)
|
||||
- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4, shared `App\Support\Diff` foundation from Spec 141 (142-rbac-role-definition-diff-ux-upgrade)
|
||||
- PostgreSQL via Laravel Sail for existing `findings.evidence_jsonb`; no schema or persistence changes (142-rbac-role-definition-diff-ux-upgrade)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4 (143-tenant-lifecycle-operability-context-semantics)
|
||||
- PostgreSQL via Laravel Eloquent models and workspace/tenant scoped tables (143-tenant-lifecycle-operability-context-semantics)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Gates and Policies, `OperateHubShell`, `OperationRunLinks` (144-canonical-operation-viewer-context-decoupling)
|
||||
- PostgreSQL plus session-backed workspace and remembered tenant context (no schema changes) (144-canonical-operation-viewer-context-decoupling)
|
||||
- PHP 8.4.15 with Laravel 12, Filament v5, Livewire v4.0+ + Filament Actions/Tables/Infolists, Laravel Gates/Policies, `UiEnforcement`, `WorkspaceUiEnforcement`, `ActionSurfaceDeclaration`, `BadgeCatalog`, `TenantOperabilityService`, `OnboardingLifecycleService` (145-tenant-action-taxonomy-lifecycle-safe-visibility)
|
||||
- PostgreSQL for tenants, onboarding sessions, audit logs, operation runs, and workspace membership data (145-tenant-action-taxonomy-lifecycle-safe-visibility)
|
||||
- PostgreSQL (existing tenant and operation records only; no schema changes planned) (146-central-tenant-status-presentation)
|
||||
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned (147-tenant-selector-remembered-context-enforcement)
|
||||
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing support-layer helpers such as `UiEnforcement`, `CapabilityResolver`, `WorkspaceContext`, `OperateHubShell`, `TenantOperabilityService`, and `TenantActionPolicySurface` (148-central-tenant-operability-policy)
|
||||
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned for the first implementation slice (148-central-tenant-operability-policy)
|
||||
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing `OperationRunService`, `TrackOperationRun`, `ProviderOperationStartGate`, `TenantOperabilityService`, `CapabilityResolver`, and `WriteGateInterface` seams (149-queued-execution-reauthorization)
|
||||
- PostgreSQL-backed application data plus queue-serialized `OperationRun` context; no schema migration planned for the first implementation slice (149-queued-execution-reauthorization)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Pest 4 (150-tenant-owned-query-canon-and-wrong-tenant-guards)
|
||||
- PostgreSQL with existing `findings` and `audit_logs` tables; no new storage engine or external log store (151-findings-workflow-backstop)
|
||||
- PostgreSQL with existing workspace-, tenant-, onboarding-, and audit-related tables; no new persistent storage planned for the first slice (152-livewire-context-locking)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `StoredReport`, `Finding`, `OperationRun`, and `AuditLog` infrastructure (153-evidence-domain-foundation)
|
||||
- PostgreSQL with JSONB-backed snapshot metadata; existing private storage remains a downstream-consumer concern, not a primary evidence-foundation store (153-evidence-domain-foundation)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns (001-finding-risk-acceptance)
|
||||
- PostgreSQL with new tenant-owned exception tables and JSONB-backed supporting metadata (001-finding-risk-acceptance)
|
||||
- PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService` (155-tenant-review-layer)
|
||||
- PostgreSQL with JSONB-backed summary payloads and tenant/workspace ownership columns (155-tenant-review-layer)
|
||||
- PostgreSQL-backed existing domain records; no new business-domain table is required for the first slice; shared taxonomy reference will live in repository documentation and code-level metadata (156-operator-outcome-taxonomy)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -24,9 +116,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- feat/005-bulk-operations: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3
|
||||
|
||||
- feat/005-bulk-operations: Added PHP 8.4.15
|
||||
|
||||
- 156-operator-outcome-taxonomy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
|
||||
- 155-tenant-review-layer: Added PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService`
|
||||
- 001-finding-risk-acceptance: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
669
.github/copilot-instructions.md
vendored
Normal file
669
.github/copilot-instructions.md
vendored
Normal file
@ -0,0 +1,669 @@
|
||||
<laravel-boost-guidelines>
|
||||
=== .ai/filament-v5-blueprint rules ===
|
||||
|
||||
## Source of Truth
|
||||
If any Filament behavior is uncertain, lookup the exact section in:
|
||||
- docs/research/filament-v5-notes.md
|
||||
and prefer that over guesses.
|
||||
|
||||
# SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES)
|
||||
|
||||
# Filament Blueprint (v5)
|
||||
|
||||
## 1) Non-negotiables
|
||||
- Filament v5 requires Livewire v4.0+.
|
||||
- Laravel 11+: register panel providers in `bootstrap/providers.php` (never `bootstrap/app.php`).
|
||||
- Global search hard rule: If a Resource should appear in Global Search, it must have an Edit or View page; otherwise it will return no results.
|
||||
- Destructive actions must execute via `Action::make(...)->action(...)` and include `->requiresConfirmation()` (no exceptions).
|
||||
- Prefer render hooks + CSS hook classes over publishing Filament internal views.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||
- https://filamentphp.com/docs/5.x/actions/modals
|
||||
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
||||
|
||||
## 2) Directory & naming conventions
|
||||
- Default to Filament discovery conventions for Resources/Pages/Widgets unless you adopt modular architecture.
|
||||
- Clusters: directory layout is recommended, not mandatory; functional behavior depends on `$cluster`.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/navigation/clusters
|
||||
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
|
||||
|
||||
## 3) Panel setup defaults
|
||||
- Default to a single `/admin` panel unless multiple audiences/configs demand multiple panels.
|
||||
- Verify provider registration (Laravel 11+: `bootstrap/providers.php`) when adding a panel.
|
||||
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
|
||||
- Assets policy:
|
||||
- Panel-only assets: register via panel config.
|
||||
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||
- Deployment must include `php artisan filament:assets`.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||
|
||||
## 4) Navigation & information architecture
|
||||
- Use nav groups + sort order intentionally; apply conditional visibility for clarity, but enforce authorization separately.
|
||||
- Use clusters to introduce hierarchy and sub-navigation when sidebar complexity grows.
|
||||
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
|
||||
- User menu:
|
||||
- Configure via `userMenuItems()` with Action objects.
|
||||
- Never put destructive actions there without confirmation + authorization.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/navigation/overview
|
||||
- https://filamentphp.com/docs/5.x/navigation/clusters
|
||||
- https://filamentphp.com/docs/5.x/navigation/user-menu
|
||||
|
||||
## 5) Resource patterns
|
||||
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
|
||||
- Global search:
|
||||
- If a resource is intended for global search: ensure Edit/View page exists.
|
||||
- Otherwise disable global search for that resource (don’t “expect it to work”).
|
||||
- If global search renders relationship-backed details: eager-load via global search query override.
|
||||
- For very large datasets: consider disabling term splitting (only when needed).
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/resources/overview
|
||||
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||
|
||||
## 6) Page lifecycle & query rules
|
||||
- Treat relationship-backed rendering in aggregate contexts (global search details, list summaries) as requiring eager loading.
|
||||
- Prefer render hooks for layout injection; avoid publishing internal views.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||
|
||||
## 7) Infolists vs RelationManagers (decision tree)
|
||||
- Interactive CRUD / attach / detach under owner record → RelationManager.
|
||||
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
|
||||
- Inline CRUD inside owner form → Repeater.
|
||||
- Default performance stance: RelationManagers stay lazy-loaded unless explicit UX justification exists.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/resources/managing-relationships
|
||||
- https://filamentphp.com/docs/5.x/infolists/overview
|
||||
|
||||
## 8) Form patterns (validation, reactivity, state)
|
||||
- Default: minimize server-driven reactivity; only use it when schema/visibility/requirements must change server-side.
|
||||
- Prefer “on blur” semantics for chatty inputs when using reactive behavior (per docs patterns).
|
||||
- Custom field views must obey state binding modifiers.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/forms/overview
|
||||
- https://filamentphp.com/docs/5.x/forms/custom-fields
|
||||
|
||||
## 9) Table & action patterns
|
||||
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
|
||||
- Actions:
|
||||
- Execution actions use `->action(...)`.
|
||||
- Destructive actions add `->requiresConfirmation()`.
|
||||
- Navigation-only actions should use `->url(...)`.
|
||||
- UNVERIFIED: do not assert modal/confirmation behavior for URL-only actions unless verified.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/tables/empty-state
|
||||
- https://filamentphp.com/docs/5.x/actions/modals
|
||||
|
||||
## 10) Authorization & security
|
||||
- Enforce panel access in non-local environments as documented.
|
||||
- UI visibility is not security; enforce policies/access checks in addition to hiding UI.
|
||||
- Bulk operations: explicitly decide between “Any” policy methods vs per-record authorization.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/users/overview
|
||||
- https://filamentphp.com/docs/5.x/resources/deleting-records
|
||||
|
||||
## 11) Notifications & UX feedback
|
||||
- Default to explicit success/error notifications for user-triggered mutations that aren’t instantly obvious.
|
||||
- Treat polling as a cost; set intervals intentionally where polling is used.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/notifications/overview
|
||||
- https://filamentphp.com/docs/5.x/widgets/stats-overview
|
||||
|
||||
## 12) Performance defaults
|
||||
- Heavy assets: prefer on-demand loading (`loadedOnRequest()` + `x-load-css` / `x-load-js`) for heavy dependencies.
|
||||
- Styling overrides use CSS hook classes; layout injection uses render hooks; avoid view publishing.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
||||
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||
|
||||
## 13) Testing requirements
|
||||
- Test pages/relation managers/widgets as Livewire components.
|
||||
- Test actions using Filament’s action testing guidance.
|
||||
- Do not mount non-Livewire classes in Livewire tests.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/testing/overview
|
||||
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
||||
|
||||
## 14) Forbidden patterns
|
||||
- Mixing Filament v3/v4 APIs into v5 code.
|
||||
- Any mention of Livewire v3 for Filament v5.
|
||||
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
|
||||
- Destructive actions without `->requiresConfirmation()`.
|
||||
- Shipping heavy assets globally when on-demand loading fits.
|
||||
- Publishing Filament internal views as a default customization technique.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||
- https://filamentphp.com/docs/5.x/actions/modals
|
||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||
|
||||
## 15) Agent output contract
|
||||
For any implementation request, the agent must explicitly state:
|
||||
1) Livewire v4.0+ compliance.
|
||||
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
|
||||
3) For each globally searchable resource: whether it has Edit/View page (or global search is disabled).
|
||||
4) Which actions are destructive and how confirmation + authorization is handled.
|
||||
5) Asset strategy: global vs on-demand and where `filament:assets` runs in deploy.
|
||||
6) Testing plan: which pages/widgets/relation managers/actions are covered.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
||||
|
||||
=== .ai/filament-v5-checklist rules ===
|
||||
|
||||
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
|
||||
|
||||
## Version Safety
|
||||
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
|
||||
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “Upgrading Livewire”
|
||||
- [ ] All references are Filament `/docs/5.x/` only (no v3/v4 docs, no legacy APIs).
|
||||
- [ ] Upgrade assumptions match the v5 upgrade guide requirements and steps.
|
||||
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
|
||||
|
||||
## Panel & Navigation
|
||||
- [ ] Laravel 11+: panel providers are registered in `bootstrap/providers.php` (not `bootstrap/app.php`).
|
||||
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Creating a new panel”
|
||||
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
|
||||
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Changing the path”
|
||||
- [ ] Cluster usage is correctly configured (discovery + `$cluster` assignments).
|
||||
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Creating a cluster”
|
||||
- [ ] Cluster semantics (sub-navigation + grouped navigation behavior) are understood and verified against the clusters docs.
|
||||
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Introduction”
|
||||
- [ ] Cluster directory structure is treated as recommended, not mandatory.
|
||||
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Code structure recommendations for panels using clusters”
|
||||
- [ ] User menu items are registered via `userMenuItems()` and permission-gated where needed.
|
||||
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
|
||||
|
||||
## Resource Structure
|
||||
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/overview — “Record titles”
|
||||
- [ ] Hard rule enforced: every globally searchable resource has an Edit or View page; otherwise global search is disabled for it.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Setting global search result titles”
|
||||
- [ ] Relationship-backed global search details are eager-loaded via the global search query override.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
|
||||
|
||||
## Infolists & Relations
|
||||
- [ ] Each relationship uses the correct tool (RelationManager vs Select/CheckboxList vs Repeater) based on required interaction.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Choosing the right tool for the job”
|
||||
- [ ] RelationManagers remain lazy-loaded by default unless there’s an explicit UX justification.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading”
|
||||
|
||||
## Forms
|
||||
- [ ] Server-driven reactivity is minimal; chatty inputs do not trigger network requests unnecessarily.
|
||||
- Source: https://filamentphp.com/docs/5.x/forms/overview — “Reactive fields on blur”
|
||||
- [ ] Custom field views obey state binding modifiers (no hardcoded `wire:model` without modifiers).
|
||||
- Source: https://filamentphp.com/docs/5.x/forms/custom-fields — “Obeying state binding modifiers”
|
||||
|
||||
## Tables & Actions
|
||||
- [ ] Tables define a meaningful empty state (and empty-state actions where appropriate).
|
||||
- Source: https://filamentphp.com/docs/5.x/tables/empty-state — “Adding empty state actions”
|
||||
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
|
||||
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
||||
- [ ] No checklist rule assumes confirmation/modals for `->url(...)` actions unless verified in docs (UNVERIFIED behavior must not be asserted as fact).
|
||||
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
||||
|
||||
## Authorization & Security
|
||||
- [ ] Panel access is enforced for non-local environments as documented.
|
||||
- Source: https://filamentphp.com/docs/5.x/users/overview — “Authorizing access to the panel”
|
||||
- [ ] UI visibility is not treated as authorization; policies/access checks still enforce boundaries.
|
||||
- [ ] Bulk operations intentionally choose between “Any” policy methods vs per-record authorization where required.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
|
||||
|
||||
## UX & Notifications
|
||||
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes aren’t instantly obvious.
|
||||
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
|
||||
- [ ] Polling (widgets/notifications) is configured intentionally (interval set or disabled) to control load.
|
||||
- Source: https://filamentphp.com/docs/5.x/widgets/stats-overview — “Live updating stats (polling)”
|
||||
|
||||
## Performance
|
||||
- [ ] Heavy frontend assets are loaded on-demand using `loadedOnRequest()` + `x-load-css` / `x-load-js` where appropriate.
|
||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “Lazy loading CSS” / “Lazy loading JavaScript”
|
||||
- [ ] Styling overrides use CSS hook classes discovered via DevTools (no brittle selectors by default).
|
||||
- Source: https://filamentphp.com/docs/5.x/styling/css-hooks — “Discovering hook classes”
|
||||
|
||||
## Testing
|
||||
- [ ] Livewire tests mount Filament pages/relation managers/widgets (Livewire components), not static resource classes.
|
||||
- Source: https://filamentphp.com/docs/5.x/testing/overview — “What is a Livewire component when using Filament?”
|
||||
- [ ] Actions that mutate data are covered using Filament’s action testing guidance.
|
||||
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
||||
|
||||
## Deployment / Ops
|
||||
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
||||
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.15
|
||||
- filament/filament (FILAMENT) - v5
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- laravel/socialite (SOCIALITE) - v5
|
||||
- livewire/livewire (LIVEWIRE) - v4
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/sail (SAIL) - v1
|
||||
- pestphp/pest (PEST) - v4
|
||||
- phpunit/phpunit (PHPUNIT) - v12
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
|
||||
## Conventions
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
|
||||
|
||||
## Replies
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
## Documentation Files
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
## Laravel Boost
|
||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||
|
||||
## Artisan
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||
|
||||
## URLs
|
||||
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||
|
||||
## Tinker / Debugging
|
||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||
- Use the `database-query` tool when you only need to read from the database.
|
||||
|
||||
## Reading Browser Logs With the `browser-logs` Tool
|
||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||
- Only recent browser logs will be useful - ignore old logs.
|
||||
|
||||
## Searching Documentation (Critically Important)
|
||||
- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
||||
- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
|
||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||
- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
||||
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Available Search Syntax
|
||||
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||
|
||||
=== php rules ===
|
||||
|
||||
## PHP
|
||||
|
||||
- Always use curly braces for control structures, even if it has one line.
|
||||
|
||||
### Constructors
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||
|
||||
### Type Declarations
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||
protected function isAccessible(User $user, ?string $path = null): bool
|
||||
{
|
||||
...
|
||||
}
|
||||
</code-snippet>
|
||||
|
||||
## Comments
|
||||
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
|
||||
|
||||
## PHPDoc Blocks
|
||||
- Add useful array shape type definitions for arrays when appropriate.
|
||||
|
||||
## Enums
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
=== sail rules ===
|
||||
|
||||
## Laravel Sail
|
||||
|
||||
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
||||
- Open the application in the browser by running `vendor/bin/sail open`.
|
||||
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
|
||||
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
||||
- Install Composer packages: `vendor/bin/sail composer install`
|
||||
- Execute Node commands: `vendor/bin/sail npm run dev`
|
||||
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
||||
- View all available Sail commands by running `vendor/bin/sail` without arguments.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
## Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
## Do Things the Laravel Way
|
||||
|
||||
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### Database
|
||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||
- Generate code that prevents N+1 query problems by using eager loading.
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### Model Creation
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
### Controllers & Validation
|
||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||
|
||||
### Queues
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
|
||||
### Authentication & Authorization
|
||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||
|
||||
### URL Generation
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
### Configuration
|
||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||
|
||||
### Testing
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
### Vite Error
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
## Laravel 12
|
||||
|
||||
- Use the `search-docs` tool to get version-specific documentation.
|
||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||
|
||||
### Laravel 12 Structure
|
||||
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
||||
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||
- `bootstrap/providers.php` contains application specific service providers.
|
||||
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||
|
||||
### Database
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### Models
|
||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
||||
=== livewire/core rules ===
|
||||
|
||||
## Livewire
|
||||
|
||||
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
|
||||
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
|
||||
- State should live on the server, with the UI reflecting it.
|
||||
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
|
||||
|
||||
## Livewire Best Practices
|
||||
- Livewire components require a single root element.
|
||||
- Use `wire:loading` and `wire:dirty` for delightful loading states.
|
||||
- Add `wire:key` in loops:
|
||||
|
||||
```blade
|
||||
@foreach ($items as $item)
|
||||
<div wire:key="item-{{ $item->id }}">
|
||||
{{ $item->name }}
|
||||
</div>
|
||||
@endforeach
|
||||
```
|
||||
|
||||
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
|
||||
|
||||
<code-snippet name="Lifecycle Hook Examples" lang="php">
|
||||
public function mount(User $user) { $this->user = $user; }
|
||||
public function updatedSearch() { $this->resetPage(); }
|
||||
</code-snippet>
|
||||
|
||||
## Testing Livewire
|
||||
|
||||
<code-snippet name="Example Livewire Component Test" lang="php">
|
||||
Livewire::test(Counter::class)
|
||||
->assertSet('count', 0)
|
||||
->call('increment')
|
||||
->assertSet('count', 1)
|
||||
->assertSee(1)
|
||||
->assertStatus(200);
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
|
||||
$this->get('/posts/create')
|
||||
->assertSeeLivewire(CreatePost::class);
|
||||
</code-snippet>
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
## Laravel Pint Code Formatter
|
||||
|
||||
- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
### Testing
|
||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
||||
|
||||
### Pest Tests
|
||||
- All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||
- Pest tests look and behave like this:
|
||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Running Tests
|
||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
||||
- To run all tests: `vendor/bin/sail artisan test --compact`.
|
||||
- To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
||||
|
||||
### Pest Assertions
|
||||
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
|
||||
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
||||
it('returns all', function () {
|
||||
$response = $this->postJson('/api/docs', []);
|
||||
|
||||
$response->assertSuccessful();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Mocking
|
||||
- Mocking can be very helpful when appropriate.
|
||||
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
|
||||
- You can also create partial mocks using the same import or self method.
|
||||
|
||||
### Datasets
|
||||
- Use datasets in Pest to simplify tests that have a lot of duplicated data. This is often the case when testing validation rules, so consider this solution when writing tests for validation rules.
|
||||
|
||||
<code-snippet name="Pest Dataset Example" lang="php">
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
</code-snippet>
|
||||
|
||||
=== pest/v4 rules ===
|
||||
|
||||
## Pest 4
|
||||
|
||||
- Pest 4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
|
||||
- Browser testing is incredibly powerful and useful for this project.
|
||||
- Browser tests should live in `tests/Browser/`.
|
||||
- Use the `search-docs` tool for detailed guidance on utilizing these features.
|
||||
|
||||
### Browser Testing
|
||||
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest 4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
|
||||
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
|
||||
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
|
||||
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
|
||||
- Switch color schemes (light/dark mode) when appropriate.
|
||||
- Take screenshots or pause tests for debugging when appropriate.
|
||||
|
||||
### Example Tests
|
||||
|
||||
<code-snippet name="Pest Browser Test Example" lang="php">
|
||||
it('may reset the password', function () {
|
||||
Notification::fake();
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = visit('/sign-in'); // Visit on a real browser...
|
||||
|
||||
$page->assertSee('Sign In')
|
||||
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
|
||||
->click('Forgot Password?')
|
||||
->fill('email', 'nuno@laravel.com')
|
||||
->click('Send Reset Link')
|
||||
->assertSee('We have emailed your password reset link!')
|
||||
|
||||
Notification::assertSent(ResetPassword::class);
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Pest Smoke Testing Example" lang="php">
|
||||
$pages = visit(['/', '/about', '/contact']);
|
||||
|
||||
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
||||
</code-snippet>
|
||||
|
||||
=== tailwindcss/core rules ===
|
||||
|
||||
## Tailwind CSS
|
||||
|
||||
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
|
||||
- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
|
||||
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
||||
|
||||
### Spacing
|
||||
- When listing items, use gap utilities for spacing; don't use margins.
|
||||
|
||||
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
||||
<div class="flex gap-8">
|
||||
<div>Superior</div>
|
||||
<div>Michigan</div>
|
||||
<div>Erie</div>
|
||||
</div>
|
||||
</code-snippet>
|
||||
|
||||
### Dark Mode
|
||||
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
|
||||
|
||||
=== tailwindcss/v4 rules ===
|
||||
|
||||
## Tailwind CSS 4
|
||||
|
||||
- Always use Tailwind CSS v4; do not use the deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
|
||||
|
||||
<code-snippet name="Extending Theme in CSS" lang="css">
|
||||
@theme {
|
||||
--color-brand: oklch(0.72 0.11 178);
|
||||
}
|
||||
</code-snippet>
|
||||
|
||||
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
|
||||
|
||||
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
</code-snippet>
|
||||
|
||||
### Replaced Utilities
|
||||
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
|
||||
- Opacity values are still 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 |
|
||||
</laravel-boost-guidelines>
|
||||
76
.github/prompts/tenantpilot.audit.prompt.md
vendored
Normal file
76
.github/prompts/tenantpilot.audit.prompt.md
vendored
Normal file
@ -0,0 +1,76 @@
|
||||
---
|
||||
description: Scan the TenantPilot repository for architecture and safety violations against the workspace-first, RBAC-first, audit-first governance model.
|
||||
---
|
||||
|
||||
You are a Senior Staff Engineer and Enterprise SaaS Architecture Auditor reviewing TenantPilot / TenantAtlas.
|
||||
|
||||
This is not a generic code review. Audit the repository against the TenantPilot audit constitution at `docs/audits/tenantpilot-architecture-audit-constitution.md`.
|
||||
|
||||
## Audit focus
|
||||
|
||||
Prioritize:
|
||||
|
||||
- workspace and tenant isolation
|
||||
- route model binding safety
|
||||
- Filament resources, pages, relation managers, widgets, and actions
|
||||
- Livewire public properties and serialized state risks
|
||||
- jobs, queue boundaries, and backend authorization rechecks
|
||||
- provider access boundaries
|
||||
- `OperationRun` consistency
|
||||
- findings, exceptions, review, drift, and baseline workflow integrity
|
||||
- audit trail completeness
|
||||
- wrong-tenant regression coverage
|
||||
- unauthorized action coverage
|
||||
- workflow misuse and invalid transition coverage
|
||||
|
||||
## Output rules
|
||||
|
||||
Classify every finding as exactly one of:
|
||||
|
||||
- Constitutional Violation
|
||||
- Architectural Drift
|
||||
- Workflow Trust Gap
|
||||
- Test Blind Spot
|
||||
|
||||
Assign one severity:
|
||||
|
||||
- Severity 1: Critical
|
||||
- Severity 2: High
|
||||
- Severity 3: Medium
|
||||
- Severity 4: Low
|
||||
|
||||
Anything directly touching isolation, RBAC, secrets, or auditability must not be rated Low.
|
||||
|
||||
For each finding provide:
|
||||
|
||||
1. Title
|
||||
2. Classification
|
||||
3. Severity
|
||||
4. Affected Area
|
||||
5. Evidence with specific files, classes, methods, routes, or test gaps
|
||||
6. Why this matters in TenantPilot
|
||||
7. Recommended structural correction
|
||||
8. Delivery recommendation: `hotfix`, `follow-up refactor`, or `dedicated spec required`
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not praise the codebase.
|
||||
- Do not focus on style unless it affects architecture or safety.
|
||||
- Do not suggest random patterns without proving fit.
|
||||
- Group multiple symptoms under one deeper diagnosis when appropriate.
|
||||
- Be explicit when a local fix is insufficient and a dedicated spec is required.
|
||||
|
||||
## Repository context
|
||||
|
||||
TenantPilot is an enterprise SaaS product for Intune and Microsoft 365 governance, backup, restore, inventory, drift detection, findings, exceptions, operations, and auditability.
|
||||
|
||||
The strategic priorities are:
|
||||
|
||||
- workspace-first context modeling
|
||||
- capability-first RBAC
|
||||
- strong auditability
|
||||
- deterministic workflow semantics
|
||||
- provider access through canonical boundaries
|
||||
- minimal duplication of domain logic across UI surfaces
|
||||
|
||||
Return the audit as a concise but substantive findings report.
|
||||
105
.github/prompts/tenantpilot.spec-candidates.prompt.md
vendored
Normal file
105
.github/prompts/tenantpilot.spec-candidates.prompt.md
vendored
Normal file
@ -0,0 +1,105 @@
|
||||
---
|
||||
description: Turn TenantPilot architecture audit findings into bounded spec candidates without colliding with active spec numbering.
|
||||
agent: speckit.specify
|
||||
---
|
||||
|
||||
You are a Senior Staff Engineer and Enterprise SaaS Architect working on TenantPilot / TenantAtlas.
|
||||
|
||||
Your task is to produce spec candidates, not implementation code.
|
||||
|
||||
Before writing anything, read and use these repository files as binding context:
|
||||
|
||||
- `docs/audits/tenantpilot-architecture-audit-constitution.md`
|
||||
- `docs/audits/2026-03-15-audit-spec-candidates.md`
|
||||
- `specs/110-ops-ux-enforcement/spec.md`
|
||||
- `specs/111-findings-workflow-sla/spec.md`
|
||||
- `specs/134-audit-log-foundation/spec.md`
|
||||
- `specs/138-managed-tenant-onboarding-draft-identity/spec.md`
|
||||
|
||||
## Goal
|
||||
|
||||
Turn the existing audit-derived problem clusters into exactly four proposed follow-up spec candidates.
|
||||
|
||||
The four candidate themes are:
|
||||
|
||||
1. queued execution reauthorization and scope continuity
|
||||
2. tenant-owned query canon and wrong-tenant guards
|
||||
3. findings workflow enforcement and audit backstop
|
||||
4. Livewire context locking and trusted-state reduction
|
||||
|
||||
## Numbering rule
|
||||
|
||||
- Do not invent or reserve fixed spec numbers unless the current repository state proves they are available.
|
||||
- If numbering is uncertain, use `Candidate A`, `Candidate B`, `Candidate C`, and `Candidate D`.
|
||||
- Only recommend a numbering strategy; do not force numbering in the output when collisions are possible.
|
||||
|
||||
## Output requirements
|
||||
|
||||
Create exactly four spec candidates, one per problem class.
|
||||
|
||||
For each candidate provide:
|
||||
|
||||
1. Candidate label or confirmed spec number
|
||||
2. Working title
|
||||
3. Status: `Proposed`
|
||||
4. Summary
|
||||
5. Why this is needed now
|
||||
6. Boundary to existing specs
|
||||
7. Problem statement
|
||||
8. Goals
|
||||
9. Non-goals
|
||||
10. Scope
|
||||
11. Target model
|
||||
12. Key requirements
|
||||
13. Risks if not implemented
|
||||
14. Dependencies and sequencing notes
|
||||
15. Delivery recommendation: `hotfix`, `dedicated spec`, or `phased spec`
|
||||
16. Suggested implementation priority: `Critical`, `High`, or `Medium`
|
||||
17. Suggested slug
|
||||
|
||||
At the end provide:
|
||||
|
||||
A. Recommended implementation order
|
||||
B. Which candidates can run in parallel
|
||||
C. Which candidate should start first and why
|
||||
D. A numbering strategy recommendation if active spec numbers are not yet known
|
||||
|
||||
## Writing rules
|
||||
|
||||
- Write in English.
|
||||
- Use formal enterprise spec language.
|
||||
- Be concrete and opinionated.
|
||||
- Focus on structural integrity, not patch-level fixes.
|
||||
- Treat the audit constitution as binding.
|
||||
- Explicitly say when UI-only authorization is insufficient.
|
||||
- Explicitly say when Livewire public state must be treated as untrusted input.
|
||||
- Explicitly say when negative-path regression tests are required.
|
||||
- Explicitly say when `OperationRun` or audit semantics must be extended or hardened.
|
||||
- Do not duplicate adjacent specs; state the boundary clearly.
|
||||
- Do not collapse all four themes into one umbrella spec.
|
||||
|
||||
## Candidate-specific direction
|
||||
|
||||
### Candidate A — queued execution reauthorization and scope continuity
|
||||
|
||||
- Treat this as an execution trust problem, not a simple `authorize()` omission.
|
||||
- Cover dispatch-time actor and context capture, handle-time scope revalidation, capability reauthorization, execution denial semantics, and audit visibility.
|
||||
- Define what happens when authorization or tenant operability changes between dispatch and execution.
|
||||
|
||||
### Candidate B — tenant-owned query canon and wrong-tenant guards
|
||||
|
||||
- Treat this as canonical data-access hardening.
|
||||
- Cover tenant-owned and workspace-owned query rules, route model binding safety, canonical query paths, anti-pattern elimination, and required wrong-tenant regression tests.
|
||||
- Focus on ownership enforcement, not generic repository-pattern advice.
|
||||
|
||||
### Candidate C — findings workflow enforcement and audit backstop
|
||||
|
||||
- Treat this as a workflow-truth problem.
|
||||
- Cover formal lifecycle enforcement, invalid transition prevention, reopen and recurrence semantics, and audit backstop requirements.
|
||||
- Make clear how this extends but does not duplicate Spec 111.
|
||||
|
||||
### Candidate D — Livewire context locking and trusted-state reduction
|
||||
|
||||
- Treat this as a UI/server trust-boundary hardening problem.
|
||||
- Cover locked identifiers, untrusted public state, server-side reconstruction of workflow truth, sensitive-state reduction, and misuse regression tests.
|
||||
- Make clear how this complements but does not duplicate Spec 138.
|
||||
167
.github/skills/pest-testing/SKILL.md
vendored
Normal file
167
.github/skills/pest-testing/SKILL.md
vendored
Normal file
@ -0,0 +1,167 @@
|
||||
---
|
||||
name: pest-testing
|
||||
description: "Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Pest Testing 4
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate this skill when:
|
||||
|
||||
- Creating new tests (unit, feature, or browser)
|
||||
- Modifying existing tests
|
||||
- Debugging test failures
|
||||
- Working with browser testing or smoke testing
|
||||
- Writing architecture tests or visual regression tests
|
||||
|
||||
## 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}`.
|
||||
|
||||
### 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
|
||||
|
||||
<!-- Basic Pest Test Example -->
|
||||
```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()`:
|
||||
|
||||
<!-- Pest Response Assertion -->
|
||||
```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.):
|
||||
|
||||
<!-- Pest Dataset Example -->
|
||||
```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.
|
||||
|
||||
<!-- Pest Browser Test Example -->
|
||||
```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:
|
||||
|
||||
<!-- Pest Smoke Testing Example -->
|
||||
```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):
|
||||
|
||||
<!-- Architecture Test Example -->
|
||||
```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
|
||||
129
.github/skills/tailwindcss-development/SKILL.md
vendored
Normal file
129
.github/skills/tailwindcss-development/SKILL.md
vendored
Normal file
@ -0,0 +1,129 @@
|
||||
---
|
||||
name: tailwindcss-development
|
||||
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Tailwind CSS Development
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate this skill when:
|
||||
|
||||
- Adding styles to components or pages
|
||||
- Working with responsive design
|
||||
- Implementing dark mode
|
||||
- Extracting repeated patterns into components
|
||||
- Debugging spacing or layout issues
|
||||
|
||||
## 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-First Config -->
|
||||
```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:
|
||||
|
||||
<!-- v4 Import Syntax -->
|
||||
```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:
|
||||
|
||||
<!-- Gap Utilities -->
|
||||
```html
|
||||
<div class="flex gap-8">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 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:
|
||||
|
||||
<!-- Dark Mode -->
|
||||
```html
|
||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
Content adapts to color scheme
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Flexbox Layout
|
||||
|
||||
<!-- Flexbox Layout -->
|
||||
```html
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>Left content</div>
|
||||
<div>Right content</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Grid Layout
|
||||
|
||||
<!-- Grid Layout -->
|
||||
```html
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>Card 1</div>
|
||||
<div>Card 2</div>
|
||||
<div>Card 3</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 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
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@ -1,10 +1,12 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
*.cache
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
@ -13,13 +15,23 @@
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/storage/framework
|
||||
/storage/logs
|
||||
/storage/debugbar
|
||||
/vendor
|
||||
/bootstrap/cache
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
/references
|
||||
/references
|
||||
/tests/Browser/Screenshots
|
||||
*.tmp
|
||||
*.swp
|
||||
|
||||
@ -2,6 +2,7 @@ dist/
|
||||
build/
|
||||
public/build/
|
||||
node_modules/
|
||||
vendor/
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@ -3,7 +3,11 @@ dist/
|
||||
build/
|
||||
public/build/
|
||||
public/hot/
|
||||
public/storage/
|
||||
coverage/
|
||||
vendor/
|
||||
storage/
|
||||
bootstrap/cache/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
14
.specify/README.md
Normal file
14
.specify/README.md
Normal file
@ -0,0 +1,14 @@
|
||||
# `.specify/` (Tooling)
|
||||
|
||||
This folder contains **SpecKit tooling** (templates, scripts, constitution, etc.).
|
||||
|
||||
## Important
|
||||
|
||||
- **Do not** create new feature specs in `.specify/spec.md`, `.specify/plan.md`, `.specify/tasks.md`.
|
||||
- Active feature specs live under `specs/<NNN>-<slug>/`:
|
||||
- `spec.md`
|
||||
- `plan.md`
|
||||
- `tasks.md`
|
||||
- `checklists/requirements.md`
|
||||
|
||||
The files `.specify/spec.md`, `.specify/plan.md`, `.specify/tasks.md` may exist as legacy references only.
|
||||
@ -1,24 +1,431 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
|
||||
- Version change: 1.11.0 → 1.12.0
|
||||
- Modified principles:
|
||||
- None
|
||||
- Added sections:
|
||||
- Operator Surface Principles (OPSURF-001)
|
||||
- Removed sections: None
|
||||
- Templates requiring updates:
|
||||
- ✅ .specify/templates/spec-template.md
|
||||
- ✅ .specify/templates/plan-template.md
|
||||
- ✅ .specify/templates/tasks-template.md
|
||||
- ✅ docs/product/principles.md
|
||||
- ✅ docs/product/standards/README.md
|
||||
- ✅ docs/HANDOVER.md
|
||||
- Follow-up TODOs:
|
||||
- None.
|
||||
-->
|
||||
|
||||
# TenantPilot Constitution
|
||||
|
||||
## Core Principles
|
||||
|
||||
### Safety-First Restore
|
||||
- Any destructive action MUST support preview/dry-run, explicit confirmation, and a clear pre-execution summary.
|
||||
- High-risk policy types default to `preview-only` restore unless explicitly enabled by a feature spec + tests + checklist.
|
||||
- Restore must be defensive: validate inputs, detect conflicts, allow selective restore, and record outcomes per item.
|
||||
### Inventory-first, Snapshots-second
|
||||
- All modules MUST operate primarily on Inventory as “last observed” state.
|
||||
- Inventory is the source of truth for what TenantPilot last observed; Microsoft Intune remains the external truth.
|
||||
- Snapshots/Backups MUST be explicit actions (manual or scheduled) and MUST remain immutable.
|
||||
|
||||
### Auditability & Tenant Isolation
|
||||
- Every operation is tenant-scoped and MUST write an audit log entry (no secrets, no tokens).
|
||||
- Snapshots are immutable JSONB and MUST remain reproducible (who/when/what/source tenant).
|
||||
### Read/Write Separation by Default
|
||||
- Analysis, reporting, and monitoring features MUST be read-only by default.
|
||||
- Any write/change function (restore, remediation, promotion) MUST include preview/dry-run, explicit confirmation, audit logging, and tests.
|
||||
- High-risk policy types default to `preview-only` restore unless explicitly enabled by a feature spec + tests.
|
||||
|
||||
### Graph Abstraction & Contracts
|
||||
### Single Contract Path to Graph
|
||||
- All Microsoft Graph calls MUST go through `GraphClientInterface`.
|
||||
- Contract assumptions are config-driven (`config/graph_contracts.php`); do not hardcode endpoints in feature code.
|
||||
- Unknown/missing policy types MUST fail safe (preview-only / no Graph calls) rather than calling `deviceManagement/{type}`.
|
||||
- Object types and endpoints MUST be modeled first in the contract registry (`config/graph_contracts.php`).
|
||||
- Feature code MUST NOT hardcode “quick endpoints” or bypass contracts.
|
||||
- Unknown/missing policy types MUST fail safe (preview-only / no Graph calls) rather than guessing endpoints.
|
||||
|
||||
### Least Privilege
|
||||
### Deterministic Capabilities
|
||||
- Backup/restore/risk/support flags MUST be derived deterministically from config/contracts via a Capabilities Resolver.
|
||||
- The resolver output MUST be programmatically testable (snapshot/golden tests) so config changes cannot silently break behavior.
|
||||
|
||||
### Workspace Isolation is Non-negotiable
|
||||
- Workspace membership is an isolation boundary. If the actor is not entitled to the workspace scope, the system MUST respond as
|
||||
deny-as-not-found (404).
|
||||
- Workspace is the primary session context. Tenant-scoped routes/resources MUST require an established workspace context.
|
||||
- Workspace context switching is separate from Filament Tenancy (Managed Tenant switching).
|
||||
|
||||
### Tenant Isolation is Non-negotiable
|
||||
- Every tenant-plane read/write MUST be tenant-scoped.
|
||||
- Cross-tenant views (MSP/Platform) MUST be explicit, access-checked, and aggregation-based (no ID-based shortcuts).
|
||||
- Tenantless canonical views (e.g., Monitoring/Operations) MUST enforce tenant entitlement before revealing records.
|
||||
- Prefer least-privilege roles/scopes; surface warnings when higher privileges are selected.
|
||||
- Never store secrets in code/config; never log credentials or tokens.
|
||||
- Tenant membership is an isolation boundary. If the actor is not entitled to the tenant scope, the system MUST respond as
|
||||
deny-as-not-found (404).
|
||||
|
||||
Scope & Ownership Clarification (SCOPE-001)
|
||||
|
||||
- The system MUST enforce a strict ownership model:
|
||||
- Workspace-owned objects define standards, templates, and global configuration (e.g., Baseline Profiles, Notification Targets, Alert Routing Rules, Framework/Control catalogs).
|
||||
- Tenant-owned objects represent observed state, evidence, and operational artifacts for a specific tenant (e.g., Inventory, Backups/Snapshots, OperationRuns for tenant operations, Drift/Findings, Exceptions/Risk Acceptance, EvidenceItems, StoredReports/Exports).
|
||||
- Workspace-owned objects MUST NOT directly embed or persist tenant-owned records (no “copying tenant data into templates”).
|
||||
- Tenant-owned objects MUST always be bound to an established workspace + tenant scope at authorization time.
|
||||
|
||||
Database convention:
|
||||
|
||||
- Tenant-owned tables MUST include workspace_id and tenant_id as NOT NULL.
|
||||
- Workspace-owned tables MUST include workspace_id and MUST NOT include tenant_id.
|
||||
- Exception: OperationRun MAY have tenant_id nullable to support canonical workspace-context monitoring views; however, revealing any tenant-bound runs still MUST enforce entitlement checks to the referenced tenant scope.
|
||||
- Exception: AlertDelivery MAY have tenant_id nullable for workspace-scoped, non-tenant-operational artifacts (e.g., `event_type=alerts.test`). Tenant-bound delivery records still MUST enforce tenant entitlement checks, and tenantless delivery rows MUST NOT contain tenant-specific data.
|
||||
- Exception: `managed_tenant_onboarding_sessions` MAY keep `tenant_id` nullable as a workspace-scoped workflow-coordination record. It begins before tenant identification, may later reference a tenant for authorization continuity and resume semantics, and MUST always enforce workspace entitlement plus tenant entitlement once a tenant reference is attached. This exception is specific to onboarding draft workflow state and does not create a general precedent for workspace-owned domain records.
|
||||
|
||||
### RBAC & UI Enforcement Standards (RBAC-UX)
|
||||
|
||||
RBAC Context — Planes, Roles, and Auditability
|
||||
- The platform MUST maintain two strictly separated authorization planes:
|
||||
- Tenant/Admin plane (`/admin`): authenticated Entra users (`users`).
|
||||
- Tenant-context routes (`/admin/t/{tenant}/...`) are tenant-scoped.
|
||||
- Workspace-context canonical routes (`/admin/...`, e.g. Monitoring/Operations) are tenantless by URL but MUST still enforce workspace + tenant entitlement before revealing tenant-owned records.
|
||||
- Platform plane (`/system`): authenticated platform users (`platform_users`), authorization is platform-scoped.
|
||||
- Cross-plane access MUST be deny-as-not-found (404) (not 403) to avoid route enumeration.
|
||||
- Tenant role semantics MUST remain least-privilege:
|
||||
- Readonly: view-only; MUST NOT start operations and MUST NOT mutate data.
|
||||
- Operator: MAY start allowed tenant operations; MUST NOT manage credentials, settings, members, or perform destructive actions.
|
||||
- Manager: MAY manage tenant configuration and start operations; MUST NOT manage tenant memberships (Owner-only).
|
||||
- Owner: MAY manage memberships and all tenant configuration; Owner-only “danger zone” actions MUST remain Owner-only.
|
||||
- The system MUST prevent removing or demoting the last remaining Owner of a tenant.
|
||||
- All access-control relevant changes MUST write `AuditLog` entries with stable action IDs, and MUST be redacted (no secrets).
|
||||
|
||||
RBAC-UX-001 — Server-side is the source of truth
|
||||
- UI visibility / disabled state is never a security boundary.
|
||||
- Every mutating action (create/update/delete/restore/archive/force-delete), every operation start, and every credential/
|
||||
config change MUST enforce authorization server-side via `Gate::authorize(...)` or a Policy method.
|
||||
- Any missing server-side authorization is a P0 security bug.
|
||||
|
||||
RBAC-UX-002 — Deny-as-not-found for non-members
|
||||
- Tenant and workspace membership (and plane membership) are isolation boundaries.
|
||||
- If the current actor is not a member of the current workspace OR the current tenant (or otherwise not entitled to the
|
||||
workspace/tenant scope), the system MUST respond as 404 (deny-as-not-found) for tenant-scoped routes/actions/resources.
|
||||
- This applies to Filament resources/pages under tenant routing (`/admin/t/{tenant}/...`), Global Search results, and all
|
||||
action endpoints (Livewire calls included).
|
||||
|
||||
RBAC-UX-003 — Capability denial is 403 (after membership is established)
|
||||
- Within an established workspace + tenant scope, missing permissions are authorization failures.
|
||||
- If the actor is a workspace + tenant member, but lacks the required capability for an action, the server MUST fail with 403.
|
||||
- The UI may render disabled actions, but the server MUST still enforce 403 on execution.
|
||||
|
||||
RBAC-UX-004 — Visible vs disabled UX rule
|
||||
- For tenant members: actions SHOULD be visible but disabled when capability is missing.
|
||||
- Disabled actions MUST provide helper text explaining the missing permission.
|
||||
- For non-members: actions MUST behave as not found (404) and SHOULD NOT leak resource existence.
|
||||
- Exception: highly sensitive controls (e.g., credential rotation) MAY be hidden even for members without permission.
|
||||
|
||||
RBAC-UX-005 — Destructive confirmation standard
|
||||
- All destructive-like actions MUST require confirmation.
|
||||
- Delete/force-delete/archive/restore/remove membership/role downgrade/credential rotation/break-glass enter/exit MUST use
|
||||
`->requiresConfirmation()` and SHOULD include clear warning text.
|
||||
- Confirmation is UX only; authorization still MUST be server-side.
|
||||
|
||||
RBAC-UX-006 — Capability registry is canonical
|
||||
- Capabilities MUST be centrally defined in a single canonical registry (constants/enum).
|
||||
- Feature code MUST reference capabilities only via the registry (no raw string literals).
|
||||
- Role → capability mapping MUST reference only registry entries.
|
||||
- CI MUST fail if unknown/unregistered capabilities are used.
|
||||
|
||||
RBAC-UX-007 — Global search must be tenant-safe
|
||||
- Global search MUST be context-safe (workspace-context vs tenant-context).
|
||||
- Non-members MUST never learn about resources in other tenants (no results, no hints).
|
||||
- If a result exists but is not accessible, it MUST be treated as not found (404 semantics).
|
||||
- In workspace-context (no active tenant selected), Global Search MUST NOT return tenant-owned results.
|
||||
- It MAY search workspace-owned objects only (e.g., Tenants list entries, Baseline Profiles, Alert Rules/Targets, workspace settings).
|
||||
- If tenant-context is active, Global Search MUST be scoped to the current tenant only (existing rule remains).
|
||||
|
||||
RBAC-UX-008 — Regression guards are mandatory
|
||||
- The repo MUST include RBAC regression tests asserting at least:
|
||||
- Readonly cannot mutate or start operations.
|
||||
- Operator can run allowed operations but cannot manage configuration.
|
||||
- Manager/Owner behave according to the role matrix.
|
||||
- The repo SHOULD include an automated “no ad-hoc authorization” guard that blocks new status/permission mappings sprinkled
|
||||
across `app/Filament/**`, pushing patterns into central helpers.
|
||||
|
||||
### Operations / Run Observability Standard
|
||||
- Every long-running or operationally relevant action MUST be observable, deduplicated, and auditable via Monitoring → Operations.
|
||||
- An action MUST create/reuse a canonical `OperationRun` and execute asynchronously when any of the following applies:
|
||||
1. It can take > 2 seconds under normal conditions.
|
||||
2. It performs remote/external calls (e.g., Microsoft Graph).
|
||||
3. It is queued or scheduled.
|
||||
4. It is operationally relevant for troubleshooting/audit (“what ran, who started it, did it succeed, what failed?”).
|
||||
- Actions that are DB-only and typically complete in < 2 seconds MAY skip `OperationRun`.
|
||||
- OPS-EX-AUTH-001 — Auth Handshake Exception:
|
||||
- OIDC/SAML login handshakes MAY perform synchronous outbound HTTP (e.g., token exchange) without an `OperationRun`.
|
||||
- Rationale: interactive, session-critical, and not a tenant-operational “background job”.
|
||||
- Guardrail: outbound HTTP for auth handshakes is allowed only on `/auth/*` endpoints and MUST NOT occur on Monitoring/Operations pages.
|
||||
- If an action is security-relevant or affects operational behavior (e.g., “Ignore policy”), it MUST write an `AuditLog` entry
|
||||
including actor, tenant, action, target, before/after, and timestamp.
|
||||
- The `OperationRun` record is the canonical source of truth for Monitoring (status, timestamps, counts, failures),
|
||||
even if implemented by multiple jobs/steps (“umbrella run”).
|
||||
- “Single-row” runs MUST still use consistent counters (e.g., `total=1`, `processed=0|1`) and outcome derived from success/failure.
|
||||
- Monitoring pages MUST be DB-only at render time (no external calls).
|
||||
- Start surfaces MUST NOT perform remote work inline; they only: authorize, create/reuse run (dedupe), enqueue work,
|
||||
confirm + “View run”.
|
||||
|
||||
### Operations UX — 3-Surface Feedback (OPS-UX-055) (NON-NEGOTIABLE)
|
||||
|
||||
If a feature creates/reuses `OperationRun`, it MUST use exactly three feedback surfaces — no others:
|
||||
|
||||
1) Toast (intent only / queued-only)
|
||||
- A toast MAY be shown only when the run is accepted/queued (intent feedback).
|
||||
- The toast MUST use `OperationUxPresenter::queuedToast($operationType)->send()`.
|
||||
- Feature code MUST NOT craft ad-hoc operation toasts.
|
||||
- A dedicated dedupe message MUST use the presenter (e.g., `alreadyQueuedToast(...)`), not `Notification::make()`.
|
||||
|
||||
2) Progress (active awareness only)
|
||||
- Live progress MUST exist only in:
|
||||
- the global active-ops widget, and
|
||||
- Monitoring → Operation Run Detail.
|
||||
- These surfaces MUST show only active runs (`queued|running`) and MUST never show terminal runs.
|
||||
- Determinate progress is allowed ONLY when `summary_counts.total` and `summary_counts.processed` are valid numeric values.
|
||||
- Determinate progress MUST be clamped to 0–100. Otherwise render indeterminate + elapsed time.
|
||||
- The widget MUST NOT show percentage text (optional `processed/total` is allowed).
|
||||
|
||||
3) Terminal DB Notification (audit outcome only)
|
||||
- Each run MUST emit exactly one persistent terminal DB notification when it becomes terminal.
|
||||
- Delivery MUST be initiator-only (no tenant-wide fan-out).
|
||||
- Completion notifications MUST be `OperationRunCompleted` only.
|
||||
- Feature code MUST NOT send custom completion DB notifications for operations (no `sendToDatabase()` for completion/abort).
|
||||
|
||||
Canonical navigation:
|
||||
- All “View run” links MUST use the canonical helper and MUST point to Monitoring → Operations → Run Detail.
|
||||
|
||||
### OperationRun lifecycle is service-owned (OPS-UX-LC-001)
|
||||
|
||||
Any change to `OperationRun.status` or `OperationRun.outcome` MUST go through `OperationRunService` (canonical transition method).
|
||||
This is the only allowed path because it enforces normalization, summary sanitization, idempotency, and terminal notification emission.
|
||||
|
||||
Forbidden outside `OperationRunService`:
|
||||
- `$operationRun->update(['status' => ...])` / `$operationRun->update(['outcome' => ...])`
|
||||
- `$operationRun->status = ...` / `$operationRun->outcome = ...`
|
||||
- Query-based updates that transition `status`/`outcome`
|
||||
|
||||
Allowed outside the service:
|
||||
- Updates to `context`, `message`, `reason_code` that do not change `status`/`outcome`.
|
||||
|
||||
### Summary counts contract (OPS-UX-SUM-001)
|
||||
|
||||
- `operation_runs.summary_counts` is the canonical metrics source for Ops-UX.
|
||||
- All keys MUST come from `OperationSummaryKeys::all()` (single source of truth).
|
||||
- Values MUST be flat numeric-only; no nested objects/arrays; no free-text.
|
||||
- Producers MUST NOT introduce new keys without:
|
||||
1) updating `OperationSummaryKeys::all()`,
|
||||
2) updating the spec canonical list,
|
||||
3) adding/adjusting tests.
|
||||
|
||||
### Ops-UX regression guards are mandatory (OPS-UX-GUARD-001)
|
||||
|
||||
The repo MUST include automated guards (Pest) that fail CI if:
|
||||
- any direct `OperationRun` status/outcome transition occurs outside `OperationRunService`,
|
||||
- jobs emit DB notifications for operation completion/abort (`OperationRunCompleted` is the single terminal notification),
|
||||
- deprecated legacy operation notification classes are referenced again.
|
||||
|
||||
These guards MUST fail with actionable output (file + snippet).
|
||||
|
||||
### Scheduled/system runs (OPS-UX-SYS-001)
|
||||
|
||||
- If a run has no initiator user, no terminal DB notification is emitted (initiator-only policy).
|
||||
- Outcomes remain auditable via Monitoring → Operations / Run Detail.
|
||||
- Any tenant-wide alerting MUST go through the Alerts system (not `OperationRun` notifications).
|
||||
- Active-run dedupe MUST be enforced at DB level (partial unique index/constraint for active states).
|
||||
- Failures MUST be stored as stable reason codes + sanitized messages; never persist secrets/tokens/PII/raw payload dumps
|
||||
in failures or notifications.
|
||||
- Graph calls are allowed only via explicit user interaction and only when delegated auth is present; never as a render side-effect (restore group mapping is intentionally DB-only).
|
||||
- Monitoring → Operations is reserved for `OperationRun`-tracked operations.
|
||||
- Scheduled/queued operations MUST use locks + idempotency (no duplicates).
|
||||
- Graph throttling and transient failures MUST be handled with backoff + jitter (e.g., 429/503).
|
||||
|
||||
### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
||||
|
||||
For every new or modified Filament Resource / RelationManager / Page:
|
||||
|
||||
Required surfaces
|
||||
- List/Table MUST define: Header Actions, Row Actions, Bulk Actions, and Empty-State CTA(s).
|
||||
- Inspect affordance (List/Table): Every table MUST provide a record inspection affordance.
|
||||
- Accepted forms: clickable rows via `recordUrl()` (preferred), a dedicated row “View” action, or a primary linked column.
|
||||
- Rule: Do NOT render a lone “View” row action button. If View is the only row action, prefer clickable rows.
|
||||
- View/Detail MUST define Header Actions (Edit + “More” group when applicable).
|
||||
- View/Detail MUST be sectioned (e.g., Infolist Sections / Cards); avoid long ungrouped field lists.
|
||||
- Create/Edit MUST provide consistent Save/Cancel UX.
|
||||
|
||||
Grouping & safety
|
||||
- Max 2 visible Row Actions (typically View/Edit). Everything else MUST be in an ActionGroup “More”.
|
||||
- Bulk actions MUST be grouped via BulkActionGroup.
|
||||
- RelationManagers MUST follow the same action surface rules (grouped row actions, bulk actions where applicable, inspection affordance).
|
||||
- Destructive actions MUST NOT be primary and MUST require confirmation; typed confirmation MAY be required for large/bulk changes.
|
||||
- Relevant mutations MUST write an audit log entry.
|
||||
|
||||
RBAC enforcement
|
||||
- Non-member access MUST abort(404) and MUST NOT leak existence.
|
||||
- Member without capability: UI visible but disabled with tooltip; server-side MUST abort(403).
|
||||
- Central enforcement helpers (tenant/workspace UI enforcement) MUST be used for gating.
|
||||
|
||||
Spec / DoD gates
|
||||
- Every spec MUST include a “UI Action Matrix”.
|
||||
- A change is not “Done” unless the Action Surface Contract is met OR an explicit exemption exists with documented reason.
|
||||
- CI MUST run an automated Action Surface Contract check (test suite and/or command) that fails when required surfaces are missing.
|
||||
|
||||
### Filament UI — Layout & Information Architecture Standards (UX-001)
|
||||
|
||||
Goal: Demo-level, enterprise-grade admin UX. These rules are NON-NEGOTIABLE for new or modified Filament screens.
|
||||
|
||||
Page layout
|
||||
- Create/Edit MUST default to a Main/Aside layout using a 3-column grid with `Main=columnSpan(2)` and `Aside=columnSpan(1)`.
|
||||
- All fields MUST be inside Sections/Cards. No “naked” inputs at the root schema level.
|
||||
- Main contains domain definition/content. Aside contains status/meta (status, version label, owner, scope selectors, timestamps).
|
||||
- Related data (assignments, relations, evidence, runs, findings, etc.) MUST render as separate Sections below the main/aside grid (or as tabs/sub-navigation), never mixed as unstructured long lists.
|
||||
|
||||
View pages
|
||||
- View/Detail MUST be a read-only experience using Infolists (or equivalent), not disabled edit forms.
|
||||
- Status-like values MUST render as badges/chips using the centralized badge semantics (BADGE-001).
|
||||
- Long text MUST render as readable prose (not textarea styling).
|
||||
|
||||
Empty states
|
||||
- Empty lists/tables MUST show: a specific title, one-sentence explanation, and exactly ONE primary CTA in the empty state.
|
||||
- When non-empty, the primary CTA MUST move to the table header (top-right) and MUST NOT be duplicated in the empty state.
|
||||
|
||||
Actions & flows
|
||||
- Pages SHOULD expose at most 1 primary header action and 1 secondary action; all other actions MUST be grouped (ActionGroup / BulkActionGroup).
|
||||
- Multi-step or high-risk flows MUST use a Wizard (e.g., capture/compare/restore with preview + confirmation).
|
||||
- Destructive actions MUST remain non-primary and require confirmation (RBAC-UX-005).
|
||||
|
||||
Table work-surface defaults
|
||||
- Tables SHOULD provide search (when the dataset can grow), a meaningful default sort, and filters for core dimensions (status/severity/type/tenant/time-range).
|
||||
- Tables MUST render key statuses as badges/chips (BADGE-001) and avoid ad-hoc status mappings.
|
||||
|
||||
Enforcement
|
||||
- New resources/pages SHOULD use shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) to keep screens consistent.
|
||||
- A change is not “Done” unless UX-001 is satisfied OR an explicit exemption exists with a documented rationale in the spec/PR.
|
||||
|
||||
### Operator-facing UI Naming Standards (UI-NAMING-001)
|
||||
|
||||
Goal: operator-facing actions, run labels, notifications, audit prose, and related UI copy MUST use consistent,
|
||||
enterprise-grade product language.
|
||||
|
||||
Naming model
|
||||
- Operator-facing copy MUST distinguish four layers: Scope, Source/Domain, Operation, and Target Object.
|
||||
- Scope terms (`Workspace`, `Tenant`) describe execution context and MUST NOT be used as the primary action label unless they are the actual target object.
|
||||
- Source/Domain terms (`Intune`, `Entra`, `Teams`, future providers) are secondary and MUST NOT lead the primary label unless the current screen presents competing sources that need explicit disambiguation.
|
||||
|
||||
Primary action labels
|
||||
- Primary buttons, header actions, and menu actions MUST use `Verb + Object`.
|
||||
- Preferred examples: `Sync policies`, `Sync groups`, `Capture baseline`, `Compare baseline`, `Restore policy`, `Run review`, `Export review pack`.
|
||||
- Forbidden examples: `Sync from tenant`, `Backup tenant`, `Compare tenant`, `Sync from Intune`, `Run tenant sync now`, `Start inventory refresh from provider`.
|
||||
|
||||
Domain vocabulary
|
||||
- Operator-facing copy MUST prefer product-domain objects such as `policies`, `groups`, `baseline`, `findings`, `review pack`, `alerts`, and `operations`.
|
||||
- Primary operator-facing copy MUST NOT use implementation-first terms such as `provider`, `gateway`, `resolver`, `collector`, `contract registry`, or `job dispatch`.
|
||||
- Source/domain details MAY appear in modal descriptions, helper text, run metadata, audit metadata, and notifications when needed for precision.
|
||||
|
||||
Run, notification, and audit semantics
|
||||
- Visible run titles MUST use the same domain vocabulary as the initiating action and SHOULD be concise noun phrases such as `Policy sync`, `Baseline capture`, `Baseline compare`, `Policy restore`, and `Tenant review`.
|
||||
- Notifications MUST use either `{Object} {state}` or `{Operation} {result}` and remain short, e.g. `Policy sync queued`, `Policy sync completed`, `Policy sync failed`, `Baseline compare detected drift`.
|
||||
- Audit prose MUST use the same operator-facing language, e.g. `{actor} queued policy sync`, `{actor} captured baseline`, `{actor} reopened finding`.
|
||||
- The same user-visible action MUST keep the same domain vocabulary across button labels, modal titles, run titles, notifications, and audit prose.
|
||||
|
||||
Verb standard
|
||||
- Preferred verbs are `Sync`, `Capture`, `Compare`, `Restore`, `Review`, `Export`, `Open`, `Archive`, `Resolve`, `Reopen`, and `Assign`.
|
||||
- `Start`, `Execute`, `Trigger`, and `Perform` SHOULD be avoided for operator-facing copy unless there is a deliberate domain reason.
|
||||
- `Run` MAY be used only when the object is itself run-like, such as `Run review` or `Run compare`; it MUST NOT be the generic fallback verb for all operations.
|
||||
|
||||
Current binding decision
|
||||
- The Policies screen primary action MUST be `Sync policies`.
|
||||
- The Policies screen modal title MUST be `Sync policies`.
|
||||
- The Policies screen success toast MUST be `Policy sync queued`.
|
||||
- The visible run label for that action MUST be `Policy sync`.
|
||||
- The audit prose for that action MUST be `{actor} queued policy sync`.
|
||||
|
||||
### Operator Surface Principles (OPSURF-001)
|
||||
|
||||
Goal: operator-facing surfaces MUST optimize for the primary working audience rather than raw implementation visibility.
|
||||
|
||||
Operator-first default surfaces
|
||||
- `/admin` is operator-first.
|
||||
- Default-visible content MUST use operator-facing language, clear scope, and actionable status communication.
|
||||
- Raw implementation details MUST NOT be default-visible on primary operator surfaces.
|
||||
|
||||
Progressive disclosure for diagnostics
|
||||
- Diagnostic detail MAY be available where needed, but it MUST be secondary and explicitly revealed.
|
||||
- JSON payloads, raw IDs, internal field names, provider error details, and low-level technical metadata belong in diagnostics surfaces, drawers, tabs, accordions, or modals rather than primary content.
|
||||
- A surface MUST NOT require operators to parse raw JSON or provider-specific field names to understand the primary state or next action.
|
||||
|
||||
Distinct status dimensions
|
||||
- Operator-facing surfaces MUST distinguish at least the following dimensions when they all exist in the domain:
|
||||
- execution outcome
|
||||
- data completeness
|
||||
- governance result
|
||||
- lifecycle or readiness state
|
||||
- These dimensions MUST NOT be collapsed into a single ambiguous status model.
|
||||
- If a surface summarizes multiple status dimensions, the default-visible presentation MUST label each dimension explicitly.
|
||||
|
||||
Explicit mutation scope
|
||||
- Every action that changes state MUST communicate before execution whether it affects:
|
||||
- TenantPilot only
|
||||
- the Microsoft tenant
|
||||
- simulation only
|
||||
- Mutation scope MUST be understandable from the action label, helper text, confirmation copy, preview, or nearby status copy before the operator commits.
|
||||
- A mutating action MUST NOT rely on hidden implementation knowledge to communicate its blast radius.
|
||||
|
||||
Safe execution for dangerous actions
|
||||
- Dangerous actions MUST follow a consistent safe-execution pattern:
|
||||
- configuration
|
||||
- safety checks or simulation
|
||||
- preview
|
||||
- hard confirmation where required
|
||||
- execute
|
||||
- One-click destructive actions are not acceptable for high-blast-radius operations.
|
||||
- When a full multi-step flow is not feasible, the spec MUST document the explicit exemption and the replacement safeguards.
|
||||
|
||||
Explicit workspace and tenant context
|
||||
- Workspace and tenant scope MUST remain explicit in navigation, actions, and page semantics.
|
||||
- Tenant-scoped surfaces MUST NOT silently expose workspace-wide actions.
|
||||
- Canonical workspace views that reference tenant-owned records MUST make the workspace and tenant context legible before the operator acts.
|
||||
|
||||
Page contract requirement
|
||||
- Every new or materially refactored operator-facing page MUST define:
|
||||
- primary persona
|
||||
- surface type
|
||||
- primary operator question
|
||||
- default-visible information
|
||||
- diagnostics-only information
|
||||
- status dimensions used
|
||||
- mutation scope
|
||||
- primary actions
|
||||
- dangerous actions
|
||||
- This page contract MUST be recorded in the governing spec and kept in sync when the page semantics materially change.
|
||||
|
||||
Spec Scope Fields (SCOPE-002)
|
||||
|
||||
- Every feature spec MUST declare:
|
||||
- Scope: workspace | tenant | canonical-view
|
||||
- Primary Routes
|
||||
- Data Ownership: workspace-owned vs tenant-owned tables/records impacted
|
||||
- RBAC: membership requirements + capability requirements
|
||||
- For canonical-view specs, the spec MUST define:
|
||||
- Default filter behavior when tenant-context is active (e.g., prefilter to current tenant)
|
||||
- Explicit entitlement checks that prevent cross-tenant leakage
|
||||
|
||||
### Data Minimization & Safe Logging
|
||||
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
|
||||
- Payload-heavy content belongs in immutable snapshots/backup storage, not Inventory.
|
||||
- Logs MUST not contain secrets/tokens; monitoring MUST rely on run records + error codes (not log parsing).
|
||||
|
||||
### Badge Semantics Are Centralized (BADGE-001)
|
||||
- Status-like badges (status/outcome/severity/risk/availability/boolean signals) MUST render via `BadgeCatalog` / `BadgeRenderer`.
|
||||
- Filament resources/pages/widgets/views MUST NOT introduce ad-hoc status-like badge mappings (use a `BadgeDomain` instead).
|
||||
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
|
||||
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
|
||||
|
||||
### Incremental UI Standards Enforcement (UI-STD-001)
|
||||
- UI consistency is enforced incrementally, not by recurring cleanup passes.
|
||||
- New tables, filters, and list surfaces MUST follow established Filament-native standards from the first implementation.
|
||||
- Deviations MUST be explicit and justified in the spec or PR.
|
||||
- Canonical standards live in `docs/product/standards/` and are the source of truth for:
|
||||
- Table UX (column tiers, sort, search, toggle, pagination, persistence, empty states)
|
||||
- Filter UX (persistence, soft-delete, date range, enum sourcing, defaults)
|
||||
- Actions UX (row/bulk/header actions, grouping, destructive safety)
|
||||
- Guard tests enforce critical constraints automatically; the list surface review checklist catches the rest.
|
||||
- A new spec that adds or modifies a list surface MUST reference the review checklist (`docs/product/standards/list-surface-review-checklist.md`).
|
||||
|
||||
### Spec-First Workflow
|
||||
- For any feature that changes runtime behavior, include or update `specs/<NNN>-<slug>/` with `spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md`.
|
||||
@ -26,10 +433,22 @@ ### Spec-First Workflow
|
||||
|
||||
## Quality Gates
|
||||
- Changes MUST be programmatically tested (Pest) and run via targeted `php artisan test ...`.
|
||||
- Run `./vendor/bin/pint --dirty` before finalizing.
|
||||
- Run `./vendor/bin/sail bin pint --dirty` before finalizing.
|
||||
|
||||
## Governance
|
||||
- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones.
|
||||
- Restore semantics changes require: spec update, checklist update, and tests proving safety.
|
||||
|
||||
**Version**: 1.0.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-03
|
||||
### Scope & Compliance
|
||||
- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones.
|
||||
- Restore semantics changes require: spec update, checklist update (if applicable), and tests proving safety.
|
||||
|
||||
### Amendment Procedure
|
||||
- Propose changes as a PR that updates `.specify/memory/constitution.md`.
|
||||
- The PR MUST include a short rationale and list of impacted templates/specs.
|
||||
- Amendments MUST update **Last Amended** date.
|
||||
|
||||
### Versioning Policy (SemVer)
|
||||
- **PATCH**: clarifications/typos/non-semantic refinements.
|
||||
- **MINOR**: new principle/section or materially expanded guidance.
|
||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||
|
||||
**Version**: 1.12.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-21
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
# Implementation Plan: TenantPilot v1
|
||||
# Implementation Plan: TenantPilot v1 (LEGACY / DEPRECATED)
|
||||
|
||||
> DEPRECATED: Do not use `.specify/plan.md` for new work.
|
||||
> Active feature plans live under `specs/<NNN>-<slug>/plan.md` on `feat/<NNN>-<slug>` branches.
|
||||
> Legacy history lives under `spechistory/`.
|
||||
|
||||
**Branch**: `dev`
|
||||
**Date**: 2026-01-03
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
# Research T186 — settings_apply capability verification
|
||||
# Research T186 — settings_apply capability verification (LEGACY / DEPRECATED)
|
||||
|
||||
> DEPRECATED: Do not add new research notes under `.specify/`.
|
||||
> Active feature research should live under `specs/<NNN>-<slug>/`.
|
||||
> Legacy history lives under `spechistory/`.
|
||||
|
||||
Objective
|
||||
---------
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
# Feature Specification: TenantPilot v1
|
||||
# Feature Specification: TenantPilot v1 (LEGACY / DEPRECATED)
|
||||
|
||||
> DEPRECATED: Do not use `.specify/spec.md` for new work.
|
||||
> Active feature specs live under `specs/<NNN>-<slug>/spec.md` on `feat/<NNN>-<slug>` branches.
|
||||
> Legacy history lives under `spechistory/`.
|
||||
|
||||
**Feature Branch**: `dev`
|
||||
**Created**: 2025-12-10
|
||||
|
||||
@ -2,7 +2,11 @@
|
||||
description: "Task list for TenantPilot v1 implementation"
|
||||
---
|
||||
|
||||
# Tasks: TenantPilot v1
|
||||
# Tasks: TenantPilot v1 (LEGACY / DEPRECATED)
|
||||
|
||||
> DEPRECATED: Do not use `.specify/tasks.md` for new work.
|
||||
> Active feature task lists live under `specs/<NNN>-<slug>/tasks.md` on `feat/<NNN>-<slug>` branches.
|
||||
> Legacy history lives under `spechistory/`.
|
||||
|
||||
**Input**: Design documents from `.specify/spec.md` and `.specify/plan.md`
|
||||
**Prerequisites**: plan.md (complete), spec.md (complete)
|
||||
|
||||
@ -3,7 +3,7 @@ # Implementation Plan: [FEATURE]
|
||||
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
|
||||
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow.
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
@ -31,8 +31,33 @@ ## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
[Gates determined based on constitution file]
|
||||
|
||||
- Inventory-first: clarify what is “last observed” vs snapshots/backups
|
||||
- Read/write separation: any writes require preview + confirmation + audit + tests
|
||||
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
|
||||
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
|
||||
- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; tenant-context routes (/admin/t/{tenant}/...) are tenant-scoped; canonical workspace-context routes under /admin remain tenant-safe; non-member tenant/workspace access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks)
|
||||
- Workspace isolation: non-member workspace access is 404; tenant-plane routes require an established workspace context; workspace context switching is separate from Filament Tenancy
|
||||
- RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text
|
||||
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
|
||||
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
|
||||
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
|
||||
- Ops-UX 3-surface feedback: if `OperationRun` is used, feedback is exactly toast intent-only + progress surfaces + exactly-once terminal `OperationRunCompleted` (initiator-only); no queued/running DB notifications
|
||||
- Ops-UX lifecycle: `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`); context-only updates allowed outside
|
||||
- Ops-UX summary counts: `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only
|
||||
- Ops-UX guards: CI has regression guards that fail with actionable output (file + snippet) when these patterns regress
|
||||
- Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications)
|
||||
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
||||
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
|
||||
- Operator surfaces (OPSURF-001): `/admin` defaults are operator-first; default-visible content avoids raw implementation detail; diagnostics are explicitly revealed secondarily
|
||||
- Operator surfaces (OPSURF-001): execution outcome, data completeness, governance result, and lifecycle/readiness are modeled as distinct status dimensions when all apply; they are not collapsed into one ambiguous status
|
||||
- Operator surfaces (OPSURF-001): every mutating action communicates whether it changes TenantPilot only, the Microsoft tenant, or simulation only before execution
|
||||
- Operator surfaces (OPSURF-001): dangerous actions follow configuration → safety checks/simulation → preview → hard confirmation where required → execute, unless a spec documents an explicit exemption and replacement safeguards
|
||||
- Operator surfaces (OPSURF-001): workspace and tenant context remain explicit in navigation, actions, and page semantics; tenant surfaces do not silently expose workspace-wide actions
|
||||
- Operator surfaces (OPSURF-001): each new or materially refactored operator-facing page defines a page contract covering persona, surface type, operator question, default-visible info, diagnostics-only info, status dimensions, mutation scope, primary actions, and dangerous actions
|
||||
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
|
||||
- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
@ -5,6 +5,26 @@ # Feature Specification: [FEATURE NAME]
|
||||
**Status**: Draft
|
||||
**Input**: User description: "$ARGUMENTS"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: [workspace | tenant | canonical-view]
|
||||
- **Primary Routes**: [List the primary routes/pages affected]
|
||||
- **Data Ownership**: [workspace-owned vs tenant-owned tables/records impacted]
|
||||
- **RBAC**: [membership requirements + capability requirements]
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
If this feature adds a new operator-facing page or materially refactors one, fill out one row per affected page/surface.
|
||||
|
||||
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| e.g. Tenant policies page | Tenant operator | List/detail | What needs action right now? | Policy health, drift, assignment coverage | Raw payloads, provider IDs, low-level API details | lifecycle, data completeness, governance result | TenantPilot only / Microsoft tenant / simulation only | Sync policies, View policy | Restore policy |
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
<!--
|
||||
@ -77,6 +97,61 @@ ### Edge Cases
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
|
||||
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
|
||||
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
||||
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
||||
|
||||
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
|
||||
- explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
|
||||
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
|
||||
- describe how `summary_counts` keys/values comply with `OperationSummaryKeys::all()` and numeric-only rules,
|
||||
- clarify scheduled/system-run behavior (initiator null → no terminal DB notification; audit is via Monitoring),
|
||||
- list which regression guard tests are added/updated to keep these rules enforceable in CI.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
|
||||
- state which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`),
|
||||
- ensure any cross-plane access is deny-as-not-found (404),
|
||||
- explicitly define 404 vs 403 semantics:
|
||||
- non-member / not entitled to workspace scope OR tenant scope → 404 (deny-as-not-found)
|
||||
- member but missing capability → 403
|
||||
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
|
||||
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
|
||||
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
|
||||
- ensure destructive-like actions require confirmation (`->requiresConfirmation()`),
|
||||
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
|
||||
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
|
||||
|
||||
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
||||
notifications, audit prose, or related helper copy, the spec MUST describe:
|
||||
- the target object,
|
||||
- the operator verb,
|
||||
- whether source/domain disambiguation is actually needed,
|
||||
- how the same domain vocabulary is preserved across button labels, modal titles, run titles, notifications, and audit prose,
|
||||
- and how implementation-first terms are kept out of primary operator-facing labels.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
|
||||
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
|
||||
- which diagnostics are secondary and how they are explicitly revealed,
|
||||
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
|
||||
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),
|
||||
- how workspace and tenant context remain explicit in navigation, action copy, and page semantics,
|
||||
- and the page contract for each new or materially refactored operator-facing page.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
||||
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
||||
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
|
||||
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
|
||||
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
|
||||
title + explanation + exactly 1 CTA, and tables provide search/sort/filters for core dimensions.
|
||||
If UX-001 is not fully satisfied, the spec MUST include an explicit exemption with documented rationale.
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right functional requirements.
|
||||
@ -95,6 +170,17 @@ ### Functional Requirements
|
||||
- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?]
|
||||
- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified]
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
|
||||
|
||||
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
||||
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Resource/Page/RM | e.g. app/Filament/... | | e.g. `recordUrl()` / View action / linked column | | | | | | | |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **[Entity 1]**: [What it represents, key attributes without implementation]
|
||||
|
||||
@ -8,7 +8,62 @@ # Tasks: [FEATURE NAME]
|
||||
**Input**: Design documents from `/specs/[###-feature-name]/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||
|
||||
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
|
||||
**Tests**: For runtime behavior changes in this repo, tests are REQUIRED (Pest). Only docs-only changes may omit tests.
|
||||
**Operations**: If this feature introduces long-running/remote/queued/scheduled work, include tasks to create/reuse and update a
|
||||
canonical `OperationRun`, and ensure “View run” links route to the canonical Monitoring hub.
|
||||
If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant).
|
||||
Auth handshake exception (OPS-EX-AUTH-001): OIDC/SAML login handshakes may perform synchronous outbound HTTP on `/auth/*` endpoints
|
||||
without an `OperationRun`.
|
||||
If this feature creates/reuses an `OperationRun`, tasks MUST also include:
|
||||
- enforcing the Ops-UX 3-surface feedback contract (toast intent-only via `OperationUxPresenter`, progress only in widget + run detail, terminal notification is `OperationRunCompleted` exactly-once, initiator-only),
|
||||
- ensuring no queued/running DB notifications exist anywhere for operations (no `sendToDatabase()` for queued/running/completion/abort in feature code),
|
||||
- ensuring `OperationRun.status` / `OperationRun.outcome` transitions happen only via `OperationRunService`,
|
||||
- ensuring `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only,
|
||||
- adding/updating Ops-UX regression guards (Pest) that fail CI with actionable output (file + snippet) when these patterns regress,
|
||||
- clarifying scheduled/system-run behavior (initiator null → no terminal DB notification; audit via Monitoring; tenant-wide alerting via Alerts system).
|
||||
**RBAC**: If this feature introduces or changes authorization, tasks MUST include:
|
||||
- explicit Gate/Policy enforcement for all mutation endpoints/actions,
|
||||
- explicit 404 vs 403 semantics:
|
||||
- non-member / not entitled to workspace scope OR tenant scope → 404 (deny-as-not-found)
|
||||
- member but missing capability → 403,
|
||||
- capability registry usage (no raw capability strings; no role-string checks in feature code),
|
||||
- stating which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`),
|
||||
- tenant-safe global search scoping (no hints; inaccessible results treated as 404 semantics),
|
||||
- destructive-like actions use `->requiresConfirmation()` (authorization still server-side),
|
||||
- cross-plane deny-as-not-found (404) checks where applicable,
|
||||
- at least one positive + one negative authorization test.
|
||||
**UI Naming**: If this feature adds or changes operator-facing actions, run titles, notifications, audit prose, or helper copy, tasks MUST include:
|
||||
- aligning primary action labels to `Verb + Object`,
|
||||
- keeping scope terms (`Workspace`, `Tenant`) out of primary action labels unless they are the actual target object,
|
||||
- using source/domain terms only where same-screen disambiguation is required,
|
||||
- aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary,
|
||||
- removing implementation-first wording from primary operator-facing copy.
|
||||
**Operator Surfaces**: If this feature adds or materially refactors an operator-facing page or flow, tasks MUST include:
|
||||
- filling the spec’s Operator Surface Contract for every affected page,
|
||||
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
|
||||
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
|
||||
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
|
||||
- keeping workspace and tenant context explicit in navigation, actions, and page semantics so tenant pages do not silently expose workspace-wide actions.
|
||||
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
|
||||
- filling the spec’s “UI Action Matrix” for all changed surfaces,
|
||||
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
|
||||
- ensuring every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action),
|
||||
- enforcing the “max 2 visible row actions; everything else in More ActionGroup” rule,
|
||||
- grouping bulk actions via BulkActionGroup,
|
||||
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
||||
- adding `AuditLog` entries for relevant mutations,
|
||||
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
||||
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
||||
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
||||
- ensuring all form fields are inside Sections/Cards (no naked inputs at root schema level),
|
||||
- ensuring View pages use Infolists (not disabled edit forms); status badges use BADGE-001,
|
||||
- ensuring empty states show a specific title + explanation + exactly 1 CTA; non-empty tables move CTA to header,
|
||||
- capping header actions to max 1 primary + 1 secondary (rest grouped),
|
||||
- using shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) where available,
|
||||
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
|
||||
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
|
||||
avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
|
||||
781
Agents.md
781
Agents.md
@ -26,9 +26,9 @@ ## Scope Reference
|
||||
|
||||
## Workflow (Spec Kit)
|
||||
1. Read `.specify/constitution.md`
|
||||
2. For new work: create/update `.specify/spec.md`
|
||||
3. Produce `.specify/plan.md`
|
||||
4. Break into `.specify/tasks.md`
|
||||
2. For new work: create/update `specs/<NNN>-<slug>/spec.md`
|
||||
3. Produce `specs/<NNN>-<slug>/plan.md`
|
||||
4. Break into `specs/<NNN>-<slug>/tasks.md`
|
||||
5. Implement changes in small PRs
|
||||
|
||||
If requirements change during implementation, update spec/plan before continuing.
|
||||
@ -147,6 +147,52 @@ # Reset to before the conflict
|
||||
# Or stash conflicting changes
|
||||
git stash push -m "conflicting-agent-work-$(date +%s)"
|
||||
```
|
||||
|
||||
## Solo + Copilot Workflow (Konflikte vermeiden)
|
||||
|
||||
Wenn du alleine arbeitest (du + Copilot), sind große Konflikt-Stürme fast immer „Branch drift“: `dev` bewegt sich weiter, das Feature hängt hinterher. Diese Regeln halten Feature-Branches mergebar.
|
||||
|
||||
### Regel 1: Vor jeder Troubleshooting-Änderung zuerst `dev` ins Feature holen
|
||||
|
||||
Bevor du einen kleinen Fix auf einem Feature-Branch machst (z.B. `config/`, `tests/`, shared Services), synchronisiere:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git checkout feat/<NNN>-<slug>
|
||||
git merge origin/dev
|
||||
```
|
||||
|
||||
### Regel 2: Kurzlebige „Session Branches“ auch im Solo-Setup
|
||||
|
||||
Auch wenn du alleine bist: nutze Session-Branches für gezielte Fixes, damit du jederzeit sauber abbrechen kannst.
|
||||
|
||||
```bash
|
||||
git checkout feat/<NNN>-<slug>
|
||||
git checkout -b $(git branch --show-current)-session-$(date +%s)
|
||||
```
|
||||
|
||||
Danach wie gewohnt committen, testen, zurück-merge:
|
||||
|
||||
```bash
|
||||
SESSION_BRANCH=$(git branch --show-current)
|
||||
ORIGINAL_BRANCH=$(git branch --show-current | sed 's/-session-[0-9]*$//')
|
||||
git checkout $ORIGINAL_BRANCH
|
||||
git merge $SESSION_BRANCH --no-ff -m "merge: agent session work"
|
||||
```
|
||||
|
||||
### Regel 3: „Globale“ Fixes als Mini-PR nach `dev`
|
||||
|
||||
Wenn ein Fix nicht wirklich feature-spezifisch ist (z.B. `config/graph_contracts.php`, Test-Bootstrap, allgemeine Graph-Validation), dann:
|
||||
|
||||
- Mini-Branch von `dev` erstellen und PR → `dev` mergen.
|
||||
- Danach im Feature-Branch einfach wieder `origin/dev` mergen.
|
||||
|
||||
Das reduziert Add/Add-Konflikte drastisch, weil `dev` die gemeinsame Wahrheit bleibt.
|
||||
|
||||
### Regel 4: Kein Rebase auf geteilten Branches
|
||||
|
||||
Wenn du und Copilot über längere Zeit auf demselben Feature-Branch arbeiten, bleib bei `merge origin/dev` (kein Rebase), damit die Historie stabil bleibt.
|
||||
|
||||
## Architecture Assumptions
|
||||
- Backend: Laravel (latest stable)
|
||||
- Admin UI: Filament
|
||||
@ -224,7 +270,7 @@ ## Engineering Rules
|
||||
- Keep Microsoft Graph integration isolated behind a dedicated abstraction layer.
|
||||
- Use dependency injection and clear interfaces for Graph clients.
|
||||
- No breaking changes to data structures or API contracts without updating:
|
||||
- `.specify/spec.md`
|
||||
- `specs/<NNN>-<slug>/spec.md`
|
||||
- migration notes
|
||||
- upgrade steps
|
||||
- If a TypeScript/JS tooling package exists, use strict typing rules there too.
|
||||
@ -340,20 +386,307 @@ ## Reference Materials
|
||||
===
|
||||
|
||||
<laravel-boost-guidelines>
|
||||
=== .ai/filament-v5-blueprint rules ===
|
||||
|
||||
## Source of Truth
|
||||
|
||||
If any Filament behavior is uncertain, lookup the exact section in:
|
||||
- docs/research/filament-v5-notes.md
|
||||
and prefer that over guesses.
|
||||
|
||||
# SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES)
|
||||
|
||||
# Filament Blueprint (v5)
|
||||
|
||||
## 1) Non-negotiables
|
||||
|
||||
- Filament v5 requires Livewire v4.0+.
|
||||
- Laravel 11+: register panel providers in `bootstrap/providers.php` (never `bootstrap/app.php`).
|
||||
- Global search hard rule: If a Resource should appear in Global Search, it must have an Edit or View page; otherwise it will return no results.
|
||||
- Destructive actions must execute via `Action::make(...)->action(...)` and include `->requiresConfirmation()` (no exceptions).
|
||||
- Prefer render hooks + CSS hook classes over publishing Filament internal views.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||
- https://filamentphp.com/docs/5.x/actions/modals
|
||||
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
||||
|
||||
## 2) Directory & naming conventions
|
||||
|
||||
- Default to Filament discovery conventions for Resources/Pages/Widgets unless you adopt modular architecture.
|
||||
- Clusters: directory layout is recommended, not mandatory; functional behavior depends on `$cluster`.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/navigation/clusters
|
||||
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
|
||||
|
||||
## 3) Panel setup defaults
|
||||
|
||||
- Default to a single `/admin` panel unless multiple audiences/configs demand multiple panels.
|
||||
- Verify provider registration (Laravel 11+: `bootstrap/providers.php`) when adding a panel.
|
||||
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
|
||||
- Assets policy:
|
||||
- Panel-only assets: register via panel config.
|
||||
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||
- Deployment must include `php artisan filament:assets`.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||
|
||||
## 4) Navigation & information architecture
|
||||
|
||||
- Use nav groups + sort order intentionally; apply conditional visibility for clarity, but enforce authorization separately.
|
||||
- Use clusters to introduce hierarchy and sub-navigation when sidebar complexity grows.
|
||||
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
|
||||
- User menu:
|
||||
- Configure via `userMenuItems()` with Action objects.
|
||||
- Never put destructive actions there without confirmation + authorization.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/navigation/overview
|
||||
- https://filamentphp.com/docs/5.x/navigation/clusters
|
||||
- https://filamentphp.com/docs/5.x/navigation/user-menu
|
||||
|
||||
## 5) Resource patterns
|
||||
|
||||
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
|
||||
- Global search:
|
||||
- If a resource is intended for global search: ensure Edit/View page exists.
|
||||
- Otherwise disable global search for that resource (don’t “expect it to work”).
|
||||
- If global search renders relationship-backed details: eager-load via global search query override.
|
||||
- For very large datasets: consider disabling term splitting (only when needed).
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/resources/overview
|
||||
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||
|
||||
## 6) Page lifecycle & query rules
|
||||
|
||||
- Treat relationship-backed rendering in aggregate contexts (global search details, list summaries) as requiring eager loading.
|
||||
- Prefer render hooks for layout injection; avoid publishing internal views.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||
|
||||
## 7) Infolists vs RelationManagers (decision tree)
|
||||
|
||||
- Interactive CRUD / attach / detach under owner record → RelationManager.
|
||||
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
|
||||
- Inline CRUD inside owner form → Repeater.
|
||||
- Default performance stance: RelationManagers stay lazy-loaded unless explicit UX justification exists.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/resources/managing-relationships
|
||||
- https://filamentphp.com/docs/5.x/infolists/overview
|
||||
|
||||
## 8) Form patterns (validation, reactivity, state)
|
||||
|
||||
- Default: minimize server-driven reactivity; only use it when schema/visibility/requirements must change server-side.
|
||||
- Prefer “on blur” semantics for chatty inputs when using reactive behavior (per docs patterns).
|
||||
- Custom field views must obey state binding modifiers.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/forms/overview
|
||||
- https://filamentphp.com/docs/5.x/forms/custom-fields
|
||||
|
||||
## 9) Table & action patterns
|
||||
|
||||
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
|
||||
- Actions:
|
||||
- Execution actions use `->action(...)`.
|
||||
- Destructive actions add `->requiresConfirmation()`.
|
||||
- Navigation-only actions should use `->url(...)`.
|
||||
- UNVERIFIED: do not assert modal/confirmation behavior for URL-only actions unless verified.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/tables/empty-state
|
||||
- https://filamentphp.com/docs/5.x/actions/modals
|
||||
|
||||
## 10) Authorization & security
|
||||
|
||||
- Enforce panel access in non-local environments as documented.
|
||||
- UI visibility is not security; enforce policies/access checks in addition to hiding UI.
|
||||
- Bulk operations: explicitly decide between “Any” policy methods vs per-record authorization.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/users/overview
|
||||
- https://filamentphp.com/docs/5.x/resources/deleting-records
|
||||
|
||||
## 11) Notifications & UX feedback
|
||||
|
||||
- Default to explicit success/error notifications for user-triggered mutations that aren’t instantly obvious.
|
||||
- Treat polling as a cost; set intervals intentionally where polling is used.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/notifications/overview
|
||||
- https://filamentphp.com/docs/5.x/widgets/stats-overview
|
||||
|
||||
## 12) Performance defaults
|
||||
|
||||
- Heavy assets: prefer on-demand loading (`loadedOnRequest()` + `x-load-css` / `x-load-js`) for heavy dependencies.
|
||||
- Styling overrides use CSS hook classes; layout injection uses render hooks; avoid view publishing.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
||||
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||
|
||||
## 13) Testing requirements
|
||||
|
||||
- Test pages/relation managers/widgets as Livewire components.
|
||||
- Test actions using Filament’s action testing guidance.
|
||||
- Do not mount non-Livewire classes in Livewire tests.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/testing/overview
|
||||
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
||||
|
||||
## 14) Forbidden patterns
|
||||
|
||||
- Mixing Filament v3/v4 APIs into v5 code.
|
||||
- Any mention of Livewire v3 for Filament v5.
|
||||
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
|
||||
- Destructive actions without `->requiresConfirmation()`.
|
||||
- Shipping heavy assets globally when on-demand loading fits.
|
||||
- Publishing Filament internal views as a default customization technique.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||
- https://filamentphp.com/docs/5.x/actions/modals
|
||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||
|
||||
## 15) Agent output contract
|
||||
|
||||
For any implementation request, the agent must explicitly state:
|
||||
1) Livewire v4.0+ compliance.
|
||||
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
|
||||
3) For each globally searchable resource: whether it has Edit/View page (or global search is disabled).
|
||||
4) Which actions are destructive and how confirmation + authorization is handled.
|
||||
5) Asset strategy: global vs on-demand and where `filament:assets` runs in deploy.
|
||||
6) Testing plan: which pages/widgets/relation managers/actions are covered.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
||||
|
||||
=== .ai/filament-v5-checklist rules ===
|
||||
|
||||
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
|
||||
|
||||
## Version Safety
|
||||
|
||||
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
|
||||
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “Upgrading Livewire”
|
||||
- [ ] All references are Filament `/docs/5.x/` only (no v3/v4 docs, no legacy APIs).
|
||||
- [ ] Upgrade assumptions match the v5 upgrade guide requirements and steps.
|
||||
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
|
||||
|
||||
## Panel & Navigation
|
||||
|
||||
- [ ] Laravel 11+: panel providers are registered in `bootstrap/providers.php` (not `bootstrap/app.php`).
|
||||
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Creating a new panel”
|
||||
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
|
||||
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Changing the path”
|
||||
- [ ] Cluster usage is correctly configured (discovery + `$cluster` assignments).
|
||||
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Creating a cluster”
|
||||
- [ ] Cluster semantics (sub-navigation + grouped navigation behavior) are understood and verified against the clusters docs.
|
||||
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Introduction”
|
||||
- [ ] Cluster directory structure is treated as recommended, not mandatory.
|
||||
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Code structure recommendations for panels using clusters”
|
||||
- [ ] User menu items are registered via `userMenuItems()` and permission-gated where needed.
|
||||
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
|
||||
|
||||
## Resource Structure
|
||||
|
||||
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/overview — “Record titles”
|
||||
- [ ] Hard rule enforced: every globally searchable resource has an Edit or View page; otherwise global search is disabled for it.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Setting global search result titles”
|
||||
- [ ] Relationship-backed global search details are eager-loaded via the global search query override.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
|
||||
|
||||
## Infolists & Relations
|
||||
|
||||
- [ ] Each relationship uses the correct tool (RelationManager vs Select/CheckboxList vs Repeater) based on required interaction.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Choosing the right tool for the job”
|
||||
- [ ] RelationManagers remain lazy-loaded by default unless there’s an explicit UX justification.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading”
|
||||
|
||||
## Forms
|
||||
|
||||
- [ ] Server-driven reactivity is minimal; chatty inputs do not trigger network requests unnecessarily.
|
||||
- Source: https://filamentphp.com/docs/5.x/forms/overview — “Reactive fields on blur”
|
||||
- [ ] Custom field views obey state binding modifiers (no hardcoded `wire:model` without modifiers).
|
||||
- Source: https://filamentphp.com/docs/5.x/forms/custom-fields — “Obeying state binding modifiers”
|
||||
|
||||
## Tables & Actions
|
||||
|
||||
- [ ] Tables define a meaningful empty state (and empty-state actions where appropriate).
|
||||
- Source: https://filamentphp.com/docs/5.x/tables/empty-state — “Adding empty state actions”
|
||||
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
|
||||
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
||||
- [ ] No checklist rule assumes confirmation/modals for `->url(...)` actions unless verified in docs (UNVERIFIED behavior must not be asserted as fact).
|
||||
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
||||
|
||||
## Authorization & Security
|
||||
|
||||
- [ ] Panel access is enforced for non-local environments as documented.
|
||||
- Source: https://filamentphp.com/docs/5.x/users/overview — “Authorizing access to the panel”
|
||||
- [ ] UI visibility is not treated as authorization; policies/access checks still enforce boundaries.
|
||||
- [ ] Bulk operations intentionally choose between “Any” policy methods vs per-record authorization where required.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
|
||||
|
||||
## UX & Notifications
|
||||
|
||||
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes aren’t instantly obvious.
|
||||
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
|
||||
- [ ] Polling (widgets/notifications) is configured intentionally (interval set or disabled) to control load.
|
||||
- Source: https://filamentphp.com/docs/5.x/widgets/stats-overview — “Live updating stats (polling)”
|
||||
|
||||
## Performance
|
||||
|
||||
- [ ] Heavy frontend assets are loaded on-demand using `loadedOnRequest()` + `x-load-css` / `x-load-js` where appropriate.
|
||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “Lazy loading CSS” / “Lazy loading JavaScript”
|
||||
- [ ] Styling overrides use CSS hook classes discovered via DevTools (no brittle selectors by default).
|
||||
- Source: https://filamentphp.com/docs/5.x/styling/css-hooks — “Discovering hook classes”
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] Livewire tests mount Filament pages/relation managers/widgets (Livewire components), not static resource classes.
|
||||
- Source: https://filamentphp.com/docs/5.x/testing/overview — “What is a Livewire component when using Filament?”
|
||||
- [ ] Actions that mutate data are covered using Filament’s action testing guidance.
|
||||
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
||||
|
||||
## Deployment / Ops
|
||||
|
||||
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
||||
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.15
|
||||
- filament/filament (FILAMENT) - v4
|
||||
- filament/filament (FILAMENT) - v5
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- livewire/livewire (LIVEWIRE) - v3
|
||||
- laravel/socialite (SOCIALITE) - v5
|
||||
- livewire/livewire (LIVEWIRE) - v4
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/sail (SAIL) - v1
|
||||
@ -361,426 +694,248 @@ ## Foundational Context
|
||||
- phpunit/phpunit (PHPUNIT) - v12
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
|
||||
## Skills Activation
|
||||
|
||||
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||
|
||||
- `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works.
|
||||
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
||||
|
||||
## Conventions
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
|
||||
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
- UI consistency: Prefer Filament components (`<x-filament::section>`, infolist/table entries, etc.) over custom HTML/Tailwind for admin UI; only roll custom markup when Filament cannot express the UI.
|
||||
|
||||
## Verification Scripts
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
||||
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
- Stick to existing directory structure - don't create new base folders without approval.
|
||||
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Replies
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
## Replies
|
||||
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
## Laravel Boost
|
||||
# Laravel Boost
|
||||
|
||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||
|
||||
## Artisan
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
|
||||
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||
|
||||
## URLs
|
||||
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
|
||||
|
||||
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||
|
||||
## Tinker / Debugging
|
||||
|
||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||
- Use the `database-query` tool when you only need to read from the database.
|
||||
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
|
||||
|
||||
## Reading Browser Logs With the `browser-logs` Tool
|
||||
|
||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||
- Only recent browser logs will be useful - ignore old logs.
|
||||
|
||||
## Searching Documentation (Critically Important)
|
||||
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
||||
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
|
||||
|
||||
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
||||
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
|
||||
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Available Search Syntax
|
||||
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||
|
||||
=== php rules ===
|
||||
|
||||
## PHP
|
||||
# PHP
|
||||
|
||||
- Always use curly braces for control structures, even if it has one line.
|
||||
- Always use curly braces for control structures, even for single-line bodies.
|
||||
|
||||
## Constructors
|
||||
|
||||
### Constructors
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||
- Do not allow empty `__construct()` methods with zero parameters.
|
||||
- `public function __construct(public GitHub $github) { }`
|
||||
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||
|
||||
## Type Declarations
|
||||
|
||||
### Type Declarations
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||
<!-- Explicit Return Types and Method Params -->
|
||||
```php
|
||||
protected function isAccessible(User $user, ?string $path = null): bool
|
||||
{
|
||||
...
|
||||
}
|
||||
</code-snippet>
|
||||
|
||||
## Comments
|
||||
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
|
||||
|
||||
## PHPDoc Blocks
|
||||
- Add useful array shape type definitions for arrays when appropriate.
|
||||
```
|
||||
|
||||
## Enums
|
||||
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
## Comments
|
||||
|
||||
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
|
||||
|
||||
## PHPDoc Blocks
|
||||
|
||||
- Add useful array shape type definitions when appropriate.
|
||||
|
||||
=== sail rules ===
|
||||
|
||||
# Laravel Sail
|
||||
|
||||
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
||||
- Open the application in the browser by running `vendor/bin/sail open`.
|
||||
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
|
||||
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
||||
- Install Composer packages: `vendor/bin/sail composer install`
|
||||
- Execute Node commands: `vendor/bin/sail npm run dev`
|
||||
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
||||
- View all available Sail commands by running `vendor/bin/sail` without arguments.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
## Test Enforcement
|
||||
# Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
|
||||
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
## Do Things the Laravel Way
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### Database
|
||||
## Database
|
||||
|
||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||
- Use Eloquent models and relationships before suggesting raw database queries
|
||||
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||
- Generate code that prevents N+1 query problems by using eager loading.
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### Model Creation
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
### Controllers & Validation
|
||||
## Controllers & Validation
|
||||
|
||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||
|
||||
### Queues
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
## Authentication & Authorization
|
||||
|
||||
### Authentication & Authorization
|
||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||
|
||||
### URL Generation
|
||||
## URL Generation
|
||||
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
### Configuration
|
||||
## Queues
|
||||
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
|
||||
## Configuration
|
||||
|
||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||
|
||||
### Testing
|
||||
## Testing
|
||||
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
### Vite Error
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
## Laravel 12
|
||||
# Laravel 12
|
||||
|
||||
- Use the `search-docs` tool to get version specific documentation.
|
||||
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
|
||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||
|
||||
### Laravel 12 Structure
|
||||
- No middleware files in `app/Http/Middleware/`.
|
||||
## Laravel 12 Structure
|
||||
|
||||
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
||||
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||
- `bootstrap/providers.php` contains application specific service providers.
|
||||
- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||
|
||||
## Database
|
||||
|
||||
### Database
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### Models
|
||||
|
||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
||||
|
||||
=== livewire/core rules ===
|
||||
|
||||
## Livewire Core
|
||||
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
|
||||
- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
|
||||
- State should live on the server, with the UI reflecting it.
|
||||
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
|
||||
|
||||
## Livewire Best Practices
|
||||
- Livewire components require a single root element.
|
||||
- Use `wire:loading` and `wire:dirty` for delightful loading states.
|
||||
- Add `wire:key` in loops:
|
||||
|
||||
```blade
|
||||
@foreach ($items as $item)
|
||||
<div wire:key="item-{{ $item->id }}">
|
||||
{{ $item->name }}
|
||||
</div>
|
||||
@endforeach
|
||||
```
|
||||
|
||||
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
|
||||
|
||||
<code-snippet name="Lifecycle hook examples" lang="php">
|
||||
public function mount(User $user) { $this->user = $user; }
|
||||
public function updatedSearch() { $this->resetPage(); }
|
||||
</code-snippet>
|
||||
|
||||
|
||||
## Testing Livewire
|
||||
|
||||
<code-snippet name="Example Livewire component test" lang="php">
|
||||
Livewire::test(Counter::class)
|
||||
->assertSet('count', 0)
|
||||
->call('increment')
|
||||
->assertSet('count', 1)
|
||||
->assertSee(1)
|
||||
->assertStatus(200);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
|
||||
$this->get('/posts/create')
|
||||
->assertSeeLivewire(CreatePost::class);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== livewire/v3 rules ===
|
||||
|
||||
## Livewire 3
|
||||
|
||||
### Key Changes From Livewire 2
|
||||
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
|
||||
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
|
||||
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
|
||||
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
|
||||
- Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
|
||||
|
||||
### New Directives
|
||||
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
|
||||
|
||||
### Alpine
|
||||
- Alpine is now included with Livewire, don't manually include Alpine.js.
|
||||
- Plugins included with Alpine: persist, intersect, collapse, and focus.
|
||||
|
||||
### Lifecycle Hooks
|
||||
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
|
||||
|
||||
<code-snippet name="livewire:load example" lang="js">
|
||||
document.addEventListener('livewire:init', function () {
|
||||
Livewire.hook('request', ({ fail }) => {
|
||||
if (fail && fail.status === 419) {
|
||||
alert('Your session expired');
|
||||
}
|
||||
});
|
||||
|
||||
Livewire.hook('message.failed', (message, component) => {
|
||||
console.error(message);
|
||||
});
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
## Laravel Pint Code Formatter
|
||||
|
||||
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- You must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/sail bin pint --test --format agent`, simply run `vendor/bin/sail bin pint --format agent` to fix any formatting issues.
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
### Testing
|
||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
||||
|
||||
### Pest Tests
|
||||
- All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||
- Pest tests look and behave like this:
|
||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Running Tests
|
||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
||||
- To run all tests: `php artisan test`.
|
||||
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
|
||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
||||
|
||||
### Pest Assertions
|
||||
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
|
||||
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
||||
it('returns all', function () {
|
||||
$response = $this->postJson('/api/docs', []);
|
||||
|
||||
$response->assertSuccessful();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Mocking
|
||||
- Mocking can be very helpful when appropriate.
|
||||
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
|
||||
- You can also create partial mocks using the same import or self method.
|
||||
|
||||
### Datasets
|
||||
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
|
||||
|
||||
<code-snippet name="Pest Dataset Example" lang="php">
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== pest/v4 rules ===
|
||||
|
||||
## Pest 4
|
||||
|
||||
- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
|
||||
- Browser testing is incredibly powerful and useful for this project.
|
||||
- Browser tests should live in `tests/Browser/`.
|
||||
- Use the `search-docs` tool for detailed guidance on utilizing these features.
|
||||
|
||||
### Browser Testing
|
||||
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
|
||||
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
|
||||
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
|
||||
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
|
||||
- Switch color schemes (light/dark mode) when appropriate.
|
||||
- Take screenshots or pause tests for debugging when appropriate.
|
||||
|
||||
### Example Tests
|
||||
|
||||
<code-snippet name="Pest Browser Test Example" lang="php">
|
||||
it('may reset the password', function () {
|
||||
Notification::fake();
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = visit('/sign-in'); // Visit on a real browser...
|
||||
|
||||
$page->assertSee('Sign In')
|
||||
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
|
||||
->click('Forgot Password?')
|
||||
->fill('email', 'nuno@laravel.com')
|
||||
->click('Send Reset Link')
|
||||
->assertSee('We have emailed your password reset link!')
|
||||
|
||||
Notification::assertSent(ResetPassword::class);
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Pest Smoke Testing Example" lang="php">
|
||||
$pages = visit(['/', '/about', '/contact']);
|
||||
|
||||
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
||||
</code-snippet>
|
||||
|
||||
- This project uses Pest for testing. Create tests: `vendor/bin/sail artisan make:test --pest {name}`.
|
||||
- Run tests: `vendor/bin/sail artisan test --compact` or filter: `vendor/bin/sail artisan test --compact --filter=testName`.
|
||||
- Do NOT delete tests without approval.
|
||||
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
|
||||
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
|
||||
|
||||
=== tailwindcss/core rules ===
|
||||
|
||||
## Tailwind Core
|
||||
# Tailwind CSS
|
||||
|
||||
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
|
||||
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
|
||||
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
||||
|
||||
### Spacing
|
||||
- When listing items, use gap utilities for spacing, don't use margins.
|
||||
|
||||
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
||||
<div class="flex gap-8">
|
||||
<div>Superior</div>
|
||||
<div>Michigan</div>
|
||||
<div>Erie</div>
|
||||
</div>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Dark Mode
|
||||
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
|
||||
|
||||
|
||||
=== tailwindcss/v4 rules ===
|
||||
|
||||
## Tailwind 4
|
||||
|
||||
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
|
||||
<code-snippet name="Extending Theme in CSS" lang="css">
|
||||
@theme {
|
||||
--color-brand: oklch(0.72 0.11 178);
|
||||
}
|
||||
</code-snippet>
|
||||
|
||||
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
|
||||
|
||||
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Replaced Utilities
|
||||
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
|
||||
- Opacity values are still 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 |
|
||||
- Always use existing Tailwind conventions; check project patterns before adding new ones.
|
||||
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
|
||||
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
|
||||
</laravel-boost-guidelines>
|
||||
|
||||
## Active Technologies
|
||||
- PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4
|
||||
- PostgreSQL (Sail)
|
||||
- Tailwind CSS v4
|
||||
|
||||
## Recent Changes
|
||||
- 066-rbac-ui-enforcement-helper-v2-session-1769732329: Planned UiEnforcement v2 (spec + plan + design artifacts)
|
||||
|
||||
734
GEMINI.md
734
GEMINI.md
@ -26,9 +26,9 @@ ## Scope Reference
|
||||
|
||||
## Workflow (Spec Kit)
|
||||
1. Read `.specify/constitution.md`
|
||||
2. For new work: create/update `.specify/spec.md`
|
||||
3. Produce `.specify/plan.md`
|
||||
4. Break into `.specify/tasks.md`
|
||||
2. For new work: create/update `specs/<NNN>-<slug>/spec.md`
|
||||
3. Produce `specs/<NNN>-<slug>/plan.md`
|
||||
4. Break into `specs/<NNN>-<slug>/tasks.md`
|
||||
5. Implement changes in small PRs
|
||||
|
||||
If requirements change during implementation, update spec/plan before continuing.
|
||||
@ -110,7 +110,7 @@ ## Engineering Rules
|
||||
- Keep Microsoft Graph integration isolated behind a dedicated abstraction layer.
|
||||
- Use dependency injection and clear interfaces for Graph clients.
|
||||
- No breaking changes to data structures or API contracts without updating:
|
||||
- `.specify/spec.md`
|
||||
- `specs/<NNN>-<slug>/spec.md`
|
||||
- migration notes
|
||||
- upgrade steps
|
||||
- If a TypeScript/JS tooling package exists, use strict typing rules there too.
|
||||
@ -226,20 +226,307 @@ ## Reference Materials
|
||||
===
|
||||
|
||||
<laravel-boost-guidelines>
|
||||
=== .ai/filament-v5-blueprint rules ===
|
||||
|
||||
## Source of Truth
|
||||
|
||||
If any Filament behavior is uncertain, lookup the exact section in:
|
||||
- docs/research/filament-v5-notes.md
|
||||
and prefer that over guesses.
|
||||
|
||||
# SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES)
|
||||
|
||||
# Filament Blueprint (v5)
|
||||
|
||||
## 1) Non-negotiables
|
||||
|
||||
- Filament v5 requires Livewire v4.0+.
|
||||
- Laravel 11+: register panel providers in `bootstrap/providers.php` (never `bootstrap/app.php`).
|
||||
- Global search hard rule: If a Resource should appear in Global Search, it must have an Edit or View page; otherwise it will return no results.
|
||||
- Destructive actions must execute via `Action::make(...)->action(...)` and include `->requiresConfirmation()` (no exceptions).
|
||||
- Prefer render hooks + CSS hook classes over publishing Filament internal views.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||
- https://filamentphp.com/docs/5.x/actions/modals
|
||||
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
||||
|
||||
## 2) Directory & naming conventions
|
||||
|
||||
- Default to Filament discovery conventions for Resources/Pages/Widgets unless you adopt modular architecture.
|
||||
- Clusters: directory layout is recommended, not mandatory; functional behavior depends on `$cluster`.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/navigation/clusters
|
||||
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
|
||||
|
||||
## 3) Panel setup defaults
|
||||
|
||||
- Default to a single `/admin` panel unless multiple audiences/configs demand multiple panels.
|
||||
- Verify provider registration (Laravel 11+: `bootstrap/providers.php`) when adding a panel.
|
||||
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
|
||||
- Assets policy:
|
||||
- Panel-only assets: register via panel config.
|
||||
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||
- Deployment must include `php artisan filament:assets`.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||
|
||||
## 4) Navigation & information architecture
|
||||
|
||||
- Use nav groups + sort order intentionally; apply conditional visibility for clarity, but enforce authorization separately.
|
||||
- Use clusters to introduce hierarchy and sub-navigation when sidebar complexity grows.
|
||||
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
|
||||
- User menu:
|
||||
- Configure via `userMenuItems()` with Action objects.
|
||||
- Never put destructive actions there without confirmation + authorization.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/navigation/overview
|
||||
- https://filamentphp.com/docs/5.x/navigation/clusters
|
||||
- https://filamentphp.com/docs/5.x/navigation/user-menu
|
||||
|
||||
## 5) Resource patterns
|
||||
|
||||
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
|
||||
- Global search:
|
||||
- If a resource is intended for global search: ensure Edit/View page exists.
|
||||
- Otherwise disable global search for that resource (don’t “expect it to work”).
|
||||
- If global search renders relationship-backed details: eager-load via global search query override.
|
||||
- For very large datasets: consider disabling term splitting (only when needed).
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/resources/overview
|
||||
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||
|
||||
## 6) Page lifecycle & query rules
|
||||
|
||||
- Treat relationship-backed rendering in aggregate contexts (global search details, list summaries) as requiring eager loading.
|
||||
- Prefer render hooks for layout injection; avoid publishing internal views.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||
|
||||
## 7) Infolists vs RelationManagers (decision tree)
|
||||
|
||||
- Interactive CRUD / attach / detach under owner record → RelationManager.
|
||||
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
|
||||
- Inline CRUD inside owner form → Repeater.
|
||||
- Default performance stance: RelationManagers stay lazy-loaded unless explicit UX justification exists.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/resources/managing-relationships
|
||||
- https://filamentphp.com/docs/5.x/infolists/overview
|
||||
|
||||
## 8) Form patterns (validation, reactivity, state)
|
||||
|
||||
- Default: minimize server-driven reactivity; only use it when schema/visibility/requirements must change server-side.
|
||||
- Prefer “on blur” semantics for chatty inputs when using reactive behavior (per docs patterns).
|
||||
- Custom field views must obey state binding modifiers.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/forms/overview
|
||||
- https://filamentphp.com/docs/5.x/forms/custom-fields
|
||||
|
||||
## 9) Table & action patterns
|
||||
|
||||
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
|
||||
- Actions:
|
||||
- Execution actions use `->action(...)`.
|
||||
- Destructive actions add `->requiresConfirmation()`.
|
||||
- Navigation-only actions should use `->url(...)`.
|
||||
- UNVERIFIED: do not assert modal/confirmation behavior for URL-only actions unless verified.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/tables/empty-state
|
||||
- https://filamentphp.com/docs/5.x/actions/modals
|
||||
|
||||
## 10) Authorization & security
|
||||
|
||||
- Enforce panel access in non-local environments as documented.
|
||||
- UI visibility is not security; enforce policies/access checks in addition to hiding UI.
|
||||
- Bulk operations: explicitly decide between “Any” policy methods vs per-record authorization.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/users/overview
|
||||
- https://filamentphp.com/docs/5.x/resources/deleting-records
|
||||
|
||||
## 11) Notifications & UX feedback
|
||||
|
||||
- Default to explicit success/error notifications for user-triggered mutations that aren’t instantly obvious.
|
||||
- Treat polling as a cost; set intervals intentionally where polling is used.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/notifications/overview
|
||||
- https://filamentphp.com/docs/5.x/widgets/stats-overview
|
||||
|
||||
## 12) Performance defaults
|
||||
|
||||
- Heavy assets: prefer on-demand loading (`loadedOnRequest()` + `x-load-css` / `x-load-js`) for heavy dependencies.
|
||||
- Styling overrides use CSS hook classes; layout injection uses render hooks; avoid view publishing.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
||||
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||
|
||||
## 13) Testing requirements
|
||||
|
||||
- Test pages/relation managers/widgets as Livewire components.
|
||||
- Test actions using Filament’s action testing guidance.
|
||||
- Do not mount non-Livewire classes in Livewire tests.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/testing/overview
|
||||
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
||||
|
||||
## 14) Forbidden patterns
|
||||
|
||||
- Mixing Filament v3/v4 APIs into v5 code.
|
||||
- Any mention of Livewire v3 for Filament v5.
|
||||
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
|
||||
- Destructive actions without `->requiresConfirmation()`.
|
||||
- Shipping heavy assets globally when on-demand loading fits.
|
||||
- Publishing Filament internal views as a default customization technique.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||
- https://filamentphp.com/docs/5.x/actions/modals
|
||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||
|
||||
## 15) Agent output contract
|
||||
|
||||
For any implementation request, the agent must explicitly state:
|
||||
1) Livewire v4.0+ compliance.
|
||||
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
|
||||
3) For each globally searchable resource: whether it has Edit/View page (or global search is disabled).
|
||||
4) Which actions are destructive and how confirmation + authorization is handled.
|
||||
5) Asset strategy: global vs on-demand and where `filament:assets` runs in deploy.
|
||||
6) Testing plan: which pages/widgets/relation managers/actions are covered.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
||||
|
||||
=== .ai/filament-v5-checklist rules ===
|
||||
|
||||
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
|
||||
|
||||
## Version Safety
|
||||
|
||||
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
|
||||
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “Upgrading Livewire”
|
||||
- [ ] All references are Filament `/docs/5.x/` only (no v3/v4 docs, no legacy APIs).
|
||||
- [ ] Upgrade assumptions match the v5 upgrade guide requirements and steps.
|
||||
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
|
||||
|
||||
## Panel & Navigation
|
||||
|
||||
- [ ] Laravel 11+: panel providers are registered in `bootstrap/providers.php` (not `bootstrap/app.php`).
|
||||
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Creating a new panel”
|
||||
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
|
||||
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Changing the path”
|
||||
- [ ] Cluster usage is correctly configured (discovery + `$cluster` assignments).
|
||||
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Creating a cluster”
|
||||
- [ ] Cluster semantics (sub-navigation + grouped navigation behavior) are understood and verified against the clusters docs.
|
||||
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Introduction”
|
||||
- [ ] Cluster directory structure is treated as recommended, not mandatory.
|
||||
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Code structure recommendations for panels using clusters”
|
||||
- [ ] User menu items are registered via `userMenuItems()` and permission-gated where needed.
|
||||
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
|
||||
|
||||
## Resource Structure
|
||||
|
||||
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/overview — “Record titles”
|
||||
- [ ] Hard rule enforced: every globally searchable resource has an Edit or View page; otherwise global search is disabled for it.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Setting global search result titles”
|
||||
- [ ] Relationship-backed global search details are eager-loaded via the global search query override.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
|
||||
|
||||
## Infolists & Relations
|
||||
|
||||
- [ ] Each relationship uses the correct tool (RelationManager vs Select/CheckboxList vs Repeater) based on required interaction.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Choosing the right tool for the job”
|
||||
- [ ] RelationManagers remain lazy-loaded by default unless there’s an explicit UX justification.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading”
|
||||
|
||||
## Forms
|
||||
|
||||
- [ ] Server-driven reactivity is minimal; chatty inputs do not trigger network requests unnecessarily.
|
||||
- Source: https://filamentphp.com/docs/5.x/forms/overview — “Reactive fields on blur”
|
||||
- [ ] Custom field views obey state binding modifiers (no hardcoded `wire:model` without modifiers).
|
||||
- Source: https://filamentphp.com/docs/5.x/forms/custom-fields — “Obeying state binding modifiers”
|
||||
|
||||
## Tables & Actions
|
||||
|
||||
- [ ] Tables define a meaningful empty state (and empty-state actions where appropriate).
|
||||
- Source: https://filamentphp.com/docs/5.x/tables/empty-state — “Adding empty state actions”
|
||||
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
|
||||
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
||||
- [ ] No checklist rule assumes confirmation/modals for `->url(...)` actions unless verified in docs (UNVERIFIED behavior must not be asserted as fact).
|
||||
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
||||
|
||||
## Authorization & Security
|
||||
|
||||
- [ ] Panel access is enforced for non-local environments as documented.
|
||||
- Source: https://filamentphp.com/docs/5.x/users/overview — “Authorizing access to the panel”
|
||||
- [ ] UI visibility is not treated as authorization; policies/access checks still enforce boundaries.
|
||||
- [ ] Bulk operations intentionally choose between “Any” policy methods vs per-record authorization where required.
|
||||
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
|
||||
|
||||
## UX & Notifications
|
||||
|
||||
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes aren’t instantly obvious.
|
||||
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
|
||||
- [ ] Polling (widgets/notifications) is configured intentionally (interval set or disabled) to control load.
|
||||
- Source: https://filamentphp.com/docs/5.x/widgets/stats-overview — “Live updating stats (polling)”
|
||||
|
||||
## Performance
|
||||
|
||||
- [ ] Heavy frontend assets are loaded on-demand using `loadedOnRequest()` + `x-load-css` / `x-load-js` where appropriate.
|
||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “Lazy loading CSS” / “Lazy loading JavaScript”
|
||||
- [ ] Styling overrides use CSS hook classes discovered via DevTools (no brittle selectors by default).
|
||||
- Source: https://filamentphp.com/docs/5.x/styling/css-hooks — “Discovering hook classes”
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] Livewire tests mount Filament pages/relation managers/widgets (Livewire components), not static resource classes.
|
||||
- Source: https://filamentphp.com/docs/5.x/testing/overview — “What is a Livewire component when using Filament?”
|
||||
- [ ] Actions that mutate data are covered using Filament’s action testing guidance.
|
||||
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
||||
|
||||
## Deployment / Ops
|
||||
|
||||
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
||||
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.15
|
||||
- filament/filament (FILAMENT) - v4
|
||||
- filament/filament (FILAMENT) - v5
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- livewire/livewire (LIVEWIRE) - v3
|
||||
- laravel/socialite (SOCIALITE) - v5
|
||||
- livewire/livewire (LIVEWIRE) - v4
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/sail (SAIL) - v1
|
||||
@ -247,425 +534,248 @@ ## Foundational Context
|
||||
- phpunit/phpunit (PHPUNIT) - v12
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
|
||||
## Skills Activation
|
||||
|
||||
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||
|
||||
- `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works.
|
||||
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
||||
|
||||
## Conventions
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
|
||||
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
||||
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
- Stick to existing directory structure - don't create new base folders without approval.
|
||||
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Replies
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
## Replies
|
||||
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
## Laravel Boost
|
||||
# Laravel Boost
|
||||
|
||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||
|
||||
## Artisan
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
|
||||
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||
|
||||
## URLs
|
||||
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
|
||||
|
||||
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||
|
||||
## Tinker / Debugging
|
||||
|
||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||
- Use the `database-query` tool when you only need to read from the database.
|
||||
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
|
||||
|
||||
## Reading Browser Logs With the `browser-logs` Tool
|
||||
|
||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||
- Only recent browser logs will be useful - ignore old logs.
|
||||
|
||||
## Searching Documentation (Critically Important)
|
||||
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
||||
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
|
||||
|
||||
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
||||
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
|
||||
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Available Search Syntax
|
||||
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||
|
||||
=== php rules ===
|
||||
|
||||
## PHP
|
||||
# PHP
|
||||
|
||||
- Always use curly braces for control structures, even if it has one line.
|
||||
- Always use curly braces for control structures, even for single-line bodies.
|
||||
|
||||
## Constructors
|
||||
|
||||
### Constructors
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||
- Do not allow empty `__construct()` methods with zero parameters.
|
||||
- `public function __construct(public GitHub $github) { }`
|
||||
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||
|
||||
## Type Declarations
|
||||
|
||||
### Type Declarations
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||
<!-- Explicit Return Types and Method Params -->
|
||||
```php
|
||||
protected function isAccessible(User $user, ?string $path = null): bool
|
||||
{
|
||||
...
|
||||
}
|
||||
</code-snippet>
|
||||
|
||||
## Comments
|
||||
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
|
||||
|
||||
## PHPDoc Blocks
|
||||
- Add useful array shape type definitions for arrays when appropriate.
|
||||
```
|
||||
|
||||
## Enums
|
||||
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
## Comments
|
||||
|
||||
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
|
||||
|
||||
## PHPDoc Blocks
|
||||
|
||||
- Add useful array shape type definitions when appropriate.
|
||||
|
||||
=== sail rules ===
|
||||
|
||||
# Laravel Sail
|
||||
|
||||
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
||||
- Open the application in the browser by running `vendor/bin/sail open`.
|
||||
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
|
||||
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
||||
- Install Composer packages: `vendor/bin/sail composer install`
|
||||
- Execute Node commands: `vendor/bin/sail npm run dev`
|
||||
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
||||
- View all available Sail commands by running `vendor/bin/sail` without arguments.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
## Test Enforcement
|
||||
# Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
|
||||
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
## Do Things the Laravel Way
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### Database
|
||||
## Database
|
||||
|
||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||
- Use Eloquent models and relationships before suggesting raw database queries
|
||||
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||
- Generate code that prevents N+1 query problems by using eager loading.
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### Model Creation
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
### Controllers & Validation
|
||||
## Controllers & Validation
|
||||
|
||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||
|
||||
### Queues
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
## Authentication & Authorization
|
||||
|
||||
### Authentication & Authorization
|
||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||
|
||||
### URL Generation
|
||||
## URL Generation
|
||||
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
### Configuration
|
||||
## Queues
|
||||
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
|
||||
## Configuration
|
||||
|
||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||
|
||||
### Testing
|
||||
## Testing
|
||||
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
### Vite Error
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
## Laravel 12
|
||||
# Laravel 12
|
||||
|
||||
- Use the `search-docs` tool to get version specific documentation.
|
||||
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
|
||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||
|
||||
### Laravel 12 Structure
|
||||
- No middleware files in `app/Http/Middleware/`.
|
||||
## Laravel 12 Structure
|
||||
|
||||
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
||||
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||
- `bootstrap/providers.php` contains application specific service providers.
|
||||
- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||
|
||||
## Database
|
||||
|
||||
### Database
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### Models
|
||||
|
||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
||||
|
||||
=== livewire/core rules ===
|
||||
|
||||
## Livewire Core
|
||||
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
|
||||
- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
|
||||
- State should live on the server, with the UI reflecting it.
|
||||
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
|
||||
|
||||
## Livewire Best Practices
|
||||
- Livewire components require a single root element.
|
||||
- Use `wire:loading` and `wire:dirty` for delightful loading states.
|
||||
- Add `wire:key` in loops:
|
||||
|
||||
```blade
|
||||
@foreach ($items as $item)
|
||||
<div wire:key="item-{{ $item->id }}">
|
||||
{{ $item->name }}
|
||||
</div>
|
||||
@endforeach
|
||||
```
|
||||
|
||||
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
|
||||
|
||||
<code-snippet name="Lifecycle hook examples" lang="php">
|
||||
public function mount(User $user) { $this->user = $user; }
|
||||
public function updatedSearch() { $this->resetPage(); }
|
||||
</code-snippet>
|
||||
|
||||
|
||||
## Testing Livewire
|
||||
|
||||
<code-snippet name="Example Livewire component test" lang="php">
|
||||
Livewire::test(Counter::class)
|
||||
->assertSet('count', 0)
|
||||
->call('increment')
|
||||
->assertSet('count', 1)
|
||||
->assertSee(1)
|
||||
->assertStatus(200);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
|
||||
$this->get('/posts/create')
|
||||
->assertSeeLivewire(CreatePost::class);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== livewire/v3 rules ===
|
||||
|
||||
## Livewire 3
|
||||
|
||||
### Key Changes From Livewire 2
|
||||
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
|
||||
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
|
||||
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
|
||||
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
|
||||
- Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
|
||||
|
||||
### New Directives
|
||||
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
|
||||
|
||||
### Alpine
|
||||
- Alpine is now included with Livewire, don't manually include Alpine.js.
|
||||
- Plugins included with Alpine: persist, intersect, collapse, and focus.
|
||||
|
||||
### Lifecycle Hooks
|
||||
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
|
||||
|
||||
<code-snippet name="livewire:load example" lang="js">
|
||||
document.addEventListener('livewire:init', function () {
|
||||
Livewire.hook('request', ({ fail }) => {
|
||||
if (fail && fail.status === 419) {
|
||||
alert('Your session expired');
|
||||
}
|
||||
});
|
||||
|
||||
Livewire.hook('message.failed', (message, component) => {
|
||||
console.error(message);
|
||||
});
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
## Laravel Pint Code Formatter
|
||||
|
||||
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- You must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/sail bin pint --test --format agent`, simply run `vendor/bin/sail bin pint --format agent` to fix any formatting issues.
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
### Testing
|
||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
||||
|
||||
### Pest Tests
|
||||
- All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||
- Pest tests look and behave like this:
|
||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Running Tests
|
||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
||||
- To run all tests: `php artisan test`.
|
||||
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
|
||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
||||
|
||||
### Pest Assertions
|
||||
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
|
||||
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
||||
it('returns all', function () {
|
||||
$response = $this->postJson('/api/docs', []);
|
||||
|
||||
$response->assertSuccessful();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Mocking
|
||||
- Mocking can be very helpful when appropriate.
|
||||
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
|
||||
- You can also create partial mocks using the same import or self method.
|
||||
|
||||
### Datasets
|
||||
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
|
||||
|
||||
<code-snippet name="Pest Dataset Example" lang="php">
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== pest/v4 rules ===
|
||||
|
||||
## Pest 4
|
||||
|
||||
- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
|
||||
- Browser testing is incredibly powerful and useful for this project.
|
||||
- Browser tests should live in `tests/Browser/`.
|
||||
- Use the `search-docs` tool for detailed guidance on utilizing these features.
|
||||
|
||||
### Browser Testing
|
||||
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
|
||||
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
|
||||
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
|
||||
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
|
||||
- Switch color schemes (light/dark mode) when appropriate.
|
||||
- Take screenshots or pause tests for debugging when appropriate.
|
||||
|
||||
### Example Tests
|
||||
|
||||
<code-snippet name="Pest Browser Test Example" lang="php">
|
||||
it('may reset the password', function () {
|
||||
Notification::fake();
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = visit('/sign-in'); // Visit on a real browser...
|
||||
|
||||
$page->assertSee('Sign In')
|
||||
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
|
||||
->click('Forgot Password?')
|
||||
->fill('email', 'nuno@laravel.com')
|
||||
->click('Send Reset Link')
|
||||
->assertSee('We have emailed your password reset link!')
|
||||
|
||||
Notification::assertSent(ResetPassword::class);
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Pest Smoke Testing Example" lang="php">
|
||||
$pages = visit(['/', '/about', '/contact']);
|
||||
|
||||
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
||||
</code-snippet>
|
||||
|
||||
- This project uses Pest for testing. Create tests: `vendor/bin/sail artisan make:test --pest {name}`.
|
||||
- Run tests: `vendor/bin/sail artisan test --compact` or filter: `vendor/bin/sail artisan test --compact --filter=testName`.
|
||||
- Do NOT delete tests without approval.
|
||||
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
|
||||
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
|
||||
|
||||
=== tailwindcss/core rules ===
|
||||
|
||||
## Tailwind Core
|
||||
# Tailwind CSS
|
||||
|
||||
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
|
||||
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
|
||||
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
||||
|
||||
### Spacing
|
||||
- When listing items, use gap utilities for spacing, don't use margins.
|
||||
|
||||
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
||||
<div class="flex gap-8">
|
||||
<div>Superior</div>
|
||||
<div>Michigan</div>
|
||||
<div>Erie</div>
|
||||
</div>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Dark Mode
|
||||
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
|
||||
|
||||
|
||||
=== tailwindcss/v4 rules ===
|
||||
|
||||
## Tailwind 4
|
||||
|
||||
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
|
||||
<code-snippet name="Extending Theme in CSS" lang="css">
|
||||
@theme {
|
||||
--color-brand: oklch(0.72 0.11 178);
|
||||
}
|
||||
</code-snippet>
|
||||
|
||||
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
|
||||
|
||||
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Replaced Utilities
|
||||
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
|
||||
- Opacity values are still 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 |
|
||||
- Always use existing Tailwind conventions; check project patterns before adding new ones.
|
||||
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
|
||||
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
|
||||
</laravel-boost-guidelines>
|
||||
|
||||
## Recent Changes
|
||||
- 065-tenant-rbac-v1: Added PHP 8.4+ + Laravel 12, Filament 5, Livewire 4, Pest 4
|
||||
- 064-auth-structure: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
||||
- 063-entra-signin: Added PHP 8.4 + `laravel/framework:^12`, `livewire/livewire:^4`, `filament/filament:^5`, `laravel/socialite:^5.0`
|
||||
|
||||
## Active Technologies
|
||||
- PHP 8.4+ + Laravel 12, Filament 5, Livewire 4, Pest 4 (065-tenant-rbac-v1)
|
||||
|
||||
@ -35,6 +35,13 @@ ## Bulk operations (Feature 005)
|
||||
- Destructive operations require type-to-confirm at higher thresholds (e.g. `DELETE`).
|
||||
- Long-running bulk ops are queued; the bottom-right progress widget polls for active runs.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- **Progress stuck on “Queued…”** usually means the queue worker is not running (or not processing the queue you expect).
|
||||
- Prefer using the Sail/Docker worker (see `docker-compose.yml`) rather than starting an additional local `php artisan queue:work`.
|
||||
- Check worker status/logs: `./vendor/bin/sail ps` and `./vendor/bin/sail logs -f queue`.
|
||||
- **Exit code 137** for `queue:work` typically means the process was killed (often OOM). Increase Docker memory/limits or run the worker inside the container.
|
||||
|
||||
### Configuration
|
||||
|
||||
- `TENANTPILOT_BULK_CHUNK_SIZE` (default `10`): job refresh/progress chunk size.
|
||||
|
||||
232
app/Console/Commands/ClassifyProviderConnections.php
Normal file
232
app/Console/Commands/ClassifyProviderConnections.php
Normal file
@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\ProviderConnectionClassificationResult;
|
||||
use App\Services\Providers\ProviderConnectionClassifier;
|
||||
use App\Services\Providers\ProviderConnectionStateProjector;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderCredentialKind;
|
||||
use App\Support\Providers\ProviderCredentialSource;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ClassifyProviderConnections extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:provider-connections:classify
|
||||
{--tenant= : Restrict to a tenant id, external id, or tenant guid}
|
||||
{--connection= : Restrict to a single provider connection id}
|
||||
{--provider=microsoft : Restrict to one provider}
|
||||
{--chunk=100 : Chunk size for large write runs}
|
||||
{--write : Persist the classification results}';
|
||||
|
||||
protected $description = 'Classify legacy provider connections into platform, dedicated, or review-required outcomes.';
|
||||
|
||||
public function handle(
|
||||
ProviderConnectionClassifier $classifier,
|
||||
ProviderConnectionStateProjector $stateProjector,
|
||||
): int {
|
||||
$query = $this->query();
|
||||
$write = (bool) $this->option('write');
|
||||
$chunkSize = max(1, (int) $this->option('chunk'));
|
||||
|
||||
$candidateCount = (clone $query)->count();
|
||||
|
||||
if ($candidateCount === 0) {
|
||||
$this->info('No provider connections matched the classification scope.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$tenantCounts = (clone $query)
|
||||
->selectRaw('tenant_id, count(*) as aggregate')
|
||||
->groupBy('tenant_id')
|
||||
->pluck('aggregate', 'tenant_id')
|
||||
->map(static fn (mixed $count): int => (int) $count)
|
||||
->all();
|
||||
|
||||
$startedTenants = [];
|
||||
$classifiedCount = 0;
|
||||
$appliedCount = 0;
|
||||
$reviewRequiredCount = 0;
|
||||
|
||||
$query
|
||||
->with(['tenant', 'credential'])
|
||||
->orderBy('id')
|
||||
->chunkById($chunkSize, function ($connections) use (
|
||||
$classifier,
|
||||
$stateProjector,
|
||||
$write,
|
||||
$tenantCounts,
|
||||
&$startedTenants,
|
||||
&$classifiedCount,
|
||||
&$appliedCount,
|
||||
&$reviewRequiredCount,
|
||||
): void {
|
||||
foreach ($connections as $connection) {
|
||||
$classifiedCount++;
|
||||
|
||||
$result = $classifier->classify(
|
||||
$connection,
|
||||
source: 'tenantpilot:provider-connections:classify',
|
||||
);
|
||||
|
||||
if ($result->reviewRequired) {
|
||||
$reviewRequiredCount++;
|
||||
}
|
||||
|
||||
if (! $write) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tenant = $connection->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$this->warn(sprintf('Skipping provider connection #%d without tenant context.', (int) $connection->getKey()));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$tenantKey = (int) $tenant->getKey();
|
||||
|
||||
if (! array_key_exists($tenantKey, $startedTenants)) {
|
||||
$this->auditStart($tenant, $tenantCounts[$tenantKey] ?? 0);
|
||||
$startedTenants[$tenantKey] = true;
|
||||
}
|
||||
|
||||
$connection = $this->applyClassification($connection, $result, $stateProjector);
|
||||
$this->auditApplied($tenant, $connection, $result);
|
||||
$appliedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if ($write) {
|
||||
$this->info(sprintf('Applied classifications: %d', $appliedCount));
|
||||
} else {
|
||||
$this->info(sprintf('Dry-run classifications: %d', $classifiedCount));
|
||||
}
|
||||
|
||||
$this->info(sprintf('Review required: %d', $reviewRequiredCount));
|
||||
$this->info(sprintf('Mode: %s', $write ? 'write' : 'dry-run'));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function query(): Builder
|
||||
{
|
||||
$query = ProviderConnection::query()
|
||||
->where('provider', (string) $this->option('provider'));
|
||||
|
||||
$tenantOption = $this->option('tenant');
|
||||
|
||||
if (is_string($tenantOption) && trim($tenantOption) !== '') {
|
||||
$tenant = Tenant::query()
|
||||
->forTenant(trim($tenantOption))
|
||||
->firstOrFail();
|
||||
|
||||
$query->where('tenant_id', (int) $tenant->getKey());
|
||||
}
|
||||
|
||||
$connectionOption = $this->option('connection');
|
||||
|
||||
if (is_numeric($connectionOption)) {
|
||||
$query->whereKey((int) $connectionOption);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function applyClassification(
|
||||
ProviderConnection $connection,
|
||||
ProviderConnectionClassificationResult $result,
|
||||
ProviderConnectionStateProjector $stateProjector,
|
||||
): ProviderConnection {
|
||||
DB::transaction(function () use ($connection, $result, $stateProjector): void {
|
||||
$connection->forceFill(
|
||||
$connection->classificationProjection($result, $stateProjector)
|
||||
)->save();
|
||||
|
||||
$credential = $connection->credential;
|
||||
|
||||
if (! $credential instanceof ProviderCredential) {
|
||||
return;
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
|
||||
if (
|
||||
$result->suggestedConnectionType === ProviderConnectionType::Dedicated
|
||||
&& $credential->source === null
|
||||
) {
|
||||
$updates['source'] = ProviderCredentialSource::LegacyMigrated->value;
|
||||
}
|
||||
|
||||
if ($credential->credential_kind === null && $credential->type === ProviderCredentialKind::ClientSecret->value) {
|
||||
$updates['credential_kind'] = ProviderCredentialKind::ClientSecret->value;
|
||||
}
|
||||
|
||||
if ($updates !== []) {
|
||||
$credential->forceFill($updates)->save();
|
||||
}
|
||||
});
|
||||
|
||||
return $connection->fresh(['tenant', 'credential']);
|
||||
}
|
||||
|
||||
private function auditStart(Tenant $tenant, int $candidateCount): void
|
||||
{
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.migration_classification_started',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'source' => 'tenantpilot:provider-connections:classify',
|
||||
'provider' => 'microsoft',
|
||||
'candidate_count' => $candidateCount,
|
||||
'write' => true,
|
||||
],
|
||||
],
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
}
|
||||
|
||||
private function auditApplied(
|
||||
Tenant $tenant,
|
||||
ProviderConnection $connection,
|
||||
ProviderConnectionClassificationResult $result,
|
||||
): void {
|
||||
$effectiveApp = $connection->effectiveAppMetadata();
|
||||
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.migration_classification_applied',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'source' => 'tenantpilot:provider-connections:classify',
|
||||
'workspace_id' => (int) $connection->workspace_id,
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'provider' => (string) $connection->provider,
|
||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||
'connection_type' => $connection->connection_type->value,
|
||||
'migration_review_required' => $connection->migration_review_required,
|
||||
'legacy_identity_result' => $result->suggestedConnectionType->value,
|
||||
'effective_app_id' => $effectiveApp['app_id'],
|
||||
'effective_app_source' => $effectiveApp['source'],
|
||||
'signals' => $result->signals,
|
||||
],
|
||||
],
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $connection->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphContractRegistry;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class GraphContractCheck extends Command
|
||||
@ -11,7 +12,7 @@ class GraphContractCheck extends Command
|
||||
|
||||
protected $description = 'Validate Graph contract registry against live endpoints (lightweight probes)';
|
||||
|
||||
public function handle(GraphClientInterface $graph): int
|
||||
public function handle(GraphClientInterface $graph, GraphContractRegistry $registry): int
|
||||
{
|
||||
$contracts = config('graph_contracts.types', []);
|
||||
|
||||
@ -36,11 +37,13 @@ public function handle(GraphClientInterface $graph): int
|
||||
continue;
|
||||
}
|
||||
|
||||
$query = array_filter([
|
||||
$queryInput = array_filter([
|
||||
'$top' => 1,
|
||||
'$select' => $select,
|
||||
'$expand' => $expand,
|
||||
]);
|
||||
], static fn ($value): bool => $value !== null && $value !== '' && $value !== []);
|
||||
|
||||
$query = $registry->sanitizeQuery($type, $queryInput)['query'];
|
||||
|
||||
$response = $graph->request('GET', $resource, [
|
||||
'query' => $query,
|
||||
|
||||
116
app/Console/Commands/OpsReconcileAdapterRuns.php
Normal file
116
app/Console/Commands/OpsReconcileAdapterRuns.php
Normal file
@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\AdapterRunReconciler;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class OpsReconcileAdapterRuns extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'ops:reconcile-adapter-runs
|
||||
{--type= : Adapter run type (e.g. restore.execute)}
|
||||
{--tenant= : Tenant ID}
|
||||
{--older-than=60 : Only consider runs older than N minutes}
|
||||
{--dry-run=true : Preview only (true/false)}
|
||||
{--limit=50 : Max number of runs to inspect}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Reconcile stale adapter-backed operation runs from DB-only source-of-truth records.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
try {
|
||||
/** @var AdapterRunReconciler $reconciler */
|
||||
$reconciler = app(AdapterRunReconciler::class);
|
||||
|
||||
$type = $this->option('type');
|
||||
$type = is_string($type) && trim($type) !== '' ? trim($type) : null;
|
||||
|
||||
$tenantId = $this->option('tenant');
|
||||
$tenantId = is_numeric($tenantId) ? (int) $tenantId : null;
|
||||
|
||||
$olderThanMinutes = $this->option('older-than');
|
||||
$olderThanMinutes = is_numeric($olderThanMinutes) ? (int) $olderThanMinutes : 60;
|
||||
$olderThanMinutes = max(1, $olderThanMinutes);
|
||||
|
||||
$limit = $this->option('limit');
|
||||
$limit = is_numeric($limit) ? (int) $limit : 50;
|
||||
$limit = max(1, $limit);
|
||||
|
||||
$dryRun = $this->option('dry-run');
|
||||
$dryRun = filter_var($dryRun, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
|
||||
$dryRun = $dryRun ?? true;
|
||||
|
||||
$result = $reconciler->reconcile([
|
||||
'type' => $type,
|
||||
'tenant_id' => $tenantId,
|
||||
'older_than_minutes' => $olderThanMinutes,
|
||||
'limit' => $limit,
|
||||
'dry_run' => $dryRun,
|
||||
]);
|
||||
|
||||
$changes = $result['changes'] ?? [];
|
||||
|
||||
usort($changes, static fn (array $a, array $b): int => ((int) ($a['operation_run_id'] ?? 0)) <=> ((int) ($b['operation_run_id'] ?? 0)));
|
||||
|
||||
$this->info('Adapter run reconciliation');
|
||||
$this->line('dry_run: '.($dryRun ? 'true' : 'false'));
|
||||
$this->line('type: '.($type ?? '(all supported)'));
|
||||
$this->line('tenant: '.($tenantId ? (string) $tenantId : '(all)'));
|
||||
$this->line('older_than_minutes: '.$olderThanMinutes);
|
||||
$this->line('limit: '.$limit);
|
||||
$this->newLine();
|
||||
|
||||
$this->line('candidates: '.(int) ($result['candidates'] ?? 0));
|
||||
$this->line('reconciled: '.(int) ($result['reconciled'] ?? 0));
|
||||
$this->line('skipped: '.(int) ($result['skipped'] ?? 0));
|
||||
$this->newLine();
|
||||
|
||||
if ($changes === []) {
|
||||
$this->info('No changes.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach ($changes as $change) {
|
||||
$before = is_array($change['before'] ?? null) ? $change['before'] : [];
|
||||
$after = is_array($change['after'] ?? null) ? $change['after'] : [];
|
||||
|
||||
$rows[] = [
|
||||
'applied' => ($change['applied'] ?? false) ? 'yes' : 'no',
|
||||
'operation_run_id' => (int) ($change['operation_run_id'] ?? 0),
|
||||
'type' => (string) ($change['type'] ?? ''),
|
||||
'source_id' => (int) ($change['restore_run_id'] ?? 0),
|
||||
'before' => (string) (($before['status'] ?? '').'/'.($before['outcome'] ?? '')),
|
||||
'after' => (string) (($after['status'] ?? '').'/'.($after['outcome'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['applied', 'operation_run_id', 'type', 'source_id', 'before', 'after'],
|
||||
$rows,
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
$this->error('Reconciliation failed: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class PruneBaselineEvidencePolicyVersionsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'tenantpilot:baseline-evidence:prune {--days= : Number of days to retain baseline evidence policy versions}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Soft-delete baseline-capture/baseline-compare policy versions older than the configured retention window';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) ($this->option('days') ?: config('tenantpilot.baselines.full_content_capture.retention_days', 90));
|
||||
|
||||
if ($days < 1) {
|
||||
$this->error('Retention days must be at least 1.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$cutoff = now()->subDays($days);
|
||||
|
||||
$deleted = PolicyVersion::query()
|
||||
->whereNull('deleted_at')
|
||||
->whereIn('capture_purpose', [
|
||||
PolicyVersionCapturePurpose::BaselineCapture->value,
|
||||
PolicyVersionCapturePurpose::BaselineCompare->value,
|
||||
])
|
||||
->where('captured_at', '<', $cutoff)
|
||||
->delete();
|
||||
|
||||
$this->info("Pruned {$deleted} baseline evidence policy version(s) older than {$days} days.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
77
app/Console/Commands/PruneReviewPacksCommand.php
Normal file
77
app/Console/Commands/PruneReviewPacksCommand.php
Normal file
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ReviewPack;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PruneReviewPacksCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'tenantpilot:review-pack:prune {--hard-delete : Hard-delete expired packs past grace period}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Expire review packs past retention and optionally hard-delete expired rows past grace period';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$expired = $this->expireReadyPacks();
|
||||
$hardDeleted = 0;
|
||||
|
||||
if ($this->option('hard-delete')) {
|
||||
$hardDeleted = $this->hardDeleteExpiredPacks();
|
||||
}
|
||||
|
||||
$this->info("{$expired} pack(s) expired, {$hardDeleted} pack(s) hard-deleted.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition ready packs past retention to expired and delete their files.
|
||||
*/
|
||||
private function expireReadyPacks(): int
|
||||
{
|
||||
$packs = ReviewPack::query()
|
||||
->ready()
|
||||
->pastRetention()
|
||||
->get();
|
||||
|
||||
$disk = Storage::disk('exports');
|
||||
$count = 0;
|
||||
|
||||
foreach ($packs as $pack) {
|
||||
/** @var ReviewPack $pack */
|
||||
if ($pack->file_path && $disk->exists($pack->file_path)) {
|
||||
$disk->delete($pack->file_path);
|
||||
}
|
||||
|
||||
$pack->update(['status' => ReviewPack::STATUS_EXPIRED]);
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-delete expired packs that are past the grace period.
|
||||
*/
|
||||
private function hardDeleteExpiredPacks(): int
|
||||
{
|
||||
$graceDays = (int) config('tenantpilot.review_pack.hard_delete_grace_days', 30);
|
||||
|
||||
$cutoff = now()->subDays($graceDays);
|
||||
|
||||
return ReviewPack::query()
|
||||
->expired()
|
||||
->where('updated_at', '<', $cutoff)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
42
app/Console/Commands/PruneStoredReportsCommand.php
Normal file
42
app/Console/Commands/PruneStoredReportsCommand.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\StoredReport;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class PruneStoredReportsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'stored-reports:prune {--days= : Number of days to retain reports}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Delete stored reports older than the retention period';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) ($this->option('days') ?: config('tenantpilot.stored_reports.retention_days', 90));
|
||||
|
||||
if ($days < 1) {
|
||||
$this->error('Retention days must be at least 1.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$cutoff = now()->subDays($days);
|
||||
|
||||
$deleted = StoredReport::query()
|
||||
->where('created_at', '<', $cutoff)
|
||||
->delete();
|
||||
|
||||
$this->info("Deleted {$deleted} stored report(s) older than {$days} days.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -34,6 +34,6 @@ private function resolveTenant(): Tenant
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
return Tenant::current();
|
||||
return Tenant::currentOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
120
app/Console/Commands/TenantpilotBackfillFindingLifecycle.php
Normal file
120
app/Console/Commands/TenantpilotBackfillFindingLifecycle.php
Normal file
@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TenantpilotBackfillFindingLifecycle extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:findings:backfill-lifecycle
|
||||
{--tenant=* : Limit to tenant_id/external_id}';
|
||||
|
||||
protected $description = 'Queue tenant-scoped findings lifecycle backfill jobs idempotently.';
|
||||
|
||||
public function handle(FindingsLifecycleBackfillRunbookService $runbookService): int
|
||||
{
|
||||
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
||||
|
||||
if ($tenantIdentifiers === []) {
|
||||
$this->error('Provide one or more tenants via --tenant={id|external_id}.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$tenants = $this->resolveTenants($tenantIdentifiers);
|
||||
|
||||
if ($tenants->isEmpty()) {
|
||||
$this->info('No tenants matched the provided identifiers.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$queued = 0;
|
||||
$skipped = 0;
|
||||
$nothingToDo = 0;
|
||||
|
||||
foreach ($tenants as $tenant) {
|
||||
if (! $tenant instanceof Tenant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$run = $runbookService->start(
|
||||
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
|
||||
initiator: null,
|
||||
reason: null,
|
||||
source: 'cli',
|
||||
);
|
||||
} catch (ValidationException $e) {
|
||||
$errors = $e->errors();
|
||||
|
||||
if (isset($errors['preflight.affected_count'])) {
|
||||
$nothingToDo++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->error(sprintf(
|
||||
'Backfill blocked for tenant %d: %s',
|
||||
(int) $tenant->getKey(),
|
||||
$e->getMessage(),
|
||||
));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $run->wasRecentlyCreated) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$queued++;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Queued %d backfill run(s), skipped %d duplicate run(s), nothing to do %d.',
|
||||
$queued,
|
||||
$skipped,
|
||||
$nothingToDo,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $tenantIdentifiers
|
||||
* @return \Illuminate\Support\Collection<int, Tenant>
|
||||
*/
|
||||
private function resolveTenants(array $tenantIdentifiers)
|
||||
{
|
||||
$tenantIds = [];
|
||||
|
||||
foreach ($tenantIdentifiers as $identifier) {
|
||||
$tenant = Tenant::query()
|
||||
->forTenant($identifier)
|
||||
->first();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$tenantIds[] = (int) $tenant->getKey();
|
||||
}
|
||||
}
|
||||
|
||||
$tenantIds = array_values(array_unique($tenantIds));
|
||||
|
||||
if ($tenantIds === []) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Tenant::query()
|
||||
->whereIn('id', $tenantIds)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
343
app/Console/Commands/TenantpilotBackfillWorkspaceIds.php
Normal file
343
app/Console/Commands/TenantpilotBackfillWorkspaceIds.php
Normal file
@ -0,0 +1,343 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\BackfillWorkspaceIdsJob;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\WorkspaceIsolation\TenantOwnedTables;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TenantpilotBackfillWorkspaceIds extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:backfill-workspace-ids
|
||||
{--dry-run : Print per-table counts only}
|
||||
{--table= : Restrict to a single tenant-owned table}
|
||||
{--batch-size=5000 : Rows per queued chunk}
|
||||
{--resume-from=0 : Resume from id cursor}
|
||||
{--max-rows= : Maximum rows to process per table job}';
|
||||
|
||||
protected $description = 'Backfill missing workspace_id across tenant-owned tables.';
|
||||
|
||||
public function handle(OperationRunService $operationRunService, WorkspaceAuditLogger $workspaceAuditLogger): int
|
||||
{
|
||||
$tables = $this->resolveTables();
|
||||
|
||||
if ($tables === []) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$batchSize = max(1, (int) $this->option('batch-size'));
|
||||
$resumeFrom = max(0, (int) $this->option('resume-from'));
|
||||
$maxRows = $this->normalizeMaxRows();
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$lock = Cache::lock('tenantpilot:backfill-workspace-ids', 900);
|
||||
|
||||
if (! $lock->get()) {
|
||||
$this->error('Another workspace backfill is already running.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$tableStats = $this->collectTableStats($tables);
|
||||
|
||||
$this->table(
|
||||
['Table', 'Missing workspace_id', 'Unresolvable tenant mapping', 'Sample row ids'],
|
||||
array_map(static function (array $stats): array {
|
||||
return [
|
||||
$stats['table'],
|
||||
$stats['missing'],
|
||||
$stats['unresolvable'],
|
||||
$stats['sample_ids'] === [] ? '-' : implode(',', $stats['sample_ids']),
|
||||
];
|
||||
}, $tableStats),
|
||||
);
|
||||
|
||||
$unresolvable = array_values(array_filter($tableStats, static fn (array $stats): bool => $stats['unresolvable'] > 0));
|
||||
|
||||
if ($unresolvable !== []) {
|
||||
foreach ($unresolvable as $stats) {
|
||||
$this->error(sprintf(
|
||||
'Unresolvable tenant->workspace mapping in %s (%d rows). Sample ids: %s',
|
||||
$stats['table'],
|
||||
$stats['unresolvable'],
|
||||
$stats['sample_ids'] === [] ? '-' : implode(',', $stats['sample_ids']),
|
||||
));
|
||||
}
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info('Dry-run complete. No changes written.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$workspaceWorkloads = $this->collectWorkspaceWorkloads($tables, $maxRows);
|
||||
|
||||
if ($workspaceWorkloads === []) {
|
||||
$this->info('No rows require workspace_id backfill.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$dispatchedJobs = 0;
|
||||
|
||||
foreach ($workspaceWorkloads as $workspaceId => $workload) {
|
||||
$workspace = Workspace::query()->find($workspaceId);
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$run = $operationRunService->ensureWorkspaceRunWithIdentity(
|
||||
workspace: $workspace,
|
||||
type: 'workspace_isolation_backfill_workspace_ids',
|
||||
identityInputs: [
|
||||
'tables' => array_keys($workload['tables']),
|
||||
],
|
||||
context: [
|
||||
'source' => 'tenantpilot:backfill-workspace-ids',
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'batch_size' => $batchSize,
|
||||
'max_rows' => $maxRows,
|
||||
'resume_from' => $resumeFrom,
|
||||
'tables' => array_keys($workload['tables']),
|
||||
],
|
||||
);
|
||||
|
||||
if (! $run->wasRecentlyCreated) {
|
||||
$this->line(sprintf(
|
||||
'Workspace %d already has an active backfill run (#%d).',
|
||||
(int) $workspace->getKey(),
|
||||
(int) $run->getKey(),
|
||||
));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$tableProgress = [];
|
||||
foreach ($workload['tables'] as $table => $count) {
|
||||
$tableProgress[$table] = [
|
||||
'target_rows' => (int) $count,
|
||||
'processed' => 0,
|
||||
'last_processed_id' => $resumeFrom,
|
||||
];
|
||||
}
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$context['table_progress'] = $tableProgress;
|
||||
|
||||
$run->update([
|
||||
'context' => $context,
|
||||
'summary_counts' => [
|
||||
'total' => (int) $workload['total'],
|
||||
'processed' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
],
|
||||
]);
|
||||
|
||||
$operationRunService->updateRun($run, status: 'running');
|
||||
|
||||
$workspaceAuditLogger->log(
|
||||
workspace: $workspace,
|
||||
action: 'workspace_isolation.backfill_workspace_ids.started',
|
||||
context: [
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'tables' => array_keys($workload['tables']),
|
||||
'planned_rows' => (int) $workload['total'],
|
||||
'batch_size' => $batchSize,
|
||||
],
|
||||
status: 'success',
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $run->getKey(),
|
||||
);
|
||||
|
||||
$workspaceJobs = 0;
|
||||
|
||||
foreach ($workload['tables'] as $table => $tableRows) {
|
||||
if ($tableRows <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
BackfillWorkspaceIdsJob::dispatch(
|
||||
operationRunId: (int) $run->getKey(),
|
||||
workspaceId: (int) $workspace->getKey(),
|
||||
table: $table,
|
||||
batchSize: $batchSize,
|
||||
maxRows: $maxRows,
|
||||
resumeFrom: $resumeFrom,
|
||||
);
|
||||
|
||||
$workspaceJobs++;
|
||||
$dispatchedJobs++;
|
||||
}
|
||||
|
||||
$workspaceAuditLogger->log(
|
||||
workspace: $workspace,
|
||||
action: 'workspace_isolation.backfill_workspace_ids.dispatched',
|
||||
context: [
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'jobs_dispatched' => $workspaceJobs,
|
||||
'tables' => array_keys($workload['tables']),
|
||||
],
|
||||
status: 'success',
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $run->getKey(),
|
||||
);
|
||||
|
||||
$this->line(sprintf(
|
||||
'Workspace %d run #%d queued (%d job(s)).',
|
||||
(int) $workspace->getKey(),
|
||||
(int) $run->getKey(),
|
||||
$workspaceJobs,
|
||||
));
|
||||
}
|
||||
|
||||
$this->info(sprintf('Backfill jobs dispatched: %d', $dispatchedJobs));
|
||||
|
||||
return self::SUCCESS;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function resolveTables(): array
|
||||
{
|
||||
$selectedTable = $this->option('table');
|
||||
|
||||
if (! is_string($selectedTable) || trim($selectedTable) === '') {
|
||||
return TenantOwnedTables::all();
|
||||
}
|
||||
|
||||
$selectedTable = trim($selectedTable);
|
||||
|
||||
if (! TenantOwnedTables::contains($selectedTable)) {
|
||||
$this->error(sprintf('Unknown tenant-owned table: %s', $selectedTable));
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$selectedTable];
|
||||
}
|
||||
|
||||
private function normalizeMaxRows(): ?int
|
||||
{
|
||||
$maxRows = $this->option('max-rows');
|
||||
|
||||
if (! is_numeric($maxRows)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$maxRows = (int) $maxRows;
|
||||
|
||||
return $maxRows > 0 ? $maxRows : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $tables
|
||||
* @return array<int, array{table: string, missing: int, unresolvable: int, sample_ids: array<int, int>}>
|
||||
*/
|
||||
private function collectTableStats(array $tables): array
|
||||
{
|
||||
$stats = [];
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$missing = (int) DB::table($table)->whereNull('workspace_id')->count();
|
||||
|
||||
$unresolvableQuery = DB::table($table)
|
||||
->leftJoin('tenants', 'tenants.id', '=', sprintf('%s.tenant_id', $table))
|
||||
->whereNull(sprintf('%s.workspace_id', $table))
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('tenants.id')
|
||||
->orWhereNull('tenants.workspace_id');
|
||||
});
|
||||
|
||||
$unresolvable = (int) $unresolvableQuery->count();
|
||||
|
||||
$sampleIds = DB::table($table)
|
||||
->leftJoin('tenants', 'tenants.id', '=', sprintf('%s.tenant_id', $table))
|
||||
->whereNull(sprintf('%s.workspace_id', $table))
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('tenants.id')
|
||||
->orWhereNull('tenants.workspace_id');
|
||||
})
|
||||
->orderBy(sprintf('%s.id', $table))
|
||||
->limit(5)
|
||||
->pluck(sprintf('%s.id', $table))
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$stats[] = [
|
||||
'table' => $table,
|
||||
'missing' => $missing,
|
||||
'unresolvable' => $unresolvable,
|
||||
'sample_ids' => $sampleIds,
|
||||
];
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $tables
|
||||
* @return array<int, array{total: int, tables: array<string, int>}>
|
||||
*/
|
||||
private function collectWorkspaceWorkloads(array $tables, ?int $maxRows): array
|
||||
{
|
||||
$workloads = [];
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$rows = DB::table($table)
|
||||
->join('tenants', 'tenants.id', '=', sprintf('%s.tenant_id', $table))
|
||||
->whereNull(sprintf('%s.workspace_id', $table))
|
||||
->whereNotNull('tenants.workspace_id')
|
||||
->selectRaw('tenants.workspace_id as workspace_id, COUNT(*) as row_count')
|
||||
->groupBy('tenants.workspace_id')
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$workspaceId = (int) $row->workspace_id;
|
||||
|
||||
if ($workspaceId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rowCount = (int) $row->row_count;
|
||||
|
||||
if ($maxRows !== null) {
|
||||
$rowCount = min($rowCount, $maxRows);
|
||||
}
|
||||
|
||||
if ($rowCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! isset($workloads[$workspaceId])) {
|
||||
$workloads[$workspaceId] = [
|
||||
'total' => 0,
|
||||
'tables' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$workloads[$workspaceId]['tables'][$table] = $rowCount;
|
||||
$workloads[$workspaceId]['total'] += $rowCount;
|
||||
}
|
||||
}
|
||||
|
||||
return $workloads;
|
||||
}
|
||||
}
|
||||
106
app/Console/Commands/TenantpilotDispatchAlerts.php
Normal file
106
app/Console/Commands/TenantpilotDispatchAlerts.php
Normal file
@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Alerts\DeliverAlertsJob;
|
||||
use App\Jobs\Alerts\EvaluateAlertsJob;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\OperationRunService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class TenantpilotDispatchAlerts extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:alerts:dispatch {--workspace=* : Limit dispatch to one or more workspace IDs}';
|
||||
|
||||
protected $description = 'Queue workspace-scoped alert evaluation and delivery jobs idempotently.';
|
||||
|
||||
public function handle(OperationRunService $operationRuns): int
|
||||
{
|
||||
if (! (bool) config('tenantpilot.alerts.enabled', true)) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$workspaceFilter = array_values(array_filter(array_map(
|
||||
static fn (mixed $value): int => (int) $value,
|
||||
(array) $this->option('workspace'),
|
||||
)));
|
||||
|
||||
$workspaces = $this->resolveWorkspaces($workspaceFilter);
|
||||
|
||||
$slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z';
|
||||
|
||||
$queuedEvaluate = 0;
|
||||
$queuedDeliver = 0;
|
||||
$skippedEvaluate = 0;
|
||||
$skippedDeliver = 0;
|
||||
|
||||
foreach ($workspaces as $workspace) {
|
||||
$evaluateRun = $operationRuns->ensureWorkspaceRunWithIdentity(
|
||||
workspace: $workspace,
|
||||
type: 'alerts.evaluate',
|
||||
identityInputs: ['slot_key' => $slotKey],
|
||||
context: [
|
||||
'trigger' => 'scheduled_dispatch',
|
||||
'slot_key' => $slotKey,
|
||||
],
|
||||
initiator: null,
|
||||
);
|
||||
|
||||
if ($evaluateRun->wasRecentlyCreated) {
|
||||
EvaluateAlertsJob::dispatch((int) $workspace->getKey(), (int) $evaluateRun->getKey());
|
||||
$queuedEvaluate++;
|
||||
} else {
|
||||
$skippedEvaluate++;
|
||||
}
|
||||
|
||||
$deliverRun = $operationRuns->ensureWorkspaceRunWithIdentity(
|
||||
workspace: $workspace,
|
||||
type: 'alerts.deliver',
|
||||
identityInputs: ['slot_key' => $slotKey],
|
||||
context: [
|
||||
'trigger' => 'scheduled_dispatch',
|
||||
'slot_key' => $slotKey,
|
||||
],
|
||||
initiator: null,
|
||||
);
|
||||
|
||||
if ($deliverRun->wasRecentlyCreated) {
|
||||
DeliverAlertsJob::dispatch((int) $workspace->getKey(), (int) $deliverRun->getKey());
|
||||
$queuedDeliver++;
|
||||
} else {
|
||||
$skippedDeliver++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Alert dispatch scanned %d workspace(s): evaluate queued=%d skipped=%d, deliver queued=%d skipped=%d.',
|
||||
$workspaces->count(),
|
||||
$queuedEvaluate,
|
||||
$skippedEvaluate,
|
||||
$queuedDeliver,
|
||||
$skippedDeliver,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $workspaceIds
|
||||
* @return Collection<int, Workspace>
|
||||
*/
|
||||
private function resolveWorkspaces(array $workspaceIds): Collection
|
||||
{
|
||||
return Workspace::query()
|
||||
->when(
|
||||
$workspaceIds !== [],
|
||||
fn ($query) => $query->whereIn('id', $workspaceIds),
|
||||
fn ($query) => $query->whereHas('tenants'),
|
||||
)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
29
app/Console/Commands/TenantpilotDispatchBackupSchedules.php
Normal file
29
app/Console/Commands/TenantpilotDispatchBackupSchedules.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\BackupScheduling\BackupScheduleDispatcher;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class TenantpilotDispatchBackupSchedules extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:schedules:dispatch {--tenant=* : Limit to tenant_id/external_id}';
|
||||
|
||||
protected $description = 'Dispatch due backup schedules (idempotent per schedule minute-slot).';
|
||||
|
||||
public function handle(BackupScheduleDispatcher $dispatcher): int
|
||||
{
|
||||
$tenantIdentifiers = (array) $this->option('tenant');
|
||||
|
||||
$result = $dispatcher->dispatchDue($tenantIdentifiers);
|
||||
|
||||
$this->info(sprintf(
|
||||
'Scanned %d schedule(s), created %d run(s), skipped %d duplicate run(s).',
|
||||
$result['scanned_schedules'],
|
||||
$result['created_runs'],
|
||||
$result['skipped_runs'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
127
app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php
Normal file
127
app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php
Normal file
@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\OperationRunService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class TenantpilotDispatchDirectoryGroupsSync extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:directory-groups:dispatch {--tenant=* : Limit to tenant_id/external_id}';
|
||||
|
||||
protected $description = 'Dispatch scheduled directory group sync runs (idempotent per tenant minute-slot).';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! (bool) config('directory_groups.schedule.enabled', false)) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$now = CarbonImmutable::now('UTC');
|
||||
$timeUtc = (string) config('directory_groups.schedule.time_utc', '02:00');
|
||||
|
||||
if (! $this->isDueAt($now, $timeUtc)) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! class_exists(\App\Jobs\EntraGroupSyncJob::class)) {
|
||||
$this->warn('EntraGroupSyncJob is not available; skipping scheduled directory group sync dispatch.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$tenantIdentifiers = array_values(array_filter(array_map('strval', array_merge(
|
||||
(array) $this->option('tenant'),
|
||||
(array) config('directory_groups.schedule.tenants', []),
|
||||
))));
|
||||
|
||||
$tenants = $this->resolveTenants($tenantIdentifiers);
|
||||
|
||||
$selectionKey = 'groups-v1:all';
|
||||
$slotKey = $now->format('YmdHi').'Z';
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($tenants as $tenant) {
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRunWithIdentityStrict(
|
||||
tenant: $tenant,
|
||||
type: 'entra_group_sync',
|
||||
identityInputs: [
|
||||
'selection_key' => $selectionKey,
|
||||
'slot_key' => $slotKey,
|
||||
],
|
||||
context: [
|
||||
'selection_key' => $selectionKey,
|
||||
'slot_key' => $slotKey,
|
||||
'trigger' => 'scheduled',
|
||||
],
|
||||
initiator: null,
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$created++;
|
||||
|
||||
dispatch(new \App\Jobs\EntraGroupSyncJob(
|
||||
tenantId: $tenant->getKey(),
|
||||
selectionKey: $selectionKey,
|
||||
slotKey: $slotKey,
|
||||
runId: null,
|
||||
operationRun: $opRun,
|
||||
));
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Scanned %d tenant(s), created %d run(s), skipped %d duplicate run(s).',
|
||||
$tenants->count(),
|
||||
$created,
|
||||
$skipped,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $tenantIdentifiers
|
||||
*/
|
||||
private function resolveTenants(array $tenantIdentifiers): \Illuminate\Support\Collection
|
||||
{
|
||||
$query = Tenant::activeQuery();
|
||||
|
||||
if ($tenantIdentifiers !== []) {
|
||||
$query->where(function ($subQuery) use ($tenantIdentifiers) {
|
||||
foreach ($tenantIdentifiers as $identifier) {
|
||||
if (ctype_digit($identifier)) {
|
||||
$subQuery->orWhereKey((int) $identifier);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$subQuery->orWhere('tenant_id', $identifier)
|
||||
->orWhere('external_id', $identifier);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
private function isDueAt(CarbonImmutable $now, string $timeUtc): bool
|
||||
{
|
||||
if (! preg_match('/^(?<hour>[01]\\d|2[0-3]):(?<minute>[0-5]\\d)$/', $timeUtc, $matches)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $matches['hour'] === (int) $now->format('H')
|
||||
&& (int) $matches['minute'] === (int) $now->format('i');
|
||||
}
|
||||
}
|
||||
196
app/Console/Commands/TenantpilotPurgeNonPersistentData.php
Normal file
196
app/Console/Commands/TenantpilotPurgeNonPersistentData.php
Normal file
@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class TenantpilotPurgeNonPersistentData extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'tenantpilot:purge-nonpersistent
|
||||
{tenant? : Tenant id / tenant_id / external_id (defaults to current tenant)}
|
||||
{--all : Purge for all tenants}
|
||||
{--force : Actually delete rows}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Permanently delete non-persistent tenant data like policies, backups, and runs while preserving durable audit logs.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$tenants = $this->resolveTenants();
|
||||
|
||||
if ($tenants->isEmpty()) {
|
||||
$this->error('No tenants selected. Provide {tenant} or use --all.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$isDryRun = ! (bool) $this->option('force');
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn('Dry run: no rows will be deleted. Re-run with --force to apply.');
|
||||
} else {
|
||||
$this->warn('This will PERMANENTLY delete non-persistent tenant data.');
|
||||
|
||||
if ($this->input->isInteractive() && ! $this->confirm('Proceed?', false)) {
|
||||
$this->info('Aborted.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($tenants as $tenant) {
|
||||
$counts = $this->countsForTenant($tenant);
|
||||
|
||||
$this->line('');
|
||||
$this->info("Tenant: {$tenant->id} ({$tenant->name})");
|
||||
$this->table(
|
||||
['Table', 'Rows'],
|
||||
collect($counts)
|
||||
->map(fn (int $count, string $table) => [$table, $count])
|
||||
->values()
|
||||
->all(),
|
||||
);
|
||||
|
||||
if ($isDryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($tenant): void {
|
||||
BackupSchedule::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->delete();
|
||||
|
||||
OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->delete();
|
||||
|
||||
RestoreRun::withTrashed()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->forceDelete();
|
||||
|
||||
BackupItem::withTrashed()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->forceDelete();
|
||||
|
||||
BackupSet::withTrashed()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->forceDelete();
|
||||
|
||||
PolicyVersion::withTrashed()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->forceDelete();
|
||||
|
||||
Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->delete();
|
||||
});
|
||||
|
||||
$this->recordPurgeOperationRun($tenant, $counts);
|
||||
|
||||
$this->info('Purged.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveTenants()
|
||||
{
|
||||
if ((bool) $this->option('all')) {
|
||||
return Tenant::query()->get();
|
||||
}
|
||||
|
||||
$tenantArg = $this->argument('tenant');
|
||||
|
||||
if ($tenantArg !== null && $tenantArg !== '') {
|
||||
$tenant = Tenant::query()->forTenant($tenantArg)->first();
|
||||
|
||||
return $tenant ? collect([$tenant]) : collect();
|
||||
}
|
||||
|
||||
try {
|
||||
return collect([Tenant::currentOrFail()]);
|
||||
} catch (RuntimeException) {
|
||||
return collect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,int>
|
||||
*/
|
||||
private function countsForTenant(Tenant $tenant): array
|
||||
{
|
||||
return [
|
||||
'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'operation_runs' => OperationRun::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'audit_logs_retained' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'restore_runs' => RestoreRun::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||
'backup_items' => BackupItem::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||
'backup_sets' => BackupSet::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||
'policy_versions' => PolicyVersion::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||
'policies' => Policy::query()->where('tenant_id', $tenant->id)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $counts
|
||||
*/
|
||||
private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
|
||||
{
|
||||
$deletedRows = Arr::except($counts, ['audit_logs_retained']);
|
||||
|
||||
OperationRun::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->id,
|
||||
'user_id' => null,
|
||||
'initiator_name' => 'System',
|
||||
'type' => 'backup_schedule_purge',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'run_identity_hash' => hash('sha256', implode(':', [
|
||||
(string) $tenant->id,
|
||||
'backup_schedule_purge',
|
||||
now()->toISOString(),
|
||||
Str::uuid()->toString(),
|
||||
])),
|
||||
'summary_counts' => [
|
||||
'total' => array_sum($deletedRows),
|
||||
'processed' => array_sum($deletedRows),
|
||||
'succeeded' => array_sum($deletedRows),
|
||||
'failed' => 0,
|
||||
],
|
||||
'failure_summary' => [],
|
||||
'context' => [
|
||||
'source' => 'tenantpilot:purge-nonpersistent',
|
||||
'deleted_rows' => $deletedRows,
|
||||
'audit_logs_retained' => $counts['audit_logs_retained'] ?? 0,
|
||||
],
|
||||
'started_at' => now(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:operation-runs:reconcile-backup-schedules
|
||||
{--tenant=* : Limit to tenant_id/external_id}
|
||||
{--older-than=5 : Only reconcile runs older than N minutes}
|
||||
{--dry-run : Do not write changes}';
|
||||
|
||||
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
|
||||
|
||||
public function handle(OperationRunService $operationRunService): int
|
||||
{
|
||||
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
||||
$olderThanMinutes = max(0, (int) $this->option('older-than'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$query = OperationRun::query()
|
||||
->where('type', 'backup_schedule_run')
|
||||
->whereIn('status', ['queued', 'running']);
|
||||
|
||||
if ($olderThanMinutes > 0) {
|
||||
$query->where('created_at', '<', now()->subMinutes($olderThanMinutes));
|
||||
}
|
||||
|
||||
if ($tenantIdentifiers !== []) {
|
||||
$tenantIds = $this->resolveTenantIds($tenantIdentifiers);
|
||||
|
||||
if ($tenantIds === []) {
|
||||
$this->info('No tenants matched the provided identifiers.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$query->whereIn('tenant_id', $tenantIds);
|
||||
}
|
||||
|
||||
$reconciled = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($query->cursor() as $operationRun) {
|
||||
$backupScheduleId = data_get($operationRun->context, 'backup_schedule_id');
|
||||
|
||||
if (! is_numeric($backupScheduleId)) {
|
||||
if (! $dryRun) {
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: 'completed',
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'backup_schedule.missing_context',
|
||||
'message' => 'Backup schedule context is missing from this operation run.',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$failed++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$schedule = BackupSchedule::query()
|
||||
->whereKey((int) $backupScheduleId)
|
||||
->where('tenant_id', (int) $operationRun->tenant_id)
|
||||
->first();
|
||||
|
||||
if (! $schedule instanceof BackupSchedule) {
|
||||
if (! $dryRun) {
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: 'completed',
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'backup_schedule.not_found',
|
||||
'message' => 'Backup schedule not found for this operation run.',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$failed++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($operationRun->status === 'queued' && $operationRunService->isStaleQueuedRun($operationRun, max(1, $olderThanMinutes))) {
|
||||
if (! $dryRun) {
|
||||
$operationRunService->failStaleQueuedRun($operationRun, 'Backup schedule run was queued but never started.');
|
||||
}
|
||||
|
||||
$reconciled++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($operationRun->status === 'running') {
|
||||
if (! $dryRun) {
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: 'completed',
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'backup_schedule.stalled',
|
||||
'message' => 'Backup schedule run exceeded reconciliation timeout and was marked failed.',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$reconciled++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$skipped++;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Reconciled %d run(s), skipped %d, failed %d.',
|
||||
$reconciled,
|
||||
$skipped,
|
||||
$failed,
|
||||
));
|
||||
|
||||
if ($dryRun) {
|
||||
$this->comment('Dry-run: no changes written.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $tenantIdentifiers
|
||||
* @return array<int>
|
||||
*/
|
||||
private function resolveTenantIds(array $tenantIdentifiers): array
|
||||
{
|
||||
$tenantIds = [];
|
||||
|
||||
foreach ($tenantIdentifiers as $identifier) {
|
||||
$tenant = Tenant::query()
|
||||
->forTenant($identifier)
|
||||
->first();
|
||||
|
||||
if ($tenant) {
|
||||
$tenantIds[] = (int) $tenant->getKey();
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($tenantIds));
|
||||
}
|
||||
}
|
||||
51
app/Console/Commands/TenantpilotRunDeployRunbooks.php
Normal file
51
app/Console/Commands/TenantpilotRunDeployRunbooks.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Services\Runbooks\RunbookReason;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TenantpilotRunDeployRunbooks extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:run-deploy-runbooks';
|
||||
|
||||
protected $description = 'Run deploy-time runbooks idempotently.';
|
||||
|
||||
public function handle(FindingsLifecycleBackfillRunbookService $runbookService): int
|
||||
{
|
||||
try {
|
||||
$runbookService->start(
|
||||
scope: FindingsLifecycleBackfillScope::allTenants(),
|
||||
initiator: null,
|
||||
reason: new RunbookReason(
|
||||
reasonCode: RunbookReason::CODE_DATA_REPAIR,
|
||||
reasonText: 'Deploy hook automated runbooks',
|
||||
),
|
||||
source: 'deploy_hook',
|
||||
);
|
||||
|
||||
$this->info('Deploy runbooks started (if needed).');
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (ValidationException $e) {
|
||||
$errors = $e->errors();
|
||||
|
||||
$skippable = isset($errors['preflight.affected_count']) || isset($errors['scope']);
|
||||
|
||||
if ($skippable) {
|
||||
$this->info('Deploy runbooks skipped (nothing to do or already running).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->error('Deploy runbooks blocked by validation errors.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
app/Contracts/Hardening/WriteGateInterface.php
Normal file
23
app/Contracts/Hardening/WriteGateInterface.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts\Hardening;
|
||||
|
||||
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
||||
use App\Models\Tenant;
|
||||
|
||||
interface WriteGateInterface
|
||||
{
|
||||
/**
|
||||
* Evaluate whether a write operation is allowed for the given tenant.
|
||||
*
|
||||
* @throws ProviderAccessHardeningRequired when the operation is blocked
|
||||
*/
|
||||
public function evaluate(Tenant $tenant, string $operationType): void;
|
||||
|
||||
/**
|
||||
* Check whether the gate would block a write operation for the given tenant.
|
||||
*
|
||||
* Non-throwing variant for UI disabled-state checks.
|
||||
*/
|
||||
public function wouldBlock(Tenant $tenant): bool;
|
||||
}
|
||||
17
app/Exceptions/Hardening/ProviderAccessHardeningRequired.php
Normal file
17
app/Exceptions/Hardening/ProviderAccessHardeningRequired.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions\Hardening;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class ProviderAccessHardeningRequired extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $tenantId,
|
||||
public readonly string $operationType,
|
||||
public readonly string $reasonCode,
|
||||
public readonly string $reasonMessage,
|
||||
) {
|
||||
parent::__construct($reasonMessage);
|
||||
}
|
||||
}
|
||||
17
app/Exceptions/InvalidPolicyTypeException.php
Normal file
17
app/Exceptions/InvalidPolicyTypeException.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class InvalidPolicyTypeException extends RuntimeException
|
||||
{
|
||||
public array $unknownPolicyTypes;
|
||||
|
||||
public function __construct(array $unknownPolicyTypes)
|
||||
{
|
||||
$this->unknownPolicyTypes = array_values($unknownPolicyTypes);
|
||||
|
||||
parent::__construct('Unknown policy types: '.implode(', ', $this->unknownPolicyTypes));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Onboarding;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class OnboardingDraftConflictException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $draftId,
|
||||
public readonly int $expectedVersion,
|
||||
public readonly int $actualVersion,
|
||||
string $message = 'This onboarding draft changed in another tab or session.',
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Onboarding;
|
||||
|
||||
use App\Support\Onboarding\OnboardingLifecycleState;
|
||||
use RuntimeException;
|
||||
|
||||
class OnboardingDraftImmutableException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $draftId,
|
||||
public readonly OnboardingLifecycleState $lifecycleState,
|
||||
string $message = 'This onboarding draft is no longer editable.',
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
27
app/Exceptions/ReviewPackEvidenceResolutionException.php
Normal file
27
app/Exceptions/ReviewPackEvidenceResolutionException.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use App\Services\Evidence\EvidenceResolutionResult;
|
||||
use RuntimeException;
|
||||
|
||||
class ReviewPackEvidenceResolutionException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly EvidenceResolutionResult $result,
|
||||
?string $message = null,
|
||||
) {
|
||||
parent::__construct($message ?? self::defaultMessage($result));
|
||||
}
|
||||
|
||||
private static function defaultMessage(EvidenceResolutionResult $result): string
|
||||
{
|
||||
return match ($result->outcome) {
|
||||
'missing_snapshot' => 'No eligible evidence snapshot is available for this review pack.',
|
||||
'snapshot_ineligible' => 'The latest evidence snapshot is not eligible for review-pack generation.',
|
||||
default => 'Evidence snapshot resolution failed.',
|
||||
};
|
||||
}
|
||||
}
|
||||
31
app/Filament/Clusters/Inventory/InventoryCluster.php
Normal file
31
app/Filament/Clusters/Inventory/InventoryCluster.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Clusters\Inventory;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Clusters\Cluster;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Enums\SubNavigationPosition;
|
||||
use UnitEnum;
|
||||
|
||||
class InventoryCluster extends Cluster
|
||||
{
|
||||
protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Start;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
|
||||
protected static ?string $navigationLabel = 'Items';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
}
|
||||
27
app/Filament/Clusters/Monitoring/AlertsCluster.php
Normal file
27
app/Filament/Clusters/Monitoring/AlertsCluster.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Clusters\Monitoring;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Clusters\Cluster;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Enums\SubNavigationPosition;
|
||||
use UnitEnum;
|
||||
|
||||
class AlertsCluster extends Cluster
|
||||
{
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
protected static ?int $navigationSort = 20;
|
||||
|
||||
protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Start;
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
return Filament::getCurrentPanel()?->getId() === 'admin';
|
||||
}
|
||||
}
|
||||
72
app/Filament/Concerns/InteractsWithTenantOwnedRecords.php
Normal file
72
app/Filament/Concerns/InteractsWithTenantOwnedRecords.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Concerns;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\WorkspaceIsolation\TenantOwnedQueryScope;
|
||||
use App\Support\WorkspaceIsolation\TenantOwnedRecordResolver;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
trait InteractsWithTenantOwnedRecords
|
||||
{
|
||||
protected static function tenantOwnedRelationshipName(): string
|
||||
{
|
||||
$relationshipName = property_exists(static::class, 'tenantOwnershipRelationshipName')
|
||||
? static::$tenantOwnershipRelationshipName
|
||||
: null;
|
||||
|
||||
return is_string($relationshipName) && $relationshipName !== ''
|
||||
? $relationshipName
|
||||
: 'tenant';
|
||||
}
|
||||
|
||||
protected static function resolveTenantContextForTenantOwnedRecords(): ?Tenant
|
||||
{
|
||||
if (method_exists(static::class, 'resolveTenantContextForCurrentPanel')) {
|
||||
return static::resolveTenantContextForCurrentPanel();
|
||||
}
|
||||
|
||||
if (method_exists(static::class, 'panelTenantContext')) {
|
||||
return static::panelTenantContext();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function getTenantOwnedEloquentQuery(): Builder
|
||||
{
|
||||
return static::scopeTenantOwnedQuery(parent::getEloquentQuery());
|
||||
}
|
||||
|
||||
protected static function scopeTenantOwnedQuery(Builder $query, ?Tenant $tenant = null): Builder
|
||||
{
|
||||
return app(TenantOwnedQueryScope::class)->apply(
|
||||
$query,
|
||||
$tenant ?? static::resolveTenantContextForTenantOwnedRecords(),
|
||||
static::tenantOwnedRelationshipName(),
|
||||
);
|
||||
}
|
||||
|
||||
protected static function resolveTenantOwnedRecord(Model|int|string|null $record, ?Builder $query = null, ?Tenant $tenant = null): ?Model
|
||||
{
|
||||
$scopedQuery = static::scopeTenantOwnedQuery(
|
||||
$query ?? parent::getEloquentQuery(),
|
||||
$tenant,
|
||||
);
|
||||
|
||||
return app(TenantOwnedRecordResolver::class)->resolve($scopedQuery, $record);
|
||||
}
|
||||
|
||||
protected static function resolveTenantOwnedRecordOrFail(Model|int|string|null $record, ?Builder $query = null, ?Tenant $tenant = null): Model
|
||||
{
|
||||
$scopedQuery = static::scopeTenantOwnedQuery(
|
||||
$query ?? parent::getEloquentQuery(),
|
||||
$tenant,
|
||||
);
|
||||
|
||||
return app(TenantOwnedRecordResolver::class)->resolveOrFail($scopedQuery, $record);
|
||||
}
|
||||
}
|
||||
52
app/Filament/Concerns/ResolvesPanelTenantContext.php
Normal file
52
app/Filament/Concerns/ResolvesPanelTenantContext.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Concerns;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use Filament\Facades\Filament;
|
||||
use RuntimeException;
|
||||
|
||||
trait ResolvesPanelTenantContext
|
||||
{
|
||||
protected static function resolveTenantContextForCurrentPanel(): ?Tenant
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
$tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request());
|
||||
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
}
|
||||
|
||||
public static function panelTenantContext(): ?Tenant
|
||||
{
|
||||
return static::resolveTenantContextForCurrentPanel();
|
||||
}
|
||||
|
||||
public static function trustedPanelTenantContext(): ?Tenant
|
||||
{
|
||||
return static::panelTenantContext();
|
||||
}
|
||||
|
||||
protected static function resolveTenantContextForCurrentPanelOrFail(): Tenant
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new RuntimeException('No tenant context selected.');
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
protected static function resolveTrustedPanelTenantContextOrFail(): Tenant
|
||||
{
|
||||
return static::resolveTenantContextForCurrentPanelOrFail();
|
||||
}
|
||||
}
|
||||
64
app/Filament/Concerns/ScopesGlobalSearchToTenant.php
Normal file
64
app/Filament/Concerns/ScopesGlobalSearchToTenant.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Concerns;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
trait ScopesGlobalSearchToTenant
|
||||
{
|
||||
/**
|
||||
* The Eloquent relationship name used to scope records to the current tenant.
|
||||
*/
|
||||
protected static string $globalSearchTenantRelationship = 'tenant';
|
||||
|
||||
public static function getGlobalSearchEloquentQuery(): Builder
|
||||
{
|
||||
$query = static::getModel()::query();
|
||||
|
||||
if (! TenantOwnedModelFamilies::supportsScopedGlobalSearch(static::getModel())) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
if (! static::isScopedToTenant()) {
|
||||
$panel = Filament::getCurrentOrDefaultPanel();
|
||||
|
||||
if ($panel?->hasTenancy()) {
|
||||
$query->withoutGlobalScope($panel->getTenancyScopeName());
|
||||
}
|
||||
}
|
||||
|
||||
$tenant = static::resolveGlobalSearchTenant();
|
||||
|
||||
if (! $tenant instanceof Model) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user || ! method_exists($user, 'canAccessTenant') || ! $user->canAccessTenant($tenant)) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return $query->whereBelongsTo($tenant, static::$globalSearchTenantRelationship);
|
||||
}
|
||||
|
||||
protected static function resolveGlobalSearchTenant(): ?Model
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
}
|
||||
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
return $tenant instanceof Model ? $tenant : null;
|
||||
}
|
||||
}
|
||||
12
app/Filament/Pages/Auth/Login.php
Normal file
12
app/Filament/Pages/Auth/Login.php
Normal file
@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Auth;
|
||||
|
||||
use Filament\Auth\Pages\Login as BaseLogin;
|
||||
|
||||
class Login extends BaseLogin
|
||||
{
|
||||
protected string $view = 'filament.pages.auth.login';
|
||||
}
|
||||
369
app/Filament/Pages/BaselineCompareLanding.php
Normal file
369
app/Filament/Pages/BaselineCompareLanding.php
Normal file
@ -0,0 +1,369 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Baselines\BaselineCompareService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
class BaselineCompareLanding extends Page
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
|
||||
protected static ?string $navigationLabel = 'Baseline Compare';
|
||||
|
||||
protected static ?int $navigationSort = 10;
|
||||
|
||||
protected static ?string $title = 'Baseline Compare';
|
||||
|
||||
protected string $view = 'filament.pages.baseline-compare-landing';
|
||||
|
||||
public ?string $state = null;
|
||||
|
||||
public ?string $message = null;
|
||||
|
||||
public ?string $reasonCode = null;
|
||||
|
||||
public ?string $reasonMessage = null;
|
||||
|
||||
public ?string $profileName = null;
|
||||
|
||||
public ?int $profileId = null;
|
||||
|
||||
public ?int $snapshotId = null;
|
||||
|
||||
public ?int $duplicateNamePoliciesCount = null;
|
||||
|
||||
public ?int $operationRunId = null;
|
||||
|
||||
public ?int $findingsCount = null;
|
||||
|
||||
/** @var array<string, int>|null */
|
||||
public ?array $severityCounts = null;
|
||||
|
||||
public ?string $lastComparedAt = null;
|
||||
|
||||
public ?string $lastComparedIso = null;
|
||||
|
||||
public ?string $failureReason = null;
|
||||
|
||||
public ?string $coverageStatus = null;
|
||||
|
||||
public ?int $uncoveredTypesCount = null;
|
||||
|
||||
/** @var list<string>|null */
|
||||
public ?array $uncoveredTypes = null;
|
||||
|
||||
public ?string $fidelity = null;
|
||||
|
||||
public ?int $evidenceGapsCount = null;
|
||||
|
||||
/** @var array<string, int>|null */
|
||||
public ?array $evidenceGapsTopReasons = null;
|
||||
|
||||
/** @var array<string, int>|null */
|
||||
public ?array $rbacRoleDefinitionSummary = null;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->refreshStats();
|
||||
}
|
||||
|
||||
public function refreshStats(): void
|
||||
{
|
||||
$stats = BaselineCompareStats::forTenant(static::resolveTenantContextForCurrentPanel());
|
||||
|
||||
$this->state = $stats->state;
|
||||
$this->message = $stats->message;
|
||||
$this->profileName = $stats->profileName;
|
||||
$this->profileId = $stats->profileId;
|
||||
$this->snapshotId = $stats->snapshotId;
|
||||
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
|
||||
$this->operationRunId = $stats->operationRunId;
|
||||
$this->findingsCount = $stats->findingsCount;
|
||||
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
|
||||
$this->lastComparedAt = $stats->lastComparedHuman;
|
||||
$this->lastComparedIso = $stats->lastComparedIso;
|
||||
$this->failureReason = $stats->failureReason;
|
||||
$this->reasonCode = $stats->reasonCode;
|
||||
$this->reasonMessage = $stats->reasonMessage;
|
||||
|
||||
$this->coverageStatus = $stats->coverageStatus;
|
||||
$this->uncoveredTypesCount = $stats->uncoveredTypesCount;
|
||||
$this->uncoveredTypes = $stats->uncoveredTypes !== [] ? $stats->uncoveredTypes : null;
|
||||
$this->fidelity = $stats->fidelity;
|
||||
|
||||
$this->evidenceGapsCount = $stats->evidenceGapsCount;
|
||||
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
|
||||
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed view data exposed to the Blade template.
|
||||
*
|
||||
* Moves presentational logic out of Blade `@php` blocks so the
|
||||
* template only receives ready-to-render values.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
|
||||
$evidenceGapsCountValue = (int) ($this->evidenceGapsCount ?? 0);
|
||||
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
|
||||
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
||||
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
|
||||
&& array_sum($this->rbacRoleDefinitionSummary) > 0;
|
||||
|
||||
$evidenceGapsSummary = null;
|
||||
$evidenceGapsTooltip = null;
|
||||
|
||||
if ($hasEvidenceGaps && is_array($this->evidenceGapsTopReasons) && $this->evidenceGapsTopReasons !== []) {
|
||||
$parts = [];
|
||||
|
||||
foreach (array_slice($this->evidenceGapsTopReasons, 0, 5, true) as $reason => $count) {
|
||||
if (! is_string($reason) || $reason === '' || ! is_numeric($count)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts[] = $reason.' ('.((int) $count).')';
|
||||
}
|
||||
|
||||
if ($parts !== []) {
|
||||
$evidenceGapsSummary = implode(', ', $parts);
|
||||
$evidenceGapsTooltip = __('baseline-compare.evidence_gaps_tooltip', ['summary' => $evidenceGapsSummary]);
|
||||
}
|
||||
}
|
||||
|
||||
// Derive the colour class for the findings-count stat card.
|
||||
// Only show danger-red when high-severity findings exist;
|
||||
// use warning-orange for low/medium-only, and success-green for zero.
|
||||
$findingsColorClass = $this->resolveFindingsColorClass($hasWarnings);
|
||||
|
||||
// "Why no findings" explanation when count is zero.
|
||||
$whyNoFindingsMessage = filled($this->reasonMessage) ? (string) $this->reasonMessage : null;
|
||||
$whyNoFindingsFallback = ! $hasWarnings
|
||||
? __('baseline-compare.no_findings_all_clear')
|
||||
: ($hasCoverageWarnings
|
||||
? __('baseline-compare.no_findings_coverage_warnings')
|
||||
: ($hasEvidenceGaps
|
||||
? __('baseline-compare.no_findings_evidence_gaps')
|
||||
: __('baseline-compare.no_findings_default')));
|
||||
$whyNoFindingsColor = $hasWarnings
|
||||
? 'text-warning-600 dark:text-warning-400'
|
||||
: 'text-success-600 dark:text-success-400';
|
||||
|
||||
if ($this->reasonCode === 'no_subjects_in_scope') {
|
||||
$whyNoFindingsColor = 'text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
|
||||
return [
|
||||
'hasCoverageWarnings' => $hasCoverageWarnings,
|
||||
'evidenceGapsCountValue' => $evidenceGapsCountValue,
|
||||
'hasEvidenceGaps' => $hasEvidenceGaps,
|
||||
'hasWarnings' => $hasWarnings,
|
||||
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
|
||||
'evidenceGapsSummary' => $evidenceGapsSummary,
|
||||
'evidenceGapsTooltip' => $evidenceGapsTooltip,
|
||||
'findingsColorClass' => $findingsColorClass,
|
||||
'whyNoFindingsMessage' => $whyNoFindingsMessage,
|
||||
'whyNoFindingsFallback' => $whyNoFindingsFallback,
|
||||
'whyNoFindingsColor' => $whyNoFindingsColor,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Tailwind colour class for the Total Findings stat.
|
||||
*
|
||||
* - Red (danger) only when high-severity findings exist
|
||||
* - Orange (warning) for medium/low-only findings or when warnings present
|
||||
* - Green (success) when fully clear
|
||||
*/
|
||||
private function resolveFindingsColorClass(bool $hasWarnings): string
|
||||
{
|
||||
$count = (int) ($this->findingsCount ?? 0);
|
||||
|
||||
if ($count === 0) {
|
||||
return $hasWarnings
|
||||
? 'text-warning-600 dark:text-warning-400'
|
||||
: 'text-success-600 dark:text-success-400';
|
||||
}
|
||||
|
||||
$hasHigh = ($this->severityCounts['high'] ?? 0) > 0;
|
||||
|
||||
return $hasHigh
|
||||
? 'text-danger-600 dark:text-danger-400'
|
||||
: 'text-warning-600 dark:text-warning-400';
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Compare Now (confirmation modal, capability-gated).')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'This is a tenant-scoped landing page, not a record inspect surface.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'This page does not render table rows with secondary actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'This page has no bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Page renders explicit empty states for missing tenant, missing assignment, and missing snapshot, with guidance messaging.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'This page does not have a record detail header; it uses a page header action instead.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
$this->compareNowAction(),
|
||||
];
|
||||
}
|
||||
|
||||
private function compareNowAction(): Action
|
||||
{
|
||||
$isFullContent = false;
|
||||
|
||||
if (is_int($this->profileId) && $this->profileId > 0) {
|
||||
$profile = \App\Models\BaselineProfile::query()->find($this->profileId);
|
||||
$mode = $profile?->capture_mode instanceof BaselineCaptureMode
|
||||
? $profile->capture_mode
|
||||
: (is_string($profile?->capture_mode) ? BaselineCaptureMode::tryFrom($profile->capture_mode) : null);
|
||||
|
||||
$isFullContent = $mode === BaselineCaptureMode::FullContent;
|
||||
}
|
||||
|
||||
$label = $isFullContent ? 'Compare now (full content)' : 'Compare now';
|
||||
$modalDescription = $isFullContent
|
||||
? 'This will refresh content evidence on demand (redacted) before comparing the current tenant inventory against the assigned baseline snapshot.'
|
||||
: 'This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.';
|
||||
|
||||
$action = Action::make('compareNow')
|
||||
->label($label)
|
||||
->icon('heroicon-o-play')
|
||||
->requiresConfirmation()
|
||||
->modalHeading($label)
|
||||
->modalDescription($modalDescription)
|
||||
->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready', 'failed'], true))
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
Notification::make()->title('Not authenticated')->danger()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
Notification::make()->title('Select a tenant to compare baselines')->danger()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(BaselineCompareService::class);
|
||||
$result = $service->startCompare($tenant, $user);
|
||||
|
||||
if (! ($result['ok'] ?? false)) {
|
||||
Notification::make()
|
||||
->title('Cannot start comparison')
|
||||
->body('Reason: '.($result['reason_code'] ?? 'unknown'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$run = $result['run'] ?? null;
|
||||
|
||||
if ($run instanceof OperationRun) {
|
||||
$this->operationRunId = (int) $run->getKey();
|
||||
}
|
||||
|
||||
$this->state = 'comparing';
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : 'baseline_compare')
|
||||
->actions($run instanceof OperationRun ? [
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($run, $tenant)),
|
||||
] : [])
|
||||
->send();
|
||||
});
|
||||
|
||||
return UiEnforcement::forAction($action)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->preserveDisabled()
|
||||
->apply();
|
||||
}
|
||||
|
||||
public function getFindingsUrl(): ?string
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return FindingResource::getUrl('index', tenant: $tenant);
|
||||
}
|
||||
|
||||
public function getRunUrl(): ?string
|
||||
{
|
||||
if ($this->operationRunId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return OperationRunLinks::view($this->operationRunId, $tenant);
|
||||
}
|
||||
}
|
||||
35
app/Filament/Pages/BreakGlassRecovery.php
Normal file
35
app/Filament/Pages/BreakGlassRecovery.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
class BreakGlassRecovery extends Page
|
||||
{
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?string $navigationLabel = 'Break-glass recovery';
|
||||
|
||||
protected static ?int $navigationSort = 999;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected string $view = 'filament.pages.break-glass-recovery';
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
154
app/Filament/Pages/ChooseTenant.php
Normal file
154
app/Filament/Pages/ChooseTenant.php
Normal file
@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTenantPreference;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantLifecyclePresentation;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ChooseTenant extends Page
|
||||
{
|
||||
protected static string $layout = 'filament-panels::components.layout.simple';
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static ?string $slug = 'choose-tenant';
|
||||
|
||||
protected static ?string $title = 'Choose tenant';
|
||||
|
||||
protected string $view = 'filament.pages.choose-tenant';
|
||||
|
||||
/**
|
||||
* Disable the simple-layout topbar to prevent lazy-loaded
|
||||
* DatabaseNotifications from triggering Livewire update 404s.
|
||||
*/
|
||||
protected function getLayoutData(): array
|
||||
{
|
||||
return [
|
||||
'hasTopbar' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Tenant>
|
||||
*/
|
||||
public function getTenants(): Collection
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return Tenant::query()->whereRaw('1 = 0')->get();
|
||||
}
|
||||
|
||||
$tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel());
|
||||
|
||||
if ($tenants instanceof Collection) {
|
||||
return app(TenantOperabilityService::class)->filterSelectable($tenants);
|
||||
}
|
||||
|
||||
return app(TenantOperabilityService::class)->filterSelectable(collect($tenants));
|
||||
}
|
||||
|
||||
public function selectTenant(int $tenantId): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
$workspaceId = $workspaceContext->currentWorkspaceId(request());
|
||||
$tenant = null;
|
||||
|
||||
if ($workspaceId === null) {
|
||||
$tenant = Tenant::query()->whereKey($tenantId)->first();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$workspace = $tenant->workspace;
|
||||
|
||||
if ($workspace !== null && $user->canAccessTenant($tenant)) {
|
||||
$workspaceContext->setCurrentWorkspace($workspace, $user, request());
|
||||
$workspaceId = (int) $workspace->getKey();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($workspaceId === null) {
|
||||
$this->redirect(route('filament.admin.pages.choose-workspace'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== $workspaceId) {
|
||||
$tenant = Tenant::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->whereKey($tenantId)
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$outcome = app(TenantOperabilityService::class)->outcomeFor(
|
||||
tenant: $tenant,
|
||||
question: TenantOperabilityQuestion::SelectorEligibility,
|
||||
actor: $user,
|
||||
workspaceId: $workspaceId,
|
||||
lane: TenantInteractionLane::StandardActiveOperating,
|
||||
);
|
||||
|
||||
if (! $outcome->allowed) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->persistLastTenant($user, $tenant);
|
||||
|
||||
if (! $workspaceContext->rememberTenantContext($tenant, request())) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||
}
|
||||
|
||||
public function tenantLifecyclePresentation(Tenant $tenant): TenantLifecyclePresentation
|
||||
{
|
||||
return TenantLifecyclePresentation::fromTenant($tenant);
|
||||
}
|
||||
|
||||
private function persistLastTenant(User $user, Tenant $tenant): void
|
||||
{
|
||||
if (Schema::hasColumn('users', 'last_tenant_id')) {
|
||||
$user->forceFill(['last_tenant_id' => $tenant->getKey()])->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('user_tenant_preferences')) {
|
||||
return;
|
||||
}
|
||||
|
||||
UserTenantPreference::query()->updateOrCreate(
|
||||
['user_id' => $user->getKey(), 'tenant_id' => $tenant->getKey()],
|
||||
['last_used_at' => now()]
|
||||
);
|
||||
}
|
||||
}
|
||||
182
app/Filament/Pages/ChooseWorkspace.php
Normal file
182
app/Filament/Pages/ChooseWorkspace.php
Normal file
@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use App\Support\Workspaces\WorkspaceIntendedUrl;
|
||||
use App\Support\Workspaces\WorkspaceRedirectResolver;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class ChooseWorkspace extends Page
|
||||
{
|
||||
protected static string $layout = 'filament-panels::components.layout.simple';
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static ?string $slug = 'choose-workspace';
|
||||
|
||||
protected static ?string $title = 'Choose workspace';
|
||||
|
||||
protected string $view = 'filament.pages.choose-workspace';
|
||||
|
||||
/**
|
||||
* Workspace roles keyed by workspace_id.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public array $workspaceRoles = [];
|
||||
|
||||
/**
|
||||
* @return array<\Filament\Actions\Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Workspace>
|
||||
*/
|
||||
public function getWorkspaces(): Collection
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return Workspace::query()->whereRaw('1 = 0')->get();
|
||||
}
|
||||
|
||||
$workspaces = Workspace::query()
|
||||
->whereIn('id', function ($query) use ($user): void {
|
||||
$query->from('workspace_memberships')
|
||||
->select('workspace_id')
|
||||
->where('user_id', $user->getKey());
|
||||
})
|
||||
->whereNull('archived_at')
|
||||
->withCount(['tenants' => function ($query): void {
|
||||
$query->where('status', 'active');
|
||||
}])
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Build roles map from memberships.
|
||||
$memberships = WorkspaceMembership::query()
|
||||
->where('user_id', $user->getKey())
|
||||
->whereIn('workspace_id', $workspaces->pluck('id'))
|
||||
->pluck('role', 'workspace_id');
|
||||
|
||||
$this->workspaceRoles = $memberships->mapWithKeys(fn ($role, $id) => [(int) $id => (string) $role])->all();
|
||||
|
||||
return $workspaces;
|
||||
}
|
||||
|
||||
public function selectWorkspace(int $workspaceId): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! empty($workspace->archived_at)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$context = app(WorkspaceContext::class);
|
||||
|
||||
if (! $context->isMember($user, $workspace)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$prevWorkspaceId = $context->currentWorkspaceId(request());
|
||||
|
||||
$context->setCurrentWorkspace($workspace, $user, request());
|
||||
|
||||
// Audit: manual workspace selection.
|
||||
/** @var WorkspaceAuditLogger $logger */
|
||||
$logger = app(WorkspaceAuditLogger::class);
|
||||
|
||||
$logger->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::WorkspaceSelected->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'method' => 'manual',
|
||||
'reason' => 'chooser',
|
||||
'prev_workspace_id' => $prevWorkspaceId,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'workspace',
|
||||
resourceId: (string) $workspace->getKey(),
|
||||
);
|
||||
|
||||
$intendedUrl = WorkspaceIntendedUrl::consume(request());
|
||||
|
||||
/** @var WorkspaceRedirectResolver $resolver */
|
||||
$resolver = app(WorkspaceRedirectResolver::class);
|
||||
|
||||
$redirectTarget = $intendedUrl ?: $resolver->resolve($workspace, $user);
|
||||
|
||||
$this->redirect($redirectTarget);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{name: string, slug?: string|null} $data
|
||||
*/
|
||||
public function createWorkspace(array $data): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->can('create', Workspace::class)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->create([
|
||||
'name' => $data['name'],
|
||||
'slug' => $data['slug'] ?? null,
|
||||
]);
|
||||
|
||||
WorkspaceMembership::query()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request());
|
||||
|
||||
Notification::make()
|
||||
->title('Workspace created')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$intendedUrl = WorkspaceIntendedUrl::consume(request());
|
||||
|
||||
/** @var WorkspaceRedirectResolver $resolver */
|
||||
$resolver = app(WorkspaceRedirectResolver::class);
|
||||
|
||||
$redirectTarget = $intendedUrl ?: $resolver->resolve($workspace, $user);
|
||||
|
||||
$this->redirect($redirectTarget);
|
||||
}
|
||||
}
|
||||
453
app/Filament/Pages/InventoryCoverage.php
Normal file
453
app/Filament/Pages/InventoryCoverage.php
Normal file
@ -0,0 +1,453 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Badges\TagBadgeCatalog;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Support\Enums\FontFamily;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use UnitEnum;
|
||||
|
||||
class InventoryCoverage extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-table-cells';
|
||||
|
||||
protected static ?int $navigationSort = 3;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
|
||||
protected static ?string $navigationLabel = 'Coverage';
|
||||
|
||||
protected static ?string $cluster = InventoryCluster::class;
|
||||
|
||||
protected string $view = 'filament.pages.inventory-coverage';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $tenant)
|
||||
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
InventoryKpiHeader::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->searchable()
|
||||
->searchPlaceholder('Search by policy type or label')
|
||||
->defaultSort('label')
|
||||
->defaultPaginationPageOption(50)
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
|
||||
->records(function (
|
||||
?string $sortColumn,
|
||||
?string $sortDirection,
|
||||
?string $search,
|
||||
array $filters,
|
||||
int $page,
|
||||
int $recordsPerPage
|
||||
): LengthAwarePaginator {
|
||||
$rows = $this->filterRows(
|
||||
rows: $this->coverageRows(),
|
||||
search: $search,
|
||||
filters: $filters,
|
||||
);
|
||||
|
||||
$rows = $this->sortRows(
|
||||
rows: $rows,
|
||||
sortColumn: $sortColumn,
|
||||
sortDirection: $sortDirection,
|
||||
);
|
||||
|
||||
return $this->paginateRows(
|
||||
rows: $rows,
|
||||
page: $page,
|
||||
recordsPerPage: $recordsPerPage,
|
||||
);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('type')
|
||||
->label('Type')
|
||||
->sortable()
|
||||
->fontFamily(FontFamily::Mono)
|
||||
->copyable()
|
||||
->wrap(),
|
||||
TextColumn::make('label')
|
||||
->label('Label')
|
||||
->sortable()
|
||||
->badge()
|
||||
->formatStateUsing(function (?string $state, array $record): string {
|
||||
return TagBadgeCatalog::spec(
|
||||
TagBadgeDomain::PolicyType,
|
||||
$record['type'] ?? $state,
|
||||
)->label;
|
||||
})
|
||||
->color(function (?string $state, array $record): string {
|
||||
return TagBadgeCatalog::spec(
|
||||
TagBadgeDomain::PolicyType,
|
||||
$record['type'] ?? $state,
|
||||
)->color;
|
||||
})
|
||||
->icon(function (?string $state, array $record): ?string {
|
||||
return TagBadgeCatalog::spec(
|
||||
TagBadgeDomain::PolicyType,
|
||||
$record['type'] ?? $state,
|
||||
)->icon;
|
||||
})
|
||||
->iconColor(function (?string $state, array $record): ?string {
|
||||
$spec = TagBadgeCatalog::spec(
|
||||
TagBadgeDomain::PolicyType,
|
||||
$record['type'] ?? $state,
|
||||
);
|
||||
|
||||
return $spec->iconColor ?? $spec->color;
|
||||
})
|
||||
->wrap(),
|
||||
TextColumn::make('risk')
|
||||
->label('Risk')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
|
||||
TextColumn::make('restore')
|
||||
->label('Restore')
|
||||
->badge()
|
||||
->state(fn (array $record): ?string => $record['restore'])
|
||||
->formatStateUsing(function (?string $state): string {
|
||||
return filled($state)
|
||||
? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->label
|
||||
: 'Not provided';
|
||||
})
|
||||
->color(function (?string $state): string {
|
||||
return filled($state)
|
||||
? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->color
|
||||
: 'gray';
|
||||
})
|
||||
->icon(function (?string $state): ?string {
|
||||
return filled($state)
|
||||
? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->icon
|
||||
: 'heroicon-m-minus-circle';
|
||||
})
|
||||
->iconColor(function (?string $state): ?string {
|
||||
if (! filled($state)) {
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state);
|
||||
|
||||
return $spec->iconColor ?? $spec->color;
|
||||
}),
|
||||
TextColumn::make('category')
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory))
|
||||
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyCategory))
|
||||
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyCategory))
|
||||
->toggleable()
|
||||
->wrap(),
|
||||
TextColumn::make('segment')
|
||||
->label('Segment')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => $state === 'foundation' ? 'Foundation' : 'Policy')
|
||||
->color(fn (?string $state): string => $state === 'foundation' ? 'gray' : 'info')
|
||||
->toggleable(),
|
||||
IconColumn::make('dependencies')
|
||||
->label('Dependencies')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-m-check-circle')
|
||||
->falseIcon('heroicon-m-minus-circle')
|
||||
->trueColor('success')
|
||||
->falseColor('gray')
|
||||
->alignCenter()
|
||||
->toggleable(),
|
||||
])
|
||||
->filters($this->tableFilters())
|
||||
->emptyStateHeading('No coverage entries match this view')
|
||||
->emptyStateDescription('Clear the current search or filters to return to the full coverage matrix.')
|
||||
->emptyStateIcon('heroicon-o-funnel')
|
||||
->emptyStateActions([
|
||||
Action::make('clear_filters')
|
||||
->label('Clear filters')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
$this->resetTable();
|
||||
}),
|
||||
])
|
||||
->actions([])
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, SelectFilter>
|
||||
*/
|
||||
protected function tableFilters(): array
|
||||
{
|
||||
$filters = [
|
||||
SelectFilter::make('category')
|
||||
->label('Category')
|
||||
->options($this->categoryFilterOptions()),
|
||||
];
|
||||
|
||||
if ($this->restoreFilterOptions() !== []) {
|
||||
$filters[] = SelectFilter::make('restore')
|
||||
->label('Restore mode')
|
||||
->options($this->restoreFilterOptions());
|
||||
}
|
||||
|
||||
return $filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<string, array{
|
||||
* __key: string,
|
||||
* key: string,
|
||||
* segment: string,
|
||||
* type: string,
|
||||
* label: string,
|
||||
* category: string,
|
||||
* dependencies: bool,
|
||||
* restore: ?string,
|
||||
* risk: string,
|
||||
* source_order: int
|
||||
* }>
|
||||
*/
|
||||
protected function coverageRows(): Collection
|
||||
{
|
||||
$resolver = app(CoverageCapabilitiesResolver::class);
|
||||
|
||||
$supported = $this->mapCoverageRows(
|
||||
rows: InventoryPolicyTypeMeta::supported(),
|
||||
segment: 'policy',
|
||||
sourceOrderOffset: 0,
|
||||
resolver: $resolver,
|
||||
);
|
||||
|
||||
return $supported->merge($this->mapCoverageRows(
|
||||
rows: InventoryPolicyTypeMeta::foundations(),
|
||||
segment: 'foundation',
|
||||
sourceOrderOffset: $supported->count(),
|
||||
resolver: $resolver,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return Collection<string, array{
|
||||
* __key: string,
|
||||
* key: string,
|
||||
* segment: string,
|
||||
* type: string,
|
||||
* label: string,
|
||||
* category: string,
|
||||
* dependencies: bool,
|
||||
* restore: ?string,
|
||||
* risk: string,
|
||||
* source_order: int
|
||||
* }>
|
||||
*/
|
||||
protected function mapCoverageRows(
|
||||
array $rows,
|
||||
string $segment,
|
||||
int $sourceOrderOffset,
|
||||
CoverageCapabilitiesResolver $resolver
|
||||
): Collection {
|
||||
return collect($rows)
|
||||
->values()
|
||||
->mapWithKeys(function (array $row, int $index) use ($resolver, $segment, $sourceOrderOffset): array {
|
||||
$type = (string) ($row['type'] ?? '');
|
||||
|
||||
if ($type === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$key = "{$segment}:{$type}";
|
||||
$restore = $row['restore'] ?? null;
|
||||
$risk = $row['risk'] ?? 'n/a';
|
||||
|
||||
return [
|
||||
$key => [
|
||||
'__key' => $key,
|
||||
'key' => $key,
|
||||
'segment' => $segment,
|
||||
'type' => $type,
|
||||
'label' => (string) ($row['label'] ?? $type),
|
||||
'category' => (string) ($row['category'] ?? 'Other'),
|
||||
'dependencies' => $segment === 'policy' && $resolver->supportsDependencies($type),
|
||||
'restore' => is_string($restore) ? $restore : null,
|
||||
'risk' => is_string($risk) ? $risk : 'n/a',
|
||||
'source_order' => $sourceOrderOffset + $index,
|
||||
],
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<string, array<string, mixed>> $rows
|
||||
* @param array<string, mixed> $filters
|
||||
* @return Collection<string, array<string, mixed>>
|
||||
*/
|
||||
protected function filterRows(Collection $rows, ?string $search, array $filters): Collection
|
||||
{
|
||||
$normalizedSearch = Str::lower(trim((string) $search));
|
||||
$category = $filters['category']['value'] ?? null;
|
||||
$restore = $filters['restore']['value'] ?? null;
|
||||
|
||||
return $rows
|
||||
->when(
|
||||
$normalizedSearch !== '',
|
||||
function (Collection $rows) use ($normalizedSearch): Collection {
|
||||
return $rows->filter(function (array $row) use ($normalizedSearch): bool {
|
||||
return str_contains(Str::lower((string) $row['type']), $normalizedSearch)
|
||||
|| str_contains(Str::lower((string) $row['label']), $normalizedSearch);
|
||||
});
|
||||
},
|
||||
)
|
||||
->when(
|
||||
filled($category),
|
||||
fn (Collection $rows): Collection => $rows->where('category', (string) $category),
|
||||
)
|
||||
->when(
|
||||
filled($restore),
|
||||
fn (Collection $rows): Collection => $rows->where('restore', (string) $restore),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<string, array<string, mixed>> $rows
|
||||
* @return Collection<string, array<string, mixed>>
|
||||
*/
|
||||
protected function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
|
||||
{
|
||||
$sortColumn = in_array($sortColumn, ['type', 'label'], true) ? $sortColumn : null;
|
||||
|
||||
if ($sortColumn === null) {
|
||||
return $rows->sortBy('source_order');
|
||||
}
|
||||
|
||||
$records = $rows->all();
|
||||
|
||||
uasort($records, function (array $left, array $right) use ($sortColumn, $sortDirection): int {
|
||||
$comparison = strnatcasecmp(
|
||||
(string) ($left[$sortColumn] ?? ''),
|
||||
(string) ($right[$sortColumn] ?? ''),
|
||||
);
|
||||
|
||||
if ($comparison === 0) {
|
||||
$comparison = ((int) ($left['source_order'] ?? 0)) <=> ((int) ($right['source_order'] ?? 0));
|
||||
}
|
||||
|
||||
return $sortDirection === 'desc' ? ($comparison * -1) : $comparison;
|
||||
});
|
||||
|
||||
return collect($records);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<string, array<string, mixed>> $rows
|
||||
*/
|
||||
protected function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
|
||||
{
|
||||
return new LengthAwarePaginator(
|
||||
items: $rows->forPage($page, $recordsPerPage),
|
||||
total: $rows->count(),
|
||||
perPage: $recordsPerPage,
|
||||
currentPage: $page,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function categoryFilterOptions(): array
|
||||
{
|
||||
return $this->coverageRows()
|
||||
->pluck('category')
|
||||
->filter(fn (mixed $category): bool => is_string($category) && $category !== '')
|
||||
->unique()
|
||||
->sort()
|
||||
->mapWithKeys(function (string $category): array {
|
||||
return [
|
||||
$category => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $category)->label,
|
||||
];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function restoreFilterOptions(): array
|
||||
{
|
||||
return $this->coverageRows()
|
||||
->pluck('restore')
|
||||
->filter(fn (mixed $restore): bool => is_string($restore) && $restore !== '')
|
||||
->unique()
|
||||
->sort()
|
||||
->mapWithKeys(function (string $restore): array {
|
||||
return [
|
||||
$restore => BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $restore)->label,
|
||||
];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
}
|
||||
87
app/Filament/Pages/Monitoring/Alerts.php
Normal file
87
app/Filament/Pages/Monitoring/Alerts.php
Normal file
@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Monitoring;
|
||||
|
||||
use App\Filament\Clusters\Monitoring\AlertsCluster;
|
||||
use App\Filament\Widgets\Alerts\AlertsKpiHeader;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
class Alerts extends Page
|
||||
{
|
||||
protected static ?string $cluster = AlertsCluster::class;
|
||||
|
||||
protected static ?int $navigationSort = 20;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
protected static ?string $navigationLabel = 'Overview';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
|
||||
|
||||
protected static ?string $slug = 'overview';
|
||||
|
||||
protected static ?string $title = 'Alerts';
|
||||
|
||||
protected string $view = 'filament.pages.monitoring.alerts';
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $workspace)
|
||||
&& $resolver->can($user, $workspace, Capabilities::ALERTS_VIEW);
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
AlertsKpiHeader::class,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return app(OperateHubShell::class)->headerActions(
|
||||
scopeActionName: 'operate_hub_scope_alerts',
|
||||
returnActionName: 'operate_hub_return_alerts',
|
||||
);
|
||||
}
|
||||
}
|
||||
398
app/Filament/Pages/Monitoring/AuditLog.php
Normal file
398
app/Filament/Pages/Monitoring/AuditLog.php
Normal file
@ -0,0 +1,398 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Monitoring;
|
||||
|
||||
use App\Models\AuditLog as AuditLogModel;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
|
||||
class AuditLog extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
public ?int $selectedAuditLogId = null;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
protected static ?string $navigationLabel = 'Audit Log';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-list';
|
||||
|
||||
protected static ?string $slug = 'audit-log';
|
||||
|
||||
protected static ?string $title = 'Audit Log';
|
||||
|
||||
protected string $view = 'filament.pages.monitoring.audit-log';
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
*/
|
||||
private ?array $authorizedTenants = null;
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||
->withDefaults(new ActionSurfaceDefaults(
|
||||
moreGroupLabel: 'More',
|
||||
exportIsDefaultBulkActionForReadOnly: false,
|
||||
))
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the Monitoring scope visible and expose selected-event detail actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Audit history is immutable and intentionally omits bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The table exposes a clear-filters CTA when no audit events match the current view.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected-event detail keeps close-inspection and related-navigation actions at the page header.');
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorizePageAccess();
|
||||
$this->selectedAuditLogId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
||||
|
||||
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
||||
|
||||
$this->mountInteractsWithTable();
|
||||
|
||||
if ($this->selectedAuditLogId !== null) {
|
||||
$this->selectedAuditLog();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = app(OperateHubShell::class)->headerActions(
|
||||
scopeActionName: 'operate_hub_scope_audit_log',
|
||||
returnActionName: 'operate_hub_return_audit_log',
|
||||
);
|
||||
|
||||
if ($this->selectedAuditLog() instanceof AuditLogModel) {
|
||||
$actions[] = Action::make('clear_selected_audit_event')
|
||||
->label('Close details')
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
$this->clearSelectedAuditLog();
|
||||
});
|
||||
|
||||
$relatedLink = $this->selectedAuditLink();
|
||||
|
||||
if (is_array($relatedLink)) {
|
||||
$actions[] = Action::make('open_selected_audit_target')
|
||||
->label($relatedLink['label'])
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->url($relatedLink['url']);
|
||||
}
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query(fn (): Builder => $this->auditBaseQuery())
|
||||
->defaultSort('recorded_at', 'desc')
|
||||
->paginated(TablePaginationProfiles::customPage())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->columns([
|
||||
TextColumn::make('outcome')
|
||||
->label('Outcome')
|
||||
->badge()
|
||||
->getStateUsing(fn (AuditLogModel $record): string => $record->normalizedOutcome()->value)
|
||||
->formatStateUsing(fn (string $state): string => BadgeRenderer::label(BadgeDomain::AuditOutcome)($state))
|
||||
->color(fn (string $state): string => BadgeRenderer::color(BadgeDomain::AuditOutcome)($state))
|
||||
->icon(fn (string $state): ?string => BadgeRenderer::icon(BadgeDomain::AuditOutcome)($state))
|
||||
->iconColor(fn (string $state): ?string => BadgeRenderer::iconColor(BadgeDomain::AuditOutcome)($state)),
|
||||
TextColumn::make('summary')
|
||||
->label('Event')
|
||||
->getStateUsing(fn (AuditLogModel $record): string => $record->summaryText())
|
||||
->description(fn (AuditLogModel $record): string => AuditActionId::labelFor((string) $record->action))
|
||||
->searchable()
|
||||
->wrap(),
|
||||
TextColumn::make('actor_label')
|
||||
->label('Actor')
|
||||
->getStateUsing(fn (AuditLogModel $record): string => $record->actorDisplayLabel())
|
||||
->description(fn (AuditLogModel $record): string => BadgeRenderer::label(BadgeDomain::AuditActorType)($record->actorSnapshot()->type->value))
|
||||
->searchable(),
|
||||
TextColumn::make('target_label')
|
||||
->label('Target')
|
||||
->getStateUsing(fn (AuditLogModel $record): string => $record->targetDisplayLabel() ?? 'No target snapshot')
|
||||
->searchable()
|
||||
->toggleable(),
|
||||
TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->formatStateUsing(fn (?string $state): string => $state ?: 'Workspace')
|
||||
->toggleable(),
|
||||
TextColumn::make('recorded_at')
|
||||
->label('Recorded')
|
||||
->since()
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
->options(fn (): array => $this->tenantFilterOptions())
|
||||
->default(fn (): ?string => $this->defaultTenantFilter())
|
||||
->searchable(),
|
||||
SelectFilter::make('action')
|
||||
->label('Event type')
|
||||
->options(fn (): array => $this->actionFilterOptions())
|
||||
->searchable(),
|
||||
SelectFilter::make('outcome')
|
||||
->label('Outcome')
|
||||
->options(FilterOptionCatalog::auditOutcomes()),
|
||||
SelectFilter::make('actor_label')
|
||||
->label('Actor')
|
||||
->options(fn (): array => $this->actorFilterOptions())
|
||||
->searchable(),
|
||||
SelectFilter::make('resource_type')
|
||||
->label('Target type')
|
||||
->options(fn (): array => $this->targetTypeFilterOptions()),
|
||||
FilterPresets::dateRange('recorded_at', 'Recorded', 'recorded_at'),
|
||||
])
|
||||
->actions([
|
||||
Action::make('inspect')
|
||||
->label('Inspect event')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->action(function (AuditLogModel $record): void {
|
||||
$this->selectedAuditLogId = (int) $record->getKey();
|
||||
}),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No audit events match this view')
|
||||
->emptyStateDescription('Clear the current search or filters to return to the workspace-wide audit history.')
|
||||
->emptyStateIcon('heroicon-o-funnel')
|
||||
->emptyStateActions([
|
||||
Action::make('clear_filters')
|
||||
->label('Clear filters')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
$this->selectedAuditLogId = null;
|
||||
$this->resetTable();
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
public function clearSelectedAuditLog(): void
|
||||
{
|
||||
$this->selectedAuditLogId = null;
|
||||
}
|
||||
|
||||
public function selectedAuditLog(): ?AuditLogModel
|
||||
{
|
||||
if (! is_numeric($this->selectedAuditLogId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$record = $this->auditBaseQuery()
|
||||
->whereKey((int) $this->selectedAuditLogId)
|
||||
->first();
|
||||
|
||||
if (! $record instanceof AuditLogModel) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, url: string}|null
|
||||
*/
|
||||
public function selectedAuditLink(): ?array
|
||||
{
|
||||
$record = $this->selectedAuditLog();
|
||||
|
||||
if (! $record instanceof AuditLogModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(RelatedNavigationResolver::class)->auditTargetLink($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
*/
|
||||
public function authorizedTenants(): array
|
||||
{
|
||||
if ($this->authorizedTenants !== null) {
|
||||
return $this->authorizedTenants;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! $user instanceof User || ! is_numeric($workspaceId)) {
|
||||
return $this->authorizedTenants = [];
|
||||
}
|
||||
|
||||
$tenants = collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
||||
->filter(static fn (Tenant $tenant): bool => (int) $tenant->workspace_id === (int) $workspaceId)
|
||||
->filter(static fn (Tenant $tenant): bool => $tenant->isActive())
|
||||
->keyBy(fn (Tenant $tenant): int => (int) $tenant->getKey())
|
||||
->all();
|
||||
|
||||
return $this->authorizedTenants = $tenants;
|
||||
}
|
||||
|
||||
private function authorizePageAccess(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$workspace = is_numeric($workspaceId) ? Workspace::query()->whereKey((int) $workspaceId)->first() : null;
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $workspace)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if (! $resolver->can($user, $workspace, Capabilities::AUDIT_VIEW)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
private function auditBaseQuery(): Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$authorizedTenantIds = array_map(
|
||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
||||
$this->authorizedTenants(),
|
||||
);
|
||||
|
||||
return AuditLogModel::query()
|
||||
->with(['tenant', 'workspace', 'operationRun'])
|
||||
->forWorkspace((int) $workspaceId)
|
||||
->where(function (Builder $query) use ($authorizedTenantIds): void {
|
||||
$query->whereNull('tenant_id');
|
||||
|
||||
if ($authorizedTenantIds !== []) {
|
||||
$query->orWhereIn('tenant_id', $authorizedTenantIds);
|
||||
}
|
||||
})
|
||||
->latestFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function tenantFilterOptions(): array
|
||||
{
|
||||
return collect($this->authorizedTenants())
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
(string) $tenant->getKey() => $tenant->getFilamentName(),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function defaultTenantFilter(): ?string
|
||||
{
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if (! $activeTenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_key_exists((int) $activeTenant->getKey(), $this->authorizedTenants())
|
||||
? (string) $activeTenant->getKey()
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function actionFilterOptions(): array
|
||||
{
|
||||
$values = (clone $this->auditBaseQuery())
|
||||
->reorder()
|
||||
->select('action')
|
||||
->distinct()
|
||||
->orderBy('action')
|
||||
->pluck('action')
|
||||
->all();
|
||||
|
||||
return FilterOptionCatalog::auditActions($values);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function actorFilterOptions(): array
|
||||
{
|
||||
return (clone $this->auditBaseQuery())
|
||||
->reorder()
|
||||
->whereNotNull('actor_label')
|
||||
->select('actor_label')
|
||||
->distinct()
|
||||
->orderBy('actor_label')
|
||||
->pluck('actor_label', 'actor_label')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function targetTypeFilterOptions(): array
|
||||
{
|
||||
$values = (clone $this->auditBaseQuery())
|
||||
->reorder()
|
||||
->whereNotNull('resource_type')
|
||||
->select('resource_type')
|
||||
->distinct()
|
||||
->orderBy('resource_type')
|
||||
->pluck('resource_type')
|
||||
->all();
|
||||
|
||||
return FilterOptionCatalog::auditTargetTypes($values);
|
||||
}
|
||||
}
|
||||
116
app/Filament/Pages/Monitoring/EvidenceOverview.php
Normal file
116
app/Filament/Pages/Monitoring/EvidenceOverview.php
Normal file
@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Monitoring;
|
||||
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use UnitEnum;
|
||||
|
||||
class EvidenceOverview extends Page
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
protected static ?string $title = 'Evidence Overview';
|
||||
|
||||
protected string $view = 'filament.pages.monitoring.evidence-overview';
|
||||
|
||||
/**
|
||||
* @var list<array<string, mixed>>
|
||||
*/
|
||||
public array $rows = [];
|
||||
|
||||
public ?int $tenantFilter = null;
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'The overview header exposes a clear-filters action when a tenant prefilter is active.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The overview exposes a single drill-down link per row without a More menu.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The overview does not expose bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains the current scope and offers a clear-filters CTA.');
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
throw new AuthenticationException;
|
||||
}
|
||||
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
$workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request());
|
||||
$workspaceId = (int) $workspace->getKey();
|
||||
|
||||
$accessibleTenants = $user->tenants()
|
||||
->where('tenants.workspace_id', $workspaceId)
|
||||
->orderBy('tenants.name')
|
||||
->get()
|
||||
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant))
|
||||
->values();
|
||||
|
||||
$this->tenantFilter = is_numeric(request()->query('tenant_id')) ? (int) request()->query('tenant_id') : null;
|
||||
|
||||
$tenantIds = $accessibleTenants->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all();
|
||||
|
||||
$query = EvidenceSnapshot::query()
|
||||
->with('tenant')
|
||||
->where('workspace_id', $workspaceId)
|
||||
->whereIn('tenant_id', $tenantIds)
|
||||
->where('status', 'active')
|
||||
->latest('generated_at');
|
||||
|
||||
if ($this->tenantFilter !== null) {
|
||||
$query->where('tenant_id', $this->tenantFilter);
|
||||
}
|
||||
|
||||
$snapshots = $query->get()->unique('tenant_id')->values();
|
||||
|
||||
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
|
||||
return [
|
||||
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
||||
'tenant_id' => (int) $snapshot->tenant_id,
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'completeness_state' => (string) $snapshot->completeness_state,
|
||||
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
||||
'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)),
|
||||
'stale_dimensions' => (int) (($snapshot->summary['stale_dimensions'] ?? 0)),
|
||||
'view_url' => EvidenceSnapshotResource::getUrl('index', tenant: $snapshot->tenant),
|
||||
];
|
||||
})->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('clear_filters')
|
||||
->label('Clear filters')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->tenantFilter !== null)
|
||||
->url(route('admin.evidence.overview')),
|
||||
];
|
||||
}
|
||||
}
|
||||
503
app/Filament/Pages/Monitoring/FindingExceptionsQueue.php
Normal file
503
app/Filament/Pages/Monitoring/FindingExceptionsQueue.php
Normal file
@ -0,0 +1,503 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Monitoring;
|
||||
|
||||
use App\Filament\Resources\FindingExceptionResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
|
||||
class FindingExceptionsQueue extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
public ?int $selectedFindingExceptionId = null;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
protected static ?string $navigationLabel = 'Finding exceptions';
|
||||
|
||||
protected static ?string $slug = 'finding-exceptions/queue';
|
||||
|
||||
protected static ?string $title = 'Finding Exceptions Queue';
|
||||
|
||||
protected string $view = 'filament.pages.monitoring.finding-exceptions-queue';
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
*/
|
||||
private ?array $authorizedTenants = null;
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||
->withDefaults(new ActionSurfaceDefaults(
|
||||
moreGroupLabel: 'More',
|
||||
exportIsDefaultBulkActionForReadOnly: false,
|
||||
))
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep workspace approval scope visible and expose selected exception review actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions are reviewed one record at a time in v1 and do not expose bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains when the approval queue is empty and keeps navigation back to tenant findings available.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected exception detail exposes approve, reject, and related-record navigation actions in the page header.');
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $workspace)
|
||||
&& $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE);
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->selectedFindingExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null;
|
||||
$this->mountInteractsWithTable();
|
||||
$this->applyRequestedTenantPrefilter();
|
||||
|
||||
if ($this->selectedFindingExceptionId !== null) {
|
||||
$this->selectedFindingException();
|
||||
}
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = app(OperateHubShell::class)->headerActions(
|
||||
scopeActionName: 'operate_hub_scope_finding_exceptions',
|
||||
returnActionName: 'operate_hub_return_finding_exceptions',
|
||||
);
|
||||
|
||||
$actions[] = Action::make('clear_filters')
|
||||
->label('Clear filters')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->hasActiveQueueFilters())
|
||||
->action(function (): void {
|
||||
$this->removeTableFilter('tenant_id');
|
||||
$this->removeTableFilter('status');
|
||||
$this->removeTableFilter('current_validity_state');
|
||||
$this->selectedFindingExceptionId = null;
|
||||
$this->resetTable();
|
||||
});
|
||||
|
||||
$actions[] = Action::make('view_tenant_register')
|
||||
->label('View tenant register')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->filteredTenant() instanceof Tenant)
|
||||
->url(function (): ?string {
|
||||
$tenant = $this->filteredTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
});
|
||||
|
||||
$actions[] = Action::make('clear_selected_exception')
|
||||
->label('Close details')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||
->action(function (): void {
|
||||
$this->selectedFindingExceptionId = null;
|
||||
});
|
||||
|
||||
$actions[] = Action::make('open_selected_exception')
|
||||
->label('Open tenant detail')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||
->url(fn (): ?string => $this->selectedExceptionUrl());
|
||||
|
||||
$actions[] = Action::make('open_selected_finding')
|
||||
->label('Open finding')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||
->url(fn (): ?string => $this->selectedFindingUrl());
|
||||
|
||||
$actions[] = Action::make('approve_selected_exception')
|
||||
->label('Approve exception')
|
||||
->color('success')
|
||||
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
DateTimePicker::make('effective_from')
|
||||
->label('Effective from')
|
||||
->required()
|
||||
->seconds(false),
|
||||
DateTimePicker::make('expires_at')
|
||||
->label('Expires at')
|
||||
->required()
|
||||
->seconds(false),
|
||||
Textarea::make('approval_reason')
|
||||
->label('Approval reason')
|
||||
->rows(3)
|
||||
->maxLength(2000),
|
||||
])
|
||||
->action(function (array $data, FindingExceptionService $service): void {
|
||||
$record = $this->selectedFindingException();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $record instanceof FindingException || ! $user instanceof User) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$wasRenewalRequest = $record->isPendingRenewal();
|
||||
$updated = $service->approve($record, $user, $data);
|
||||
$this->selectedFindingExceptionId = (int) $updated->getKey();
|
||||
$this->resetTable();
|
||||
|
||||
Notification::make()
|
||||
->title($wasRenewalRequest ? 'Exception renewed' : 'Exception approved')
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
|
||||
$actions[] = Action::make('reject_selected_exception')
|
||||
->label('Reject exception')
|
||||
->color('danger')
|
||||
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Textarea::make('rejection_reason')
|
||||
->label('Rejection reason')
|
||||
->rows(3)
|
||||
->required()
|
||||
->maxLength(2000),
|
||||
])
|
||||
->action(function (array $data, FindingExceptionService $service): void {
|
||||
$record = $this->selectedFindingException();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $record instanceof FindingException || ! $user instanceof User) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$wasRenewalRequest = $record->isPendingRenewal();
|
||||
$updated = $service->reject($record, $user, $data);
|
||||
$this->selectedFindingExceptionId = (int) $updated->getKey();
|
||||
$this->resetTable();
|
||||
|
||||
Notification::make()
|
||||
->title($wasRenewalRequest ? 'Renewal rejected' : 'Exception rejected')
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query(fn (): Builder => $this->queueBaseQuery())
|
||||
->defaultSort('requested_at', 'asc')
|
||||
->paginated(TablePaginationProfiles::customPage())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->columns([
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus)),
|
||||
TextColumn::make('current_validity_state')
|
||||
->label('Validity')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
|
||||
TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->searchable(),
|
||||
TextColumn::make('finding_summary')
|
||||
->label('Finding')
|
||||
->state(fn (FindingException $record): string => $record->finding?->resolvedSubjectDisplayName() ?: 'Finding #'.$record->finding_id)
|
||||
->searchable(),
|
||||
TextColumn::make('requester.name')
|
||||
->label('Requested by')
|
||||
->placeholder('—'),
|
||||
TextColumn::make('owner.name')
|
||||
->label('Owner')
|
||||
->placeholder('—'),
|
||||
TextColumn::make('review_due_at')
|
||||
->label('Review due')
|
||||
->dateTime()
|
||||
->placeholder('—')
|
||||
->sortable(),
|
||||
TextColumn::make('expires_at')
|
||||
->label('Expires')
|
||||
->dateTime()
|
||||
->placeholder('—')
|
||||
->sortable(),
|
||||
TextColumn::make('requested_at')
|
||||
->label('Requested')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
->options(fn (): array => $this->tenantFilterOptions())
|
||||
->searchable(),
|
||||
SelectFilter::make('status')
|
||||
->options(FilterOptionCatalog::findingExceptionStatuses()),
|
||||
SelectFilter::make('current_validity_state')
|
||||
->label('Validity')
|
||||
->options(FilterOptionCatalog::findingExceptionValidityStates()),
|
||||
])
|
||||
->actions([
|
||||
Action::make('inspect_exception')
|
||||
->label('Inspect exception')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->action(function (FindingException $record): void {
|
||||
$this->selectedFindingExceptionId = (int) $record->getKey();
|
||||
}),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No exceptions match this queue')
|
||||
->emptyStateDescription('Adjust the current tenant or lifecycle filters to review governed exceptions in this workspace.')
|
||||
->emptyStateIcon('heroicon-o-shield-check')
|
||||
->emptyStateActions([
|
||||
Action::make('clear_filters')
|
||||
->label('Clear filters')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
$this->removeTableFilter('tenant_id');
|
||||
$this->removeTableFilter('status');
|
||||
$this->removeTableFilter('current_validity_state');
|
||||
$this->selectedFindingExceptionId = null;
|
||||
$this->resetTable();
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
public function selectedFindingException(): ?FindingException
|
||||
{
|
||||
if (! is_int($this->selectedFindingExceptionId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$record = $this->queueBaseQuery()
|
||||
->whereKey($this->selectedFindingExceptionId)
|
||||
->first();
|
||||
|
||||
if (! $record instanceof FindingException) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
public function selectedExceptionUrl(): ?string
|
||||
{
|
||||
$record = $this->selectedFindingException();
|
||||
|
||||
if (! $record instanceof FindingException || ! $record->tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant);
|
||||
}
|
||||
|
||||
public function selectedFindingUrl(): ?string
|
||||
{
|
||||
$record = $this->selectedFindingException();
|
||||
|
||||
if (! $record instanceof FindingException || ! $record->finding || ! $record->tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
*/
|
||||
public function authorizedTenants(): array
|
||||
{
|
||||
if ($this->authorizedTenants !== null) {
|
||||
return $this->authorizedTenants;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return $this->authorizedTenants = [];
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return $this->authorizedTenants = [];
|
||||
}
|
||||
|
||||
$tenants = $user->tenants()
|
||||
->where('tenants.workspace_id', $workspaceId)
|
||||
->orderBy('tenants.name')
|
||||
->get();
|
||||
|
||||
return $this->authorizedTenants = $tenants
|
||||
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function queueBaseQuery(): Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$tenantIds = array_values(array_map(
|
||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
||||
$this->authorizedTenants(),
|
||||
));
|
||||
|
||||
return FindingException::query()
|
||||
->with([
|
||||
'tenant',
|
||||
'requester',
|
||||
'owner',
|
||||
'approver',
|
||||
'finding' => fn ($query) => $query->withSubjectDisplayName(),
|
||||
'decisions.actor',
|
||||
'evidenceReferences',
|
||||
])
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function tenantFilterOptions(): array
|
||||
{
|
||||
return Collection::make($this->authorizedTenants())
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
(string) $tenant->getKey() => $tenant->name,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function applyRequestedTenantPrefilter(): void
|
||||
{
|
||||
$requestedTenant = request()->query('tenant');
|
||||
|
||||
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->authorizedTenants() as $tenant) {
|
||||
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private function filteredTenant(): ?Tenant
|
||||
{
|
||||
$tenantId = $this->currentTenantFilterId();
|
||||
|
||||
if (! is_int($tenantId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($this->authorizedTenants() as $tenant) {
|
||||
if ((int) $tenant->getKey() === $tenantId) {
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function currentTenantFilterId(): ?int
|
||||
{
|
||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||
|
||||
if (! is_numeric($tenantFilter)) {
|
||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||
}
|
||||
|
||||
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
|
||||
}
|
||||
|
||||
private function hasActiveQueueFilters(): bool
|
||||
{
|
||||
return $this->currentTenantFilterId() !== null
|
||||
|| is_string(data_get($this->tableFilters, 'status.value'))
|
||||
|| is_string(data_get($this->tableFilters, 'current_validity_state.value'));
|
||||
}
|
||||
}
|
||||
190
app/Filament/Pages/Monitoring/Operations.php
Normal file
190
app/Filament/Pages/Monitoring/Operations.php
Normal file
@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Monitoring;
|
||||
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use UnitEnum;
|
||||
|
||||
class Operations extends Page implements HasForms, HasTable
|
||||
{
|
||||
use InteractsWithForms;
|
||||
use InteractsWithTable;
|
||||
|
||||
public string $activeTab = 'all';
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $navigationContextPayload = null;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
protected static ?string $title = 'Operations';
|
||||
|
||||
// Must be non-static
|
||||
protected string $view = 'filament.pages.monitoring.operations';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
['type', 'initiator_name'],
|
||||
request(),
|
||||
);
|
||||
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
OperationsKpiHeader::class,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$operateHubShell = app(OperateHubShell::class);
|
||||
$navigationContext = $this->navigationContext();
|
||||
|
||||
$actions = [
|
||||
Action::make('operate_hub_scope_operations')
|
||||
->label($operateHubShell->scopeLabel(request()))
|
||||
->color('gray')
|
||||
->disabled(),
|
||||
];
|
||||
|
||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
$actions[] = Action::make('operate_hub_back_to_origin_operations')
|
||||
->label($navigationContext->backLinkLabel)
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url($navigationContext->backLinkUrl);
|
||||
} elseif ($activeTenant instanceof Tenant) {
|
||||
$actions[] = Action::make('operate_hub_back_to_tenant_operations')
|
||||
->label('Back to '.$activeTenant->name)
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
||||
}
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
$actions[] = Action::make('operate_hub_show_all_tenants')
|
||||
->label('Show all tenants')
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
app(WorkspaceContext::class)->clearLastTenantId(request());
|
||||
|
||||
$this->removeTableFilter('tenant_id');
|
||||
|
||||
$this->redirect('/admin/operations');
|
||||
});
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
private function navigationContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
if (! is_array($this->navigationContextPayload)) {
|
||||
return CanonicalNavigationContext::fromRequest(request());
|
||||
}
|
||||
|
||||
$request = request()->duplicate(query: ['nav' => $this->navigationContextPayload]);
|
||||
|
||||
return CanonicalNavigationContext::fromRequest($request);
|
||||
}
|
||||
|
||||
public function updatedActiveTab(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return OperationRunResource::table($table)
|
||||
->query(function (): Builder {
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||
|
||||
if (! is_numeric($tenantFilter)) {
|
||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||
}
|
||||
|
||||
$query = OperationRun::query()
|
||||
->with('user')
|
||||
->latest('id')
|
||||
->when(
|
||||
$workspaceId,
|
||||
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
|
||||
)
|
||||
->when(
|
||||
! $workspaceId,
|
||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||
)
|
||||
->when(
|
||||
is_numeric($tenantFilter),
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
|
||||
);
|
||||
|
||||
return $this->applyActiveTab($query);
|
||||
});
|
||||
}
|
||||
|
||||
private function applyActiveTab(Builder $query): Builder
|
||||
{
|
||||
return match ($this->activeTab) {
|
||||
'active' => $query->whereIn('status', [
|
||||
OperationRunStatus::Queued->value,
|
||||
OperationRunStatus::Running->value,
|
||||
]),
|
||||
'blocked' => $query
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Blocked->value),
|
||||
'succeeded' => $query
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Succeeded->value),
|
||||
'partial' => $query
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::PartiallySucceeded->value),
|
||||
'failed' => $query
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Failed->value),
|
||||
default => $query,
|
||||
};
|
||||
}
|
||||
}
|
||||
85
app/Filament/Pages/NoAccess.php
Normal file
85
app/Filament/Pages/NoAccess.php
Normal file
@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class NoAccess extends Page
|
||||
{
|
||||
protected static string $layout = 'filament-panels::components.layout.simple';
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static ?string $slug = 'no-access';
|
||||
|
||||
protected static ?string $title = 'No access';
|
||||
|
||||
protected string $view = 'filament.pages.no-access';
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('createWorkspace')
|
||||
->label('Create workspace')
|
||||
->modalHeading('Create workspace')
|
||||
->form([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('slug')
|
||||
->helperText('Optional. Used in URLs if set.')
|
||||
->maxLength(255)
|
||||
->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug'])
|
||||
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
|
||||
->dehydrated(fn ($state) => filled($state)),
|
||||
])
|
||||
->action(fn (array $data) => $this->createWorkspace($data)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{name: string, slug?: string|null} $data
|
||||
*/
|
||||
public function createWorkspace(array $data): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->create([
|
||||
'name' => $data['name'],
|
||||
'slug' => $data['slug'] ?? null,
|
||||
]);
|
||||
|
||||
WorkspaceMembership::query()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request());
|
||||
|
||||
Notification::make()
|
||||
->title('Workspace created')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect(ChooseTenant::getUrl());
|
||||
}
|
||||
}
|
||||
424
app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
Normal file
424
app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
Normal file
@ -0,0 +1,424 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Operations;
|
||||
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\OpsUx\RunDetailPolling;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\EmbeddedSchema;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TenantlessOperationRunViewer extends Page
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static ?string $title = 'Operation run';
|
||||
|
||||
protected string $view = 'filament.pages.operations.tenantless-operation-run-viewer';
|
||||
|
||||
public OperationRun $run;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $navigationContextPayload = null;
|
||||
|
||||
public bool $opsUxIsTabHidden = false;
|
||||
|
||||
/**
|
||||
* @return array<Action|ActionGroup>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$operateHubShell = app(OperateHubShell::class);
|
||||
$navigationContext = $this->navigationContext();
|
||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||
$runTenantId = isset($this->run) ? (int) ($this->run->tenant_id ?? 0) : 0;
|
||||
|
||||
$actions = [
|
||||
Action::make('operate_hub_scope_run_detail')
|
||||
->label($operateHubShell->scopeLabel(request()))
|
||||
->color('gray')
|
||||
->disabled(),
|
||||
];
|
||||
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
$actions[] = Action::make('operate_hub_back_to_origin_run_detail')
|
||||
->label($navigationContext->backLinkLabel)
|
||||
->color('gray')
|
||||
->url($navigationContext->backLinkUrl);
|
||||
} elseif ($activeTenant instanceof Tenant && (int) $activeTenant->getKey() === $runTenantId) {
|
||||
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
|
||||
->label('← Back to '.$activeTenant->name)
|
||||
->color('gray')
|
||||
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
||||
} else {
|
||||
$actions[] = Action::make('operate_hub_back_to_operations')
|
||||
->label('Back to Operations')
|
||||
->color('gray')
|
||||
->url(fn (): string => route('admin.operations.index'));
|
||||
}
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
$actions[] = Action::make('operate_hub_show_all_operations')
|
||||
->label('Show all operations')
|
||||
->color('gray')
|
||||
->url(fn (): string => route('admin.operations.index'));
|
||||
}
|
||||
|
||||
$actions[] = Action::make('refresh')
|
||||
->label('Refresh')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('gray')
|
||||
->url(fn (): string => isset($this->run)
|
||||
? OperationRunLinks::tenantlessView($this->run, $navigationContext)
|
||||
: route('admin.operations.index'));
|
||||
|
||||
if (! isset($this->run)) {
|
||||
return $actions;
|
||||
}
|
||||
|
||||
$related = OperationRunLinks::related($this->run, $this->relatedLinksTenant());
|
||||
|
||||
$relatedActions = [];
|
||||
|
||||
foreach ($related as $label => $url) {
|
||||
$relatedActions[] = Action::make(Str::slug((string) $label, '_'))
|
||||
->label((string) $label)
|
||||
->url((string) $url)
|
||||
->openUrlInNewTab();
|
||||
}
|
||||
|
||||
if ($relatedActions !== []) {
|
||||
$actions[] = ActionGroup::make($relatedActions)
|
||||
->label('Open')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray');
|
||||
}
|
||||
|
||||
$actions[] = $this->resumeCaptureAction();
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function mount(OperationRun $run): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$this->authorize('view', $run);
|
||||
|
||||
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
|
||||
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||
}
|
||||
|
||||
public function infolist(Schema $schema): Schema
|
||||
{
|
||||
return OperationRunResource::infolist($schema);
|
||||
}
|
||||
|
||||
public function defaultInfolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->record($this->run)
|
||||
->columns(2);
|
||||
}
|
||||
|
||||
public function redactionIntegrityNote(): ?string
|
||||
{
|
||||
return isset($this->run) ? RedactionIntegrity::noteForRun($this->run) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{tone: string, title: string, body: string}|null
|
||||
*/
|
||||
public function blockedExecutionBanner(): ?array
|
||||
{
|
||||
if (! isset($this->run) || (string) $this->run->outcome !== 'blocked') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$context = is_array($this->run->context) ? $this->run->context : [];
|
||||
$reasonCode = data_get($context, 'reason_code');
|
||||
|
||||
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
||||
$reasonCode = data_get($context, 'execution_legitimacy.reason_code');
|
||||
}
|
||||
|
||||
$reasonCode = is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : 'unknown_error';
|
||||
$message = OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.';
|
||||
$guidance = OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.';
|
||||
|
||||
return [
|
||||
'tone' => 'amber',
|
||||
'title' => 'Blocked by prerequisite',
|
||||
'body' => sprintf('%s Reason code: %s. %s', $message, $reasonCode, $guidance),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{tone: string, title: string, body: string}|null
|
||||
*/
|
||||
public function canonicalContextBanner(): ?array
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
$runTenant = $this->run->tenant;
|
||||
|
||||
if (! $runTenant instanceof Tenant) {
|
||||
return [
|
||||
'tone' => 'slate',
|
||||
'title' => 'Workspace-level run',
|
||||
'body' => $activeTenant instanceof Tenant
|
||||
? 'This canonical workspace view is not tied to the current tenant context ('.$activeTenant->name.').'
|
||||
: 'This canonical workspace view is not tied to any tenant.',
|
||||
];
|
||||
}
|
||||
|
||||
$messages = ['Run tenant: '.$runTenant->name.'.'];
|
||||
$tone = 'sky';
|
||||
$title = null;
|
||||
|
||||
if ($activeTenant instanceof Tenant && ! $activeTenant->is($runTenant)) {
|
||||
$title = 'Current tenant context differs from this run';
|
||||
array_unshift($messages, 'Current tenant context: '.$activeTenant->name.'.');
|
||||
$messages[] = 'This canonical workspace view remains valid without switching tenant context.';
|
||||
}
|
||||
|
||||
$referencedTenant = ReferencedTenantLifecyclePresentation::forOperationRun($runTenant);
|
||||
|
||||
if ($selectorAvailabilityMessage = $referencedTenant->selectorAvailabilityMessage()) {
|
||||
$title ??= 'Run tenant is not available in the current tenant selector';
|
||||
$tone = 'amber';
|
||||
$messages[] = $selectorAvailabilityMessage;
|
||||
|
||||
if ($referencedTenant->contextNote !== null) {
|
||||
$messages[] = $referencedTenant->contextNote;
|
||||
}
|
||||
} elseif (! $activeTenant instanceof Tenant) {
|
||||
$title ??= 'Canonical workspace view';
|
||||
$messages[] = 'No tenant context is currently selected.';
|
||||
}
|
||||
|
||||
if ($title === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'tone' => $tone,
|
||||
'title' => $title,
|
||||
'body' => implode(' ', $messages),
|
||||
];
|
||||
}
|
||||
|
||||
public function pollInterval(): ?string
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->opsUxIsTabHidden === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filled($this->mountedActions ?? null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return RunDetailPolling::interval($this->run);
|
||||
}
|
||||
|
||||
public function content(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
EmbeddedSchema::make('infolist'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resumeCaptureAction(): Action
|
||||
{
|
||||
return Action::make('resumeCapture')
|
||||
->label('Resume capture')
|
||||
->icon('heroicon-o-forward')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Resume capture')
|
||||
->modalDescription('This will start a follow-up operation to capture remaining baseline evidence for this scope.')
|
||||
->visible(fn (): bool => $this->canResumeCapture())
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! isset($this->run)) {
|
||||
Notification::make()
|
||||
->title('Run not loaded')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(BaselineEvidenceCaptureResumeService::class);
|
||||
$result = $service->resume($this->run, $user);
|
||||
|
||||
if (! ($result['ok'] ?? false)) {
|
||||
$reason = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
||||
|
||||
Notification::make()
|
||||
->title('Cannot resume capture')
|
||||
->body('Reason: '.str_replace('.', ' ', $reason))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$run = $result['run'] ?? null;
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
Notification::make()
|
||||
->title('Cannot resume capture')
|
||||
->body('Reason: missing operation run')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$viewAction = Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::tenantlessView($run));
|
||||
|
||||
if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $run->type)
|
||||
->actions([$viewAction])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $run->type)
|
||||
->actions([$viewAction])
|
||||
->send();
|
||||
});
|
||||
}
|
||||
|
||||
private function navigationContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
if (! is_array($this->navigationContextPayload)) {
|
||||
return CanonicalNavigationContext::fromRequest(request());
|
||||
}
|
||||
|
||||
$request = request()->duplicate(query: ['nav' => $this->navigationContextPayload]);
|
||||
|
||||
return CanonicalNavigationContext::fromRequest($request);
|
||||
}
|
||||
|
||||
private function canResumeCapture(): bool
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((string) $this->run->status !== 'completed') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! in_array((string) $this->run->type, ['baseline_capture', 'baseline_compare'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$context = is_array($this->run->context) ? $this->run->context : [];
|
||||
$tokenKey = (string) $this->run->type === 'baseline_capture'
|
||||
? 'baseline_capture.resume_token'
|
||||
: 'baseline_compare.resume_token';
|
||||
$token = data_get($context, $tokenKey);
|
||||
|
||||
if (! is_string($token) || trim($token) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = $this->run->workspace;
|
||||
|
||||
if (! $workspace instanceof \App\Models\Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $workspace)
|
||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||
}
|
||||
|
||||
private function relatedLinksTenant(): ?Tenant
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$tenant = $this->run->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! app(CapabilityResolver::class)->isMember($user, $tenant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(TenantOperabilityService::class)->outcomeFor(
|
||||
tenant: $tenant,
|
||||
question: TenantOperabilityQuestion::SelectorEligibility,
|
||||
actor: $user,
|
||||
workspaceId: (int) ($this->run->workspace_id ?? 0),
|
||||
lane: TenantInteractionLane::StandardActiveOperating,
|
||||
)->allowed ? $tenant : null;
|
||||
}
|
||||
}
|
||||
304
app/Filament/Pages/Reviews/ReviewRegister.php
Normal file
304
app/Filament/Pages/Reviews/ReviewRegister.php
Normal file
@ -0,0 +1,304 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Reviews;
|
||||
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
|
||||
class ReviewRegister extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-magnifying-glass';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
|
||||
|
||||
protected static ?string $navigationLabel = 'Reviews';
|
||||
|
||||
protected static ?string $title = 'Review Register';
|
||||
|
||||
protected static ?string $slug = 'reviews';
|
||||
|
||||
protected string $view = 'filament.pages.reviews.review-register';
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
*/
|
||||
private ?array $authorizedTenants = null;
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the canonical review register.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The review register does not expose bulk actions in the first slice.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state keeps exactly one Clear filters CTA.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the tenant-scoped review detail rather than opening an inline canonical detail panel.');
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorizePageAccess();
|
||||
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
['status', 'published_state', 'completeness_state'],
|
||||
request(),
|
||||
);
|
||||
|
||||
$this->applyRequestedTenantPrefilter();
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('clear_filters')
|
||||
->label('Clear filters')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->hasActiveFilters())
|
||||
->action(function (): void {
|
||||
$this->resetTable();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query(fn (): Builder => $this->registerQuery())
|
||||
->defaultSort('generated_at', 'desc')
|
||||
->paginated(TablePaginationProfiles::customPage())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->recordUrl(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant'))
|
||||
->columns([
|
||||
TextColumn::make('tenant.name')->label('Tenant')->searchable(),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
||||
TextColumn::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
||||
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
||||
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||
TextColumn::make('summary.publish_blockers')
|
||||
->label('Publish blockers')
|
||||
->formatStateUsing(static function (mixed $state): string {
|
||||
if (! is_array($state) || $state === []) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
return (string) count($state);
|
||||
}),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
->options(fn (): array => $this->tenantFilterOptions())
|
||||
->default(fn (): ?string => $this->defaultTenantFilter())
|
||||
->searchable(),
|
||||
SelectFilter::make('status')
|
||||
->options([
|
||||
'draft' => 'Draft',
|
||||
'ready' => 'Ready',
|
||||
'published' => 'Published',
|
||||
'archived' => 'Archived',
|
||||
'superseded' => 'Superseded',
|
||||
'failed' => 'Failed',
|
||||
]),
|
||||
SelectFilter::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
|
||||
SelectFilter::make('published_state')
|
||||
->label('Published state')
|
||||
->options([
|
||||
'published' => 'Published',
|
||||
'unpublished' => 'Not published',
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
return match ($data['value'] ?? null) {
|
||||
'published' => $query->whereNotNull('published_at'),
|
||||
'unpublished' => $query->whereNull('published_at'),
|
||||
default => $query,
|
||||
};
|
||||
}),
|
||||
FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
||||
])
|
||||
->actions([
|
||||
Action::make('view_review')
|
||||
->label('View review')
|
||||
->url(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant')),
|
||||
Action::make('export_executive_pack')
|
||||
->label('Export executive pack')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->visible(fn (TenantReview $record): bool => auth()->user() instanceof User
|
||||
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
|
||||
&& in_array($record->status, ['ready', 'published'], true))
|
||||
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No review records match this view')
|
||||
->emptyStateDescription('Clear the current filters to return to the full review register for your entitled tenants.')
|
||||
->emptyStateActions([
|
||||
Action::make('clear_filters_empty')
|
||||
->label('Clear filters')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->action(fn (): mixed => $this->resetTable()),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
*/
|
||||
public function authorizedTenants(): array
|
||||
{
|
||||
if ($this->authorizedTenants !== null) {
|
||||
return $this->authorizedTenants;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return $this->authorizedTenants = [];
|
||||
}
|
||||
|
||||
return $this->authorizedTenants = app(TenantReviewRegisterService::class)->authorizedTenants($user, $workspace);
|
||||
}
|
||||
|
||||
private function authorizePageAccess(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
$service = app(TenantReviewRegisterService::class);
|
||||
|
||||
if (! $service->canAccessWorkspace($user, $workspace)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if ($this->authorizedTenants() === []) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
}
|
||||
|
||||
private function registerQuery(): Builder
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return TenantReview::query()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return app(TenantReviewRegisterService::class)->query($user, $workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function tenantFilterOptions(): array
|
||||
{
|
||||
return collect($this->authorizedTenants())
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
(string) $tenant->getKey() => $tenant->name,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function defaultTenantFilter(): ?string
|
||||
{
|
||||
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
|
||||
|
||||
return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants())
|
||||
? (string) $tenantId
|
||||
: null;
|
||||
}
|
||||
|
||||
private function applyRequestedTenantPrefilter(): void
|
||||
{
|
||||
$requestedTenant = request()->query('tenant');
|
||||
|
||||
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->authorizedTenants() as $tenant) {
|
||||
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private function hasActiveFilters(): bool
|
||||
{
|
||||
$filters = array_filter((array) $this->tableFilters);
|
||||
|
||||
return $filters !== [];
|
||||
}
|
||||
|
||||
private function workspace(): ?Workspace
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
return is_numeric($workspaceId)
|
||||
? Workspace::query()->whereKey((int) $workspaceId)->first()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
1166
app/Filament/Pages/Settings/WorkspaceSettings.php
Normal file
1166
app/Filament/Pages/Settings/WorkspaceSettings.php
Normal file
File diff suppressed because it is too large
Load Diff
141
app/Filament/Pages/Tenancy/RegisterTenant.php
Normal file
141
app/Filament/Pages/Tenancy/RegisterTenant.php
Normal file
@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages\Tenancy;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Forms;
|
||||
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class RegisterTenant extends BaseRegisterTenant
|
||||
{
|
||||
public static function getLabel(): string
|
||||
{
|
||||
return 'Register tenant';
|
||||
}
|
||||
|
||||
public static function canView(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
if ($workspaceId !== null) {
|
||||
$canRegisterInWorkspace = WorkspaceMembership::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('user_id', $user->getKey())
|
||||
->whereIn('role', ['owner', 'manager'])
|
||||
->exists();
|
||||
|
||||
if ($canRegisterInWorkspace) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id');
|
||||
|
||||
if ($tenantIds->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
|
||||
if ($resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\Select::make('environment')
|
||||
->options([
|
||||
'prod' => 'PROD',
|
||||
'dev' => 'DEV',
|
||||
'staging' => 'STAGING',
|
||||
'other' => 'Other',
|
||||
])
|
||||
->default('other')
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('tenant_id')
|
||||
->label('Tenant ID (GUID)')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->unique(ignoreRecord: true),
|
||||
Forms\Components\TextInput::make('domain')
|
||||
->label('Primary domain')
|
||||
->maxLength(255)
|
||||
->helperText('Credentials are managed after tenant creation in Provider connections.'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
protected function handleRegistration(array $data): Model
|
||||
{
|
||||
if (! static::canView()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
if ($workspaceId !== null) {
|
||||
$data['workspace_id'] = $workspaceId;
|
||||
}
|
||||
|
||||
$tenant = Tenant::create($data);
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if ($user instanceof User) {
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => [
|
||||
'role' => 'owner',
|
||||
'source' => 'manual',
|
||||
'created_by_user_id' => $user->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
action: 'tenant_membership.bootstrap_assign',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
'source' => 'manual',
|
||||
],
|
||||
],
|
||||
actorId: (int) $user->getKey(),
|
||||
actorEmail: $user->email,
|
||||
actorName: $user->name,
|
||||
status: 'success',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
45
app/Filament/Pages/TenantDashboard.php
Normal file
45
app/Filament/Pages/TenantDashboard.php
Normal file
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Widgets\Dashboard\BaselineCompareNow;
|
||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
|
||||
use App\Filament\Widgets\Dashboard\RecentOperations;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Widgets\Widget;
|
||||
use Filament\Widgets\WidgetConfiguration;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TenantDashboard extends Dashboard
|
||||
{
|
||||
/**
|
||||
* @param array<mixed> $parameters
|
||||
*/
|
||||
public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
|
||||
{
|
||||
return parent::getUrl($parameters, $isAbsolute, $panel ?? 'tenant', $tenant, $shouldGuessMissingParameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<class-string<Widget> | WidgetConfiguration>
|
||||
*/
|
||||
public function getWidgets(): array
|
||||
{
|
||||
return [
|
||||
DashboardKpis::class,
|
||||
NeedsAttention::class,
|
||||
BaselineCompareNow::class,
|
||||
RecentDriftFindings::class,
|
||||
RecentOperations::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getColumns(): int|array
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
110
app/Filament/Pages/TenantDiagnostics.php
Normal file
110
app/Filament/Pages/TenantDiagnostics.php
Normal file
@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\TenantDiagnosticsService;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class TenantDiagnostics extends Page
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $slug = 'diagnostics';
|
||||
|
||||
protected string $view = 'filament.pages.tenant-diagnostics';
|
||||
|
||||
public bool $missingOwner = false;
|
||||
|
||||
public bool $hasDuplicateMembershipsForCurrentUser = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
$this->missingOwner = ! TenantMembership::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('role', 'owner')
|
||||
->exists();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403, 'Not allowed');
|
||||
}
|
||||
|
||||
$this->hasDuplicateMembershipsForCurrentUser = app(TenantDiagnosticsService::class)
|
||||
->userHasDuplicateMemberships($tenant, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
UiEnforcement::forAction(
|
||||
Action::make('bootstrapOwner')
|
||||
->label('Bootstrap owner')
|
||||
->requiresConfirmation()
|
||||
->action(fn () => $this->bootstrapOwner()),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->destructive()
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply()
|
||||
->visible(fn (): bool => $this->missingOwner),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('mergeDuplicateMemberships')
|
||||
->label('Merge duplicate memberships')
|
||||
->requiresConfirmation()
|
||||
->action(fn () => $this->mergeDuplicateMemberships()),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->destructive()
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply()
|
||||
->visible(fn (): bool => $this->hasDuplicateMembershipsForCurrentUser),
|
||||
];
|
||||
}
|
||||
|
||||
public function bootstrapOwner(): void
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403, 'Not allowed');
|
||||
}
|
||||
|
||||
app(TenantMembershipManager::class)->bootstrapRecover($tenant, $user, $user);
|
||||
|
||||
$this->mount();
|
||||
}
|
||||
|
||||
public function mergeDuplicateMemberships(): void
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403, 'Not allowed');
|
||||
}
|
||||
|
||||
app(TenantDiagnosticsService::class)->mergeDuplicateMembershipsForUser($tenant, $user, $user);
|
||||
|
||||
$this->mount();
|
||||
}
|
||||
}
|
||||
283
app/Filament/Pages/TenantRequiredPermissions.php
Normal file
283
app/Filament/Pages/TenantRequiredPermissions.php
Normal file
@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Pages\Page;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class TenantRequiredPermissions extends Page
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $slug = 'tenants/{tenant}/required-permissions';
|
||||
|
||||
protected static ?string $title = 'Required permissions';
|
||||
|
||||
protected string $view = 'filament.pages.tenant-required-permissions';
|
||||
|
||||
public string $status = 'missing';
|
||||
|
||||
public string $type = 'all';
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public array $features = [];
|
||||
|
||||
public string $search = '';
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
public array $viewModel = [];
|
||||
|
||||
#[Locked]
|
||||
public ?int $scopedTenantId = null;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return static::hasScopedTenantAccess(static::resolveScopedTenant());
|
||||
}
|
||||
|
||||
public function currentTenant(): ?Tenant
|
||||
{
|
||||
return $this->trustedScopedTenant();
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$tenant = static::resolveScopedTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! static::hasScopedTenantAccess($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->scopedTenantId = (int) $tenant->getKey();
|
||||
$this->heading = $tenant->getFilamentName();
|
||||
$this->subheading = 'Required permissions';
|
||||
|
||||
$queryFeatures = request()->query('features', $this->features);
|
||||
|
||||
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
||||
'status' => request()->query('status', $this->status),
|
||||
'type' => request()->query('type', $this->type),
|
||||
'features' => is_array($queryFeatures) ? $queryFeatures : [],
|
||||
'search' => request()->query('search', $this->search),
|
||||
]);
|
||||
|
||||
$this->status = $state['status'];
|
||||
$this->type = $state['type'];
|
||||
$this->features = $state['features'];
|
||||
$this->search = $state['search'];
|
||||
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function updatedStatus(): void
|
||||
{
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function updatedType(): void
|
||||
{
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function updatedFeatures(): void
|
||||
{
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function applyFeatureFilter(string $feature): void
|
||||
{
|
||||
$feature = trim($feature);
|
||||
|
||||
if ($feature === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array($feature, $this->features, true)) {
|
||||
$this->features = array_values(array_filter(
|
||||
$this->features,
|
||||
static fn (string $value): bool => $value !== $feature,
|
||||
));
|
||||
} else {
|
||||
$this->features[] = $feature;
|
||||
}
|
||||
|
||||
$this->features = array_values(array_unique($this->features));
|
||||
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function clearFeatureFilter(): void
|
||||
{
|
||||
$this->features = [];
|
||||
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function resetFilters(): void
|
||||
{
|
||||
$this->status = 'missing';
|
||||
$this->type = 'all';
|
||||
$this->features = [];
|
||||
$this->search = '';
|
||||
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
private function refreshViewModel(): void
|
||||
{
|
||||
$tenant = $this->trustedScopedTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$this->viewModel = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$builder = app(TenantRequiredPermissionsViewModelBuilder::class);
|
||||
|
||||
$this->viewModel = $builder->build($tenant, [
|
||||
'status' => $this->status,
|
||||
'type' => $this->type,
|
||||
'features' => $this->features,
|
||||
'search' => $this->search,
|
||||
]);
|
||||
|
||||
$filters = $this->viewModel['filters'] ?? null;
|
||||
|
||||
if (is_array($filters)) {
|
||||
$this->status = (string) ($filters['status'] ?? $this->status);
|
||||
$this->type = (string) ($filters['type'] ?? $this->type);
|
||||
$this->features = is_array($filters['features'] ?? null) ? $filters['features'] : $this->features;
|
||||
$this->search = (string) ($filters['search'] ?? $this->search);
|
||||
}
|
||||
}
|
||||
|
||||
public function reRunVerificationUrl(): string
|
||||
{
|
||||
$tenant = $this->trustedScopedTenant();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return TenantResource::getUrl('view', ['record' => $tenant]);
|
||||
}
|
||||
|
||||
return route('admin.onboarding');
|
||||
}
|
||||
|
||||
public function manageProviderConnectionUrl(): ?string
|
||||
{
|
||||
$tenant = $this->trustedScopedTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
||||
}
|
||||
|
||||
protected static function resolveScopedTenant(): ?Tenant
|
||||
{
|
||||
$routeTenant = request()->route('tenant');
|
||||
|
||||
if ($routeTenant instanceof Tenant) {
|
||||
return $routeTenant;
|
||||
}
|
||||
|
||||
if (is_string($routeTenant) && $routeTenant !== '') {
|
||||
return Tenant::query()
|
||||
->where('external_id', $routeTenant)
|
||||
->first();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function hasScopedTenantAccess(?Tenant $tenant): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$isWorkspaceMember = WorkspaceMembership::query()
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->where('user_id', (int) $user->getKey())
|
||||
->exists();
|
||||
|
||||
if (! $isWorkspaceMember) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->canAccessTenant($tenant);
|
||||
}
|
||||
|
||||
private function trustedScopedTenant(): ?Tenant
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
|
||||
try {
|
||||
$workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request());
|
||||
} catch (NotFoundHttpException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$routeTenant = static::resolveScopedTenant();
|
||||
|
||||
if ($routeTenant instanceof Tenant) {
|
||||
try {
|
||||
return $workspaceContext->ensureTenantAccessibleInCurrentWorkspace($routeTenant, $user, request());
|
||||
} catch (NotFoundHttpException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->scopedTenantId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->withTrashed()->whereKey($this->scopedTenantId)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return $workspaceContext->ensureTenantAccessibleInCurrentWorkspace($tenant, $user, request());
|
||||
} catch (NotFoundHttpException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
89
app/Filament/Pages/WorkspaceOverview.php
Normal file
89
app/Filament/Pages/WorkspaceOverview.php
Normal file
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use App\Support\Workspaces\WorkspaceOverviewBuilder;
|
||||
use BackedEnum;
|
||||
use Filament\Navigation\NavigationItem;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
class WorkspaceOverview extends Page
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $title = 'Overview';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-home';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = null;
|
||||
|
||||
protected string $view = 'filament.pages.workspace-overview';
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
public array $overview = [];
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Workspace overview is a singleton landing page with no page-header actions.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'Workspace overview is already the canonical landing surface for the active workspace.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Workspace overview does not render record rows with secondary actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Workspace overview does not expose bulk actions.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Workspace overview redirects or renders overview content instead of a list-style empty state.');
|
||||
}
|
||||
|
||||
public function mount(WorkspaceOverviewBuilder $builder): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
$this->redirect('/admin/choose-workspace');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $workspace)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->overview = $builder->build($workspace, $user);
|
||||
}
|
||||
|
||||
public static function navigationItem(): NavigationItem
|
||||
{
|
||||
return NavigationItem::make('Overview')
|
||||
->url(fn (): string => route('admin.home'))
|
||||
->icon('heroicon-o-home')
|
||||
->sort(-100)
|
||||
->isActiveWhen(fn (): bool => request()->routeIs('admin.home'));
|
||||
}
|
||||
}
|
||||
4100
app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
Normal file
4100
app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
Normal file
File diff suppressed because it is too large
Load Diff
111
app/Filament/Pages/Workspaces/ManagedTenantsLanding.php
Normal file
111
app/Filament/Pages/Workspaces/ManagedTenantsLanding.php
Normal file
@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Workspaces;
|
||||
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class ManagedTenantsLanding extends Page
|
||||
{
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static string $layout = 'filament-panels::components.layout.simple';
|
||||
|
||||
protected static ?string $title = 'Managed tenants';
|
||||
|
||||
protected string $view = 'filament.pages.workspaces.managed-tenants-landing';
|
||||
|
||||
public Workspace $workspace;
|
||||
|
||||
/**
|
||||
* The Filament simple layout renders the topbar by default, which includes
|
||||
* lazy-loaded database notifications. On this workspace-scoped landing page,
|
||||
* those background Livewire requests currently 404.
|
||||
*/
|
||||
protected function getLayoutData(): array
|
||||
{
|
||||
return [
|
||||
'hasTopbar' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function mount(Workspace $workspace): void
|
||||
{
|
||||
$this->workspace = $workspace;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Tenant>
|
||||
*/
|
||||
public function getTenants(): Collection
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return Tenant::query()->whereRaw('1 = 0')->get();
|
||||
}
|
||||
|
||||
$tenantIds = $user->tenantMemberships()
|
||||
->pluck('tenant_id');
|
||||
|
||||
return Tenant::query()
|
||||
->withTrashed()
|
||||
->whereIn('id', $tenantIds)
|
||||
->where('workspace_id', $this->workspace->getKey())
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->filter(function (Tenant $tenant) use ($user): bool {
|
||||
return app(TenantOperabilityService::class)->outcomeFor(
|
||||
tenant: $tenant,
|
||||
question: TenantOperabilityQuestion::AdministrativeDiscoverability,
|
||||
actor: $user,
|
||||
workspaceId: app(WorkspaceContext::class)->currentWorkspaceId(request()),
|
||||
lane: TenantInteractionLane::AdministrativeManagement,
|
||||
)->allowed;
|
||||
})
|
||||
->values();
|
||||
}
|
||||
|
||||
public function goToChooseTenant(): void
|
||||
{
|
||||
$this->redirect(ChooseTenant::getUrl());
|
||||
}
|
||||
|
||||
public function openTenant(int $tenantId): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()
|
||||
->withTrashed()
|
||||
->where('workspace_id', $this->workspace->getKey())
|
||||
->whereKey($tenantId)
|
||||
->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->redirect(TenantResource::getUrl('view', ['record' => $tenant]));
|
||||
}
|
||||
}
|
||||
344
app/Filament/Resources/AlertDeliveryResource.php
Normal file
344
app/Filament/Resources/AlertDeliveryResource.php
Normal file
@ -0,0 +1,344 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Clusters\Monitoring\AlertsCluster;
|
||||
use App\Filament\Resources\AlertDeliveryResource\Pages;
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use UnitEnum;
|
||||
|
||||
class AlertDeliveryResource extends Resource
|
||||
{
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $model = AlertDelivery::class;
|
||||
|
||||
protected static ?string $slug = 'alert-deliveries';
|
||||
|
||||
protected static ?string $cluster = AlertsCluster::class;
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'id';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
protected static ?string $navigationLabel = 'Alert deliveries';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('viewAny', AlertDelivery::class);
|
||||
}
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $record instanceof AlertDelivery) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('view', $record);
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Read-only history list intentionally has no list-header actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are exposed for read-only deliveries.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk actions are exposed for read-only deliveries.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Guided empty state links to View alert rules.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational with no mutating header actions.');
|
||||
}
|
||||
|
||||
public static function makeViewAlertRulesAction(): Action
|
||||
{
|
||||
return Action::make('view_alert_rules')
|
||||
->label('View alert rules')
|
||||
->icon('heroicon-o-funnel')
|
||||
->color('primary')
|
||||
->button()
|
||||
->url(AlertRuleResource::getUrl(panel: 'admin'));
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$user = auth()->user();
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with(['tenant', 'rule', 'destination'])
|
||||
->when(
|
||||
! $user instanceof User,
|
||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||
)
|
||||
->when(
|
||||
! is_int($workspaceId),
|
||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||
)
|
||||
->when(
|
||||
is_int($workspaceId),
|
||||
fn (Builder $query): Builder => $query->where('workspace_id', $workspaceId),
|
||||
)
|
||||
->when(
|
||||
$user instanceof User,
|
||||
fn (Builder $query): Builder => $query->where(function (Builder $q) use ($user): void {
|
||||
$q->whereIn('tenant_id', $user->tenantMemberships()->select('tenant_id'))
|
||||
->orWhereNull('tenant_id');
|
||||
}),
|
||||
)
|
||||
->when(
|
||||
$activeTenant instanceof Tenant,
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
|
||||
)
|
||||
->latest('id');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Delivery')
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDeliveryStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::AlertDeliveryStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus)),
|
||||
TextEntry::make('event_type')
|
||||
->label('Event')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => AlertRuleResource::eventTypeLabel((string) $state)),
|
||||
TextEntry::make('severity')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))
|
||||
->placeholder('—'),
|
||||
TextEntry::make('tenant.name')
|
||||
->label('Tenant'),
|
||||
TextEntry::make('rule.name')
|
||||
->label('Rule')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('destination.name')
|
||||
->label('Destination')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('attempt_count')
|
||||
->label('Attempts'),
|
||||
TextEntry::make('fingerprint_hash')
|
||||
->label('Fingerprint')
|
||||
->copyable(),
|
||||
TextEntry::make('send_after')
|
||||
->dateTime()
|
||||
->placeholder('—'),
|
||||
TextEntry::make('sent_at')
|
||||
->dateTime()
|
||||
->placeholder('—'),
|
||||
TextEntry::make('last_error_code')
|
||||
->label('Last error code')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('last_error_message')
|
||||
->label('Last error message')
|
||||
->placeholder('—')
|
||||
->columnSpanFull(),
|
||||
TextEntry::make('created_at')
|
||||
->dateTime(),
|
||||
TextEntry::make('updated_at')
|
||||
->dateTime(),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Section::make('Payload')
|
||||
->schema([
|
||||
ViewEntry::make('payload')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (AlertDelivery $record): array => is_array($record->payload) ? $record->payload : [])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('id', 'desc')
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->recordUrl(fn (AlertDelivery $record): ?string => static::canView($record)
|
||||
? static::getUrl('view', ['record' => $record])
|
||||
: null)
|
||||
->columns([
|
||||
TextColumn::make('created_at')
|
||||
->label('Created')
|
||||
->since()
|
||||
->sortable(),
|
||||
TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->searchable(),
|
||||
TextColumn::make('event_type')
|
||||
->label('Event')
|
||||
->badge(),
|
||||
TextColumn::make('severity')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))
|
||||
->placeholder('—'),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDeliveryStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::AlertDeliveryStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus))
|
||||
->sortable(),
|
||||
TextColumn::make('rule.name')
|
||||
->label('Rule')
|
||||
->placeholder('—'),
|
||||
TextColumn::make('destination.name')
|
||||
->label('Destination')
|
||||
->placeholder('—'),
|
||||
TextColumn::make('attempt_count')
|
||||
->label('Attempts')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
->options(function (): array {
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
return [
|
||||
(string) $activeTenant->getKey() => $activeTenant->getFilamentName(),
|
||||
];
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
(string) $tenant->getKey() => $tenant->getFilamentName(),
|
||||
])
|
||||
->all();
|
||||
})
|
||||
->default(function (): ?string {
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if (! $activeTenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if ($workspaceId === null || (int) $activeTenant->workspace_id !== (int) $workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $activeTenant->getKey();
|
||||
})
|
||||
->searchable(),
|
||||
SelectFilter::make('status')
|
||||
->options(FilterOptionCatalog::alertDeliveryStatuses()),
|
||||
SelectFilter::make('event_type')
|
||||
->label('Event type')
|
||||
->options(function (): array {
|
||||
$options = AlertRuleResource::eventTypeOptions();
|
||||
$options[AlertDelivery::EVENT_TYPE_TEST] = 'Test';
|
||||
|
||||
return $options;
|
||||
}),
|
||||
SelectFilter::make('alert_destination_id')
|
||||
->label('Destination')
|
||||
->options(function (): array {
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return AlertDestination::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->orderBy('name')
|
||||
->pluck('name', 'id')
|
||||
->all();
|
||||
}),
|
||||
FilterPresets::dateRange('created_at', 'Created', 'created_at'),
|
||||
])
|
||||
->actions([
|
||||
ViewAction::make()->label('View'),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No alert deliveries')
|
||||
->emptyStateDescription('Deliveries appear automatically when alert rules fire.')
|
||||
->emptyStateIcon('heroicon-o-bell-alert')
|
||||
->emptyStateActions([
|
||||
static::makeViewAlertRulesAction(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListAlertDeliveries::route('/'),
|
||||
'view' => Pages\ViewAlertDelivery::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AlertDeliveryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AlertDeliveryResource;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListAlertDeliveries extends ListRecords
|
||||
{
|
||||
protected static string $resource = AlertDeliveryResource::class;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return app(OperateHubShell::class)->headerActions(
|
||||
scopeActionName: 'operate_hub_scope_alerts',
|
||||
returnActionName: 'operate_hub_return_alerts',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AlertDeliveryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AlertDeliveryResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewAlertDelivery extends ViewRecord
|
||||
{
|
||||
protected static string $resource = AlertDeliveryResource::class;
|
||||
}
|
||||
386
app/Filament/Resources/AlertDestinationResource.php
Normal file
386
app/Filament/Resources/AlertDestinationResource.php
Normal file
@ -0,0 +1,386 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Clusters\Monitoring\AlertsCluster;
|
||||
use App\Filament\Resources\AlertDestinationResource\Pages;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use UnitEnum;
|
||||
|
||||
class AlertDestinationResource extends Resource
|
||||
{
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $model = AlertDestination::class;
|
||||
|
||||
protected static ?string $slug = 'alert-destinations';
|
||||
|
||||
protected static ?string $cluster = AlertsCluster::class;
|
||||
|
||||
protected static ?int $navigationSort = 3;
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
protected static ?string $navigationLabel = 'Alert targets';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('viewAny', AlertDestination::class);
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('create', AlertDestination::class);
|
||||
}
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $record instanceof AlertDestination) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('update', $record);
|
||||
}
|
||||
|
||||
public static function canDelete(Model $record): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $record instanceof AlertDestination) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('delete', $record);
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations are exposed for alert destinations in v1.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state create CTA.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Edit page provides default save/cancel actions.');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->when(
|
||||
$workspaceId !== null,
|
||||
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
|
||||
)
|
||||
->when(
|
||||
$workspaceId === null,
|
||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||
);
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Select::make('type')
|
||||
->required()
|
||||
->options(self::typeOptions())
|
||||
->native(false)
|
||||
->live(),
|
||||
Toggle::make('is_enabled')
|
||||
->label('Enabled')
|
||||
->default(true),
|
||||
TextInput::make('teams_webhook_url')
|
||||
->label('Teams webhook URL')
|
||||
->placeholder('https://...')
|
||||
->url()
|
||||
->visible(fn (Get $get): bool => $get('type') === AlertDestination::TYPE_TEAMS_WEBHOOK),
|
||||
TagsInput::make('email_recipients')
|
||||
->label('Email recipients')
|
||||
->visible(fn (Get $get): bool => $get('type') === AlertDestination::TYPE_EMAIL)
|
||||
->placeholder('ops@example.com')
|
||||
->nestedRecursiveRules(['email']),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('name')
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||
->recordUrl(fn (AlertDestination $record): ?string => static::canEdit($record)
|
||||
? static::getUrl('edit', ['record' => $record])
|
||||
: static::getUrl('view', ['record' => $record]))
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('type')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => self::typeLabel((string) $state)),
|
||||
TextColumn::make('is_enabled')
|
||||
->label('Enabled')
|
||||
->badge()
|
||||
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No')
|
||||
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
|
||||
TextColumn::make('updated_at')
|
||||
->since(),
|
||||
])
|
||||
->actions([
|
||||
EditAction::make()
|
||||
->label('Edit')
|
||||
->visible(fn (AlertDestination $record): bool => static::canEdit($record)),
|
||||
ActionGroup::make([
|
||||
Action::make('toggle_enabled')
|
||||
->label(fn (AlertDestination $record): string => $record->is_enabled ? 'Disable' : 'Enable')
|
||||
->icon(fn (AlertDestination $record): string => $record->is_enabled ? 'heroicon-o-pause' : 'heroicon-o-play')
|
||||
->action(function (AlertDestination $record): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $user->can('update', $record)) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
$enabled = ! (bool) $record->is_enabled;
|
||||
$record->forceFill([
|
||||
'is_enabled' => $enabled,
|
||||
])->save();
|
||||
|
||||
$actionId = $enabled
|
||||
? AuditActionId::AlertDestinationEnabled
|
||||
: AuditActionId::AlertDestinationDisabled;
|
||||
|
||||
self::audit($record, $actionId, [
|
||||
'alert_destination_id' => (int) $record->getKey(),
|
||||
'name' => (string) $record->name,
|
||||
'type' => (string) $record->type,
|
||||
'is_enabled' => $enabled,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title($enabled ? 'Destination enabled' : 'Destination disabled')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('delete')
|
||||
->label('Delete')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->action(function (AlertDestination $record): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $user->can('delete', $record)) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
self::audit($record, AuditActionId::AlertDestinationDeleted, [
|
||||
'alert_destination_id' => (int) $record->getKey(),
|
||||
'name' => (string) $record->name,
|
||||
'type' => (string) $record->type,
|
||||
]);
|
||||
|
||||
$record->delete();
|
||||
|
||||
Notification::make()
|
||||
->title('Destination deleted')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])->label('More'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([])->label('More'),
|
||||
])
|
||||
->emptyStateActions([
|
||||
\Filament\Actions\CreateAction::make()
|
||||
->label('Create target')
|
||||
->disabled(fn (): bool => ! static::canCreate()),
|
||||
])
|
||||
->emptyStateHeading('No alert destinations')
|
||||
->emptyStateDescription('Create a destination so alert rules have somewhere to deliver notifications.')
|
||||
->emptyStateIcon('heroicon-o-paper-airplane');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListAlertDestinations::route('/'),
|
||||
'create' => Pages\CreateAlertDestination::route('/create'),
|
||||
'view' => Pages\ViewAlertDestination::route('/{record}'),
|
||||
'edit' => Pages\EditAlertDestination::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function normalizePayload(array $data, ?AlertDestination $record = null): array
|
||||
{
|
||||
$type = trim((string) ($data['type'] ?? $record?->type ?? ''));
|
||||
$existingConfig = is_array($record?->config ?? null) ? $record->config : [];
|
||||
|
||||
if ($type === AlertDestination::TYPE_TEAMS_WEBHOOK) {
|
||||
$webhookUrl = trim((string) ($data['teams_webhook_url'] ?? ''));
|
||||
|
||||
if ($webhookUrl === '' && $record instanceof AlertDestination) {
|
||||
$webhookUrl = trim((string) Arr::get($existingConfig, 'webhook_url', ''));
|
||||
}
|
||||
|
||||
$data['config'] = [
|
||||
'webhook_url' => $webhookUrl,
|
||||
];
|
||||
}
|
||||
|
||||
if ($type === AlertDestination::TYPE_EMAIL) {
|
||||
$recipients = Arr::wrap($data['email_recipients'] ?? []);
|
||||
$recipients = array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $recipients)));
|
||||
|
||||
if ($recipients === [] && $record instanceof AlertDestination) {
|
||||
$existingRecipients = Arr::get($existingConfig, 'recipients', []);
|
||||
$recipients = is_array($existingRecipients) ? array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $existingRecipients))) : [];
|
||||
}
|
||||
|
||||
$data['config'] = [
|
||||
'recipients' => array_values(array_unique($recipients)),
|
||||
];
|
||||
}
|
||||
|
||||
unset($data['teams_webhook_url'], $data['email_recipients']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function typeOptions(): array
|
||||
{
|
||||
return [
|
||||
AlertDestination::TYPE_TEAMS_WEBHOOK => 'Microsoft Teams webhook',
|
||||
AlertDestination::TYPE_EMAIL => 'Email',
|
||||
];
|
||||
}
|
||||
|
||||
public static function typeLabel(string $type): string
|
||||
{
|
||||
return self::typeOptions()[$type] ?? ucfirst($type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function assertValidConfigPayload(array $data): void
|
||||
{
|
||||
$type = (string) ($data['type'] ?? '');
|
||||
$config = is_array($data['config'] ?? null) ? $data['config'] : [];
|
||||
|
||||
if ($type === AlertDestination::TYPE_TEAMS_WEBHOOK) {
|
||||
$webhook = trim((string) Arr::get($config, 'webhook_url', ''));
|
||||
|
||||
if ($webhook === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'teams_webhook_url' => ['The Teams webhook URL is required.'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === AlertDestination::TYPE_EMAIL) {
|
||||
$recipients = Arr::get($config, 'recipients', []);
|
||||
$recipients = is_array($recipients) ? array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $recipients))) : [];
|
||||
|
||||
if ($recipients === []) {
|
||||
throw ValidationException::withMessages([
|
||||
'email_recipients' => ['At least one recipient is required for email destinations.'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
public static function audit(AlertDestination $record, AuditActionId $actionId, array $metadata): void
|
||||
{
|
||||
$workspace = $record->workspace;
|
||||
|
||||
if ($workspace === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $workspace,
|
||||
action: $actionId->value,
|
||||
context: [
|
||||
'metadata' => $metadata,
|
||||
],
|
||||
actor: auth()->user() instanceof User ? auth()->user() : null,
|
||||
resourceType: 'alert_destination',
|
||||
resourceId: (string) $record->getKey(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AlertDestinationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AlertDestinationResource;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateAlertDestination extends CreateRecord
|
||||
{
|
||||
protected static string $resource = AlertDestinationResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$workspaceId = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$data['workspace_id'] = (int) $workspaceId;
|
||||
$data = AlertDestinationResource::normalizePayload($data);
|
||||
AlertDestinationResource::assertValidConfigPayload($data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
$record = $this->record;
|
||||
|
||||
if (! $record instanceof AlertDestination) {
|
||||
return;
|
||||
}
|
||||
|
||||
AlertDestinationResource::audit($record, AuditActionId::AlertDestinationCreated, [
|
||||
'alert_destination_id' => (int) $record->getKey(),
|
||||
'name' => (string) $record->name,
|
||||
'type' => (string) $record->type,
|
||||
'is_enabled' => (bool) $record->is_enabled,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title('Destination created')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AlertDestinationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AlertDeliveryResource;
|
||||
use App\Filament\Resources\AlertDestinationResource;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\User;
|
||||
use App\Services\Alerts\AlertDestinationLastTestResolver;
|
||||
use App\Services\Alerts\AlertDestinationTestMessageService;
|
||||
use App\Support\Alerts\AlertDestinationLastTestStatus;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditAlertDestination extends EditRecord
|
||||
{
|
||||
protected static string $resource = AlertDestinationResource::class;
|
||||
|
||||
private ?AlertDestinationLastTestStatus $lastTestStatus = null;
|
||||
|
||||
public function mount(int|string $record): void
|
||||
{
|
||||
parent::mount($record);
|
||||
$this->resolveLastTestStatus();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
$record = $this->record;
|
||||
$canManage = $user instanceof User
|
||||
&& $record instanceof AlertDestination
|
||||
&& $user->can('update', $record);
|
||||
|
||||
return [
|
||||
Action::make('send_test_message')
|
||||
->label('Send test message')
|
||||
->icon('heroicon-o-paper-airplane')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Send test message')
|
||||
->modalDescription('A test delivery will be queued for this destination. This verifies the delivery pipeline is working.')
|
||||
->modalSubmitActionLabel('Send')
|
||||
->visible(fn (): bool => $record instanceof AlertDestination)
|
||||
->disabled(fn (): bool => ! $canManage)
|
||||
->action(function () use ($record): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $record instanceof AlertDestination) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(AlertDestinationTestMessageService::class);
|
||||
$result = $service->sendTest($record, $user);
|
||||
|
||||
if ($result['success']) {
|
||||
Notification::make()
|
||||
->title($result['message'])
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title($result['message'])
|
||||
->warning()
|
||||
->send();
|
||||
}
|
||||
|
||||
$this->resolveLastTestStatus();
|
||||
}),
|
||||
|
||||
Action::make('view_last_delivery')
|
||||
->label('View last delivery')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->url(fn (): ?string => $this->buildDeepLinkUrl())
|
||||
->openUrlInNewTab()
|
||||
->visible(fn (): bool => $this->lastTestStatus?->deliveryId !== null),
|
||||
];
|
||||
}
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
if ($this->lastTestStatus === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$label = ucfirst($this->lastTestStatus->status->value);
|
||||
$timestamp = $this->lastTestStatus->timestamp?->diffForHumans();
|
||||
|
||||
return $timestamp !== null
|
||||
? "Last test: {$label} ({$timestamp})"
|
||||
: "Last test: {$label}";
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
$record = $this->record;
|
||||
$data = AlertDestinationResource::normalizePayload(
|
||||
data: $data,
|
||||
record: $record instanceof AlertDestination ? $record : null,
|
||||
);
|
||||
AlertDestinationResource::assertValidConfigPayload($data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
$record = $this->record;
|
||||
|
||||
if (! $record instanceof AlertDestination) {
|
||||
return;
|
||||
}
|
||||
|
||||
AlertDestinationResource::audit($record, AuditActionId::AlertDestinationUpdated, [
|
||||
'alert_destination_id' => (int) $record->getKey(),
|
||||
'name' => (string) $record->name,
|
||||
'type' => (string) $record->type,
|
||||
'is_enabled' => (bool) $record->is_enabled,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title('Destination updated')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
private function resolveLastTestStatus(): void
|
||||
{
|
||||
$record = $this->record;
|
||||
|
||||
if (! $record instanceof AlertDestination) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->lastTestStatus = app(AlertDestinationLastTestResolver::class)->resolve($record);
|
||||
}
|
||||
|
||||
private function buildDeepLinkUrl(): ?string
|
||||
{
|
||||
$record = $this->record;
|
||||
|
||||
if (! $record instanceof AlertDestination || $this->lastTestStatus?->deliveryId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$baseUrl = AlertDeliveryResource::getUrl('index');
|
||||
$params = http_build_query([
|
||||
'filters' => [
|
||||
'event_type' => ['value' => 'alerts.test'],
|
||||
'alert_destination_id' => ['value' => (string) $record->getKey()],
|
||||
],
|
||||
]);
|
||||
|
||||
return "{$baseUrl}?{$params}";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AlertDestinationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AlertDestinationResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListAlertDestinations extends ListRecords
|
||||
{
|
||||
protected static string $resource = AlertDestinationResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label('Create target')
|
||||
->disabled(fn (): bool => ! AlertDestinationResource::canCreate()),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableEmptyStateActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label('Create target')
|
||||
->disabled(fn (): bool => ! AlertDestinationResource::canCreate()),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AlertDestinationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AlertDeliveryResource;
|
||||
use App\Filament\Resources\AlertDestinationResource;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\User;
|
||||
use App\Services\Alerts\AlertDestinationLastTestResolver;
|
||||
use App\Services\Alerts\AlertDestinationTestMessageService;
|
||||
use App\Support\Alerts\AlertDestinationLastTestStatus;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class ViewAlertDestination extends ViewRecord
|
||||
{
|
||||
protected static string $resource = AlertDestinationResource::class;
|
||||
|
||||
private ?AlertDestinationLastTestStatus $lastTestStatus = null;
|
||||
|
||||
public function mount(int|string $record): void
|
||||
{
|
||||
parent::mount($record);
|
||||
$this->resolveLastTestStatus();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
$record = $this->record;
|
||||
$canManage = $user instanceof User
|
||||
&& $record instanceof AlertDestination
|
||||
&& $user->can('update', $record);
|
||||
|
||||
return [
|
||||
Action::make('send_test_message')
|
||||
->label('Send test message')
|
||||
->icon('heroicon-o-paper-airplane')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Send test message')
|
||||
->modalDescription('A test delivery will be queued for this destination. This verifies the delivery pipeline is working.')
|
||||
->modalSubmitActionLabel('Send')
|
||||
->visible(fn (): bool => $record instanceof AlertDestination)
|
||||
->disabled(fn (): bool => ! $canManage)
|
||||
->action(function () use ($record): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $record instanceof AlertDestination) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(AlertDestinationTestMessageService::class);
|
||||
$result = $service->sendTest($record, $user);
|
||||
|
||||
if ($result['success']) {
|
||||
Notification::make()
|
||||
->title($result['message'])
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title($result['message'])
|
||||
->warning()
|
||||
->send();
|
||||
}
|
||||
|
||||
$this->resolveLastTestStatus();
|
||||
}),
|
||||
|
||||
Action::make('view_last_delivery')
|
||||
->label('View last delivery')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->url(fn (): ?string => $this->buildDeepLinkUrl())
|
||||
->openUrlInNewTab()
|
||||
->visible(fn (): bool => $this->lastTestStatus?->deliveryId !== null),
|
||||
];
|
||||
}
|
||||
|
||||
public function infolist(Schema $schema): Schema
|
||||
{
|
||||
$lastTest = $this->lastTestStatus ?? AlertDestinationLastTestStatus::never();
|
||||
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Last test')
|
||||
->schema([
|
||||
TextEntry::make('last_test_status')
|
||||
->label('Status')
|
||||
->badge()
|
||||
->state($lastTest->status->value)
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDestinationLastTestStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::AlertDestinationLastTestStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::AlertDestinationLastTestStatus)),
|
||||
TextEntry::make('last_test_timestamp')
|
||||
->label('Timestamp')
|
||||
->state($lastTest->timestamp?->toDateTimeString())
|
||||
->placeholder('—'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Details')
|
||||
->schema([
|
||||
TextEntry::make('name'),
|
||||
TextEntry::make('type')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => AlertDestinationResource::typeLabel((string) $state)),
|
||||
TextEntry::make('is_enabled')
|
||||
->label('Enabled')
|
||||
->badge()
|
||||
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No')
|
||||
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
|
||||
TextEntry::make('created_at')
|
||||
->dateTime(),
|
||||
TextEntry::make('updated_at')
|
||||
->dateTime(),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveLastTestStatus(): void
|
||||
{
|
||||
$record = $this->record;
|
||||
|
||||
if (! $record instanceof AlertDestination) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->lastTestStatus = app(AlertDestinationLastTestResolver::class)->resolve($record);
|
||||
}
|
||||
|
||||
private function buildDeepLinkUrl(): ?string
|
||||
{
|
||||
$record = $this->record;
|
||||
|
||||
if (! $record instanceof AlertDestination || $this->lastTestStatus?->deliveryId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return AlertDeliveryResource::getUrl(panel: 'admin').'?'.http_build_query([
|
||||
'filters' => [
|
||||
'event_type' => ['value' => 'alerts.test'],
|
||||
'alert_destination_id' => ['value' => (string) $record->getKey()],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
484
app/Filament/Resources/AlertRuleResource.php
Normal file
484
app/Filament/Resources/AlertRuleResource.php
Normal file
@ -0,0 +1,484 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Clusters\Monitoring\AlertsCluster;
|
||||
use App\Filament\Resources\AlertRuleResource\Pages;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Arr;
|
||||
use UnitEnum;
|
||||
|
||||
class AlertRuleResource extends Resource
|
||||
{
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $model = AlertRule::class;
|
||||
|
||||
protected static ?string $slug = 'alert-rules';
|
||||
|
||||
protected static ?string $cluster = AlertsCluster::class;
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-funnel';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
protected static ?string $navigationLabel = 'Alert rules';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('viewAny', AlertRule::class);
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('create', AlertRule::class);
|
||||
}
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $record instanceof AlertRule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('update', $record);
|
||||
}
|
||||
|
||||
public static function canDelete(Model $record): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $record instanceof AlertRule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('delete', $record);
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations are exposed for alert rules in v1.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state create CTA.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Edit page provides default save/cancel actions.');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with('destinations')
|
||||
->when(
|
||||
$workspaceId !== null,
|
||||
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
|
||||
)
|
||||
->when(
|
||||
$workspaceId === null,
|
||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||
);
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Rule')
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Toggle::make('is_enabled')
|
||||
->label('Enabled')
|
||||
->default(true),
|
||||
Select::make('event_type')
|
||||
->required()
|
||||
->options(self::eventTypeOptions())
|
||||
->native(false),
|
||||
Select::make('minimum_severity')
|
||||
->required()
|
||||
->options(self::severityOptions())
|
||||
->native(false),
|
||||
]),
|
||||
Section::make('Applies to')
|
||||
->schema([
|
||||
Select::make('tenant_scope_mode')
|
||||
->label('Applies to tenants')
|
||||
->required()
|
||||
->options([
|
||||
AlertRule::TENANT_SCOPE_ALL => 'All tenants',
|
||||
AlertRule::TENANT_SCOPE_ALLOWLIST => 'Selected tenants',
|
||||
])
|
||||
->default(AlertRule::TENANT_SCOPE_ALL)
|
||||
->native(false)
|
||||
->live()
|
||||
->helperText('This rule is workspace-wide. Use this to limit where it applies.'),
|
||||
Select::make('tenant_allowlist')
|
||||
->label('Selected tenants')
|
||||
->multiple()
|
||||
->options(self::tenantOptions())
|
||||
->visible(fn (Get $get): bool => $get('tenant_scope_mode') === AlertRule::TENANT_SCOPE_ALLOWLIST)
|
||||
->native(false)
|
||||
->helperText('Only these tenants will trigger this rule.'),
|
||||
]),
|
||||
Section::make('Delivery')
|
||||
->schema([
|
||||
TextInput::make('cooldown_seconds')
|
||||
->label('Cooldown (seconds)')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->nullable(),
|
||||
Toggle::make('quiet_hours_enabled')
|
||||
->label('Enable quiet hours')
|
||||
->default(false)
|
||||
->live(),
|
||||
TextInput::make('quiet_hours_start')
|
||||
->label('Quiet hours start')
|
||||
->type('time')
|
||||
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
|
||||
TextInput::make('quiet_hours_end')
|
||||
->label('Quiet hours end')
|
||||
->type('time')
|
||||
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
|
||||
Select::make('quiet_hours_timezone')
|
||||
->label('Quiet hours timezone')
|
||||
->options(self::timezoneOptions())
|
||||
->searchable()
|
||||
->native(false)
|
||||
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
|
||||
Select::make('destination_ids')
|
||||
->label('Destinations')
|
||||
->multiple()
|
||||
->required()
|
||||
->options(self::destinationOptions())
|
||||
->native(false),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('name')
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||
->recordUrl(fn (AlertRule $record): ?string => static::canEdit($record)
|
||||
? static::getUrl('edit', ['record' => $record])
|
||||
: null)
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('event_type')
|
||||
->label('Event')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => self::eventTypeLabel((string) $state)),
|
||||
TextColumn::make('minimum_severity')
|
||||
->label('Min severity')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => self::severityOptions()[(string) $state] ?? ucfirst((string) $state)),
|
||||
TextColumn::make('destinations_count')
|
||||
->label('Destinations')
|
||||
->counts('destinations'),
|
||||
TextColumn::make('is_enabled')
|
||||
->label('Enabled')
|
||||
->badge()
|
||||
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No')
|
||||
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
|
||||
])
|
||||
->actions([
|
||||
EditAction::make()
|
||||
->label('Edit')
|
||||
->visible(fn (AlertRule $record): bool => static::canEdit($record)),
|
||||
ActionGroup::make([
|
||||
Action::make('toggle_enabled')
|
||||
->label(fn (AlertRule $record): string => $record->is_enabled ? 'Disable' : 'Enable')
|
||||
->icon(fn (AlertRule $record): string => $record->is_enabled ? 'heroicon-o-pause' : 'heroicon-o-play')
|
||||
->requiresConfirmation()
|
||||
->action(function (AlertRule $record): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $user->can('update', $record)) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
$enabled = ! (bool) $record->is_enabled;
|
||||
$record->forceFill([
|
||||
'is_enabled' => $enabled,
|
||||
])->save();
|
||||
|
||||
$actionId = $enabled
|
||||
? AuditActionId::AlertRuleEnabled
|
||||
: AuditActionId::AlertRuleDisabled;
|
||||
|
||||
self::audit($record, $actionId, [
|
||||
'alert_rule_id' => (int) $record->getKey(),
|
||||
'name' => (string) $record->name,
|
||||
'event_type' => (string) $record->event_type,
|
||||
'is_enabled' => $enabled,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title($enabled ? 'Rule enabled' : 'Rule disabled')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('delete')
|
||||
->label('Delete')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->action(function (AlertRule $record): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $user->can('delete', $record)) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
self::audit($record, AuditActionId::AlertRuleDeleted, [
|
||||
'alert_rule_id' => (int) $record->getKey(),
|
||||
'name' => (string) $record->name,
|
||||
'event_type' => (string) $record->event_type,
|
||||
]);
|
||||
|
||||
$record->delete();
|
||||
|
||||
Notification::make()
|
||||
->title('Rule deleted')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])->label('More'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([])->label('More'),
|
||||
])
|
||||
->emptyStateHeading('No alert rules')
|
||||
->emptyStateDescription('Create a rule to route notifications when monitored events fire.')
|
||||
->emptyStateIcon('heroicon-o-bell');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListAlertRules::route('/'),
|
||||
'create' => Pages\CreateAlertRule::route('/create'),
|
||||
'edit' => Pages\EditAlertRule::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function normalizePayload(array $data): array
|
||||
{
|
||||
$tenantAllowlist = Arr::wrap($data['tenant_allowlist'] ?? []);
|
||||
$tenantAllowlist = array_values(array_unique(array_filter(array_map(static fn (mixed $value): int => (int) $value, $tenantAllowlist))));
|
||||
|
||||
if (($data['tenant_scope_mode'] ?? AlertRule::TENANT_SCOPE_ALL) !== AlertRule::TENANT_SCOPE_ALLOWLIST) {
|
||||
$tenantAllowlist = [];
|
||||
}
|
||||
|
||||
$quietHoursEnabled = (bool) ($data['quiet_hours_enabled'] ?? false);
|
||||
|
||||
$data['is_enabled'] = (bool) ($data['is_enabled'] ?? true);
|
||||
$data['tenant_allowlist'] = $tenantAllowlist;
|
||||
$data['cooldown_seconds'] = is_numeric($data['cooldown_seconds'] ?? null) ? (int) $data['cooldown_seconds'] : null;
|
||||
$data['quiet_hours_enabled'] = $quietHoursEnabled;
|
||||
|
||||
if (! $quietHoursEnabled) {
|
||||
$data['quiet_hours_start'] = null;
|
||||
$data['quiet_hours_end'] = null;
|
||||
$data['quiet_hours_timezone'] = null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $destinationIds
|
||||
*/
|
||||
public static function syncDestinations(AlertRule $record, array $destinationIds): void
|
||||
{
|
||||
$allowedDestinationIds = AlertDestination::query()
|
||||
->where('workspace_id', (int) $record->workspace_id)
|
||||
->whereIn('id', $destinationIds)
|
||||
->pluck('id')
|
||||
->map(static fn (mixed $value): int => (int) $value)
|
||||
->all();
|
||||
|
||||
$record->destinations()->syncWithPivotValues(
|
||||
array_values(array_unique($allowedDestinationIds)),
|
||||
['workspace_id' => (int) $record->workspace_id],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function eventTypeOptions(): array
|
||||
{
|
||||
return [
|
||||
AlertRule::EVENT_HIGH_DRIFT => 'High drift',
|
||||
AlertRule::EVENT_COMPARE_FAILED => 'Compare failed',
|
||||
AlertRule::EVENT_BASELINE_HIGH_DRIFT => 'Baseline drift',
|
||||
AlertRule::EVENT_BASELINE_COMPARE_FAILED => 'Baseline compare failed',
|
||||
AlertRule::EVENT_SLA_DUE => 'SLA due',
|
||||
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
|
||||
AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function severityOptions(): array
|
||||
{
|
||||
return [
|
||||
'low' => 'Low',
|
||||
'medium' => 'Medium',
|
||||
'high' => 'High',
|
||||
'critical' => 'Critical',
|
||||
];
|
||||
}
|
||||
|
||||
public static function eventTypeLabel(string $eventType): string
|
||||
{
|
||||
return self::eventTypeOptions()[$eventType] ?? ucfirst(str_replace('_', ' ', $eventType));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function destinationOptions(): array
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return AlertDestination::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->orderBy('name')
|
||||
->pluck('name', 'id')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function tenantOptions(): array
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Tenant::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->pluck('name', 'id')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function timezoneOptions(): array
|
||||
{
|
||||
$identifiers = \DateTimeZone::listIdentifiers();
|
||||
sort($identifiers);
|
||||
|
||||
return array_combine($identifiers, $identifiers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
public static function audit(AlertRule $record, AuditActionId $actionId, array $metadata): void
|
||||
{
|
||||
$workspace = $record->workspace;
|
||||
|
||||
if ($workspace === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$actor = auth()->user();
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $workspace,
|
||||
action: $actionId->value,
|
||||
context: [
|
||||
'metadata' => $metadata,
|
||||
],
|
||||
actor: $actor instanceof User ? $actor : null,
|
||||
resourceType: 'alert_rule',
|
||||
resourceId: (string) $record->getKey(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AlertRuleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AlertRuleResource;
|
||||
use App\Models\AlertRule;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class CreateAlertRule extends CreateRecord
|
||||
{
|
||||
protected static string $resource = AlertRuleResource::class;
|
||||
|
||||
/**
|
||||
* @var array<int, int>
|
||||
*/
|
||||
private array $destinationIds = [];
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$workspaceId = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$data['workspace_id'] = (int) $workspaceId;
|
||||
|
||||
$this->destinationIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $value): int => (int) $value,
|
||||
Arr::wrap($data['destination_ids'] ?? []),
|
||||
))));
|
||||
|
||||
unset($data['destination_ids']);
|
||||
|
||||
return AlertRuleResource::normalizePayload($data);
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
$record = $this->record;
|
||||
|
||||
if (! $record instanceof AlertRule) {
|
||||
return;
|
||||
}
|
||||
|
||||
AlertRuleResource::syncDestinations($record, $this->destinationIds);
|
||||
|
||||
AlertRuleResource::audit($record, AuditActionId::AlertRuleCreated, [
|
||||
'alert_rule_id' => (int) $record->getKey(),
|
||||
'name' => (string) $record->name,
|
||||
'event_type' => (string) $record->event_type,
|
||||
'minimum_severity' => (string) $record->minimum_severity,
|
||||
'is_enabled' => (bool) $record->is_enabled,
|
||||
'destination_ids' => $this->destinationIds,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title('Rule created')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AlertRuleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AlertRuleResource;
|
||||
use App\Models\AlertRule;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class EditAlertRule extends EditRecord
|
||||
{
|
||||
protected static string $resource = AlertRuleResource::class;
|
||||
|
||||
/**
|
||||
* @var array<int, int>
|
||||
*/
|
||||
private array $destinationIds = [];
|
||||
|
||||
protected function mutateFormDataBeforeFill(array $data): array
|
||||
{
|
||||
$record = $this->record;
|
||||
|
||||
if ($record instanceof AlertRule) {
|
||||
$data['destination_ids'] = $record->destinations()
|
||||
->pluck('alert_destinations.id')
|
||||
->map(static fn (mixed $value): int => (int) $value)
|
||||
->all();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
$this->destinationIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $value): int => (int) $value,
|
||||
Arr::wrap($data['destination_ids'] ?? []),
|
||||
))));
|
||||
|
||||
unset($data['destination_ids']);
|
||||
|
||||
return AlertRuleResource::normalizePayload($data);
|
||||
}
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
$record = $this->record;
|
||||
|
||||
if (! $record instanceof AlertRule) {
|
||||
return;
|
||||
}
|
||||
|
||||
AlertRuleResource::syncDestinations($record, $this->destinationIds);
|
||||
|
||||
AlertRuleResource::audit($record, AuditActionId::AlertRuleUpdated, [
|
||||
'alert_rule_id' => (int) $record->getKey(),
|
||||
'name' => (string) $record->name,
|
||||
'event_type' => (string) $record->event_type,
|
||||
'minimum_severity' => (string) $record->minimum_severity,
|
||||
'is_enabled' => (bool) $record->is_enabled,
|
||||
'destination_ids' => $this->destinationIds,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title('Rule updated')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AlertRuleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AlertRuleResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListAlertRules extends ListRecords
|
||||
{
|
||||
protected static string $resource = AlertRuleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label('Create rule')
|
||||
->disabled(fn (): bool => ! AlertRuleResource::canCreate()),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableEmptyStateActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label('Create rule')
|
||||
->disabled(fn (): bool => ! AlertRuleResource::canCreate()),
|
||||
];
|
||||
}
|
||||
}
|
||||
1155
app/Filament/Resources/BackupScheduleResource.php
Normal file
1155
app/Filament/Resources/BackupScheduleResource.php
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BackupScheduleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateBackupSchedule extends CreateRecord
|
||||
{
|
||||
protected static string $resource = BackupScheduleResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$data = BackupScheduleResource::ensurePolicyTypes($data);
|
||||
$data = BackupScheduleResource::assignTenant($data);
|
||||
|
||||
return BackupScheduleResource::hydrateNextRun($data);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BackupScheduleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class EditBackupSchedule extends EditRecord
|
||||
{
|
||||
protected static string $resource = BackupScheduleResource::class;
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return BackupScheduleResource::resolveScopedRecordOrFail($key);
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
$data = BackupScheduleResource::ensurePolicyTypes($data);
|
||||
|
||||
return BackupScheduleResource::hydrateNextRun($data);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BackupScheduleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
|
||||
class ListBackupSchedules extends ListRecords
|
||||
{
|
||||
protected static string $resource = BackupScheduleResource::class;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
||||
{
|
||||
if (($context['table'] ?? false) === true && filled($context['recordKey'] ?? null) && in_array($name, ['archive', 'restore', 'forceDelete'], true)) {
|
||||
try {
|
||||
BackupScheduleResource::resolveScopedRecordOrFail($context['recordKey']);
|
||||
} catch (ModelNotFoundException) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
return parent::mountAction($name, $arguments, $context);
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->syncCanonicalAdminTenantFilterState();
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
BackupScheduleResource::makeCreateAction()
|
||||
->visible(fn (): bool => $this->tableHasRecords()),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableEmptyStateActions(): array
|
||||
{
|
||||
return [
|
||||
BackupScheduleResource::makeCreateAction(),
|
||||
];
|
||||
}
|
||||
|
||||
private function tableHasRecords(): bool
|
||||
{
|
||||
return $this->getTableRecords()->count() > 0;
|
||||
}
|
||||
|
||||
private function syncCanonicalAdminTenantFilterState(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
tenantSensitiveFilters: [],
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BackupScheduleResource\RelationManagers;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use Closure;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class BackupScheduleOperationRunsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'operationRuns';
|
||||
|
||||
protected static ?string $title = 'Executions';
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
||||
{
|
||||
if (($context['table'] ?? false) === true && $name === 'view' && filled($context['recordKey'] ?? null)) {
|
||||
$this->resolveOwnerScopedOperationRun($context['recordKey']);
|
||||
}
|
||||
|
||||
return parent::mountAction($name, $arguments, $context);
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Executions relation list has no header actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Single primary row action is exposed, so no row menu is needed.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for operation run safety.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed in this embedded relation manager.');
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey()))
|
||||
->defaultSort('created_at', 'desc')
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('Enqueued')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('type')
|
||||
->label('Type')
|
||||
->formatStateUsing(Closure::fromCallable([self::class, 'formatOperationType'])),
|
||||
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||
|
||||
Tables\Columns\TextColumn::make('outcome')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||
|
||||
Tables\Columns\TextColumn::make('counts')
|
||||
->label('Counts')
|
||||
->getStateUsing(function (OperationRun $record): string {
|
||||
$counts = is_array($record->summary_counts) ? $record->summary_counts : [];
|
||||
|
||||
$total = (int) ($counts['total'] ?? 0);
|
||||
$succeeded = (int) ($counts['succeeded'] ?? 0);
|
||||
$failed = (int) ($counts['failed'] ?? 0);
|
||||
|
||||
if ($total === 0 && $succeeded === 0 && $failed === 0) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return sprintf('%d/%d (%d failed)', $succeeded, $total, $failed);
|
||||
}),
|
||||
])
|
||||
->filters([])
|
||||
->headerActions([])
|
||||
->actions([
|
||||
Actions\Action::make('view')
|
||||
->label('View')
|
||||
->icon('heroicon-o-eye')
|
||||
->url(function (OperationRun $record): string {
|
||||
$record = $this->resolveOwnerScopedOperationRun($record);
|
||||
$tenant = Tenant::currentOrFail();
|
||||
|
||||
return OperationRunLinks::view($record, $tenant);
|
||||
})
|
||||
->openUrlInNewTab(true),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No schedule runs yet')
|
||||
->emptyStateDescription('Operation history will appear here after this schedule has been enqueued.');
|
||||
}
|
||||
|
||||
private function resolveOwnerScopedOperationRun(mixed $record): OperationRun
|
||||
{
|
||||
$recordId = $record instanceof OperationRun
|
||||
? (int) $record->getKey()
|
||||
: (is_numeric($record) ? (int) $record : 0);
|
||||
|
||||
if ($recordId <= 0) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$resolvedRecord = $this->getOwnerRecord()
|
||||
->operationRuns()
|
||||
->where('tenant_id', Tenant::currentOrFail()->getKey())
|
||||
->whereKey($recordId)
|
||||
->first();
|
||||
|
||||
if (! $resolvedRecord instanceof OperationRun) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $resolvedRecord;
|
||||
}
|
||||
|
||||
public static function formatOperationType(?string $state): string
|
||||
{
|
||||
return OperationCatalog::label($state);
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\BackupSetResource\Pages;
|
||||
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
||||
use App\Jobs\BulkBackupSetDeleteJob;
|
||||
@ -9,14 +11,36 @@
|
||||
use App\Jobs\BulkBackupSetRestoreJob;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms;
|
||||
use Filament\Infolists;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -26,17 +50,86 @@
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\TrashedFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use UnitEnum;
|
||||
|
||||
class BackupSetResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = BackupSet::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-archive-box';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Create backup set is available in the list header.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Restore, archive, and force delete remain grouped in the More menu on table rows.')
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk archive, restore, and force delete stay grouped under More.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create backup set remains available from the empty state.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Primary related navigation and grouped mutation actions render in the view header.');
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $tenant)
|
||||
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $tenant)
|
||||
&& $resolver->can($user, $tenant, Capabilities::TENANT_SYNC);
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return static::getTenantOwnedEloquentQuery();
|
||||
}
|
||||
|
||||
public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model
|
||||
{
|
||||
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->withTrashed());
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
@ -48,16 +141,40 @@ public static function form(Schema $schema): Schema
|
||||
]);
|
||||
}
|
||||
|
||||
public static function makeCreateAction(): Actions\CreateAction
|
||||
{
|
||||
$action = Actions\CreateAction::make()
|
||||
->label('Create backup set');
|
||||
|
||||
UiEnforcement::forAction($action)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->apply();
|
||||
|
||||
return $action;
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->paginated(TablePaginationProfiles::resource())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')->searchable(),
|
||||
Tables\Columns\TextColumn::make('status')->badge(),
|
||||
Tables\Columns\TextColumn::make('item_count')->label('Items'),
|
||||
Tables\Columns\TextColumn::make('created_by')->label('Created by'),
|
||||
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(),
|
||||
Tables\Columns\TextColumn::make('created_at')->dateTime()->since(),
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupSetStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::BackupSetStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
|
||||
Tables\Columns\TextColumn::make('item_count')->label('Items')->numeric()->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_by')->label('Created by')->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('completed_at')->label('Completed')->dateTime()->since()->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_at')->label('Captured')->dateTime()->since()->sortable()->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\TrashedFilter::make()
|
||||
@ -66,257 +183,367 @@ public static function table(Table $table): Table
|
||||
->trueLabel('All')
|
||||
->falseLabel('Archived'),
|
||||
])
|
||||
->recordUrl(fn (BackupSet $record): string => static::getUrl('view', ['record' => $record]))
|
||||
->actions([
|
||||
Actions\ViewAction::make()
|
||||
->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record]))
|
||||
->openUrlInNewTab(false),
|
||||
static::primaryRelatedAction(),
|
||||
ActionGroup::make([
|
||||
Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->color('success')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSet $record) => $record->trashed())
|
||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||
$record->restore();
|
||||
$record->items()->withTrashed()->restore();
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->color('success')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSet $record): bool => $record->trashed())
|
||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if ($record->tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup.restored',
|
||||
resourceType: 'backup_set',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['name' => $record->name]]
|
||||
);
|
||||
}
|
||||
$record->restore();
|
||||
$record->items()->withTrashed()->restore();
|
||||
|
||||
Notification::make()
|
||||
->title('Backup set restored')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Actions\Action::make('archive')
|
||||
->label('Archive')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSet $record) => ! $record->trashed())
|
||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||
$record->delete();
|
||||
if ($record->tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup.restored',
|
||||
resourceType: 'backup_set',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['name' => $record->name]]
|
||||
);
|
||||
}
|
||||
|
||||
if ($record->tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup.deleted',
|
||||
resourceType: 'backup_set',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['name' => $record->name]]
|
||||
);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Backup set archived')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Actions\Action::make('forceDelete')
|
||||
->label('Force delete')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-trash')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSet $record) => $record->trashed())
|
||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||
if ($record->restoreRuns()->withTrashed()->exists()) {
|
||||
Notification::make()
|
||||
->title('Cannot force delete backup set')
|
||||
->body('Backup sets referenced by restore runs cannot be removed.')
|
||||
->danger()
|
||||
->title('Backup set restored')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('archive')
|
||||
->label('Archive')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSet $record): bool => ! $record->trashed())
|
||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
return;
|
||||
}
|
||||
$record->delete();
|
||||
|
||||
if ($record->tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup.force_deleted',
|
||||
resourceType: 'backup_set',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['name' => $record->name]]
|
||||
);
|
||||
}
|
||||
if ($record->tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup.deleted',
|
||||
resourceType: 'backup_set',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['name' => $record->name]]
|
||||
);
|
||||
}
|
||||
|
||||
$record->items()->withTrashed()->forceDelete();
|
||||
$record->forceDelete();
|
||||
Notification::make()
|
||||
->title('Backup set archived')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('forceDelete')
|
||||
->label('Force delete')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-trash')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSet $record): bool => $record->trashed())
|
||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
Notification::make()
|
||||
->title('Backup set permanently deleted')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
if ($record->restoreRuns()->withTrashed()->exists()) {
|
||||
Notification::make()
|
||||
->title('Cannot force delete backup set')
|
||||
->body('Backup sets referenced by restore runs cannot be removed.')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($record->tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup.force_deleted',
|
||||
resourceType: 'backup_set',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['name' => $record->name]]
|
||||
);
|
||||
}
|
||||
|
||||
$record->items()->withTrashed()->forceDelete();
|
||||
$record->forceDelete();
|
||||
|
||||
Notification::make()
|
||||
->title('Backup set permanently deleted')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
])->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
BulkAction::make('bulk_delete')
|
||||
->label('Archive Backup Sets')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_delete')
|
||||
->label('Archive Backup Sets')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
|
||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||
|
||||
return $isOnlyTrashed;
|
||||
})
|
||||
->modalDescription('This archives backup sets (soft delete). Already archived backup sets will be skipped.')
|
||||
->form(function (Collection $records) {
|
||||
if ($records->count() >= 10) {
|
||||
return [
|
||||
Forms\Components\TextInput::make('confirmation')
|
||||
->label('Type DELETE to confirm')
|
||||
->required()
|
||||
->in(['DELETE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type DELETE to confirm.',
|
||||
]),
|
||||
];
|
||||
}
|
||||
return $isOnlyTrashed;
|
||||
})
|
||||
->modalDescription('This archives backup sets (soft delete). Already archived backup sets will be skipped.')
|
||||
->form(function (Collection $records) {
|
||||
if ($records->count() >= 10) {
|
||||
return [
|
||||
Forms\Components\TextInput::make('confirmation')
|
||||
->label('Type DELETE to confirm')
|
||||
->required()
|
||||
->in(['DELETE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type DELETE to confirm.',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records) {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'backup_set', 'delete', $ids, $count);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($count >= 10) {
|
||||
Notification::make()
|
||||
->title('Bulk archive started')
|
||||
->body("Archiving {$count} backup sets in the background. Check the progress bar in the bottom right corner.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->duration(8000)
|
||||
->sendToDatabase($user)
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'backup_set.delete',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||
BulkBackupSetDeleteJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) ($initiator?->getKey() ?? 0),
|
||||
backupSetIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
extraContext: [
|
||||
'backup_set_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
OperationUxPresenter::queuedToast('backup_set.delete')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
|
||||
BulkBackupSetDeleteJob::dispatch($run->id);
|
||||
} else {
|
||||
BulkBackupSetDeleteJob::dispatchSync($run->id);
|
||||
}
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_restore')
|
||||
->label('Restore Backup Sets')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
|
||||
BulkAction::make('bulk_restore')
|
||||
->label('Restore Backup Sets')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||
|
||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||
return ! $isOnlyTrashed;
|
||||
})
|
||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?")
|
||||
->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.')
|
||||
->action(function (Collection $records) {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
return ! $isOnlyTrashed;
|
||||
})
|
||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?")
|
||||
->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.')
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'backup_set', 'restore', $ids, $count);
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
if ($count >= 10) {
|
||||
Notification::make()
|
||||
->title('Bulk restore started')
|
||||
->body("Restoring {$count} backup sets in the background. Check the progress bar in the bottom right corner.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->duration(8000)
|
||||
->sendToDatabase($user)
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'backup_set.restore',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||
BulkBackupSetRestoreJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) ($initiator?->getKey() ?? 0),
|
||||
backupSetIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
extraContext: [
|
||||
'backup_set_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
OperationUxPresenter::queuedToast('backup_set.restore')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
|
||||
BulkBackupSetRestoreJob::dispatch($run->id);
|
||||
} else {
|
||||
BulkBackupSetRestoreJob::dispatchSync($run->id);
|
||||
}
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_force_delete')
|
||||
->label('Force Delete Backup Sets')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
|
||||
BulkAction::make('bulk_force_delete')
|
||||
->label('Force Delete Backup Sets')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||
|
||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||
return ! $isOnlyTrashed;
|
||||
})
|
||||
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} backup sets?")
|
||||
->modalDescription('This is permanent. Only archived backup sets will be permanently deleted; active backup sets will be skipped.')
|
||||
->form(function (Collection $records) {
|
||||
if ($records->count() >= 10) {
|
||||
return [
|
||||
Forms\Components\TextInput::make('confirmation')
|
||||
->label('Type DELETE to confirm')
|
||||
->required()
|
||||
->in(['DELETE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type DELETE to confirm.',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
return ! $isOnlyTrashed;
|
||||
})
|
||||
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} backup sets?")
|
||||
->modalDescription('This is permanent. Only archived backup sets will be permanently deleted; active backup sets will be skipped.')
|
||||
->form(function (Collection $records) {
|
||||
if ($records->count() >= 10) {
|
||||
return [
|
||||
Forms\Components\TextInput::make('confirmation')
|
||||
->label('Type DELETE to confirm')
|
||||
->required()
|
||||
->in(['DELETE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type DELETE to confirm.',
|
||||
]),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records) {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'backup_set', 'force_delete', $ids, $count);
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
if ($count >= 10) {
|
||||
Notification::make()
|
||||
->title('Bulk force delete started')
|
||||
->body("Force deleting {$count} backup sets in the background. Check the progress bar in the bottom right corner.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->duration(8000)
|
||||
->sendToDatabase($user)
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'backup_set.force_delete',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||
BulkBackupSetForceDeleteJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) ($initiator?->getKey() ?? 0),
|
||||
backupSetIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
extraContext: [
|
||||
'backup_set_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
OperationUxPresenter::queuedToast('backup_set.force_delete')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
BulkBackupSetForceDeleteJob::dispatch($run->id);
|
||||
} else {
|
||||
BulkBackupSetForceDeleteJob::dispatchSync($run->id);
|
||||
}
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
]),
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
])->label('More'),
|
||||
])
|
||||
->emptyStateHeading('No backup sets')
|
||||
->emptyStateDescription('Create a backup set to start protecting your configurations.')
|
||||
->emptyStateIcon('heroicon-o-archive-box')
|
||||
->emptyStateActions([
|
||||
static::makeCreateAction(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -324,16 +551,11 @@ public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('name'),
|
||||
Infolists\Components\TextEntry::make('status')->badge(),
|
||||
Infolists\Components\TextEntry::make('item_count')->label('Items'),
|
||||
Infolists\Components\TextEntry::make('created_by')->label('Created by'),
|
||||
Infolists\Components\TextEntry::make('completed_at')->dateTime(),
|
||||
Infolists\Components\TextEntry::make('metadata')
|
||||
->label('Metadata')
|
||||
->formatStateUsing(fn ($state) => json_encode($state ?? [], JSON_PRETTY_PRINT))
|
||||
->copyable()
|
||||
->copyMessage('Metadata copied'),
|
||||
Infolists\Components\ViewEntry::make('enterprise_detail')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.enterprise-detail.layout')
|
||||
->state(fn (BackupSet $record): array => static::enterpriseDetailPage($record)->toArray())
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -371,13 +593,49 @@ private static function typeMeta(?string $type): array
|
||||
->firstWhere('type', $type) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* key: string,
|
||||
* label: string,
|
||||
* value: string,
|
||||
* secondaryValue: ?string,
|
||||
* targetUrl: ?string,
|
||||
* targetKind: string,
|
||||
* availability: string,
|
||||
* unavailableReason: ?string,
|
||||
* contextBadge: ?string,
|
||||
* priority: int,
|
||||
* actionLabel: string
|
||||
* }>
|
||||
*/
|
||||
public static function relatedContextEntries(BackupSet $record): array
|
||||
{
|
||||
return app(RelatedNavigationResolver::class)
|
||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $record);
|
||||
}
|
||||
|
||||
private static function primaryRelatedAction(): Actions\Action
|
||||
{
|
||||
return Actions\Action::make('primary_drill_down')
|
||||
->label(fn (BackupSet $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? 'Open related record')
|
||||
->url(fn (BackupSet $record): ?string => static::primaryRelatedEntry($record)?->targetUrl)
|
||||
->hidden(fn (BackupSet $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false))
|
||||
->color('gray');
|
||||
}
|
||||
|
||||
private static function primaryRelatedEntry(BackupSet $record): ?RelatedContextEntry
|
||||
{
|
||||
return app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup set via the domain service instead of direct model mass-assignment.
|
||||
*/
|
||||
public static function createBackupSet(array $data): BackupSet
|
||||
{
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
/** @var BackupService $service */
|
||||
$service = app(BackupService::class);
|
||||
@ -392,4 +650,94 @@ public static function createBackupSet(array $data): BackupSet
|
||||
includeScopeTags: $data['include_scope_tags'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetailPageData
|
||||
{
|
||||
$factory = new EnterpriseDetailSectionFactory;
|
||||
$statusSpec = BadgeRenderer::spec(BadgeDomain::BackupSetStatus, $record->status);
|
||||
$metadata = is_array($record->metadata) ? $record->metadata : [];
|
||||
$metadataKeyCount = count($metadata);
|
||||
$relatedContext = static::relatedContextEntries($record);
|
||||
$isArchived = $record->trashed();
|
||||
|
||||
return EnterpriseDetailBuilder::make('backup_set', 'tenant')
|
||||
->header(new SummaryHeaderData(
|
||||
title: (string) $record->name,
|
||||
subtitle: 'Backup set #'.$record->getKey(),
|
||||
statusBadges: [
|
||||
$factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
|
||||
$factory->statusBadge($isArchived ? 'Archived' : 'Active', $isArchived ? 'warning' : 'success'),
|
||||
],
|
||||
keyFacts: [
|
||||
$factory->keyFact('Items', $record->item_count),
|
||||
$factory->keyFact('Created by', $record->created_by),
|
||||
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
|
||||
$factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)),
|
||||
],
|
||||
descriptionHint: 'Lifecycle status, recovery readiness, and related operations stay ahead of raw backup metadata.',
|
||||
))
|
||||
->addSection(
|
||||
$factory->factsSection(
|
||||
id: 'lifecycle_overview',
|
||||
kind: 'core_details',
|
||||
title: 'Lifecycle overview',
|
||||
items: [
|
||||
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
|
||||
$factory->keyFact('Items', $record->item_count),
|
||||
$factory->keyFact('Created by', $record->created_by),
|
||||
$factory->keyFact('Archived', $isArchived),
|
||||
],
|
||||
),
|
||||
$factory->viewSection(
|
||||
id: 'related_context',
|
||||
kind: 'related_context',
|
||||
title: 'Related context',
|
||||
view: 'filament.infolists.entries.related-context',
|
||||
viewData: ['entries' => $relatedContext],
|
||||
emptyState: $factory->emptyState('No related context is available for this record.'),
|
||||
),
|
||||
)
|
||||
->addSupportingCard(
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'status',
|
||||
title: 'Recovery readiness',
|
||||
items: [
|
||||
$factory->keyFact('Backup state', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
|
||||
$factory->keyFact('Archived', $isArchived),
|
||||
$factory->keyFact('Metadata keys', $metadataKeyCount),
|
||||
],
|
||||
),
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'timestamps',
|
||||
title: 'Timing',
|
||||
items: [
|
||||
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
|
||||
$factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)),
|
||||
],
|
||||
),
|
||||
)
|
||||
->addTechnicalSection(
|
||||
$factory->technicalDetail(
|
||||
title: 'Technical detail',
|
||||
entries: [
|
||||
$factory->keyFact('Metadata keys', $metadataKeyCount),
|
||||
$factory->keyFact('Archived', $isArchived),
|
||||
],
|
||||
description: 'Low-signal backup metadata stays available here without taking over the recovery workflow.',
|
||||
view: $metadata !== [] ? 'filament.infolists.entries.snapshot-json' : null,
|
||||
viewData: ['payload' => $metadata],
|
||||
emptyState: $factory->emptyState('No backup metadata was recorded for this backup set.'),
|
||||
),
|
||||
)
|
||||
->build();
|
||||
}
|
||||
|
||||
private static function formatDetailTimestamp(mixed $value): string
|
||||
{
|
||||
if (! $value instanceof Carbon) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return $value->toDayDateTimeString();
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,17 +3,41 @@
|
||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use Filament\Actions;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBackupSets extends ListRecords
|
||||
{
|
||||
protected static string $resource = BackupSetResource::class;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
private function tableHasRecords(): bool
|
||||
{
|
||||
return $this->getTableRecords()->count() > 0;
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
BackupSetResource::makeCreateAction()
|
||||
->visible(fn (): bool => $this->tableHasRecords()),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableEmptyStateActions(): array
|
||||
{
|
||||
return [
|
||||
BackupSetResource::makeCreateAction(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,194 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ViewBackupSet extends ViewRecord
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static string $resource = BackupSetResource::class;
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return BackupSetResource::resolveScopedRecordOrFail($key);
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = [
|
||||
Action::make('primary_related')
|
||||
->label(fn (): string => app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $this->getRecord())?->actionLabel ?? 'Open related record')
|
||||
->url(fn (): ?string => app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $this->getRecord())?->targetUrl)
|
||||
->hidden(fn (): bool => ! (app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $this->getRecord())?->isAvailable() ?? false))
|
||||
->color('gray'),
|
||||
];
|
||||
|
||||
$mutationActions = [
|
||||
$this->restoreAction(),
|
||||
$this->archiveAction(),
|
||||
$this->forceDeleteAction(),
|
||||
];
|
||||
|
||||
$actions[] = ActionGroup::make($mutationActions)
|
||||
->label('More')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
->color('gray');
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
private function restoreAction(): Action
|
||||
{
|
||||
$action = Action::make('restore')
|
||||
->label('Restore')
|
||||
->color('success')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->getRecord() instanceof BackupSet && $this->getRecord()->trashed())
|
||||
->action(function (AuditLogger $auditLogger): void {
|
||||
/** @var BackupSet $record */
|
||||
$record = $this->getRecord();
|
||||
|
||||
$record->restore();
|
||||
$record->items()->withTrashed()->restore();
|
||||
|
||||
if ($record->tenant instanceof Tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup.restored',
|
||||
resourceType: 'backup_set',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
context: ['metadata' => ['name' => $record->name]],
|
||||
);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Backup set restored')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect(BackupSetResource::getUrl('view', ['record' => $record], tenant: static::resolveTenantContextForCurrentPanel()), navigate: true);
|
||||
});
|
||||
|
||||
UiEnforcement::forAction($action)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply();
|
||||
|
||||
return $action;
|
||||
}
|
||||
|
||||
private function archiveAction(): Action
|
||||
{
|
||||
$action = Action::make('archive')
|
||||
->label('Archive')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->getRecord() instanceof BackupSet && ! $this->getRecord()->trashed())
|
||||
->action(function (AuditLogger $auditLogger): void {
|
||||
/** @var BackupSet $record */
|
||||
$record = $this->getRecord();
|
||||
|
||||
$record->delete();
|
||||
|
||||
if ($record->tenant instanceof Tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup.deleted',
|
||||
resourceType: 'backup_set',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
context: ['metadata' => ['name' => $record->name]],
|
||||
);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Backup set archived')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect(BackupSetResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()), navigate: true);
|
||||
});
|
||||
|
||||
UiEnforcement::forAction($action)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply();
|
||||
|
||||
return $action;
|
||||
}
|
||||
|
||||
private function forceDeleteAction(): Action
|
||||
{
|
||||
$action = Action::make('forceDelete')
|
||||
->label('Force delete')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-trash')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->getRecord() instanceof BackupSet && $this->getRecord()->trashed())
|
||||
->action(function (AuditLogger $auditLogger): void {
|
||||
/** @var BackupSet $record */
|
||||
$record = $this->getRecord();
|
||||
|
||||
if ($record->restoreRuns()->withTrashed()->exists()) {
|
||||
Notification::make()
|
||||
->title('Cannot force delete backup set')
|
||||
->body('Backup sets referenced by restore runs cannot be removed.')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($record->tenant instanceof Tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup.force_deleted',
|
||||
resourceType: 'backup_set',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
context: ['metadata' => ['name' => $record->name]],
|
||||
);
|
||||
}
|
||||
|
||||
$record->items()->withTrashed()->forceDelete();
|
||||
$record->forceDelete();
|
||||
|
||||
Notification::make()
|
||||
->title('Backup set permanently deleted')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect(BackupSetResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()), navigate: true);
|
||||
});
|
||||
|
||||
UiEnforcement::forAction($action)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply();
|
||||
|
||||
return $action;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,12 +3,28 @@
|
||||
namespace App\Filament\Resources\BackupSetResource\RelationManagers;
|
||||
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Jobs\RemovePoliciesFromBackupSetJob;
|
||||
use App\Models\BackupItem;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@ -18,10 +34,229 @@ class BackupItemsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'items';
|
||||
|
||||
protected $listeners = [
|
||||
'backup-set-policy-picker:close' => 'closeAddPoliciesModal',
|
||||
];
|
||||
|
||||
public function closeAddPoliciesModal(): void
|
||||
{
|
||||
$this->unmountAction();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
||||
{
|
||||
if (($context['table'] ?? false) === true) {
|
||||
$backupSet = $this->getOwnerRecord();
|
||||
|
||||
if ($name === 'remove' && filled($context['recordKey'] ?? null)) {
|
||||
$this->resolveOwnerScopedBackupItemId($backupSet, $context['recordKey']);
|
||||
}
|
||||
|
||||
if ($name === 'bulk_remove' && ($context['bulk'] ?? false) === true) {
|
||||
$this->resolveOwnerScopedBackupItemIdsFromKeys($backupSet, $this->selectedTableRecords);
|
||||
}
|
||||
}
|
||||
|
||||
return parent::mountAction($name, $arguments, $context);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
$refreshTable = Actions\Action::make('refreshTable')
|
||||
->label('Refresh')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->action(function (): void {
|
||||
$this->resetTable();
|
||||
});
|
||||
|
||||
$addPolicies = Actions\Action::make('addPolicies')
|
||||
->label('Add Policies')
|
||||
->icon('heroicon-o-plus')
|
||||
->tooltip('You do not have permission to add policies.')
|
||||
->modalHeading('Add Policies')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Close')
|
||||
->modalContent(function (): View {
|
||||
$backupSet = $this->getOwnerRecord();
|
||||
|
||||
return view('filament.modals.backup-set-policy-picker', [
|
||||
'backupSetId' => $backupSet->getKey(),
|
||||
]);
|
||||
});
|
||||
|
||||
UiEnforcement::forAction($addPolicies)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->tooltip('You do not have permission to add policies.')
|
||||
->apply();
|
||||
|
||||
$removeItem = Actions\Action::make('remove')
|
||||
->label('Remove')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->requiresConfirmation()
|
||||
->action(function (mixed $record): void {
|
||||
$backupSet = $this->getOwnerRecord();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$backupItemIds = [$this->resolveOwnerScopedBackupItemId($backupSet, $record)];
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'backup_set.remove_policies',
|
||||
inputs: [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'backup_item_ids' => $backupItemIds,
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
|
||||
RemovePoliciesFromBackupSetJob::dispatch(
|
||||
backupSetId: (int) $backupSet->getKey(),
|
||||
backupItemIds: $backupItemIds,
|
||||
initiatorUserId: (int) $user->getKey(),
|
||||
operationRun: $opRun,
|
||||
);
|
||||
});
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
});
|
||||
|
||||
UiEnforcement::forAction($removeItem)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->tooltip('You do not have permission to remove policies.')
|
||||
->apply();
|
||||
|
||||
$bulkRemove = Actions\BulkAction::make('bulk_remove')
|
||||
->label('Remove selected')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->deselectRecordsAfterCompletion()
|
||||
->action(function (Collection $records): void {
|
||||
if ($records->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$backupSet = $this->getOwnerRecord();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$backupItemIds = $this->resolveOwnerScopedBackupItemIdsFromKeys($backupSet, $this->selectedTableRecords);
|
||||
|
||||
if ($backupItemIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'backup_set.remove_policies',
|
||||
inputs: [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'backup_item_ids' => $backupItemIds,
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
|
||||
RemovePoliciesFromBackupSetJob::dispatch(
|
||||
backupSetId: (int) $backupSet->getKey(),
|
||||
backupItemIds: $backupItemIds,
|
||||
initiatorUserId: (int) $user->getKey(),
|
||||
operationRun: $opRun,
|
||||
);
|
||||
});
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
});
|
||||
|
||||
UiEnforcement::forBulkAction($bulkRemove)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->tooltip('You do not have permission to remove policies.')
|
||||
->apply();
|
||||
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with(['policy', 'policyVersion', 'policyVersion.policy']))
|
||||
->defaultSort('policy.display_name')
|
||||
->paginated(TablePaginationProfiles::relationManager())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('policy.display_name')
|
||||
->label('Item')
|
||||
@ -36,21 +271,32 @@ public function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state) => static::typeMeta($state)['label'] ?? $state),
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||
Tables\Columns\TextColumn::make('restore_mode')
|
||||
->label('Restore')
|
||||
->badge()
|
||||
->state(fn (BackupItem $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
|
||||
->color(fn (?string $state) => $state === 'preview-only' ? 'warning' : 'success'),
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRestoreMode))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicyRestoreMode))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRestoreMode))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRestoreMode)),
|
||||
Tables\Columns\TextColumn::make('risk')
|
||||
->label('Risk')
|
||||
->badge()
|
||||
->state(fn (BackupItem $record) => static::typeMeta($record->policy_type)['risk'] ?? 'n/a')
|
||||
->color(fn (?string $state) => str_contains((string) $state, 'high') ? 'danger' : 'gray'),
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
|
||||
Tables\Columns\TextColumn::make('policy_identifier')
|
||||
->label('Policy ID')
|
||||
->copyable(),
|
||||
Tables\Columns\TextColumn::make('platform')->badge(),
|
||||
->copyable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('platform')
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||
Tables\Columns\TextColumn::make('assignments')
|
||||
->label('Assignments')
|
||||
->badge()
|
||||
@ -88,110 +334,62 @@ public function table(Table $table): Table
|
||||
}
|
||||
|
||||
return '—';
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('captured_at')->dateTime(),
|
||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||
})
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('created_at')->since()->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('policy_type')
|
||||
->label('Type')
|
||||
->options(FilterOptionCatalog::policyTypes())
|
||||
->searchable(),
|
||||
SelectFilter::make('restore_mode')
|
||||
->label('Restore')
|
||||
->options(static::restoreModeOptions())
|
||||
->query(fn (Builder $query, array $data): Builder => static::applyRestoreModeFilter($query, $data['value'] ?? null)),
|
||||
SelectFilter::make('platform')
|
||||
->options(FilterOptionCatalog::platforms())
|
||||
->searchable(),
|
||||
])
|
||||
->filters([])
|
||||
->headerActions([
|
||||
Actions\Action::make('addPolicies')
|
||||
->label('Add Policies')
|
||||
->icon('heroicon-o-plus')
|
||||
->modalHeading('Add Policies')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Close')
|
||||
->modalContent(function (): View {
|
||||
$backupSet = $this->getOwnerRecord();
|
||||
|
||||
return view('filament.modals.backup-set-policy-picker', [
|
||||
'backupSetId' => $backupSet->getKey(),
|
||||
]);
|
||||
}),
|
||||
$refreshTable,
|
||||
$addPolicies,
|
||||
])
|
||||
->actions([
|
||||
Actions\ActionGroup::make([
|
||||
Actions\ViewAction::make()
|
||||
->label('View policy')
|
||||
->url(fn ($record) => $record->policy_id ? PolicyResource::getUrl('view', ['record' => $record->policy_id]) : null)
|
||||
->hidden(fn ($record) => ! $record->policy_id)
|
||||
->label(fn (BackupItem $record): string => $record->policy_version_id ? 'View version' : 'View policy')
|
||||
->url(function (BackupItem $record): ?string {
|
||||
$tenant = $this->getOwnerRecord()->tenant ?? \App\Models\Tenant::current();
|
||||
|
||||
if ($record->policy_version_id) {
|
||||
return PolicyVersionResource::getUrl('view', ['record' => $record->policy_version_id], tenant: $tenant);
|
||||
}
|
||||
|
||||
if (! $record->policy_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PolicyResource::getUrl('view', ['record' => $record->policy_id], tenant: $tenant);
|
||||
})
|
||||
->hidden(fn (BackupItem $record) => ! $record->policy_version_id && ! $record->policy_id)
|
||||
->openUrlInNewTab(true),
|
||||
Actions\Action::make('remove')
|
||||
->label('Remove')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->requiresConfirmation()
|
||||
->action(function (BackupItem $record, AuditLogger $auditLogger) {
|
||||
$record->delete();
|
||||
|
||||
if ($record->backupSet) {
|
||||
$record->backupSet->update([
|
||||
'item_count' => $record->backupSet->items()->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($record->tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup.item_removed',
|
||||
resourceType: 'backup_set',
|
||||
resourceId: (string) $record->backup_set_id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['policy_id' => $record->policy_id]]
|
||||
);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Policy removed from backup')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])->icon('heroicon-o-ellipsis-vertical'),
|
||||
$removeItem,
|
||||
])
|
||||
->label('More')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
->color('gray'),
|
||||
])
|
||||
->bulkActions([
|
||||
Actions\BulkActionGroup::make([
|
||||
Actions\BulkAction::make('bulk_remove')
|
||||
->label('Remove selected')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->action(function (Collection $records, AuditLogger $auditLogger) {
|
||||
if ($records->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$backupSet = $this->getOwnerRecord();
|
||||
|
||||
$records->each(fn (BackupItem $record) => $record->delete());
|
||||
|
||||
$backupSet->update([
|
||||
'item_count' => $backupSet->items()->count(),
|
||||
]);
|
||||
|
||||
$tenant = $records->first()?->tenant;
|
||||
|
||||
if ($tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup.items_removed',
|
||||
resourceType: 'backup_set',
|
||||
resourceId: (string) $backupSet->id,
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'removed_count' => $records->count(),
|
||||
'policy_ids' => $records->pluck('policy_id')->filter()->values()->all(),
|
||||
'policy_identifiers' => $records->pluck('policy_identifier')->filter()->values()->all(),
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Policies removed from backup')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
]),
|
||||
$bulkRemove,
|
||||
])->label('More'),
|
||||
])
|
||||
->emptyStateHeading('No policies in this backup set')
|
||||
->emptyStateDescription('Add policies to capture versions and assignments inside this backup set.')
|
||||
->emptyStateActions([
|
||||
$addPolicies->name('addPoliciesEmpty'),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -212,4 +410,106 @@ private static function typeMeta(?string $type): array
|
||||
return collect($types)
|
||||
->firstWhere('type', $type) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function restoreModeOptions(): array
|
||||
{
|
||||
return collect(InventoryPolicyTypeMeta::all())
|
||||
->pluck('restore')
|
||||
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
->map(fn (string $value): string => trim($value))
|
||||
->unique()
|
||||
->sort()
|
||||
->mapWithKeys(fn (string $value): array => [
|
||||
$value => BadgeRenderer::spec(BadgeDomain::PolicyRestoreMode, $value)->label,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private static function applyRestoreModeFilter(Builder $query, mixed $value): Builder
|
||||
{
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$types = collect(InventoryPolicyTypeMeta::all())
|
||||
->filter(fn (array $meta): bool => ($meta['restore'] ?? null) === $value)
|
||||
->pluck('type')
|
||||
->filter(fn (mixed $type): bool => is_string($type) && trim($type) !== '')
|
||||
->map(fn (string $type): string => trim($type))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($types === []) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return $query->whereIn('policy_type', $types);
|
||||
}
|
||||
|
||||
private function resolveOwnerScopedBackupItemId(\App\Models\BackupSet $backupSet, mixed $record): int
|
||||
{
|
||||
$recordId = $this->normalizeBackupItemKey($record);
|
||||
|
||||
if ($recordId <= 0) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$resolvedId = $backupSet->items()
|
||||
->where('tenant_id', (int) $backupSet->tenant_id)
|
||||
->whereKey($recordId)
|
||||
->value('id');
|
||||
|
||||
if (! is_numeric($resolvedId) || (int) $resolvedId <= 0) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return (int) $resolvedId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function resolveOwnerScopedBackupItemIdsFromKeys(\App\Models\BackupSet $backupSet, array $recordKeys): array
|
||||
{
|
||||
$requestedIds = collect($recordKeys)
|
||||
->map(fn (mixed $record): int => $this->normalizeBackupItemKey($record))
|
||||
->filter(fn (int $value): bool => $value > 0)
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($requestedIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$resolvedIds = $backupSet->items()
|
||||
->where('tenant_id', (int) $backupSet->tenant_id)
|
||||
->whereIn('id', $requestedIds)
|
||||
->pluck('id')
|
||||
->map(fn (mixed $value): int => (int) $value)
|
||||
->filter(fn (int $value): bool => $value > 0)
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if (count($resolvedIds) !== count($requestedIds)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $resolvedIds;
|
||||
}
|
||||
|
||||
private function normalizeBackupItemKey(mixed $record): int
|
||||
{
|
||||
if ($record instanceof BackupItem) {
|
||||
return (int) $record->getKey();
|
||||
}
|
||||
|
||||
return is_numeric($record) ? (int) $record : 0;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user