12-05-2026 Frontend dev
This commit is contained in:
parent
405df0a122
commit
5b8bdf4182
779 changed files with 480564 additions and 6241 deletions
84
.agents/skills/fluxui-development/SKILL.md
Normal file
84
.agents/skills/fluxui-development/SKILL.md
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
---
|
||||||
|
name: fluxui-development
|
||||||
|
description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with <flux:*> components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Flux UI Development
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Flux UI patterns and documentation.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
This project uses the Pro version of Flux UI, which includes all free and Pro components and variants.
|
||||||
|
|
||||||
|
Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize.
|
||||||
|
|
||||||
|
Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs.
|
||||||
|
|
||||||
|
<!-- Basic Button -->
|
||||||
|
```blade
|
||||||
|
<flux:button variant="primary">Click me</flux:button>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Components (Pro Edition)
|
||||||
|
|
||||||
|
Available: accordion, autocomplete, avatar, badge, brand, breadcrumbs, button, calendar, callout, card, chart, checkbox, command, composer, context, date-picker, dropdown, editor, field, file-upload, heading, icon, input, kanban, modal, navbar, otp-input, pagination, pillbox, popover, profile, radio, select, separator, skeleton, slider, switch, table, tabs, text, textarea, time-picker, toast, tooltip
|
||||||
|
|
||||||
|
## Icons
|
||||||
|
|
||||||
|
Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names.
|
||||||
|
|
||||||
|
<!-- Icon Button -->
|
||||||
|
```blade
|
||||||
|
<flux:button icon="arrow-down-tray">Export</flux:button>
|
||||||
|
```
|
||||||
|
|
||||||
|
For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan flux:icon crown grip-vertical github
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Form Fields
|
||||||
|
|
||||||
|
<!-- Form Field -->
|
||||||
|
```blade
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>Email</flux:label>
|
||||||
|
<flux:input type="email" wire:model="email" />
|
||||||
|
<flux:error name="email" />
|
||||||
|
</flux:field>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
```blade
|
||||||
|
<flux:table>
|
||||||
|
<flux:table.columns>
|
||||||
|
<flux:table.cell>Column Name</flux:table.cell>
|
||||||
|
</flux:table.columns>
|
||||||
|
<flux:table.row>
|
||||||
|
<flux:table.cell>Value</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
</flux:table>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Check component renders correctly
|
||||||
|
2. Test interactive states
|
||||||
|
3. Verify mobile responsiveness
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Not checking if a Flux component exists before creating custom implementations
|
||||||
|
- Forgetting to use the `search-docs` tool for component-specific documentation
|
||||||
|
- Not following existing project patterns for Flux usage
|
||||||
131
.agents/skills/fortify-development/SKILL.md
Normal file
131
.agents/skills/fortify-development/SKILL.md
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
---
|
||||||
|
name: fortify-development
|
||||||
|
description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.'
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Laravel Fortify Development
|
||||||
|
|
||||||
|
Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Laravel Fortify patterns and documentation.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints
|
||||||
|
- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.)
|
||||||
|
- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field
|
||||||
|
- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.)
|
||||||
|
- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc.
|
||||||
|
|
||||||
|
## Available Features
|
||||||
|
|
||||||
|
Enable in `config/fortify.php` features array:
|
||||||
|
|
||||||
|
- `Features::registration()` - User registration
|
||||||
|
- `Features::resetPasswords()` - Password reset via email
|
||||||
|
- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail`
|
||||||
|
- `Features::updateProfileInformation()` - Profile updates
|
||||||
|
- `Features::updatePasswords()` - Password changes
|
||||||
|
- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
|
||||||
|
|
||||||
|
> Use `search-docs` for feature configuration options and customization patterns.
|
||||||
|
|
||||||
|
## Setup Workflows
|
||||||
|
|
||||||
|
### Two-Factor Authentication Setup
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Add TwoFactorAuthenticatable trait to User model
|
||||||
|
- [ ] Enable feature in config/fortify.php
|
||||||
|
- [ ] If the `*_add_two_factor_columns_to_users_table.php` migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
|
||||||
|
- [ ] Set up view callbacks in FortifyServiceProvider
|
||||||
|
- [ ] Create 2FA management UI
|
||||||
|
- [ ] Test QR code and recovery codes
|
||||||
|
```
|
||||||
|
|
||||||
|
> Use `search-docs` for TOTP implementation and recovery code handling patterns.
|
||||||
|
|
||||||
|
### Email Verification Setup
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Enable emailVerification feature in config
|
||||||
|
- [ ] Implement MustVerifyEmail interface on User model
|
||||||
|
- [ ] Set up verifyEmailView callback
|
||||||
|
- [ ] Add verified middleware to protected routes
|
||||||
|
- [ ] Test verification email flow
|
||||||
|
```
|
||||||
|
|
||||||
|
> Use `search-docs` for MustVerifyEmail implementation patterns.
|
||||||
|
|
||||||
|
### Password Reset Setup
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Enable resetPasswords feature in config
|
||||||
|
- [ ] Set up requestPasswordResetLinkView callback
|
||||||
|
- [ ] Set up resetPasswordView callback
|
||||||
|
- [ ] Define password.reset named route (if views disabled)
|
||||||
|
- [ ] Test reset email and link flow
|
||||||
|
```
|
||||||
|
|
||||||
|
> Use `search-docs` for custom password reset flow patterns.
|
||||||
|
|
||||||
|
### SPA Authentication Setup
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Set 'views' => false in config/fortify.php
|
||||||
|
- [ ] Install and configure Laravel Sanctum for session-based SPA authentication
|
||||||
|
- [ ] Use the 'web' guard in config/fortify.php (required for session-based authentication)
|
||||||
|
- [ ] Set up CSRF token handling
|
||||||
|
- [ ] Test XHR authentication flows
|
||||||
|
```
|
||||||
|
|
||||||
|
> Use `search-docs` for integration and SPA authentication patterns.
|
||||||
|
|
||||||
|
#### Two-Factor Authentication in SPA Mode
|
||||||
|
|
||||||
|
When `views` is set to `false`, Fortify returns JSON responses instead of redirects.
|
||||||
|
|
||||||
|
If a user attempts to log in and two-factor authentication is enabled, the login request will return a JSON response indicating that a two-factor challenge is required:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"two_factor": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Custom Authentication Logic
|
||||||
|
|
||||||
|
Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects.
|
||||||
|
|
||||||
|
### Registration Customization
|
||||||
|
|
||||||
|
Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields.
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination.
|
||||||
|
|
||||||
|
## Key Endpoints
|
||||||
|
|
||||||
|
| Feature | Method | Endpoint |
|
||||||
|
|------------------------|----------|---------------------------------------------|
|
||||||
|
| Login | POST | `/login` |
|
||||||
|
| Logout | POST | `/logout` |
|
||||||
|
| Register | POST | `/register` |
|
||||||
|
| Password Reset Request | POST | `/forgot-password` |
|
||||||
|
| Password Reset | POST | `/reset-password` |
|
||||||
|
| Email Verify Notice | GET | `/email/verify` |
|
||||||
|
| Resend Verification | POST | `/email/verification-notification` |
|
||||||
|
| Password Confirm | POST | `/user/confirm-password` |
|
||||||
|
| Enable 2FA | POST | `/user/two-factor-authentication` |
|
||||||
|
| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` |
|
||||||
|
| 2FA Challenge | POST | `/two-factor-challenge` |
|
||||||
|
| Get QR Code | GET | `/user/two-factor-qr-code` |
|
||||||
|
| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
|
||||||
190
.agents/skills/laravel-best-practices/SKILL.md
Normal file
190
.agents/skills/laravel-best-practices/SKILL.md
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
---
|
||||||
|
name: laravel-best-practices
|
||||||
|
description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Laravel Best Practices
|
||||||
|
|
||||||
|
Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`.
|
||||||
|
|
||||||
|
## Consistency First
|
||||||
|
|
||||||
|
Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.
|
||||||
|
|
||||||
|
Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### 1. Database Performance → `rules/db-performance.md`
|
||||||
|
|
||||||
|
- Eager load with `with()` to prevent N+1 queries
|
||||||
|
- Enable `Model::preventLazyLoading()` in development
|
||||||
|
- Select only needed columns, avoid `SELECT *`
|
||||||
|
- `chunk()` / `chunkById()` for large datasets
|
||||||
|
- Index columns used in `WHERE`, `ORDER BY`, `JOIN`
|
||||||
|
- `withCount()` instead of loading relations to count
|
||||||
|
- `cursor()` for memory-efficient read-only iteration
|
||||||
|
- Never query in Blade templates
|
||||||
|
|
||||||
|
### 2. Advanced Query Patterns → `rules/advanced-queries.md`
|
||||||
|
|
||||||
|
- `addSelect()` subqueries over eager-loading entire has-many for a single value
|
||||||
|
- Dynamic relationships via subquery FK + `belongsTo`
|
||||||
|
- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries
|
||||||
|
- `setRelation()` to prevent circular N+1 queries
|
||||||
|
- `whereIn` + `pluck()` over `whereHas` for better index usage
|
||||||
|
- Two simple queries can beat one complex query
|
||||||
|
- Compound indexes matching `orderBy` column order
|
||||||
|
- Correlated subqueries in `orderBy` for has-many sorting (avoid joins)
|
||||||
|
|
||||||
|
### 3. Security → `rules/security.md`
|
||||||
|
|
||||||
|
- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates
|
||||||
|
- No raw SQL with user input — use Eloquent or query builder
|
||||||
|
- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes
|
||||||
|
- Validate MIME type, extension, and size for file uploads
|
||||||
|
- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields
|
||||||
|
|
||||||
|
### 4. Caching → `rules/caching.md`
|
||||||
|
|
||||||
|
- `Cache::remember()` over manual get/put
|
||||||
|
- `Cache::flexible()` for stale-while-revalidate on high-traffic data
|
||||||
|
- `Cache::memo()` to avoid redundant cache hits within a request
|
||||||
|
- Cache tags to invalidate related groups
|
||||||
|
- `Cache::add()` for atomic conditional writes
|
||||||
|
- `once()` to memoize per-request or per-object lifetime
|
||||||
|
- `Cache::lock()` / `lockForUpdate()` for race conditions
|
||||||
|
- Failover cache stores in production
|
||||||
|
|
||||||
|
### 5. Eloquent Patterns → `rules/eloquent.md`
|
||||||
|
|
||||||
|
- Correct relationship types with return type hints
|
||||||
|
- Local scopes for reusable query constraints
|
||||||
|
- Global scopes sparingly — document their existence
|
||||||
|
- Attribute casts in the `casts()` method
|
||||||
|
- Cast date columns, use Carbon instances in templates
|
||||||
|
- `whereBelongsTo($model)` for cleaner queries
|
||||||
|
- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries
|
||||||
|
|
||||||
|
### 6. Validation & Forms → `rules/validation.md`
|
||||||
|
|
||||||
|
- Form Request classes, not inline validation
|
||||||
|
- Array notation `['required', 'email']` for new code; follow existing convention
|
||||||
|
- `$request->validated()` only — never `$request->all()`
|
||||||
|
- `Rule::when()` for conditional validation
|
||||||
|
- `after()` instead of `withValidator()`
|
||||||
|
|
||||||
|
### 7. Configuration → `rules/config.md`
|
||||||
|
|
||||||
|
- `env()` only inside config files
|
||||||
|
- `App::environment()` or `app()->isProduction()`
|
||||||
|
- Config, lang files, and constants over hardcoded text
|
||||||
|
|
||||||
|
### 8. Testing Patterns → `rules/testing.md`
|
||||||
|
|
||||||
|
- `LazilyRefreshDatabase` over `RefreshDatabase` for speed
|
||||||
|
- `assertModelExists()` over raw `assertDatabaseHas()`
|
||||||
|
- Factory states and sequences over manual overrides
|
||||||
|
- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before
|
||||||
|
- `recycle()` to share relationship instances across factories
|
||||||
|
|
||||||
|
### 9. Queue & Job Patterns → `rules/queue-jobs.md`
|
||||||
|
|
||||||
|
- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
|
||||||
|
- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release
|
||||||
|
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
|
||||||
|
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
|
||||||
|
- Horizon for complex multi-queue scenarios
|
||||||
|
|
||||||
|
### 10. Routing & Controllers → `rules/routing.md`
|
||||||
|
|
||||||
|
- Implicit route model binding
|
||||||
|
- Scoped bindings for nested resources
|
||||||
|
- `Route::resource()` or `apiResource()`
|
||||||
|
- Methods under 10 lines — extract to actions/services
|
||||||
|
- Type-hint Form Requests for auto-validation
|
||||||
|
|
||||||
|
### 11. HTTP Client → `rules/http-client.md`
|
||||||
|
|
||||||
|
- Explicit `timeout` and `connectTimeout` on every request
|
||||||
|
- `retry()` with exponential backoff for external APIs
|
||||||
|
- Check response status or use `throw()`
|
||||||
|
- `Http::pool()` for concurrent independent requests
|
||||||
|
- `Http::fake()` and `preventStrayRequests()` in tests
|
||||||
|
|
||||||
|
### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md`
|
||||||
|
|
||||||
|
- Event discovery over manual registration; `event:cache` in production
|
||||||
|
- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions
|
||||||
|
- Queue notifications and mailables with `ShouldQueue`
|
||||||
|
- On-demand notifications for non-user recipients
|
||||||
|
- `HasLocalePreference` on notifiable models
|
||||||
|
- `assertQueued()` not `assertSent()` for queued mailables
|
||||||
|
- Markdown mailables for transactional emails
|
||||||
|
|
||||||
|
### 13. Error Handling → `rules/error-handling.md`
|
||||||
|
|
||||||
|
- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern
|
||||||
|
- `ShouldntReport` for exceptions that should never log
|
||||||
|
- Throttle high-volume exceptions to protect log sinks
|
||||||
|
- `dontReportDuplicates()` for multi-catch scenarios
|
||||||
|
- Force JSON rendering for API routes
|
||||||
|
- Structured context via `context()` on exception classes
|
||||||
|
|
||||||
|
### 14. Task Scheduling → `rules/scheduling.md`
|
||||||
|
|
||||||
|
- `withoutOverlapping()` on variable-duration tasks
|
||||||
|
- `onOneServer()` on multi-server deployments
|
||||||
|
- `runInBackground()` for concurrent long tasks
|
||||||
|
- `environments()` to restrict to appropriate environments
|
||||||
|
- `takeUntilTimeout()` for time-bounded processing
|
||||||
|
- Schedule groups for shared configuration
|
||||||
|
|
||||||
|
### 15. Architecture → `rules/architecture.md`
|
||||||
|
|
||||||
|
- Single-purpose Action classes; dependency injection over `app()` helper
|
||||||
|
- Prefer official Laravel packages and follow conventions, don't override defaults
|
||||||
|
- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety
|
||||||
|
- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution
|
||||||
|
|
||||||
|
### 16. Migrations → `rules/migrations.md`
|
||||||
|
|
||||||
|
- Generate migrations with `php artisan make:migration`
|
||||||
|
- `constrained()` for foreign keys
|
||||||
|
- Never modify migrations that have run in production
|
||||||
|
- Add indexes in the migration, not as an afterthought
|
||||||
|
- Mirror column defaults in model `$attributes`
|
||||||
|
- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes
|
||||||
|
- One concern per migration — never mix DDL and DML
|
||||||
|
|
||||||
|
### 17. Collections → `rules/collections.md`
|
||||||
|
|
||||||
|
- Higher-order messages for simple collection operations
|
||||||
|
- `cursor()` vs. `lazy()` — choose based on relationship needs
|
||||||
|
- `lazyById()` when updating records while iterating
|
||||||
|
- `toQuery()` for bulk operations on collections
|
||||||
|
|
||||||
|
### 18. Blade & Views → `rules/blade-views.md`
|
||||||
|
|
||||||
|
- `$attributes->merge()` in component templates
|
||||||
|
- Blade components over `@include`; `@pushOnce` for per-component scripts
|
||||||
|
- View Composers for shared view data
|
||||||
|
- `@aware` for deeply nested component props
|
||||||
|
|
||||||
|
### 19. Conventions & Style → `rules/style.md`
|
||||||
|
|
||||||
|
- Follow Laravel naming conventions for all entities
|
||||||
|
- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions
|
||||||
|
- No JS/CSS in Blade, no HTML in PHP classes
|
||||||
|
- Code should be readable; comments only for config files
|
||||||
|
|
||||||
|
## How to Apply
|
||||||
|
|
||||||
|
Always use a sub-agent to read rule files and explore this skill's content.
|
||||||
|
|
||||||
|
1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
|
||||||
|
2. Check sibling files for existing patterns — follow those first per Consistency First
|
||||||
|
3. Verify API syntax with `search-docs` for the installed Laravel version
|
||||||
106
.agents/skills/laravel-best-practices/rules/advanced-queries.md
Normal file
106
.agents/skills/laravel-best-practices/rules/advanced-queries.md
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
# Advanced Query Patterns
|
||||||
|
|
||||||
|
## Use `addSelect()` Subqueries for Single Values from Has-Many
|
||||||
|
|
||||||
|
Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function scopeWithLastLoginAt($query): void
|
||||||
|
{
|
||||||
|
$query->addSelect([
|
||||||
|
'last_login_at' => Login::select('created_at')
|
||||||
|
->whereColumn('user_id', 'users.id')
|
||||||
|
->latest()
|
||||||
|
->take(1),
|
||||||
|
])->withCasts(['last_login_at' => 'datetime']);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Create Dynamic Relationships via Subquery FK
|
||||||
|
|
||||||
|
Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function lastLogin(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Login::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeWithLastLogin($query): void
|
||||||
|
{
|
||||||
|
$query->addSelect([
|
||||||
|
'last_login_id' => Login::select('id')
|
||||||
|
->whereColumn('user_id', 'users.id')
|
||||||
|
->latest()
|
||||||
|
->take(1),
|
||||||
|
])->with('lastLogin');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Conditional Aggregates Instead of Multiple Count Queries
|
||||||
|
|
||||||
|
Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$statuses = Feature::toBase()
|
||||||
|
->selectRaw("count(case when status = 'Requested' then 1 end) as requested")
|
||||||
|
->selectRaw("count(case when status = 'Planned' then 1 end) as planned")
|
||||||
|
->selectRaw("count(case when status = 'Completed' then 1 end) as completed")
|
||||||
|
->first();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `setRelation()` to Prevent Circular N+1
|
||||||
|
|
||||||
|
When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$feature->load('comments.user');
|
||||||
|
$feature->comments->each->setRelation('feature', $feature);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prefer `whereIn` + Subquery Over `whereHas`
|
||||||
|
|
||||||
|
`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory.
|
||||||
|
|
||||||
|
Incorrect (correlated EXISTS re-executes per row):
|
||||||
|
|
||||||
|
```php
|
||||||
|
$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term));
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct (index-friendly subquery, no PHP memory overhead):
|
||||||
|
|
||||||
|
```php
|
||||||
|
$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id'));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sometimes Two Simple Queries Beat One Complex Query
|
||||||
|
|
||||||
|
Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index.
|
||||||
|
|
||||||
|
## Use Compound Indexes Matching `orderBy` Column Order
|
||||||
|
|
||||||
|
When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Migration
|
||||||
|
$table->index(['last_name', 'first_name']);
|
||||||
|
|
||||||
|
// Query — column order must match the index
|
||||||
|
User::query()->orderBy('last_name')->orderBy('first_name')->paginate();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Correlated Subqueries for Has-Many Ordering
|
||||||
|
|
||||||
|
When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function scopeOrderByLastLogin($query): void
|
||||||
|
{
|
||||||
|
$query->orderByDesc(Login::select('created_at')
|
||||||
|
->whereColumn('user_id', 'users.id')
|
||||||
|
->latest()
|
||||||
|
->take(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
202
.agents/skills/laravel-best-practices/rules/architecture.md
Normal file
202
.agents/skills/laravel-best-practices/rules/architecture.md
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
# Architecture Best Practices
|
||||||
|
|
||||||
|
## Single-Purpose Action Classes
|
||||||
|
|
||||||
|
Extract discrete business operations into invokable Action classes.
|
||||||
|
|
||||||
|
```php
|
||||||
|
class CreateOrderAction
|
||||||
|
{
|
||||||
|
public function __construct(private InventoryService $inventory) {}
|
||||||
|
|
||||||
|
public function execute(array $data): Order
|
||||||
|
{
|
||||||
|
$order = Order::create($data);
|
||||||
|
$this->inventory->reserve($order);
|
||||||
|
|
||||||
|
return $order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Dependency Injection
|
||||||
|
|
||||||
|
Always use constructor injection. Avoid `app()` or `resolve()` inside classes.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
class OrderController extends Controller
|
||||||
|
{
|
||||||
|
public function store(StoreOrderRequest $request)
|
||||||
|
{
|
||||||
|
$service = app(OrderService::class);
|
||||||
|
|
||||||
|
return $service->create($request->validated());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
class OrderController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private OrderService $service) {}
|
||||||
|
|
||||||
|
public function store(StoreOrderRequest $request)
|
||||||
|
{
|
||||||
|
return $this->service->create($request->validated());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code to Interfaces
|
||||||
|
|
||||||
|
Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability.
|
||||||
|
|
||||||
|
Incorrect (concrete dependency):
|
||||||
|
```php
|
||||||
|
class OrderService
|
||||||
|
{
|
||||||
|
public function __construct(private StripeGateway $gateway) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct (interface dependency):
|
||||||
|
```php
|
||||||
|
interface PaymentGateway
|
||||||
|
{
|
||||||
|
public function charge(int $amount, string $customerId): PaymentResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OrderService
|
||||||
|
{
|
||||||
|
public function __construct(private PaymentGateway $gateway) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Bind in a service provider:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$this->app->bind(PaymentGateway::class, StripeGateway::class);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Default Sort by Descending
|
||||||
|
|
||||||
|
When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$posts = Post::paginate();
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
$posts = Post::latest()->paginate();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Atomic Locks for Race Conditions
|
||||||
|
|
||||||
|
Prevent race conditions with `Cache::lock()` or `lockForUpdate()`.
|
||||||
|
|
||||||
|
```php
|
||||||
|
Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) {
|
||||||
|
$order->process();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Or at query level
|
||||||
|
$product = Product::where('id', $id)->lockForUpdate()->first();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `mb_*` String Functions
|
||||||
|
|
||||||
|
When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
strlen('José'); // 5 (bytes, not characters)
|
||||||
|
strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
mb_strlen('José'); // 4 (characters)
|
||||||
|
mb_strtolower('MÜNCHEN'); // 'münchen'
|
||||||
|
|
||||||
|
// Prefer Laravel's Str helpers when available
|
||||||
|
Str::length('José'); // 4
|
||||||
|
Str::lower('MÜNCHEN'); // 'münchen'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `defer()` for Post-Response Work
|
||||||
|
|
||||||
|
For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead.
|
||||||
|
|
||||||
|
Incorrect (job overhead for trivial work):
|
||||||
|
```php
|
||||||
|
dispatch(new LogPageView($page));
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct (runs after response, same process):
|
||||||
|
```php
|
||||||
|
defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()]));
|
||||||
|
```
|
||||||
|
|
||||||
|
Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work.
|
||||||
|
|
||||||
|
## Use `Context` for Request-Scoped Data
|
||||||
|
|
||||||
|
The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In middleware
|
||||||
|
Context::add('tenant_id', $request->header('X-Tenant-ID'));
|
||||||
|
|
||||||
|
// Anywhere later — controllers, jobs, log context
|
||||||
|
$tenantId = Context::get('tenant_id');
|
||||||
|
```
|
||||||
|
|
||||||
|
Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`.
|
||||||
|
|
||||||
|
## Use `Concurrency::run()` for Parallel Execution
|
||||||
|
|
||||||
|
Run independent operations in parallel using child processes — no async libraries needed.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Illuminate\Support\Facades\Concurrency;
|
||||||
|
|
||||||
|
[$users, $orders] = Concurrency::run([
|
||||||
|
fn () => User::count(),
|
||||||
|
fn () => Order::where('status', 'pending')->count(),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially.
|
||||||
|
|
||||||
|
## Convention Over Configuration
|
||||||
|
|
||||||
|
Follow Laravel conventions. Don't override defaults unnecessarily.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
class Customer extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'Customer';
|
||||||
|
protected $primaryKey = 'customer_id';
|
||||||
|
|
||||||
|
public function roles(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
class Customer extends Model
|
||||||
|
{
|
||||||
|
public function roles(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Role::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
36
.agents/skills/laravel-best-practices/rules/blade-views.md
Normal file
36
.agents/skills/laravel-best-practices/rules/blade-views.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Blade & Views Best Practices
|
||||||
|
|
||||||
|
## Use `$attributes->merge()` in Component Templates
|
||||||
|
|
||||||
|
Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly.
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<div {{ $attributes->merge(['class' => 'alert alert-'.$type]) }}>
|
||||||
|
{{ $message }}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `@pushOnce` for Per-Component Scripts
|
||||||
|
|
||||||
|
If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once.
|
||||||
|
|
||||||
|
## Prefer Blade Components Over `@include`
|
||||||
|
|
||||||
|
`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots.
|
||||||
|
|
||||||
|
## Use View Composers for Shared View Data
|
||||||
|
|
||||||
|
If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it.
|
||||||
|
|
||||||
|
## Use Blade Fragments for Partial Re-Renders (htmx/Turbo)
|
||||||
|
|
||||||
|
A single view can return either the full page or just a fragment, keeping routing clean.
|
||||||
|
|
||||||
|
```php
|
||||||
|
return view('dashboard', compact('users'))
|
||||||
|
->fragmentIf($request->hasHeader('HX-Request'), 'user-list');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `@aware` for Deeply Nested Component Props
|
||||||
|
|
||||||
|
Avoids re-passing parent props through every level of nested components.
|
||||||
70
.agents/skills/laravel-best-practices/rules/caching.md
Normal file
70
.agents/skills/laravel-best-practices/rules/caching.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# Caching Best Practices
|
||||||
|
|
||||||
|
## Use `Cache::remember()` Instead of Manual Get/Put
|
||||||
|
|
||||||
|
Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$val = Cache::get('stats');
|
||||||
|
if (! $val) {
|
||||||
|
$val = $this->computeStats();
|
||||||
|
Cache::put('stats', $val, 60);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
$val = Cache::remember('stats', 60, fn () => $this->computeStats());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `Cache::flexible()` for Stale-While-Revalidate
|
||||||
|
|
||||||
|
On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background.
|
||||||
|
|
||||||
|
Incorrect: `Cache::remember('users', 300, fn () => User::all());`
|
||||||
|
|
||||||
|
Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function.
|
||||||
|
|
||||||
|
## Use `Cache::memo()` to Avoid Redundant Hits Within a Request
|
||||||
|
|
||||||
|
If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory.
|
||||||
|
|
||||||
|
`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5.
|
||||||
|
|
||||||
|
## Use Cache Tags to Invalidate Related Groups
|
||||||
|
|
||||||
|
Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`.
|
||||||
|
|
||||||
|
```php
|
||||||
|
Cache::tags(['user-1'])->flush();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `Cache::add()` for Atomic Conditional Writes
|
||||||
|
|
||||||
|
`add()` only writes if the key does not exist — atomic, no race condition between checking and writing.
|
||||||
|
|
||||||
|
Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }`
|
||||||
|
|
||||||
|
Correct: `Cache::add('lock', true, 10);`
|
||||||
|
|
||||||
|
## Use `once()` for Per-Request Memoization
|
||||||
|
|
||||||
|
`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function roles(): Collection
|
||||||
|
{
|
||||||
|
return once(fn () => $this->loadRoles());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching.
|
||||||
|
|
||||||
|
## Configure Failover Cache Stores in Production
|
||||||
|
|
||||||
|
If Redis goes down, the app falls back to a secondary store automatically.
|
||||||
|
|
||||||
|
```php
|
||||||
|
'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
|
||||||
|
```
|
||||||
44
.agents/skills/laravel-best-practices/rules/collections.md
Normal file
44
.agents/skills/laravel-best-practices/rules/collections.md
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Collection Best Practices
|
||||||
|
|
||||||
|
## Use Higher-Order Messages for Simple Operations
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$users->each(function (User $user) {
|
||||||
|
$user->markAsVip();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct: `$users->each->markAsVip();`
|
||||||
|
|
||||||
|
Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc.
|
||||||
|
|
||||||
|
## Choose `cursor()` vs. `lazy()` Correctly
|
||||||
|
|
||||||
|
- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk).
|
||||||
|
- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading.
|
||||||
|
|
||||||
|
Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored.
|
||||||
|
|
||||||
|
Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work.
|
||||||
|
|
||||||
|
## Use `lazyById()` When Updating Records While Iterating
|
||||||
|
|
||||||
|
`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation.
|
||||||
|
|
||||||
|
## Use `toQuery()` for Bulk Operations on Collections
|
||||||
|
|
||||||
|
Avoids manual `whereIn` construction.
|
||||||
|
|
||||||
|
Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);`
|
||||||
|
|
||||||
|
Correct: `$users->toQuery()->update([...]);`
|
||||||
|
|
||||||
|
## Use `#[CollectedBy]` for Custom Collection Classes
|
||||||
|
|
||||||
|
More declarative than overriding `newCollection()`.
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[CollectedBy(UserCollection::class)]
|
||||||
|
class User extends Model {}
|
||||||
|
```
|
||||||
73
.agents/skills/laravel-best-practices/rules/config.md
Normal file
73
.agents/skills/laravel-best-practices/rules/config.md
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
# Configuration Best Practices
|
||||||
|
|
||||||
|
## `env()` Only in Config Files
|
||||||
|
|
||||||
|
Direct `env()` calls may return `null` when config is cached.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$key = env('API_KEY');
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
// config/services.php
|
||||||
|
'key' => env('API_KEY'),
|
||||||
|
|
||||||
|
// Application code
|
||||||
|
$key = config('services.key');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Encrypted Env or External Secrets
|
||||||
|
|
||||||
|
Never store production secrets in plain `.env` files in version control.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# .env committed to repo or shared in Slack
|
||||||
|
|
||||||
|
STRIPE_SECRET=sk_live_abc123
|
||||||
|
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```bash
|
||||||
|
php artisan env:encrypt --env=production --readable
|
||||||
|
php artisan env:decrypt --env=production
|
||||||
|
```
|
||||||
|
|
||||||
|
For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime.
|
||||||
|
|
||||||
|
## Use `App::environment()` for Environment Checks
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
if (env('APP_ENV') === 'production') {
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
if (app()->isProduction()) {
|
||||||
|
// or
|
||||||
|
if (App::environment('production')) {
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Constants and Language Files
|
||||||
|
|
||||||
|
Use class constants instead of hardcoded magic strings for model states, types, and statuses.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Incorrect
|
||||||
|
return $this->type === 'normal';
|
||||||
|
|
||||||
|
// Correct
|
||||||
|
return $this->type === self::TYPE_NORMAL;
|
||||||
|
```
|
||||||
|
|
||||||
|
If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Only when lang files already exist in the project
|
||||||
|
return back()->with('message', __('app.article_added'));
|
||||||
|
```
|
||||||
192
.agents/skills/laravel-best-practices/rules/db-performance.md
Normal file
192
.agents/skills/laravel-best-practices/rules/db-performance.md
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
# Database Performance Best Practices
|
||||||
|
|
||||||
|
## Always Eager Load Relationships
|
||||||
|
|
||||||
|
Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront.
|
||||||
|
|
||||||
|
Incorrect (N+1 — executes 1 + N queries):
|
||||||
|
```php
|
||||||
|
$posts = Post::all();
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
echo $post->author->name;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct (2 queries total):
|
||||||
|
```php
|
||||||
|
$posts = Post::with('author')->get();
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
echo $post->author->name;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Constrain eager loads to select only needed columns (always include the foreign key):
|
||||||
|
|
||||||
|
```php
|
||||||
|
$users = User::with(['posts' => function ($query) {
|
||||||
|
$query->select('id', 'user_id', 'title')
|
||||||
|
->where('published', true)
|
||||||
|
->latest()
|
||||||
|
->limit(10);
|
||||||
|
}])->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prevent Lazy Loading in Development
|
||||||
|
|
||||||
|
Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
Model::preventLazyLoading(! app()->isProduction());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded.
|
||||||
|
|
||||||
|
## Select Only Needed Columns
|
||||||
|
|
||||||
|
Avoid `SELECT *` — especially when tables have large text or JSON columns.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$posts = Post::with('author')->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
$posts = Post::select('id', 'title', 'user_id', 'created_at')
|
||||||
|
->with(['author:id,name,avatar'])
|
||||||
|
->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match.
|
||||||
|
|
||||||
|
## Chunk Large Datasets
|
||||||
|
|
||||||
|
Never load thousands of records at once. Use chunking for batch processing.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$users = User::all();
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$user->notify(new WeeklyDigest);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
User::where('subscribed', true)->chunk(200, function ($users) {
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$user->notify(new WeeklyDigest);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change:
|
||||||
|
|
||||||
|
```php
|
||||||
|
User::where('active', false)->chunkById(200, function ($users) {
|
||||||
|
$users->each->delete();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Add Database Indexes
|
||||||
|
|
||||||
|
Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
Schema::create('orders', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained();
|
||||||
|
$table->string('status');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
Schema::create('orders', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->index()->constrained();
|
||||||
|
$table->string('status')->index();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->index(['status', 'created_at']);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`).
|
||||||
|
|
||||||
|
## Use `withCount()` for Counting Relations
|
||||||
|
|
||||||
|
Never load entire collections just to count them.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$posts = Post::all();
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
echo $post->comments->count();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
$posts = Post::withCount('comments')->get();
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
echo $post->comments_count;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Conditional counting:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$posts = Post::withCount([
|
||||||
|
'comments',
|
||||||
|
'comments as approved_comments_count' => function ($query) {
|
||||||
|
$query->where('approved', true);
|
||||||
|
},
|
||||||
|
])->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `cursor()` for Memory-Efficient Iteration
|
||||||
|
|
||||||
|
For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$users = User::where('active', true)->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
foreach (User::where('active', true)->cursor() as $user) {
|
||||||
|
ProcessUser::dispatch($user->id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records.
|
||||||
|
|
||||||
|
## No Queries in Blade Templates
|
||||||
|
|
||||||
|
Never execute queries in Blade templates. Pass data from controllers.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```blade
|
||||||
|
@foreach (User::all() as $user)
|
||||||
|
{{ $user->profile->name }}
|
||||||
|
@endforeach
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
// Controller
|
||||||
|
$users = User::with('profile')->get();
|
||||||
|
return view('users.index', compact('users'));
|
||||||
|
```
|
||||||
|
|
||||||
|
```blade
|
||||||
|
@foreach ($users as $user)
|
||||||
|
{{ $user->profile->name }}
|
||||||
|
@endforeach
|
||||||
|
```
|
||||||
148
.agents/skills/laravel-best-practices/rules/eloquent.md
Normal file
148
.agents/skills/laravel-best-practices/rules/eloquent.md
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
# Eloquent Best Practices
|
||||||
|
|
||||||
|
## Use Correct Relationship Types
|
||||||
|
|
||||||
|
Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function comments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Comment::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function author(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'user_id');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Local Scopes for Reusable Queries
|
||||||
|
|
||||||
|
Extract reusable query constraints into local scopes to avoid duplication.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$active = User::where('verified', true)->whereNotNull('activated_at')->get();
|
||||||
|
$articles = Article::whereHas('user', function ($q) {
|
||||||
|
$q->where('verified', true)->whereNotNull('activated_at');
|
||||||
|
})->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
public function scopeActive(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('verified', true)->whereNotNull('activated_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
$active = User::active()->get();
|
||||||
|
$articles = Article::whereHas('user', fn ($q) => $q->active())->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Apply Global Scopes Sparingly
|
||||||
|
|
||||||
|
Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy.
|
||||||
|
|
||||||
|
Incorrect (global scope for a conditional filter):
|
||||||
|
```php
|
||||||
|
class PublishedScope implements Scope
|
||||||
|
{
|
||||||
|
public function apply(Builder $builder, Model $model): void
|
||||||
|
{
|
||||||
|
$builder->where('published', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Now admin panels, reports, and background jobs all silently skip drafts
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct (local scope you opt into):
|
||||||
|
```php
|
||||||
|
public function scopePublished(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('published', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Post::published()->paginate(); // Explicit
|
||||||
|
Post::paginate(); // Admin sees all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Define Attribute Casts
|
||||||
|
|
||||||
|
Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion.
|
||||||
|
|
||||||
|
```php
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'metadata' => 'array',
|
||||||
|
'total' => 'decimal:2',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cast Date Columns Properly
|
||||||
|
|
||||||
|
Always cast date columns. Use Carbon instances in templates instead of formatting strings manually.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```blade
|
||||||
|
{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ordered_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{ $order->ordered_at->toDateString() }}
|
||||||
|
{{ $order->ordered_at->format('m-d') }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `whereBelongsTo()` for Relationship Queries
|
||||||
|
|
||||||
|
Cleaner than manually specifying foreign keys.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
Post::where('user_id', $user->id)->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
Post::whereBelongsTo($user)->get();
|
||||||
|
Post::whereBelongsTo($user, 'author')->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Avoid Hardcoded Table Names in Queries
|
||||||
|
|
||||||
|
Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string).
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
DB::table('users')->where('active', true)->get();
|
||||||
|
|
||||||
|
$query->join('companies', 'companies.id', '=', 'users.company_id');
|
||||||
|
|
||||||
|
DB::select('SELECT * FROM orders WHERE status = ?', ['pending']);
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct — reference the model's table:
|
||||||
|
```php
|
||||||
|
DB::table((new User)->getTable())->where('active', true)->get();
|
||||||
|
|
||||||
|
// Even better — use Eloquent or the query builder instead of raw SQL
|
||||||
|
User::where('active', true)->get();
|
||||||
|
Order::where('status', 'pending')->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable.
|
||||||
|
|
||||||
|
**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration.
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
# Error Handling Best Practices
|
||||||
|
|
||||||
|
## Exception Reporting and Rendering
|
||||||
|
|
||||||
|
There are two valid approaches — choose one and apply it consistently across the project.
|
||||||
|
|
||||||
|
**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class InvalidOrderException extends Exception
|
||||||
|
{
|
||||||
|
public function report(): void { /* custom reporting */ }
|
||||||
|
|
||||||
|
public function render(Request $request): Response
|
||||||
|
{
|
||||||
|
return response()->view('errors.invalid-order', status: 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture:
|
||||||
|
|
||||||
|
```php
|
||||||
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
|
$exceptions->report(function (InvalidOrderException $e) { /* ... */ });
|
||||||
|
$exceptions->render(function (InvalidOrderException $e, Request $request) {
|
||||||
|
return response()->view('errors.invalid-order', status: 422);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Check the existing codebase and follow whichever pattern is already established.
|
||||||
|
|
||||||
|
## Use `ShouldntReport` for Exceptions That Should Never Log
|
||||||
|
|
||||||
|
More discoverable than listing classes in `dontReport()`.
|
||||||
|
|
||||||
|
```php
|
||||||
|
class PodcastProcessingException extends Exception implements ShouldntReport {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Throttle High-Volume Exceptions
|
||||||
|
|
||||||
|
A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type.
|
||||||
|
|
||||||
|
## Enable `dontReportDuplicates()`
|
||||||
|
|
||||||
|
Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks.
|
||||||
|
|
||||||
|
## Force JSON Error Rendering for API Routes
|
||||||
|
|
||||||
|
Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
|
||||||
|
return $request->is('api/*') || $request->expectsJson();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Add Context to Exception Classes
|
||||||
|
|
||||||
|
Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry.
|
||||||
|
|
||||||
|
```php
|
||||||
|
class InvalidOrderException extends Exception
|
||||||
|
{
|
||||||
|
public function context(): array
|
||||||
|
{
|
||||||
|
return ['order_id' => $this->orderId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
# Events & Notifications Best Practices
|
||||||
|
|
||||||
|
## Rely on Event Discovery
|
||||||
|
|
||||||
|
Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`.
|
||||||
|
|
||||||
|
## Run `event:cache` in Production Deploy
|
||||||
|
|
||||||
|
Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`.
|
||||||
|
|
||||||
|
## Use `ShouldDispatchAfterCommit` Inside Transactions
|
||||||
|
|
||||||
|
Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet.
|
||||||
|
|
||||||
|
```php
|
||||||
|
class OrderShipped implements ShouldDispatchAfterCommit {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Always Queue Notifications
|
||||||
|
|
||||||
|
Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response.
|
||||||
|
|
||||||
|
```php
|
||||||
|
class InvoicePaid extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `afterCommit()` on Notifications in Transactions
|
||||||
|
|
||||||
|
Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$user->notify((new InvoicePaid($invoice))->afterCommit());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Route Notification Channels to Dedicated Queues
|
||||||
|
|
||||||
|
Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues.
|
||||||
|
|
||||||
|
## Use On-Demand Notifications for Non-User Recipients
|
||||||
|
|
||||||
|
Avoid creating dummy models to send notifications to arbitrary addresses.
|
||||||
|
|
||||||
|
```php
|
||||||
|
Notification::route('mail', 'admin@example.com')->notify(new SystemAlert());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implement `HasLocalePreference` on Notifiable Models
|
||||||
|
|
||||||
|
Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.
|
||||||
160
.agents/skills/laravel-best-practices/rules/http-client.md
Normal file
160
.agents/skills/laravel-best-practices/rules/http-client.md
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
# HTTP Client Best Practices
|
||||||
|
|
||||||
|
## Always Set Explicit Timeouts
|
||||||
|
|
||||||
|
The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$response = Http::get('https://api.example.com/users');
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
$response = Http::timeout(5)
|
||||||
|
->connectTimeout(3)
|
||||||
|
->get('https://api.example.com/users');
|
||||||
|
```
|
||||||
|
|
||||||
|
For service-specific clients, define timeouts in a macro:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Http::macro('github', function () {
|
||||||
|
return Http::baseUrl('https://api.github.com')
|
||||||
|
->timeout(10)
|
||||||
|
->connectTimeout(3)
|
||||||
|
->withToken(config('services.github.token'));
|
||||||
|
});
|
||||||
|
|
||||||
|
$response = Http::github()->get('/repos/laravel/framework');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Retry with Backoff for External APIs
|
||||||
|
|
||||||
|
External APIs have transient failures. Use `retry()` with increasing delays.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$response = Http::post('https://api.stripe.com/v1/charges', $data);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
throw new PaymentFailedException('Charge failed');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
$response = Http::retry([100, 500, 1000])
|
||||||
|
->timeout(10)
|
||||||
|
->post('https://api.stripe.com/v1/charges', $data);
|
||||||
|
```
|
||||||
|
|
||||||
|
Only retry on specific errors:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) {
|
||||||
|
return $exception instanceof ConnectionException
|
||||||
|
|| ($exception instanceof RequestException && $exception->response->serverError());
|
||||||
|
})->post('https://api.example.com/data');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Handle Errors Explicitly
|
||||||
|
|
||||||
|
The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$response = Http::get('https://api.example.com/users/1');
|
||||||
|
$user = $response->json(); // Could be an error body
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
$response = Http::timeout(5)
|
||||||
|
->get('https://api.example.com/users/1')
|
||||||
|
->throw();
|
||||||
|
|
||||||
|
$user = $response->json();
|
||||||
|
```
|
||||||
|
|
||||||
|
For graceful degradation:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$response = Http::get('https://api.example.com/users/1');
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return $response->json();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->notFound()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response->throw();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Request Pooling for Concurrent Requests
|
||||||
|
|
||||||
|
When making multiple independent API calls, use `Http::pool()` instead of sequential calls.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$users = Http::get('https://api.example.com/users')->json();
|
||||||
|
$posts = Http::get('https://api.example.com/posts')->json();
|
||||||
|
$comments = Http::get('https://api.example.com/comments')->json();
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
use Illuminate\Http\Client\Pool;
|
||||||
|
|
||||||
|
$responses = Http::pool(fn (Pool $pool) => [
|
||||||
|
$pool->as('users')->get('https://api.example.com/users'),
|
||||||
|
$pool->as('posts')->get('https://api.example.com/posts'),
|
||||||
|
$pool->as('comments')->get('https://api.example.com/comments'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$users = $responses['users']->json();
|
||||||
|
$posts = $responses['posts']->json();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fake HTTP Calls in Tests
|
||||||
|
|
||||||
|
Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
it('syncs user from API', function () {
|
||||||
|
$service = new UserSyncService;
|
||||||
|
$service->sync(1); // Hits the real API
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
it('syncs user from API', function () {
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'api.example.com/users/1' => Http::response([
|
||||||
|
'name' => 'John Doe',
|
||||||
|
'email' => 'john@example.com',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = new UserSyncService;
|
||||||
|
$service->sync(1);
|
||||||
|
|
||||||
|
Http::assertSent(function (Request $request) {
|
||||||
|
return $request->url() === 'https://api.example.com/users/1';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Test failure scenarios too:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Http::fake([
|
||||||
|
'api.example.com/*' => Http::failedConnection(),
|
||||||
|
]);
|
||||||
|
```
|
||||||
27
.agents/skills/laravel-best-practices/rules/mail.md
Normal file
27
.agents/skills/laravel-best-practices/rules/mail.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Mail Best Practices
|
||||||
|
|
||||||
|
## Implement `ShouldQueue` on the Mailable Class
|
||||||
|
|
||||||
|
Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it.
|
||||||
|
|
||||||
|
## Use `afterCommit()` on Mailables Inside Transactions
|
||||||
|
|
||||||
|
A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor.
|
||||||
|
|
||||||
|
## Use `assertQueued()` Not `assertSent()` for Queued Mailables
|
||||||
|
|
||||||
|
`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint.
|
||||||
|
|
||||||
|
Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
|
||||||
|
|
||||||
|
Correct: `Mail::assertQueued(OrderShipped::class);`
|
||||||
|
|
||||||
|
## Use Markdown Mailables for Transactional Emails
|
||||||
|
|
||||||
|
Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag.
|
||||||
|
|
||||||
|
## Separate Content Tests from Sending Tests
|
||||||
|
|
||||||
|
Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
|
||||||
|
Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
|
||||||
|
Don't mix them — it conflates concerns and makes tests brittle.
|
||||||
121
.agents/skills/laravel-best-practices/rules/migrations.md
Normal file
121
.agents/skills/laravel-best-practices/rules/migrations.md
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# Migration Best Practices
|
||||||
|
|
||||||
|
## Generate Migrations with Artisan
|
||||||
|
|
||||||
|
Always use `php artisan make:migration` for consistent naming and timestamps.
|
||||||
|
|
||||||
|
Incorrect (manually created file):
|
||||||
|
```php
|
||||||
|
// database/migrations/posts_migration.php ← wrong naming, no timestamp
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct (Artisan-generated):
|
||||||
|
```bash
|
||||||
|
php artisan make:migration create_posts_table
|
||||||
|
php artisan make:migration add_slug_to_posts_table
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `constrained()` for Foreign Keys
|
||||||
|
|
||||||
|
Automatic naming and referential integrity.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
|
||||||
|
// Non-standard names
|
||||||
|
$table->foreignId('author_id')->constrained('users');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Never Modify Deployed Migrations
|
||||||
|
|
||||||
|
Once a migration has run in production, treat it as immutable. Create a new migration to change the table.
|
||||||
|
|
||||||
|
Incorrect (editing a deployed migration):
|
||||||
|
```php
|
||||||
|
// 2024_01_01_create_posts_table.php — already in production
|
||||||
|
$table->string('slug')->unique(); // ← added after deployment
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct (new migration to alter):
|
||||||
|
```php
|
||||||
|
// 2024_03_15_add_slug_to_posts_table.php
|
||||||
|
Schema::table('posts', function (Blueprint $table) {
|
||||||
|
$table->string('slug')->unique()->after('title');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Add Indexes in the Migration
|
||||||
|
|
||||||
|
Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
Schema::create('orders', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained();
|
||||||
|
$table->string('status');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
Schema::create('orders', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->index();
|
||||||
|
$table->string('status')->index();
|
||||||
|
$table->timestamp('shipped_at')->nullable()->index();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mirror Defaults in Model `$attributes`
|
||||||
|
|
||||||
|
When a column has a database default, mirror it in the model so new instances have correct values before saving.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Migration
|
||||||
|
$table->string('status')->default('pending');
|
||||||
|
|
||||||
|
// Model
|
||||||
|
protected $attributes = [
|
||||||
|
'status' => 'pending',
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Write Reversible `down()` Methods by Default
|
||||||
|
|
||||||
|
Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('posts', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('slug');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported.
|
||||||
|
|
||||||
|
## Keep Migrations Focused
|
||||||
|
|
||||||
|
One concern per migration. Never mix DDL (schema changes) and DML (data manipulation).
|
||||||
|
|
||||||
|
Incorrect (partial failure creates unrecoverable state):
|
||||||
|
```php
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('settings', function (Blueprint $table) { ... });
|
||||||
|
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct (separate migrations):
|
||||||
|
```php
|
||||||
|
// Migration 1: create_settings_table
|
||||||
|
Schema::create('settings', function (Blueprint $table) { ... });
|
||||||
|
|
||||||
|
// Migration 2: seed_default_settings
|
||||||
|
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
|
||||||
|
```
|
||||||
144
.agents/skills/laravel-best-practices/rules/queue-jobs.md
Normal file
144
.agents/skills/laravel-best-practices/rules/queue-jobs.md
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
# Queue & Job Best Practices
|
||||||
|
|
||||||
|
## Set `retry_after` Greater Than `timeout`
|
||||||
|
|
||||||
|
If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution.
|
||||||
|
|
||||||
|
Incorrect (`retry_after` ≤ `timeout`):
|
||||||
|
```php
|
||||||
|
class ProcessReport implements ShouldQueue
|
||||||
|
{
|
||||||
|
public $timeout = 120;
|
||||||
|
}
|
||||||
|
|
||||||
|
// config/queue.php — retry_after: 90 ← job retried while still running!
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct (`retry_after` > `timeout`):
|
||||||
|
```php
|
||||||
|
class ProcessReport implements ShouldQueue
|
||||||
|
{
|
||||||
|
public $timeout = 120;
|
||||||
|
}
|
||||||
|
|
||||||
|
// config/queue.php — retry_after: 180 ← safely longer than any job timeout
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Exponential Backoff
|
||||||
|
|
||||||
|
Use progressively longer delays between retries to avoid hammering failing services.
|
||||||
|
|
||||||
|
Incorrect (fixed retry interval):
|
||||||
|
```php
|
||||||
|
class SyncWithStripe implements ShouldQueue
|
||||||
|
{
|
||||||
|
public $tries = 3;
|
||||||
|
// Default: retries immediately, overwhelming the API
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct (exponential backoff):
|
||||||
|
```php
|
||||||
|
class SyncWithStripe implements ShouldQueue
|
||||||
|
{
|
||||||
|
public $tries = 3;
|
||||||
|
public $backoff = [1, 5, 10];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implement `ShouldBeUnique`
|
||||||
|
|
||||||
|
Prevent duplicate job processing.
|
||||||
|
|
||||||
|
```php
|
||||||
|
class GenerateInvoice implements ShouldQueue, ShouldBeUnique
|
||||||
|
{
|
||||||
|
public function uniqueId(): string
|
||||||
|
{
|
||||||
|
return $this->order->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public $uniqueFor = 3600;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Always Implement `failed()`
|
||||||
|
|
||||||
|
Handle errors explicitly — don't rely on silent failure.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function failed(?Throwable $exception): void
|
||||||
|
{
|
||||||
|
$this->podcast->update(['status' => 'failed']);
|
||||||
|
Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limit External API Calls in Jobs
|
||||||
|
|
||||||
|
Use `RateLimited` middleware to throttle jobs calling third-party APIs.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new RateLimited('external-api')];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Batch Related Jobs
|
||||||
|
|
||||||
|
Use `Bus::batch()` when jobs should succeed or fail together.
|
||||||
|
|
||||||
|
```php
|
||||||
|
Bus::batch([
|
||||||
|
new ImportCsvChunk($chunk1),
|
||||||
|
new ImportCsvChunk($chunk2),
|
||||||
|
])
|
||||||
|
->then(fn (Batch $batch) => Notification::send($user, new ImportComplete))
|
||||||
|
->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed'))
|
||||||
|
->dispatch();
|
||||||
|
```
|
||||||
|
|
||||||
|
## `retryUntil()` Needs `$tries = 0`
|
||||||
|
|
||||||
|
When using time-based retry limits, set `$tries = 0` to avoid premature failure.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public $tries = 0;
|
||||||
|
|
||||||
|
public function retryUntil(): \DateTimeInterface
|
||||||
|
{
|
||||||
|
return now()->addHours(4);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release
|
||||||
|
|
||||||
|
`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue.
|
||||||
|
|
||||||
|
```php
|
||||||
|
class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
|
||||||
|
{
|
||||||
|
// Lock releases when processing begins, not when it finishes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Horizon for Complex Queue Scenarios
|
||||||
|
|
||||||
|
Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/horizon.php
|
||||||
|
'environments' => [
|
||||||
|
'production' => [
|
||||||
|
'supervisor-1' => [
|
||||||
|
'connection' => 'redis',
|
||||||
|
'queue' => ['high', 'default', 'low'],
|
||||||
|
'balance' => 'auto',
|
||||||
|
'minProcesses' => 1,
|
||||||
|
'maxProcesses' => 10,
|
||||||
|
'tries' => 3,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
```
|
||||||
99
.agents/skills/laravel-best-practices/rules/routing.md
Normal file
99
.agents/skills/laravel-best-practices/rules/routing.md
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
# Routing & Controllers Best Practices
|
||||||
|
|
||||||
|
## Use Implicit Route Model Binding
|
||||||
|
|
||||||
|
Let Laravel resolve models automatically from route parameters.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
public function show(int $id)
|
||||||
|
{
|
||||||
|
$post = Post::findOrFail($id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
public function show(Post $post)
|
||||||
|
{
|
||||||
|
return view('posts.show', ['post' => $post]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Scoped Bindings for Nested Resources
|
||||||
|
|
||||||
|
Enforce parent-child relationships automatically.
|
||||||
|
|
||||||
|
```php
|
||||||
|
Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
|
||||||
|
// $post is automatically scoped to $user
|
||||||
|
})->scopeBindings();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Resource Controllers
|
||||||
|
|
||||||
|
Use `Route::resource()` or `apiResource()` for RESTful endpoints.
|
||||||
|
|
||||||
|
```php
|
||||||
|
Route::resource('posts', PostController::class);
|
||||||
|
// In routes/api.php — the /api prefix is applied automatically
|
||||||
|
Route::apiResource('posts', Api\PostController::class);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keep Controllers Thin
|
||||||
|
|
||||||
|
Aim for under 10 lines per method. Extract business logic to action or service classes.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([...]);
|
||||||
|
if ($request->hasFile('image')) {
|
||||||
|
$request->file('image')->move(public_path('images'));
|
||||||
|
}
|
||||||
|
$post = Post::create($validated);
|
||||||
|
$post->tags()->sync($validated['tags']);
|
||||||
|
event(new PostCreated($post));
|
||||||
|
return redirect()->route('posts.show', $post);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
public function store(StorePostRequest $request, CreatePostAction $create)
|
||||||
|
{
|
||||||
|
$post = $create->execute($request->validated());
|
||||||
|
|
||||||
|
return redirect()->route('posts.show', $post);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Type-Hint Form Requests
|
||||||
|
|
||||||
|
Type-hinting Form Requests triggers automatic validation and authorization before the method executes.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'title' => ['required', 'max:255'],
|
||||||
|
'body' => ['required'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Post::create($validated);
|
||||||
|
|
||||||
|
return redirect()->route('posts.index');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
public function store(StorePostRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
Post::create($request->validated());
|
||||||
|
|
||||||
|
return redirect()->route('posts.index');
|
||||||
|
}
|
||||||
|
```
|
||||||
39
.agents/skills/laravel-best-practices/rules/scheduling.md
Normal file
39
.agents/skills/laravel-best-practices/rules/scheduling.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Task Scheduling Best Practices
|
||||||
|
|
||||||
|
## Use `withoutOverlapping()` on Variable-Duration Tasks
|
||||||
|
|
||||||
|
Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion.
|
||||||
|
|
||||||
|
## Use `onOneServer()` on Multi-Server Deployments
|
||||||
|
|
||||||
|
Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached).
|
||||||
|
|
||||||
|
## Use `runInBackground()` for Concurrent Long Tasks
|
||||||
|
|
||||||
|
By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes.
|
||||||
|
|
||||||
|
## Use `environments()` to Restrict Tasks
|
||||||
|
|
||||||
|
Prevent accidental execution of production-only tasks (billing, reporting) on staging.
|
||||||
|
|
||||||
|
```php
|
||||||
|
Schedule::command('billing:charge')->monthly()->environments(['production']);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `takeUntilTimeout()` for Time-Bounded Processing
|
||||||
|
|
||||||
|
A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time.
|
||||||
|
|
||||||
|
## Use Schedule Groups for Shared Configuration
|
||||||
|
|
||||||
|
Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks.
|
||||||
|
|
||||||
|
```php
|
||||||
|
Schedule::daily()
|
||||||
|
->onOneServer()
|
||||||
|
->timezone('America/New_York')
|
||||||
|
->group(function () {
|
||||||
|
Schedule::command('emails:send --force');
|
||||||
|
Schedule::command('emails:prune');
|
||||||
|
});
|
||||||
|
```
|
||||||
198
.agents/skills/laravel-best-practices/rules/security.md
Normal file
198
.agents/skills/laravel-best-practices/rules/security.md
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
# Security Best Practices
|
||||||
|
|
||||||
|
## Mass Assignment Protection
|
||||||
|
|
||||||
|
Every model must define `$fillable` (whitelist) or `$guarded` (blacklist).
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
class User extends Model
|
||||||
|
{
|
||||||
|
protected $guarded = []; // All fields are mass assignable
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
class User extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'email',
|
||||||
|
'password',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Never use `$guarded = []` on models that accept user input.
|
||||||
|
|
||||||
|
## Authorize Every Action
|
||||||
|
|
||||||
|
Use policies or gates in controllers. Never skip authorization.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
public function update(UpdatePostRequest $request, Post $post)
|
||||||
|
{
|
||||||
|
$post->update($request->validated());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
public function update(UpdatePostRequest $request, Post $post)
|
||||||
|
{
|
||||||
|
Gate::authorize('update', $post);
|
||||||
|
|
||||||
|
$post->update($request->validated());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or via Form Request:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()->can('update', $this->route('post'));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prevent SQL Injection
|
||||||
|
|
||||||
|
Always use parameter binding. Never interpolate user input into queries.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
DB::select("SELECT * FROM users WHERE name = '{$request->name}'");
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
User::where('name', $request->name)->get();
|
||||||
|
|
||||||
|
// Raw expressions with bindings
|
||||||
|
User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Escape Output to Prevent XSS
|
||||||
|
|
||||||
|
Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```blade
|
||||||
|
{!! $user->bio !!}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```blade
|
||||||
|
{{ $user->bio }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSRF Protection
|
||||||
|
|
||||||
|
Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```blade
|
||||||
|
<form method="POST" action="/posts">
|
||||||
|
<input type="text" name="title">
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```blade
|
||||||
|
<form method="POST" action="/posts">
|
||||||
|
@csrf
|
||||||
|
<input type="text" name="title">
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limit Auth and API Routes
|
||||||
|
|
||||||
|
Apply `throttle` middleware to authentication and API routes.
|
||||||
|
|
||||||
|
```php
|
||||||
|
RateLimiter::for('login', function (Request $request) {
|
||||||
|
return Limit::perMinute(5)->by($request->ip());
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::post('/login', LoginController::class)->middleware('throttle:login');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validate File Uploads
|
||||||
|
|
||||||
|
Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Store with generated filenames:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$path = $request->file('avatar')->store('avatars', 'public');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keep Secrets Out of Code
|
||||||
|
|
||||||
|
Never commit `.env`. Access secrets via `config()` only.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$key = env('API_KEY');
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
// config/services.php
|
||||||
|
'api_key' => env('API_KEY'),
|
||||||
|
|
||||||
|
// In application code
|
||||||
|
$key = config('services.api_key');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audit Dependencies
|
||||||
|
|
||||||
|
Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer audit
|
||||||
|
```
|
||||||
|
|
||||||
|
## Encrypt Sensitive Database Fields
|
||||||
|
|
||||||
|
Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
class Integration extends Model
|
||||||
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'api_key' => 'string',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
class Integration extends Model
|
||||||
|
{
|
||||||
|
protected $hidden = ['api_key', 'api_secret'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'api_key' => 'encrypted',
|
||||||
|
'api_secret' => 'encrypted',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
125
.agents/skills/laravel-best-practices/rules/style.md
Normal file
125
.agents/skills/laravel-best-practices/rules/style.md
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
# Conventions & Style
|
||||||
|
|
||||||
|
## Follow Laravel Naming Conventions
|
||||||
|
|
||||||
|
| What | Convention | Good | Bad |
|
||||||
|
|------|-----------|------|-----|
|
||||||
|
| Controller | singular | `ArticleController` | `ArticlesController` |
|
||||||
|
| Model | singular | `User` | `Users` |
|
||||||
|
| Table | plural, snake_case | `article_comments` | `articleComments` |
|
||||||
|
| Pivot table | singular alphabetical | `article_user` | `user_article` |
|
||||||
|
| Column | snake_case, no model name | `meta_title` | `article_meta_title` |
|
||||||
|
| Foreign key | singular model + `_id` | `article_id` | `articles_id` |
|
||||||
|
| Route | plural | `articles/1` | `article/1` |
|
||||||
|
| Route name | snake_case with dots | `users.show_active` | `users.show-active` |
|
||||||
|
| Method | camelCase | `getAll` | `get_all` |
|
||||||
|
| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` |
|
||||||
|
| Collection | descriptive, plural | `$activeUsers` | `$data` |
|
||||||
|
| Object | descriptive, singular | `$activeUser` | `$users` |
|
||||||
|
| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` |
|
||||||
|
| Config | snake_case | `google_calendar.php` | `googleCalendar.php` |
|
||||||
|
| Enum | singular | `UserType` | `UserTypes` |
|
||||||
|
|
||||||
|
## Prefer Shorter Readable Syntax
|
||||||
|
|
||||||
|
| Verbose | Shorter |
|
||||||
|
|---------|---------|
|
||||||
|
| `Session::get('cart')` | `session('cart')` |
|
||||||
|
| `$request->session()->get('cart')` | `session('cart')` |
|
||||||
|
| `$request->input('name')` | `$request->name` |
|
||||||
|
| `return Redirect::back()` | `return back()` |
|
||||||
|
| `Carbon::now()` | `now()` |
|
||||||
|
| `App::make('Class')` | `app('Class')` |
|
||||||
|
| `->where('column', '=', 1)` | `->where('column', 1)` |
|
||||||
|
| `->orderBy('created_at', 'desc')` | `->latest()` |
|
||||||
|
| `->orderBy('created_at', 'asc')` | `->oldest()` |
|
||||||
|
| `->first()->name` | `->value('name')` |
|
||||||
|
|
||||||
|
## Use Laravel String & Array Helpers
|
||||||
|
|
||||||
|
Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them.
|
||||||
|
|
||||||
|
Strings — use `Str` and fluent `Str::of()` over raw PHP:
|
||||||
|
```php
|
||||||
|
// Incorrect
|
||||||
|
$slug = strtolower(str_replace(' ', '-', $title));
|
||||||
|
$short = substr($text, 0, 100) . '...';
|
||||||
|
$class = substr(strrchr('App\Models\User', '\'), 1);
|
||||||
|
|
||||||
|
// Correct
|
||||||
|
$slug = Str::slug($title);
|
||||||
|
$short = Str::limit($text, 100);
|
||||||
|
$class = class_basename('App\Models\User');
|
||||||
|
```
|
||||||
|
|
||||||
|
Fluent strings — chain operations for complex transformations:
|
||||||
|
```php
|
||||||
|
// Incorrect
|
||||||
|
$result = strtolower(trim(str_replace('_', '-', $input)));
|
||||||
|
|
||||||
|
// Correct
|
||||||
|
$result = Str::of($input)->trim()->replace('_', '-')->lower();
|
||||||
|
```
|
||||||
|
|
||||||
|
Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`.
|
||||||
|
|
||||||
|
Arrays — use `Arr` over raw PHP:
|
||||||
|
```php
|
||||||
|
// Incorrect
|
||||||
|
$name = isset($array['user']['name']) ? $array['user']['name'] : 'default';
|
||||||
|
|
||||||
|
// Correct
|
||||||
|
$name = Arr::get($array, 'user.name', 'default');
|
||||||
|
```
|
||||||
|
|
||||||
|
Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`.
|
||||||
|
|
||||||
|
Numbers — use `Number` for display formatting:
|
||||||
|
```php
|
||||||
|
Number::format(1000000); // "1,000,000"
|
||||||
|
Number::currency(1500, 'USD'); // "$1,500.00"
|
||||||
|
Number::abbreviate(1000000); // "1M"
|
||||||
|
Number::fileSize(1024 * 1024); // "1 MB"
|
||||||
|
Number::percentage(75.5); // "75.5%"
|
||||||
|
```
|
||||||
|
|
||||||
|
URIs — use `Uri` for URL manipulation:
|
||||||
|
```php
|
||||||
|
$uri = Uri::of('https://example.com/search')
|
||||||
|
->withQuery(['q' => 'laravel', 'page' => 1]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining.
|
||||||
|
|
||||||
|
Use `search-docs` for the full list of available methods — these helpers are extensive.
|
||||||
|
|
||||||
|
## No Inline JS/CSS in Blade
|
||||||
|
|
||||||
|
Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```blade
|
||||||
|
let article = `{{ json_encode($article) }}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```blade
|
||||||
|
<button class="js-fav-article" data-article='@json($article)'>{{ $article->name }}</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass data to JS via data attributes or use a dedicated PHP-to-JS package.
|
||||||
|
|
||||||
|
## No Unnecessary Comments
|
||||||
|
|
||||||
|
Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
// Check if there are any joins
|
||||||
|
if (count((array) $builder->getQuery()->joins) > 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
if ($this->hasJoins())
|
||||||
|
```
|
||||||
43
.agents/skills/laravel-best-practices/rules/testing.md
Normal file
43
.agents/skills/laravel-best-practices/rules/testing.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Testing Best Practices
|
||||||
|
|
||||||
|
## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
|
||||||
|
|
||||||
|
`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date.
|
||||||
|
|
||||||
|
## Use Model Assertions Over Raw Database Assertions
|
||||||
|
|
||||||
|
Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);`
|
||||||
|
|
||||||
|
Correct: `$this->assertModelExists($user);`
|
||||||
|
|
||||||
|
More expressive, type-safe, and fails with clearer messages.
|
||||||
|
|
||||||
|
## Use Factory States and Sequences
|
||||||
|
|
||||||
|
Named states make tests self-documenting. Sequences eliminate repetitive setup.
|
||||||
|
|
||||||
|
Incorrect: `User::factory()->create(['email_verified_at' => null]);`
|
||||||
|
|
||||||
|
Correct: `User::factory()->unverified()->create();`
|
||||||
|
|
||||||
|
## Use `Exceptions::fake()` to Assert Exception Reporting
|
||||||
|
|
||||||
|
Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally.
|
||||||
|
|
||||||
|
## Call `Event::fake()` After Factory Setup
|
||||||
|
|
||||||
|
Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models.
|
||||||
|
|
||||||
|
Incorrect: `Event::fake(); $user = User::factory()->create();`
|
||||||
|
|
||||||
|
Correct: `$user = User::factory()->create(); Event::fake();`
|
||||||
|
|
||||||
|
## Use `recycle()` to Share Relationship Instances Across Factories
|
||||||
|
|
||||||
|
Without `recycle()`, nested factories create separate instances of the same conceptual entity.
|
||||||
|
|
||||||
|
```php
|
||||||
|
Ticket::factory()
|
||||||
|
->recycle(Airline::factory()->create())
|
||||||
|
->create();
|
||||||
|
```
|
||||||
75
.agents/skills/laravel-best-practices/rules/validation.md
Normal file
75
.agents/skills/laravel-best-practices/rules/validation.md
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# Validation & Forms Best Practices
|
||||||
|
|
||||||
|
## Use Form Request Classes
|
||||||
|
|
||||||
|
Extract validation from controllers into dedicated Form Request classes.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'title' => 'required|max:255',
|
||||||
|
'body' => 'required',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
public function store(StorePostRequest $request)
|
||||||
|
{
|
||||||
|
Post::create($request->validated());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Array vs. String Notation for Rules
|
||||||
|
|
||||||
|
Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Preferred for new code
|
||||||
|
'email' => ['required', 'email', Rule::unique('users')],
|
||||||
|
|
||||||
|
// Follow existing convention if the project uses string notation
|
||||||
|
'email' => 'required|email|unique:users',
|
||||||
|
```
|
||||||
|
|
||||||
|
## Always Use `validated()`
|
||||||
|
|
||||||
|
Get only validated data. Never use `$request->all()` for mass operations.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
Post::create($request->all());
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
Post::create($request->validated());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `Rule::when()` for Conditional Validation
|
||||||
|
|
||||||
|
```php
|
||||||
|
'company_name' => [
|
||||||
|
Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']),
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use the `after()` Method for Custom Validation
|
||||||
|
|
||||||
|
Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function after(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
function (Validator $validator) {
|
||||||
|
if ($this->quantity > Product::find($this->product_id)?->stock) {
|
||||||
|
$validator->errors()->add('quantity', 'Not enough stock.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
110
.agents/skills/pest-testing/SKILL.md
Normal file
110
.agents/skills/pest-testing/SKILL.md
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
---
|
||||||
|
name: pest-testing
|
||||||
|
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit) or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 3 features. Do not use for editing factories, seeders, migrations, controllers, models, or non-test PHP code."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pest Testing 3
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Pest 3 patterns and documentation.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
### Creating Tests
|
||||||
|
|
||||||
|
All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`.
|
||||||
|
|
||||||
|
### Test Organization
|
||||||
|
|
||||||
|
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||||
|
- Do NOT remove tests without approval - these are core application code.
|
||||||
|
- Test happy paths, failure paths, and edge cases.
|
||||||
|
|
||||||
|
### Basic Test Structure
|
||||||
|
|
||||||
|
Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
|
||||||
|
|
||||||
|
<!-- Basic Pest Test Example -->
|
||||||
|
```php
|
||||||
|
it('is true', function () {
|
||||||
|
expect(true)->toBeTrue();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
- Run minimal tests with filter before finalizing: `vendor/bin/sail artisan test --compact --filter=testName`.
|
||||||
|
- Run all tests: `vendor/bin/sail artisan test --compact`.
|
||||||
|
- Run file: `vendor/bin/sail 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 3 Features
|
||||||
|
|
||||||
|
### Architecture Testing
|
||||||
|
|
||||||
|
Pest 3 includes architecture testing to enforce code conventions:
|
||||||
|
|
||||||
|
<!-- Architecture Test Example -->
|
||||||
|
```php
|
||||||
|
arch('controllers')
|
||||||
|
->expect('App\Http\Controllers')
|
||||||
|
->toExtendNothing()
|
||||||
|
->toHaveSuffix('Controller');
|
||||||
|
|
||||||
|
arch('models')
|
||||||
|
->expect('App\Models')
|
||||||
|
->toExtend('Illuminate\Database\Eloquent\Model');
|
||||||
|
|
||||||
|
arch('no debugging')
|
||||||
|
->expect(['dd', 'dump', 'ray'])
|
||||||
|
->not->toBeUsed();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type Coverage
|
||||||
|
|
||||||
|
Pest 3 provides improved type coverage analysis. Run with `--type-coverage` flag.
|
||||||
|
|
||||||
|
## 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
|
||||||
119
.agents/skills/tailwindcss-development/SKILL.md
Normal file
119
.agents/skills/tailwindcss-development/SKILL.md
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
---
|
||||||
|
name: tailwindcss-development
|
||||||
|
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tailwind CSS Development
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
|
||||||
|
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
|
||||||
|
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
|
||||||
|
|
||||||
|
## Tailwind CSS v4 Specifics
|
||||||
|
|
||||||
|
- Always use Tailwind CSS v4 and avoid deprecated utilities.
|
||||||
|
- `corePlugins` is not supported in Tailwind v4.
|
||||||
|
|
||||||
|
### CSS-First Configuration
|
||||||
|
|
||||||
|
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
|
||||||
|
|
||||||
|
<!-- CSS-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
|
||||||
88
.agents/skills/volt-development/SKILL.md
Normal file
88
.agents/skills/volt-development/SKILL.md
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
---
|
||||||
|
name: volt-development
|
||||||
|
description: "Develops single-file Livewire components with Volt. Activates when creating Volt components, converting Livewire to Volt, working with @volt directive, functional or class-based Volt APIs; or when the user mentions Volt, single-file components, functional Livewire, or inline component logic in Blade files."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Volt Development
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Volt patterns and documentation.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
Create components with `vendor/bin/sail artisan make:volt [name] [--test] [--pest]`.
|
||||||
|
|
||||||
|
Important: Check existing Volt components to determine if they use functional or class-based style before creating new ones.
|
||||||
|
|
||||||
|
### Functional Components
|
||||||
|
|
||||||
|
<!-- Volt Functional Component -->
|
||||||
|
```php
|
||||||
|
@@volt
|
||||||
|
<?php
|
||||||
|
use function Livewire\Volt\{state, computed};
|
||||||
|
|
||||||
|
state(['count' => 0]);
|
||||||
|
|
||||||
|
$increment = fn () => $this->count++;
|
||||||
|
$double = computed(fn () => $this->count * 2);
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1>Count: @{{ $count }} (Double: @{{ $this->double }})</h1>
|
||||||
|
<button wire:click="increment">+</button>
|
||||||
|
</div>
|
||||||
|
@@endvolt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Class-Based Components
|
||||||
|
|
||||||
|
<!-- Volt Class-based Component -->
|
||||||
|
```php
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
public int $count = 0;
|
||||||
|
|
||||||
|
public function increment(): void
|
||||||
|
{
|
||||||
|
$this->count++;
|
||||||
|
}
|
||||||
|
} ?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1>@{{ $count }}</h1>
|
||||||
|
<button wire:click="increment">+</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests go in existing Volt test directory or `tests/Feature/Volt`:
|
||||||
|
|
||||||
|
<!-- Volt Test Example -->
|
||||||
|
```php
|
||||||
|
use Livewire\Volt\Volt;
|
||||||
|
|
||||||
|
test('counter increments', function () {
|
||||||
|
Volt::test('counter')
|
||||||
|
->assertSee('Count: 0')
|
||||||
|
->call('increment')
|
||||||
|
->assertSee('Count: 1');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Check existing components for functional vs class-based style
|
||||||
|
2. Test component with `Volt::test()`
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Not checking existing style (functional vs class-based) before creating
|
||||||
|
- Forgetting `@volt` directive wrapper
|
||||||
|
- Missing `--test` or `--pest` flag when tests are needed
|
||||||
22
.codex/config.toml
Normal file
22
.codex/config.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
[mcp_servers.context7]
|
||||||
|
args = [
|
||||||
|
"-y",
|
||||||
|
"@upstash/context7-mcp",
|
||||||
|
"--api-key",
|
||||||
|
"ctx7sk-119cd4ab-8983-4229-8702-e84c59c34fc9",
|
||||||
|
]
|
||||||
|
command = "npx"
|
||||||
|
[mcp_servers.playwright]
|
||||||
|
url = "https://playwright.test/mcp"
|
||||||
|
|
||||||
|
[mcp_servers.sequential-thinking]
|
||||||
|
args = [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-sequential-thinking",
|
||||||
|
]
|
||||||
|
command = "npx"
|
||||||
|
|
||||||
|
[mcp_servers.laravel-boost]
|
||||||
|
command = "vendor/bin/sail"
|
||||||
|
args = ["artisan", "boost:mcp"]
|
||||||
|
cwd = "/var/www/html"
|
||||||
15
.cursorrules
Normal file
15
.cursorrules
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Cursor Rules: Laravel 12 CRM & Presse-Portal
|
||||||
|
|
||||||
|
## Tech Stack & Paradigmen
|
||||||
|
- **Framework:** Laravel 12 (PHP 8.4+)
|
||||||
|
- **Frontend-Architektur:** Livewire Volt (ausschließlich Functional API).
|
||||||
|
- **UI-Komponenten:** FluxUI (nutze `<flux:...>` statt Standard-Blade/HTML).
|
||||||
|
- **Styling:** Tailwind CSS (für Layout) & FluxUI (für Komponenten).
|
||||||
|
- **Backend:** Klassisches MVC für das Admin-System, Services für Business-Logik.
|
||||||
|
|
||||||
|
## Documentation & Sources
|
||||||
|
- **Projekt-Workflow (Dokumentation in `docs/`, Forgejo `tea`, Git/Conventional Commits):** `docs/KI-UND-ENTWICKLER-WORKFLOW.md`
|
||||||
|
- **FluxUI:** Nutze primär die indizierte Dokumentation unter @FluxUI (falls vorhanden).
|
||||||
|
- **Verhaltensregel:** Wenn du dir bei einer FluxUI-Komponente unsicher bist (da sich die API oft ändert), frage mich oder nutze das @Docs Tool, anstatt zu raten.
|
||||||
|
- **Wichtige Syntax-Note:** FluxUI nutzt oft spezifische Attribute (wie `variant="subtle"` oder `icon-trailing`). Bevorzuge immer die Syntax der neuesten Version 2.x.
|
||||||
|
|
||||||
|
|
@ -47,7 +47,7 @@ Das Dockerfile wurde angepasst um:
|
||||||
Falls die automatische Installation fehlschlägt, können Sie den Container manuell bauen:
|
Falls die automatische Installation fehlschlägt, können Sie den Container manuell bauen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /Users/pandora/Sites/pr-copilot.eu
|
cd /Users/pandora/Sites/presseportale.com
|
||||||
docker build --build-arg WWWUSER=501 --build-arg WWWGROUP=20 -f docker/8.4/Dockerfile -t sail-8.4/app docker/8.4
|
docker build --build-arg WWWUSER=501 --build-arg WWWGROUP=20 -f docker/8.4/Dockerfile -t sail-8.4/app docker/8.4
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,45 @@
|
||||||
{
|
{
|
||||||
"name": "PR-Copilot (Dev Container)",
|
"name": "Presseportale (Dev Container)",
|
||||||
// 1. DIES IST DER WICHTIGSTE TEIL:
|
|
||||||
// Wir verwenden Docker Compose für alle Services
|
|
||||||
"dockerComposeFile": [
|
"dockerComposeFile": [
|
||||||
"../docker-compose.yml"
|
"../docker-compose.yml"
|
||||||
],
|
],
|
||||||
"service": "laravel.test",
|
"service": "laravel.test",
|
||||||
// 3. WIR DEFINIEREN DEN ARBEITSBEREICH:
|
|
||||||
// Das ist der Pfad, in dem Ihr Code *innerhalb* des Containers liegt.
|
|
||||||
"workspaceFolder": "/var/www/html",
|
"workspaceFolder": "/var/www/html",
|
||||||
// 4. WIR LEGEN DEN BENUTZER FEST:
|
|
||||||
// Laravel Sail führt Befehle standardmäßig als 'sail'-Benutzer aus, um Berechtigungsprobleme zu vermeiden.
|
|
||||||
"remoteUser": "sail",
|
"remoteUser": "sail",
|
||||||
// 5. ZUSÄTZLICHE ENTWICKLER-TOOLS (FEATURES):
|
|
||||||
// Features werden über postCreateCommand installiert um Kompatibilitätsprobleme zu vermeiden
|
|
||||||
"features": {},
|
"features": {},
|
||||||
// 6. BEFEHLE NACH DEM ERSTELLEN:
|
|
||||||
// Installiert nur die Tools die ohne Root-Rechte funktionieren
|
|
||||||
//"postCreateCommand": "composer install --no-interaction --prefer-dist --optimize-autoloader",
|
|
||||||
// 7. EDITOR-ANPASSUNGEN (Optional, aber sehr empfohlen):
|
|
||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"bmewburn.vscode-intelephense-client",
|
"bmewburn.vscode-intelephense-client",
|
||||||
"onecentlin.laravel-blade",
|
"onecentlin.laravel-blade",
|
||||||
"shufo.vscode-blade-formatter",
|
"shufo.vscode-blade-formatter",
|
||||||
"bradlc.vscode-tailwindcss"
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"Anthropic.claude-code",
|
||||||
|
"adrianwilczynski.alpine-js-intellisense",
|
||||||
|
"onecentlin.laravel-extension-pack",
|
||||||
|
"cierra.livewire-vscode"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// 8. ZU STARTENDE DIENSTE:
|
// WICHTIG: Nur noch der Haupt-Container bleibt drin
|
||||||
// Legt fest, welche Dienste aus der docker-compose.yml gestartet werden sollen.
|
|
||||||
"runServices": [
|
"runServices": [
|
||||||
"laravel.test",
|
"laravel.test"
|
||||||
"mysql",
|
|
||||||
"redis",
|
|
||||||
"mailpit"
|
|
||||||
],
|
],
|
||||||
// 9. ZUSÄTZLICHE KONFIGURATION:
|
|
||||||
// Umgebungsvariablen für den DevContainer
|
|
||||||
"containerEnv": {
|
"containerEnv": {
|
||||||
"WWWUSER": "501",
|
"WWWUSER": "501",
|
||||||
"WWWGROUP": "20",
|
"WWWGROUP": "20",
|
||||||
"LARAVEL_SAIL": "1"
|
"LARAVEL_SAIL": "1"
|
||||||
},
|
},
|
||||||
// 10. MOUNT-KONFIGURATION:
|
|
||||||
// Stellt sicher, dass der Code korrekt gemountet wird
|
|
||||||
"mounts": [
|
"mounts": [
|
||||||
"source=${localWorkspaceFolder},target=/var/www/html,type=bind,consistency=cached"
|
"source=${localWorkspaceFolder},target=/var/www/html,type=bind,consistency=cached",
|
||||||
|
"source=/Users/pandora/Library/Mobile Documents/iCloud~md~obsidian/Documents/DEV-Vault/presseportale,target=/var/www/html/docs,type=bind",
|
||||||
|
"source=/Users/pandora/.forgejo_token,target=/tmp/.forgejo_token,type=bind,readonly",
|
||||||
|
"source=/Users/pandora/.ssh,target=/home/sail/.ssh,type=bind,readonly"
|
||||||
],
|
],
|
||||||
// 11. FORWARD PORTS:
|
"postCreateCommand": "mkdir -p ~/.local/bin && curl -L https://gitea.com/gitea/tea/releases/download/v0.14.0/tea-0.14.0-linux-arm64 -o ~/.local/bin/tea && chmod +x ~/.local/bin/tea && echo 'export PATH=\"$HOME/.local/bin:$PATH\"' >> ~/.bashrc && (~/.local/bin/tea login add --name 'gitmedia' --url 'https://git.adametz.media' --token $(cat /tmp/.forgejo_token) || true)",
|
||||||
// Ports die automatisch weitergeleitet werden sollen
|
|
||||||
"forwardPorts": [
|
"forwardPorts": [
|
||||||
5177,
|
5177,
|
||||||
5178,
|
5178
|
||||||
33069,
|
|
||||||
6382,
|
|
||||||
1027,
|
|
||||||
8027
|
|
||||||
],
|
],
|
||||||
"portsAttributes": {
|
"portsAttributes": {
|
||||||
"5177": {
|
"5177": {
|
||||||
|
|
@ -67,22 +49,9 @@
|
||||||
"5178": {
|
"5178": {
|
||||||
"label": "Vite Dev Server (Web)",
|
"label": "Vite Dev Server (Web)",
|
||||||
"onAutoForward": "notify"
|
"onAutoForward": "notify"
|
||||||
},
|
|
||||||
"33069": {
|
|
||||||
"label": "MySQL",
|
|
||||||
"onAutoForward": "silent"
|
|
||||||
},
|
|
||||||
"6382": {
|
|
||||||
"label": "Redis",
|
|
||||||
"onAutoForward": "silent"
|
|
||||||
},
|
|
||||||
"8027": {
|
|
||||||
"label": "Mailpit Dashboard",
|
|
||||||
"onAutoForward": "notify"
|
|
||||||
},
|
|
||||||
"1027": {
|
|
||||||
"label": "Mailpit",
|
|
||||||
"onAutoForward": "silent"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"runArgs": [
|
||||||
|
"--network=host"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -56,3 +56,4 @@ Icon
|
||||||
_static/
|
_static/
|
||||||
_work/
|
_work/
|
||||||
_storage/
|
_storage/
|
||||||
|
_businessportal24.com/
|
||||||
30
.mcp.json
Normal file
30
.mcp.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"laravel-boost": {
|
||||||
|
"command": "php",
|
||||||
|
"args": [
|
||||||
|
"artisan",
|
||||||
|
"boost:mcp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"context7": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@upstash/context7-mcp",
|
||||||
|
"--api-key",
|
||||||
|
"ctx7sk-119cd4ab-8983-4229-8702-e84c59c34fc9"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sequential-thinking": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-sequential-thinking"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"playwright": {
|
||||||
|
"url": "https://playwright.test/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
226
AGENTS.md
Normal file
226
AGENTS.md
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
<laravel-boost-guidelines>
|
||||||
|
=== 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 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
|
||||||
|
- laravel/fortify (FORTIFY) - v1
|
||||||
|
- laravel/framework (LARAVEL) - v12
|
||||||
|
- laravel/prompts (PROMPTS) - v0
|
||||||
|
- laravel/sanctum (SANCTUM) - v4
|
||||||
|
- livewire/flux (FLUXUI_FREE) - v2
|
||||||
|
- livewire/flux-pro (FLUXUI_PRO) - v2
|
||||||
|
- livewire/livewire (LIVEWIRE) - v4
|
||||||
|
- livewire/volt (VOLT) - v1
|
||||||
|
- larastan/larastan (LARASTAN) - v3
|
||||||
|
- laravel/boost (BOOST) - v2
|
||||||
|
- laravel/mcp (MCP) - v0
|
||||||
|
- laravel/pail (PAIL) - v1
|
||||||
|
- laravel/pint (PINT) - v1
|
||||||
|
- laravel/sail (SAIL) - v1
|
||||||
|
- pestphp/pest (PEST) - v3
|
||||||
|
- phpunit/phpunit (PHPUNIT) - v11
|
||||||
|
- alpinejs (ALPINEJS) - v3
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
|
||||||
|
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
|
||||||
|
- `fluxui-development` — Use this skill for Flux UI development in Livewire applications only. Trigger when working with <flux:*> components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling.
|
||||||
|
- `volt-development` — Develops single-file Livewire components with Volt. Activates when creating Volt components, converting Livewire to Volt, working with @volt directive, functional or class-based Volt APIs; or when the user mentions Volt, single-file components, functional Livewire, or inline component logic in Blade files.
|
||||||
|
- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit) or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 3 features. Do not use for editing factories, seeders, migrations, controllers, models, or non-test PHP code.
|
||||||
|
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
|
||||||
|
|
||||||
|
## 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 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
|
||||||
|
- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker.
|
||||||
|
- Use `database-schema` to inspect table structure before writing migrations or models.
|
||||||
|
- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user.
|
||||||
|
- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
|
||||||
|
|
||||||
|
## Searching Documentation (IMPORTANT)
|
||||||
|
|
||||||
|
- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically.
|
||||||
|
- Pass a `packages` array to scope results when you know which packages are relevant.
|
||||||
|
- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first.
|
||||||
|
- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`.
|
||||||
|
|
||||||
|
### Search Syntax
|
||||||
|
|
||||||
|
1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit".
|
||||||
|
2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order.
|
||||||
|
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
|
||||||
|
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
|
||||||
|
|
||||||
|
## Artisan
|
||||||
|
|
||||||
|
- Run Artisan commands directly via the command line (e.g., `vendor/bin/sail artisan route:list`). Use `vendor/bin/sail artisan list` to discover available commands and `vendor/bin/sail artisan [command] --help` to check parameters.
|
||||||
|
- Inspect routes with `vendor/bin/sail artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
|
||||||
|
- Read configuration values using dot notation: `vendor/bin/sail artisan config:show app.name`, `vendor/bin/sail artisan config:show database.default`. Or read config files directly from the `config/` directory.
|
||||||
|
- To check environment variables, read the `.env` file directly.
|
||||||
|
|
||||||
|
## Tinker
|
||||||
|
|
||||||
|
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
|
||||||
|
- Always use single quotes to prevent shell expansion: `vendor/bin/sail artisan tinker --execute 'Your::code();'`
|
||||||
|
- Double quotes for PHP strings inside: `vendor/bin/sail artisan tinker --execute 'User::where("active", true)->count();'`
|
||||||
|
|
||||||
|
=== php rules ===
|
||||||
|
|
||||||
|
# PHP
|
||||||
|
|
||||||
|
- Always use curly braces for control structures, even for single-line bodies.
|
||||||
|
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
|
||||||
|
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
|
||||||
|
- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||||
|
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
|
||||||
|
- Use array shape type definitions in PHPDoc blocks.
|
||||||
|
|
||||||
|
=== deployments rules ===
|
||||||
|
|
||||||
|
# Deployment
|
||||||
|
|
||||||
|
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
|
||||||
|
|
||||||
|
=== 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 `vendor/bin/sail artisan list` and check their parameters with `vendor/bin/sail artisan [command] --help`.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
### Model Creation
|
||||||
|
|
||||||
|
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `vendor/bin/sail artisan make:model --help` to check the available options.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## URL Generation
|
||||||
|
|
||||||
|
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
=== volt/core rules ===
|
||||||
|
|
||||||
|
# Livewire Volt
|
||||||
|
|
||||||
|
- Single-file Livewire components: PHP logic and Blade templates in one file.
|
||||||
|
- Always check existing Volt components to determine functional vs class-based style.
|
||||||
|
- IMPORTANT: Always use `search-docs` tool for version-specific Volt documentation and updated code examples.
|
||||||
|
- IMPORTANT: Activate `volt-development` every time you're working with a Volt or single-file component-related task.
|
||||||
|
|
||||||
|
=== pint/core rules ===
|
||||||
|
|
||||||
|
# Laravel Pint Code Formatter
|
||||||
|
|
||||||
|
- If you have modified any PHP files, 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
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
</laravel-boost-guidelines>
|
||||||
233
CLAUDE.md
233
CLAUDE.md
|
|
@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
This is a multi-domain Laravel application called "pr-copilot" that supports different domains with distinct themes and styling. The application uses Laravel with Livewire, Volt, and Fortify for authentication, along with Flux UI components.
|
This is a multi-domain Laravel application ("Presseportale") that supports different domains with distinct themes and styling. The application uses Laravel with Livewire, Volt, and Fortify for authentication, along with Flux UI components.
|
||||||
|
|
||||||
### Supported Domains
|
### Supported Domains
|
||||||
|
|
||||||
- **Main Portal**: pr-copilot.test - Main admin portal page
|
- **Main Portal**: presseportale.test (local) / presseportale.com (live) – Main admin portal page
|
||||||
- **Presseecho**: presseecho.test - Landing page with presseecho theme
|
- **Presseecho**: presseecho.test - Landing page with presseecho theme
|
||||||
- **Business Portal**: businessportal24.test - Landing page with business portal theme
|
- **Business Portal**: businessportal24.test - Landing page with business portal theme
|
||||||
|
|
||||||
|
|
@ -153,3 +153,232 @@ The project uses a **dual-port Vite setup** with separate configurations:
|
||||||
- `DOMAINS-CONFIG.md` - Detailed domain setup instructions
|
- `DOMAINS-CONFIG.md` - Detailed domain setup instructions
|
||||||
- `FORTIFY-SANCTUM-SETUP.md` - Authentication setup guide
|
- `FORTIFY-SANCTUM-SETUP.md` - Authentication setup guide
|
||||||
- `VITE-SETUP.md` - Dual-port Vite architecture explanation
|
- `VITE-SETUP.md` - Dual-port Vite architecture explanation
|
||||||
|
|
||||||
|
===
|
||||||
|
|
||||||
|
<laravel-boost-guidelines>
|
||||||
|
=== 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 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
|
||||||
|
- laravel/fortify (FORTIFY) - v1
|
||||||
|
- laravel/framework (LARAVEL) - v12
|
||||||
|
- laravel/prompts (PROMPTS) - v0
|
||||||
|
- laravel/sanctum (SANCTUM) - v4
|
||||||
|
- livewire/flux (FLUXUI_FREE) - v2
|
||||||
|
- livewire/flux-pro (FLUXUI_PRO) - v2
|
||||||
|
- livewire/livewire (LIVEWIRE) - v4
|
||||||
|
- livewire/volt (VOLT) - v1
|
||||||
|
- larastan/larastan (LARASTAN) - v3
|
||||||
|
- laravel/boost (BOOST) - v2
|
||||||
|
- laravel/mcp (MCP) - v0
|
||||||
|
- laravel/pail (PAIL) - v1
|
||||||
|
- laravel/pint (PINT) - v1
|
||||||
|
- laravel/sail (SAIL) - v1
|
||||||
|
- pestphp/pest (PEST) - v3
|
||||||
|
- phpunit/phpunit (PHPUNIT) - v11
|
||||||
|
- alpinejs (ALPINEJS) - v3
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
|
||||||
|
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
|
||||||
|
- `fluxui-development` — Use this skill for Flux UI development in Livewire applications only. Trigger when working with <flux:*> components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling.
|
||||||
|
- `volt-development` — Develops single-file Livewire components with Volt. Activates when creating Volt components, converting Livewire to Volt, working with @volt directive, functional or class-based Volt APIs; or when the user mentions Volt, single-file components, functional Livewire, or inline component logic in Blade files.
|
||||||
|
- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit) or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 3 features. Do not use for editing factories, seeders, migrations, controllers, models, or non-test PHP code.
|
||||||
|
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
|
||||||
|
|
||||||
|
## 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 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
|
||||||
|
- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker.
|
||||||
|
- Use `database-schema` to inspect table structure before writing migrations or models.
|
||||||
|
- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user.
|
||||||
|
- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
|
||||||
|
|
||||||
|
## Searching Documentation (IMPORTANT)
|
||||||
|
|
||||||
|
- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically.
|
||||||
|
- Pass a `packages` array to scope results when you know which packages are relevant.
|
||||||
|
- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first.
|
||||||
|
- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`.
|
||||||
|
|
||||||
|
### Search Syntax
|
||||||
|
|
||||||
|
1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit".
|
||||||
|
2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order.
|
||||||
|
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
|
||||||
|
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
|
||||||
|
|
||||||
|
## Artisan
|
||||||
|
|
||||||
|
- Run Artisan commands directly via the command line (e.g., `vendor/bin/sail artisan route:list`). Use `vendor/bin/sail artisan list` to discover available commands and `vendor/bin/sail artisan [command] --help` to check parameters.
|
||||||
|
- Inspect routes with `vendor/bin/sail artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
|
||||||
|
- Read configuration values using dot notation: `vendor/bin/sail artisan config:show app.name`, `vendor/bin/sail artisan config:show database.default`. Or read config files directly from the `config/` directory.
|
||||||
|
- To check environment variables, read the `.env` file directly.
|
||||||
|
|
||||||
|
## Tinker
|
||||||
|
|
||||||
|
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
|
||||||
|
- Always use single quotes to prevent shell expansion: `vendor/bin/sail artisan tinker --execute 'Your::code();'`
|
||||||
|
- Double quotes for PHP strings inside: `vendor/bin/sail artisan tinker --execute 'User::where("active", true)->count();'`
|
||||||
|
|
||||||
|
=== php rules ===
|
||||||
|
|
||||||
|
# PHP
|
||||||
|
|
||||||
|
- Always use curly braces for control structures, even for single-line bodies.
|
||||||
|
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
|
||||||
|
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
|
||||||
|
- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||||
|
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
|
||||||
|
- Use array shape type definitions in PHPDoc blocks.
|
||||||
|
|
||||||
|
=== deployments rules ===
|
||||||
|
|
||||||
|
# Deployment
|
||||||
|
|
||||||
|
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
|
||||||
|
|
||||||
|
=== 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 `vendor/bin/sail artisan list` and check their parameters with `vendor/bin/sail artisan [command] --help`.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
### Model Creation
|
||||||
|
|
||||||
|
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `vendor/bin/sail artisan make:model --help` to check the available options.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## URL Generation
|
||||||
|
|
||||||
|
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
=== volt/core rules ===
|
||||||
|
|
||||||
|
# Livewire Volt
|
||||||
|
|
||||||
|
- Single-file Livewire components: PHP logic and Blade templates in one file.
|
||||||
|
- Always check existing Volt components to determine functional vs class-based style.
|
||||||
|
- IMPORTANT: Always use `search-docs` tool for version-specific Volt documentation and updated code examples.
|
||||||
|
- IMPORTANT: Activate `volt-development` every time you're working with a Volt or single-file component-related task.
|
||||||
|
|
||||||
|
=== pint/core rules ===
|
||||||
|
|
||||||
|
# Laravel Pint Code Formatter
|
||||||
|
|
||||||
|
- If you have modified any PHP files, 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
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
</laravel-boost-guidelines>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
Diese Laravel-Anwendung unterstützt verschiedene Domains mit unterschiedlichen Styles:
|
Diese Laravel-Anwendung unterstützt verschiedene Domains mit unterschiedlichen Styles:
|
||||||
|
|
||||||
- **Haupt-Website**: =https://pr-copilot.test - Haupt-Protal Admin-Page
|
- **Haupt-Website**: https://presseportale.test (lokal) / https://presseportale.com (live) – Haupt-Portal Admin-Page
|
||||||
- **APP_PRESSEECHO**: https://presseecho.test
|
- **APP_PRESSEECHO**: https://presseecho.test
|
||||||
- **APP_BUSINESSPORTAL**: https://businessportal24.test
|
- **APP_BUSINESSPORTAL**: https://businessportal24.test
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,18 +8,18 @@ Für dein Multi-Domain-Setup empfehle ich folgende Asset-URLs:
|
||||||
|
|
||||||
| Bereich | Domain | Asset-URL | Port | Verwendung |
|
| Bereich | Domain | Asset-URL | Port | Verwendung |
|
||||||
|---------|--------|-----------|------|------------|
|
|---------|--------|-----------|------|------------|
|
||||||
| **Backend** | pr-copilot.test | `assets.pr-copilot.test` | 5177 | Portal + FluxUI |
|
| **Backend** | presseportale.test | `assets.presseportale.test` | 5177 | Portal + FluxUI |
|
||||||
| **Frontend** | presseecho.test<br>businessportal24.test | `assets-web.pr-copilot.test` | 5178 | Beide Frontend-Domains |
|
| **Frontend** | presseecho.test<br>businessportal24.test | `assets-web.presseportale.test` | 5178 | Beide Frontend-Domains |
|
||||||
|
|
||||||
### Warum diese URLs?
|
### Warum diese URLs?
|
||||||
|
|
||||||
#### 1. **assets.pr-copilot.test** (Portal/Backend)
|
#### 1. **assets.presseportale.test** (Portal/Backend)
|
||||||
- ✅ Kurz und prägnant
|
- ✅ Kurz und prägnant
|
||||||
- ✅ Eindeutig dem Portal zugeordnet
|
- ✅ Eindeutig dem Portal zugeordnet
|
||||||
- ✅ Keine zusätzliche Subdomain-Tiefe
|
- ✅ Keine zusätzliche Subdomain-Tiefe
|
||||||
- ✅ Folgt gängiger Konvention
|
- ✅ Folgt gängiger Konvention
|
||||||
|
|
||||||
#### 2. **assets-web.pr-copilot.test** (Web/Frontend)
|
#### 2. **assets-web.presseportale.test** (Web/Frontend)
|
||||||
- ✅ Klar als "Web" (Frontend) gekennzeichnet
|
- ✅ Klar als "Web" (Frontend) gekennzeichnet
|
||||||
- ✅ Ein Asset-Server für beide Frontend-Domains
|
- ✅ Ein Asset-Server für beide Frontend-Domains
|
||||||
- ✅ Gute Trennung zu Portal-Assets
|
- ✅ Gute Trennung zu Portal-Assets
|
||||||
|
|
@ -31,23 +31,23 @@ Falls du andere URLs bevorzugst, hier sind Alternativen:
|
||||||
|
|
||||||
### Option A: Mit Suffix-Präfix
|
### Option A: Mit Suffix-Präfix
|
||||||
```
|
```
|
||||||
portal-assets.pr-copilot.test → Port 5177
|
portal-assets.presseportale.test → Port 5177
|
||||||
web-assets.pr-copilot.test → Port 5178
|
web-assets.presseportale.test → Port 5178
|
||||||
```
|
```
|
||||||
- ⚠️ Etwas länger
|
- ⚠️ Etwas länger
|
||||||
- ✅ Sehr explizit
|
- ✅ Sehr explizit
|
||||||
|
|
||||||
### Option B: Mit "vite" im Namen
|
### Option B: Mit "vite" im Namen
|
||||||
```
|
```
|
||||||
vite.pr-copilot.test → Port 5177
|
vite.presseportale.test → Port 5177
|
||||||
vite-web.pr-copilot.test → Port 5178
|
vite-web.presseportale.test → Port 5178
|
||||||
```
|
```
|
||||||
- ⚠️ Technologie-spezifisch (was wenn du später zu einem anderen Build-Tool wechselst?)
|
- ⚠️ Technologie-spezifisch (was wenn du später zu einem anderen Build-Tool wechselst?)
|
||||||
- ⚠️ Weniger klar was geladen wird
|
- ⚠️ Weniger klar was geladen wird
|
||||||
|
|
||||||
### Option C: Separate Domains pro Frontend
|
### Option C: Separate Domains pro Frontend
|
||||||
```
|
```
|
||||||
assets.pr-copilot.test → Port 5177 (Portal)
|
assets.presseportale.test → Port 5177 (Portal)
|
||||||
assets.presseecho.test → Port 5178 (Presseecho)
|
assets.presseecho.test → Port 5178 (Presseecho)
|
||||||
assets.businessportal24.test → Port 5178 (Businessportal24)
|
assets.businessportal24.test → Port 5178 (Businessportal24)
|
||||||
```
|
```
|
||||||
|
|
@ -61,7 +61,7 @@ assets.businessportal24.test → Port 5178 (Businessportal24)
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Portal Assets (Backend)
|
# Portal Assets (Backend)
|
||||||
- "traefik.http.routers.assets-portal.rule=Host(`assets.pr-copilot.test`)"
|
- "traefik.http.routers.assets-portal.rule=Host(`assets.presseportale.test`)"
|
||||||
- "traefik.http.routers.assets-portal.entrypoints=websecure"
|
- "traefik.http.routers.assets-portal.entrypoints=websecure"
|
||||||
- "traefik.http.routers.assets-portal.tls=true"
|
- "traefik.http.routers.assets-portal.tls=true"
|
||||||
- "traefik.http.routers.assets-portal.service=assets-portal-service"
|
- "traefik.http.routers.assets-portal.service=assets-portal-service"
|
||||||
|
|
@ -69,7 +69,7 @@ assets.businessportal24.test → Port 5178 (Businessportal24)
|
||||||
- "traefik.http.services.assets-portal-service.loadbalancer.server.scheme=http"
|
- "traefik.http.services.assets-portal-service.loadbalancer.server.scheme=http"
|
||||||
|
|
||||||
# Web Assets (Frontend)
|
# Web Assets (Frontend)
|
||||||
- "traefik.http.routers.assets-web.rule=Host(`assets-web.pr-copilot.test`)"
|
- "traefik.http.routers.assets-web.rule=Host(`assets-web.presseportale.test`)"
|
||||||
- "traefik.http.routers.assets-web.entrypoints=websecure"
|
- "traefik.http.routers.assets-web.entrypoints=websecure"
|
||||||
- "traefik.http.routers.assets-web.tls=true"
|
- "traefik.http.routers.assets-web.tls=true"
|
||||||
- "traefik.http.routers.assets-web.service=assets-web-service"
|
- "traefik.http.routers.assets-web.service=assets-web-service"
|
||||||
|
|
@ -89,8 +89,8 @@ ports:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Vite Asset Domains
|
# Vite Asset Domains
|
||||||
ASSET_URL_PORTAL=https://assets.pr-copilot.test
|
ASSET_URL_PORTAL=https://assets.presseportale.test
|
||||||
ASSET_URL_WEB=https://assets-web.pr-copilot.test
|
ASSET_URL_WEB=https://assets-web.presseportale.test
|
||||||
|
|
||||||
# Vite Development Ports
|
# Vite Development Ports
|
||||||
VITE_PORT_PORTAL=5177
|
VITE_PORT_PORTAL=5177
|
||||||
|
|
@ -102,11 +102,11 @@ VITE_PORT_WEB=5178
|
||||||
Füge folgende Einträge zu deiner `/etc/hosts` (Linux/Mac) oder `C:\Windows\System32\drivers\etc\hosts` (Windows) hinzu:
|
Füge folgende Einträge zu deiner `/etc/hosts` (Linux/Mac) oder `C:\Windows\System32\drivers\etc\hosts` (Windows) hinzu:
|
||||||
|
|
||||||
```
|
```
|
||||||
127.0.0.1 pr-copilot.test
|
127.0.0.1 presseportale.test
|
||||||
127.0.0.1 presseecho.test
|
127.0.0.1 presseecho.test
|
||||||
127.0.0.1 businessportal24.test
|
127.0.0.1 businessportal24.test
|
||||||
127.0.0.1 assets.pr-copilot.test
|
127.0.0.1 assets.presseportale.test
|
||||||
127.0.0.1 assets-web.pr-copilot.test
|
127.0.0.1 assets-web.presseportale.test
|
||||||
```
|
```
|
||||||
|
|
||||||
## Vite-Konfigurationen
|
## Vite-Konfigurationen
|
||||||
|
|
@ -121,7 +121,7 @@ export default defineConfig({
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
port: 5177,
|
port: 5177,
|
||||||
hmr: {
|
hmr: {
|
||||||
host: "assets.pr-copilot.test", // ← Asset-URL
|
host: "assets.presseportale.test", // ← Asset-URL
|
||||||
protocol: "wss",
|
protocol: "wss",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -139,7 +139,7 @@ export default defineConfig({
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
port: 5178,
|
port: 5178,
|
||||||
hmr: {
|
hmr: {
|
||||||
host: "assets-web.pr-copilot.test", // ← Asset-URL
|
host: "assets-web.presseportale.test", // ← Asset-URL
|
||||||
protocol: "wss",
|
protocol: "wss",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -156,16 +156,16 @@ Browser-Request
|
||||||
↓
|
↓
|
||||||
2. Laravel lädt View mit: @vite(['resources/css/web/theme-presseecho.css', ...])
|
2. Laravel lädt View mit: @vite(['resources/css/web/theme-presseecho.css', ...])
|
||||||
↓
|
↓
|
||||||
3. Vite-Helper generiert: <script src="https://assets-web.pr-copilot.test/@vite/client"></script>
|
3. Vite-Helper generiert: <script src="https://assets-web.presseportale.test/@vite/client"></script>
|
||||||
<link href="https://assets-web.pr-copilot.test/resources/css/web/theme-presseecho.css">
|
<link href="https://assets-web.presseportale.test/resources/css/web/theme-presseecho.css">
|
||||||
↓
|
↓
|
||||||
4. Browser requested: assets-web.pr-copilot.test
|
4. Browser requested: assets-web.presseportale.test
|
||||||
↓
|
↓
|
||||||
5. Traefik routet zu: Container Port 5178
|
5. Traefik routet zu: Container Port 5178
|
||||||
↓
|
↓
|
||||||
6. Vite Web Server antwortet
|
6. Vite Web Server antwortet
|
||||||
↓
|
↓
|
||||||
7. HMR WebSocket öffnet: wss://assets-web.pr-copilot.test
|
7. HMR WebSocket öffnet: wss://assets-web.presseportale.test
|
||||||
↓
|
↓
|
||||||
8. ✅ Hot Module Replacement funktioniert!
|
8. ✅ Hot Module Replacement funktioniert!
|
||||||
```
|
```
|
||||||
|
|
@ -175,8 +175,8 @@ Browser-Request
|
||||||
### 1. DNS-Auflösung testen
|
### 1. DNS-Auflösung testen
|
||||||
```bash
|
```bash
|
||||||
# Sollte zu 127.0.0.1 auflösen
|
# Sollte zu 127.0.0.1 auflösen
|
||||||
ping assets.pr-copilot.test
|
ping assets.presseportale.test
|
||||||
ping assets-web.pr-copilot.test
|
ping assets-web.presseportale.test
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Vite-Server starten
|
### 2. Vite-Server starten
|
||||||
|
|
@ -195,15 +195,15 @@ Du solltest sehen:
|
||||||
|
|
||||||
### 3. Browser-Test
|
### 3. Browser-Test
|
||||||
Öffne:
|
Öffne:
|
||||||
- https://pr-copilot.test (sollte Assets von assets.pr-copilot.test laden)
|
- https://presseportale.test (sollte Assets von assets.presseportale.test laden)
|
||||||
- https://presseecho.test (sollte Assets von assets-web.pr-copilot.test laden)
|
- https://presseecho.test (sollte Assets von assets-web.presseportale.test laden)
|
||||||
- https://businessportal24.test (sollte Assets von assets-web.pr-copilot.test laden)
|
- https://businessportal24.test (sollte Assets von assets-web.presseportale.test laden)
|
||||||
|
|
||||||
### 4. HMR-Test
|
### 4. HMR-Test
|
||||||
1. Öffne Browser DevTools (F12)
|
1. Öffne Browser DevTools (F12)
|
||||||
2. Gehe zu "Network" Tab
|
2. Gehe zu "Network" Tab
|
||||||
3. Filter auf "WS" (WebSocket)
|
3. Filter auf "WS" (WebSocket)
|
||||||
4. Du solltest Verbindungen zu `wss://assets.*.pr-copilot.test` sehen
|
4. Du solltest Verbindungen zu `wss://assets.*.presseportale.test` sehen
|
||||||
5. Ändere eine CSS-Datei
|
5. Ändere eine CSS-Datei
|
||||||
6. Browser sollte automatisch neu laden (ohne vollständigen Page-Refresh)
|
6. Browser sollte automatisch neu laden (ohne vollständigen Page-Refresh)
|
||||||
|
|
||||||
|
|
@ -256,8 +256,8 @@ docker compose logs laravel.test | grep traefik
|
||||||
### ✅ Verwende diese Asset-URLs:
|
### ✅ Verwende diese Asset-URLs:
|
||||||
|
|
||||||
```
|
```
|
||||||
assets.pr-copilot.test → Port 5177 (Portal/Backend)
|
assets.presseportale.test → Port 5177 (Portal/Backend)
|
||||||
assets-web.pr-copilot.test → Port 5178 (Web/Frontend)
|
assets-web.presseportale.test → Port 5178 (Web/Frontend)
|
||||||
```
|
```
|
||||||
|
|
||||||
### ✅ Vorteile:
|
### ✅ Vorteile:
|
||||||
232
_docs/BUSINESSPORTAL24_CSS_OPTIMIZATION.md
Normal file
232
_docs/BUSINESSPORTAL24_CSS_OPTIMIZATION.md
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
# Businessportal24 CSS Optimierung
|
||||||
|
|
||||||
|
## 📊 Übersicht
|
||||||
|
|
||||||
|
Die `theme-businessportal24.css` wurde optimiert und von **669 auf 560 Zeilen** reduziert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❌ Entfernte Duplikate (verschoben nach shared-styles.css)
|
||||||
|
|
||||||
|
### 1. **Hero Banner Styles** (Zeilen 218-237)
|
||||||
|
```css
|
||||||
|
/* ENTFERNT - Jetzt in shared-styles.css */
|
||||||
|
.hero-gradient { ... }
|
||||||
|
.hero-title { ... }
|
||||||
|
.hero-subtitle { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Grund:** Identisch in beiden Themes, jetzt zentral verwaltet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **Section Indicator** (Zeilen 262-271)
|
||||||
|
```css
|
||||||
|
/* ENTFERNT - Nutzt jetzt .gradient-indicator aus shared-styles.css */
|
||||||
|
.section-indicator { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Grund:** Nahezu identisch zu `.gradient-indicator` - gemeinsame Klasse nutzen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **Industry Icon Badge** (duplikate Definition)
|
||||||
|
```css
|
||||||
|
/* ENTFERNT - Jetzt in shared-styles.css */
|
||||||
|
.industry-icon-badge img { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Grund:** Bereits in shared-styles.css definiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Behaltene Businessportal24-spezifische Styles
|
||||||
|
|
||||||
|
### 1. **Float & Glow Design System**
|
||||||
|
```css
|
||||||
|
.variant-float-glow → Haupthintergrund
|
||||||
|
.variant-float-glow .card-hover:hover → Enhanced Shadow Glow
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **Pagination mit Gradient**
|
||||||
|
```css
|
||||||
|
.pagination-btn → Glassy Pagination Buttons
|
||||||
|
.pagination-active → Gradient für aktive Seite
|
||||||
|
.pagination-nav → Navigation Arrows
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **Glassmorphism Components**
|
||||||
|
```css
|
||||||
|
.glass-button → Glassmorphism Button Style
|
||||||
|
.company-glass-badge → Glassy Company Badge
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. **Content Cards mit Glow**
|
||||||
|
```css
|
||||||
|
.featured-content article → Float-Cards mit Glow
|
||||||
|
.press-releases-wrapper article → Press Release Cards
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. **Businessportal24 Utilities**
|
||||||
|
```css
|
||||||
|
.ad-badge → Anzeige-Kennzeichnung
|
||||||
|
.glow-soft → Weiche Glows
|
||||||
|
.glow-medium → Mittlere Glows
|
||||||
|
.glow-strong → Starke Glows
|
||||||
|
.shadow-elegant → Elegante Schatten
|
||||||
|
.shadow-card → Card Schatten
|
||||||
|
.shadow-premium → Premium Schatten
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Struktur der optimierten Datei
|
||||||
|
|
||||||
|
```
|
||||||
|
1. CSS Variables (Zeilen 1-190)
|
||||||
|
├── Theme-spezifische HSL-Farben
|
||||||
|
├── Dark Mode Variablen
|
||||||
|
├── Shadow-Definitionen
|
||||||
|
└── Zinc-Color-System
|
||||||
|
|
||||||
|
2. Base & Components (Zeilen 191-155)
|
||||||
|
├── Base Overrides
|
||||||
|
└── Component Overrides
|
||||||
|
|
||||||
|
3. Float & Glow Design (Zeilen 156-560)
|
||||||
|
├── Haupthintergrund
|
||||||
|
├── Filter Bar
|
||||||
|
├── AD Badge
|
||||||
|
├── Content Kacheln
|
||||||
|
├── Glass Button
|
||||||
|
├── Pagination
|
||||||
|
├── Glow Utilities
|
||||||
|
└── Responsive
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Migration
|
||||||
|
|
||||||
|
### Schritt 1: Backup erstellen
|
||||||
|
```bash
|
||||||
|
cp resources/css/web/theme-businessportal24.css resources/css/web/theme-businessportal24-backup.css
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 2: Optimierte Version aktivieren
|
||||||
|
```bash
|
||||||
|
mv resources/css/web/theme-businessportal24-optimized.css resources/css/web/theme-businessportal24.css
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 3: Testen
|
||||||
|
- Businessportal24-Seite aufrufen
|
||||||
|
- Hero Banner prüfen
|
||||||
|
- Highlights-Slider prüfen
|
||||||
|
- Pagination prüfen
|
||||||
|
- Float & Glow Effekte prüfen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Vorteile
|
||||||
|
|
||||||
|
### 1. **Keine Duplikate mehr**
|
||||||
|
- Hero-Styles nur einmal (shared-styles.css)
|
||||||
|
- Industry-Icons zentral verwaltet
|
||||||
|
- Section-Indicator durch gradient-indicator ersetzt
|
||||||
|
|
||||||
|
### 2. **Bessere Wartbarkeit**
|
||||||
|
- Klare Trennung: Gemeinsam vs. Theme-spezifisch
|
||||||
|
- Einfachere Updates
|
||||||
|
- Konsistenz garantiert
|
||||||
|
|
||||||
|
### 3. **Kleinere Dateigröße**
|
||||||
|
- Von 669 auf 560 Zeilen (-16%)
|
||||||
|
- Weniger Code zum Laden
|
||||||
|
- Bessere Performance
|
||||||
|
|
||||||
|
### 4. **Theme-spezifische Identität bewahrt**
|
||||||
|
- Float & Glow Design bleibt
|
||||||
|
- Glassmorphism-Effekte bleiben
|
||||||
|
- Pagination-Gradient bleibt
|
||||||
|
- Alle Businessportal24-spezifischen Features intakt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Checkliste nach Migration
|
||||||
|
|
||||||
|
- [ ] Hero Banner wird korrekt angezeigt
|
||||||
|
- [ ] Highlights-Slider funktioniert
|
||||||
|
- [ ] Gradient-Indicator erscheint bei Titles
|
||||||
|
- [ ] Pagination mit Gradient funktioniert
|
||||||
|
- [ ] Float & Glow Effekte auf Cards funktionieren
|
||||||
|
- [ ] Dark Mode funktioniert
|
||||||
|
- [ ] Glass-Button-Style funktioniert
|
||||||
|
- [ ] Industry-Icons werden angezeigt
|
||||||
|
- [ ] Responsive Design funktioniert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Vergleich: Vorher vs. Nachher
|
||||||
|
|
||||||
|
### Vorher (669 Zeilen)
|
||||||
|
```
|
||||||
|
✗ Hero-Styles dupliziert (20 Zeilen)
|
||||||
|
✗ Industry-Icons dupliziert (10 Zeilen)
|
||||||
|
✗ Section-Indicator redundant (10 Zeilen)
|
||||||
|
✗ Unstrukturiert
|
||||||
|
✗ Schwer wartbar
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nachher (560 Zeilen)
|
||||||
|
```
|
||||||
|
✓ Keine Duplikate
|
||||||
|
✓ Zentral verwaltete gemeinsame Styles
|
||||||
|
✓ Klare Struktur
|
||||||
|
✓ Leicht wartbar
|
||||||
|
✓ Theme-Identität bewahrt
|
||||||
|
✓ -109 Zeilen (-16%)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Best Practice für zukünftige Styles
|
||||||
|
|
||||||
|
### Fragen vor dem Hinzufügen neuer Styles:
|
||||||
|
|
||||||
|
1. **Ist dieser Style gemeinsam?**
|
||||||
|
- JA → In `shared-styles.css` einfügen
|
||||||
|
- NEIN → In Theme-CSS einfügen
|
||||||
|
|
||||||
|
2. **Gibt es einen ähnlichen Style bereits?**
|
||||||
|
- JA → Bestehenden Style erweitern
|
||||||
|
- NEIN → Neuen Style erstellen
|
||||||
|
|
||||||
|
3. **Ist dieser Style theme-spezifisch?**
|
||||||
|
- JA → Theme-CSS mit Kommentar
|
||||||
|
- NEIN → shared-styles.css
|
||||||
|
|
||||||
|
4. **Brauche ich CSS-Variables?**
|
||||||
|
- JA → In Theme-CSS im :root definieren
|
||||||
|
- NEIN → Direkte Werte nutzen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Zusammenfassung
|
||||||
|
|
||||||
|
Die Businessportal24-CSS ist jetzt:
|
||||||
|
- ✅ **Optimiert** (16% kleiner)
|
||||||
|
- ✅ **Duplikatfrei**
|
||||||
|
- ✅ **Gut strukturiert**
|
||||||
|
- ✅ **Wartbar**
|
||||||
|
- ✅ **Theme-Identität bewahrt**
|
||||||
|
|
||||||
|
Alle Businessportal24-spezifischen Features (Float & Glow, Glassmorphism, Pagination-Gradient) bleiben vollständig erhalten!
|
||||||
|
|
||||||
353
_docs/CATEGORY_SERVICE_DOCUMENTATION.md
Normal file
353
_docs/CATEGORY_SERVICE_DOCUMENTATION.md
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
# CategoryService Dokumentation
|
||||||
|
|
||||||
|
Der `CategoryService` ist eine zentrale Service-Klasse für die Verwaltung aller Kategorien in der Anwendung.
|
||||||
|
|
||||||
|
## 📁 Pfad
|
||||||
|
```
|
||||||
|
app/Services/CategoryService.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Zweck
|
||||||
|
|
||||||
|
Der CategoryService bietet eine zentrale Stelle für:
|
||||||
|
- Kategorie-Definitionen (Name, Slug, Icon, Farbe, etc.)
|
||||||
|
- Farb-Mappings und Gradients
|
||||||
|
- Icon-Pfade für Heroicons
|
||||||
|
- Helper-Methoden für Kategorie-Zugriff
|
||||||
|
|
||||||
|
## 📖 Verfügbare Methoden
|
||||||
|
|
||||||
|
### 1. `getCategories()`
|
||||||
|
Gibt alle verfügbaren Kategorien zurück.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Services\CategoryService;
|
||||||
|
|
||||||
|
$categories = CategoryService::getCategories();
|
||||||
|
// Returns: Array mit allen Kategorien
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rückgabe-Struktur:**
|
||||||
|
```php
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'name' => 'Wirtschaft',
|
||||||
|
'slug' => 'wirtschaft',
|
||||||
|
'description' => 'Unternehmensnachrichten, Finanzberichte, Wirtschaftstrends',
|
||||||
|
'count' => '2.450+',
|
||||||
|
'icon' => 'chart-bar',
|
||||||
|
'color' => 'blue',
|
||||||
|
],
|
||||||
|
// ... weitere Kategorien
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. `getCategoryBySlug(string $slug)`
|
||||||
|
Findet eine Kategorie anhand ihres Slugs.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$category = CategoryService::getCategoryBySlug('wirtschaft');
|
||||||
|
// Returns: Array mit Kategorie-Daten oder null
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. `getCategorySlugs()`
|
||||||
|
Gibt alle Kategorie-Slugs als Array zurück.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$slugs = CategoryService::getCategorySlugs();
|
||||||
|
// Returns: ['wirtschaft', 'technologie', 'gesundheit', ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. `getColorGradients()`
|
||||||
|
Gibt alle Farb-Gradient-Mappings zurück.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$gradients = CategoryService::getColorGradients();
|
||||||
|
// Returns: ['blue' => 'from-blue-500/10 to-blue-600/10', ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. `getColorClasses()`
|
||||||
|
Gibt alle Farb-CSS-Klassen zurück.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$classes = CategoryService::getColorClasses();
|
||||||
|
// Returns: ['blue' => 'text-blue-500', ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. `getGradientForColor(string $color)`
|
||||||
|
Gibt den Gradient für eine bestimmte Farbe zurück.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$gradient = CategoryService::getGradientForColor('blue');
|
||||||
|
// Returns: 'from-blue-500/10 to-blue-600/10'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. `getClassForColor(string $color)`
|
||||||
|
Gibt die CSS-Klasse für eine bestimmte Farbe zurück.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$class = CategoryService::getClassForColor('blue');
|
||||||
|
// Returns: 'text-blue-500'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. `getIconPath(string $iconName)`
|
||||||
|
Generiert den Pfad zu einem Heroicon.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$path = CategoryService::getIconPath('chart-bar');
|
||||||
|
// Returns: '/heroicons/optimized/24/outline/chart-bar.svg'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. `count()`
|
||||||
|
Gibt die Gesamtanzahl der Kategorien zurück.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$total = CategoryService::count();
|
||||||
|
// Returns: 12
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Verwendungsbeispiele
|
||||||
|
|
||||||
|
### Beispiel 1: Kategorien in Blade anzeigen
|
||||||
|
|
||||||
|
```blade
|
||||||
|
@php
|
||||||
|
use App\Services\CategoryService;
|
||||||
|
$categories = CategoryService::getCategories();
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
@foreach($categories as $category)
|
||||||
|
<a href="/kategorie/{{ $category['slug'] }}">
|
||||||
|
<div class="category-card">
|
||||||
|
<img src="{{ CategoryService::getIconPath($category['icon']) }}"
|
||||||
|
alt="{{ $category['name'] }}">
|
||||||
|
<h3>{{ $category['name'] }}</h3>
|
||||||
|
<p>{{ $category['description'] }}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Beispiel 2: Kategorie in Controller verwenden
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Services\CategoryService;
|
||||||
|
|
||||||
|
class CategoryController extends Controller
|
||||||
|
{
|
||||||
|
public function show(string $slug)
|
||||||
|
{
|
||||||
|
$category = CategoryService::getCategoryBySlug($slug);
|
||||||
|
|
||||||
|
if (!$category) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('kategorie', [
|
||||||
|
'category' => $category,
|
||||||
|
'allCategories' => CategoryService::getCategories(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Beispiel 3: Kategorie-Navigation
|
||||||
|
|
||||||
|
```blade
|
||||||
|
@php
|
||||||
|
use App\Services\CategoryService;
|
||||||
|
$categories = CategoryService::getCategories();
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<nav class="category-nav">
|
||||||
|
@foreach($categories as $category)
|
||||||
|
<a href="/kategorie/{{ $category['slug'] }}"
|
||||||
|
class="nav-item {{ request()->segment(2) === $category['slug'] ? 'active' : '' }}">
|
||||||
|
{{ $category['name'] }}
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</nav>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Beispiel 4: Dropdown mit Kategorien
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<select name="category" class="form-select">
|
||||||
|
<option value="">Kategorie wählen</option>
|
||||||
|
@foreach(App\Services\CategoryService::getCategories() as $cat)
|
||||||
|
<option value="{{ $cat['slug'] }}">{{ $cat['name'] }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Beispiel 5: Kategorie-Filter in Livewire
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Services\CategoryService;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class PressReleaseFilter extends Component
|
||||||
|
{
|
||||||
|
public $selectedCategory = '';
|
||||||
|
public $categories = [];
|
||||||
|
|
||||||
|
public function mount()
|
||||||
|
{
|
||||||
|
$this->categories = CategoryService::getCategories();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.press-release-filter');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Beispiel 6: Farb-Gradient verwenden
|
||||||
|
|
||||||
|
```blade
|
||||||
|
@php
|
||||||
|
use App\Services\CategoryService;
|
||||||
|
$category = CategoryService::getCategoryBySlug('wirtschaft');
|
||||||
|
$gradient = CategoryService::getGradientForColor($category['color']);
|
||||||
|
$iconColor = CategoryService::getClassForColor($category['color']);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="bg-gradient-to-br {{ $gradient }} rounded-lg p-4">
|
||||||
|
<img src="{{ CategoryService::getIconPath($category['icon']) }}"
|
||||||
|
class="h-8 w-8 {{ $iconColor }}"
|
||||||
|
alt="{{ $category['name'] }}">
|
||||||
|
<h3>{{ $category['name'] }}</h3>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 Verfügbare Kategorien
|
||||||
|
|
||||||
|
Aktuell sind folgende Kategorien definiert:
|
||||||
|
|
||||||
|
| Name | Slug | Icon | Farbe |
|
||||||
|
|------|------|------|-------|
|
||||||
|
| Wirtschaft | wirtschaft | chart-bar | blue |
|
||||||
|
| Technologie | technologie | cpu-chip | purple |
|
||||||
|
| Gesundheit | gesundheit | heart | green |
|
||||||
|
| Finanzen | finanzen | currency-dollar | yellow |
|
||||||
|
| Automotive | automotive | truck | red |
|
||||||
|
| Immobilien | immobilien | building-office | indigo |
|
||||||
|
| Energie | energie | bolt | orange |
|
||||||
|
| Bildung | bildung | academic-cap | cyan |
|
||||||
|
| Handel | handel | shopping-cart | pink |
|
||||||
|
| Tourismus | tourismus | globe-alt | teal |
|
||||||
|
| Sport | sport | trophy | lime |
|
||||||
|
| Kultur | kultur | musical-note | violet |
|
||||||
|
|
||||||
|
## ➕ Neue Kategorie hinzufügen
|
||||||
|
|
||||||
|
Um eine neue Kategorie hinzuzufügen, bearbeiten Sie die `getCategories()` Methode in `app/Services/CategoryService.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public static function getCategories(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// ... bestehende Kategorien
|
||||||
|
[
|
||||||
|
'name' => 'Neue Kategorie',
|
||||||
|
'slug' => 'neue-kategorie',
|
||||||
|
'description' => 'Beschreibung der neuen Kategorie',
|
||||||
|
'count' => '0+',
|
||||||
|
'icon' => 'icon-name', // Heroicon Name
|
||||||
|
'color' => 'emerald', // Farbe
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtig:** Wenn Sie eine neue Farbe verwenden, fügen Sie diese auch in `getColorGradients()` und `getColorClasses()` hinzu!
|
||||||
|
|
||||||
|
## 🎯 Best Practices
|
||||||
|
|
||||||
|
1. **Verwenden Sie immer den CategoryService** statt hardcodierte Kategorien
|
||||||
|
2. **Caching:** Für bessere Performance können Kategorien gecacht werden
|
||||||
|
3. **Validierung:** Nutzen Sie `getCategoryBySlug()` zur Validierung von Kategorie-Parametern
|
||||||
|
4. **Konsistenz:** Alle Kategorie-Daten sollten nur über den Service abgerufen werden
|
||||||
|
|
||||||
|
## 🔄 Migration von inline Arrays
|
||||||
|
|
||||||
|
**Vorher:**
|
||||||
|
```blade
|
||||||
|
@php
|
||||||
|
$categories = [
|
||||||
|
['name' => 'Wirtschaft', 'slug' => 'wirtschaft', ...],
|
||||||
|
// ...
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nachher:**
|
||||||
|
```blade
|
||||||
|
@php
|
||||||
|
use App\Services\CategoryService;
|
||||||
|
$categories = CategoryService::getCategories();
|
||||||
|
@endphp
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Performance-Tipps
|
||||||
|
|
||||||
|
Wenn Sie die Kategorien häufig verwenden, können Sie sie in einem View Composer laden:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// app/Providers/ViewServiceProvider.php
|
||||||
|
use App\Services\CategoryService;
|
||||||
|
use Illuminate\Support\Facades\View;
|
||||||
|
|
||||||
|
public function boot()
|
||||||
|
{
|
||||||
|
View::composer('*', function ($view) {
|
||||||
|
$view->with('globalCategories', CategoryService::getCategories());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann in Blade:
|
||||||
|
```blade
|
||||||
|
@foreach($globalCategories as $category)
|
||||||
|
<!-- ... -->
|
||||||
|
@endforeach
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
Beispiel für einen Test:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Services\CategoryService;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CategoryServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_get_categories_returns_array()
|
||||||
|
{
|
||||||
|
$categories = CategoryService::getCategories();
|
||||||
|
|
||||||
|
$this->assertIsArray($categories);
|
||||||
|
$this->assertNotEmpty($categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_get_category_by_slug()
|
||||||
|
{
|
||||||
|
$category = CategoryService::getCategoryBySlug('wirtschaft');
|
||||||
|
|
||||||
|
$this->assertNotNull($category);
|
||||||
|
$this->assertEquals('Wirtschaft', $category['name']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Changelog
|
||||||
|
|
||||||
|
- **v1.0** - Initiale Version mit allen 12 Kategorien
|
||||||
|
- Unterstützt Heroicons
|
||||||
|
- Farb-Mappings für alle Kategorien
|
||||||
|
- Helper-Methoden für einfachen Zugriff
|
||||||
|
|
||||||
485
_docs/COMPONENT_DOCUMENTATION.md
Normal file
485
_docs/COMPONENT_DOCUMENTATION.md
Normal file
|
|
@ -0,0 +1,485 @@
|
||||||
|
# Komponenten-Dokumentation
|
||||||
|
|
||||||
|
Diese Dokumentation beschreibt alle wiederverwendbaren Blade-Komponenten für die Presseecho und Businessportal24 Themes.
|
||||||
|
|
||||||
|
## Inhaltsverzeichnis
|
||||||
|
1. [Layout-Komponenten](#layout-komponenten)
|
||||||
|
2. [Navigation-Komponenten](#navigation-komponenten)
|
||||||
|
3. [Content-Komponenten](#content-komponenten)
|
||||||
|
4. [Card-Komponenten](#card-komponenten)
|
||||||
|
5. [Verwendungsbeispiele](#verwendungsbeispiele)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layout-Komponenten
|
||||||
|
|
||||||
|
### Content-Layout
|
||||||
|
**Pfad:** `resources/views/components/web/content-layout.blade.php`
|
||||||
|
|
||||||
|
Hauptlayout für Unterseiten mit optionaler Sidebar.
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `$sidebar` (slot): Sidebar-Inhalt (optional)
|
||||||
|
- `$sidebarPosition` (string): Position der Sidebar - `'left'` oder `'right'` (default: `'right'`)
|
||||||
|
|
||||||
|
**Beispiel ohne Sidebar:**
|
||||||
|
```blade
|
||||||
|
<x-web.content-layout>
|
||||||
|
<article>
|
||||||
|
<!-- Ihr Content hier -->
|
||||||
|
</article>
|
||||||
|
</x-web.content-layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beispiel mit Sidebar:**
|
||||||
|
```blade
|
||||||
|
<x-web.content-layout>
|
||||||
|
<x-slot name="sidebar">
|
||||||
|
<x-web.sidebar-widget title="Filter">
|
||||||
|
<!-- Sidebar-Inhalte -->
|
||||||
|
</x-web.sidebar-widget>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<!-- Hauptcontent -->
|
||||||
|
</article>
|
||||||
|
</x-web.content-layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Page Header
|
||||||
|
**Pfad:** `resources/views/components/web/page-header.blade.php`
|
||||||
|
|
||||||
|
Header für Unterseiten mit Titel, Subtitle und optionalem Bild.
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `$title` (string, required): Hauptüberschrift
|
||||||
|
- `$subtitle` (string, optional): Untertitel
|
||||||
|
- `$meta` (slot, optional): Meta-Informationen (Badges, Datum, etc.)
|
||||||
|
- `$image` (string, optional): URL für Header-Bild
|
||||||
|
- `$compact` (bool): Kompakte Version (default: `false`)
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
```blade
|
||||||
|
<x-web.page-header
|
||||||
|
title="Die Zukunft der KI"
|
||||||
|
subtitle="Exklusive Einblicke in die Herausforderungen">
|
||||||
|
<x-slot name="meta">
|
||||||
|
<span class="badge badge-primary">Exklusiv-Interview</span>
|
||||||
|
<span>17. Oktober 2024</span>
|
||||||
|
</x-slot>
|
||||||
|
</x-web.page-header>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation-Komponenten
|
||||||
|
|
||||||
|
### Main Navigation
|
||||||
|
**Pfad:** `resources/views/components/web/main-navigation.blade.php`
|
||||||
|
|
||||||
|
Sticky Navigation für verschiedene Themes.
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `$theme` (string): Theme-Name - `'presseecho'` oder `'businessportal24'` (default: `'presseecho'`)
|
||||||
|
- `$items` (array, optional): Custom Navigation-Items
|
||||||
|
|
||||||
|
**Beispiel Standard:**
|
||||||
|
```blade
|
||||||
|
<x-web.main-navigation theme="presseecho" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beispiel Custom Items:**
|
||||||
|
```blade
|
||||||
|
<x-web.main-navigation
|
||||||
|
theme="presseecho"
|
||||||
|
:items="[
|
||||||
|
['label' => 'Kategorien', 'url' => '/kategorien'],
|
||||||
|
['label' => 'Autoren', 'url' => '/autoren'],
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Breadcrumb
|
||||||
|
**Pfad:** `resources/views/components/web/breadcrumb.blade.php`
|
||||||
|
|
||||||
|
Breadcrumb-Navigation für Unterseiten.
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `$items` (array, required): Array von Breadcrumb-Items
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
```blade
|
||||||
|
<x-web.breadcrumb :items="[
|
||||||
|
['label' => 'Themendossiers', 'url' => '/themendossiers'],
|
||||||
|
['label' => 'KI & Innovation', 'url' => '/themendossiers/ki'],
|
||||||
|
['label' => 'Artikel-Titel']
|
||||||
|
]" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Content-Komponenten
|
||||||
|
|
||||||
|
### Section Header
|
||||||
|
**Pfad:** `resources/views/components/web/section-header.blade.php`
|
||||||
|
|
||||||
|
Header für Content-Sections.
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `$title` (string, required): Section-Titel
|
||||||
|
- `$subtitle` (string, optional): Subtitle
|
||||||
|
- `$size` (string): Größe - `'small'`, `'medium'`, `'large'` (default: `'medium'`)
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
```blade
|
||||||
|
<x-web.section-header
|
||||||
|
title="Neueste Analysen"
|
||||||
|
subtitle="Fundierte Einschätzungen von Branchenexperten"
|
||||||
|
size="medium"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hero Banner
|
||||||
|
**Pfad:** `resources/views/components/web/hero-banner.blade.php`
|
||||||
|
|
||||||
|
Hero-Section für Startseiten.
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `$theme` (string): Theme-Name (default: `'presseecho'`)
|
||||||
|
- `$title` (slot): Hero-Titel
|
||||||
|
- `$subtitle` (slot): Hero-Subtitle
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
```blade
|
||||||
|
<x-web.hero-banner theme="presseecho">
|
||||||
|
<x-slot name="title">
|
||||||
|
Exklusive Fachmeldungen <span class="opacity-50">für Ihre Recherche</span>
|
||||||
|
</x-slot>
|
||||||
|
<x-slot name="subtitle">
|
||||||
|
Exklusive Wirtschaftsinformationen für Journalisten
|
||||||
|
</x-slot>
|
||||||
|
</x-web.hero-banner>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sidebar Widget
|
||||||
|
**Pfad:** `resources/views/components/web/sidebar-widget.blade.php`
|
||||||
|
|
||||||
|
Widget-Container für Sidebar-Elemente.
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `$title` (string, optional): Widget-Titel
|
||||||
|
- `$icon` (slot, optional): Icon als SVG
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
```blade
|
||||||
|
<x-web.sidebar-widget title="Über den Autor">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="w-16 h-16 rounded-full bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-secondary)]">
|
||||||
|
<!-- Avatar -->
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold">Dr. Maria Schmidt</h4>
|
||||||
|
<p class="text-sm">Expertin für KI</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-web.sidebar-widget>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Card-Komponenten
|
||||||
|
|
||||||
|
### Article Card
|
||||||
|
**Pfad:** `resources/views/components/web/article-card.blade.php`
|
||||||
|
|
||||||
|
Standard-Karte für Artikel.
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `$article` (array, required): Artikel-Daten
|
||||||
|
|
||||||
|
**Erforderliche Array-Struktur:**
|
||||||
|
```php
|
||||||
|
$article = [
|
||||||
|
'badge' => 'Analyse',
|
||||||
|
'badgeType' => 'primary', // primary, secondary, success, warning
|
||||||
|
'category' => 'Industrie 4.0',
|
||||||
|
'categoryBadgeType' => 'secondary',
|
||||||
|
'date' => '2024-10-16',
|
||||||
|
'dateFormatted' => '16. Okt 2024',
|
||||||
|
'title' => 'Artikeltitel',
|
||||||
|
'teaser' => 'Kurzbeschreibung...',
|
||||||
|
'author' => 'Thomas Müller',
|
||||||
|
'authorInitials' => 'TM',
|
||||||
|
'image' => 'https://...',
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
```blade
|
||||||
|
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
@foreach ($articles as $article)
|
||||||
|
<x-web.article-card :article="$article" />
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dossier Card
|
||||||
|
**Pfad:** `resources/views/components/web/dossier-card.blade.php`
|
||||||
|
|
||||||
|
Karte für Themendossiers.
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `$dossier` (array, required): Dossier-Daten
|
||||||
|
|
||||||
|
**Erforderliche Array-Struktur:**
|
||||||
|
```php
|
||||||
|
$dossier = [
|
||||||
|
'title' => 'KI in der Industrie',
|
||||||
|
'description' => 'Alle Facetten der künstlichen Intelligenz...',
|
||||||
|
'articleCount' => 12,
|
||||||
|
'image' => 'https://...',
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
```blade
|
||||||
|
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
@foreach ($dossiers as $dossier)
|
||||||
|
<x-web.dossier-card :dossier="$dossier" />
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Highlight Card
|
||||||
|
**Pfad:** `resources/views/components/web/highlight-card.blade.php`
|
||||||
|
|
||||||
|
Karte für Highlights (wird im Slider verwendet).
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `$highlight` (array, required): Highlight-Daten
|
||||||
|
|
||||||
|
**Erforderliche Array-Struktur:**
|
||||||
|
```php
|
||||||
|
$highlight = [
|
||||||
|
'badge' => 'Exklusiv-Interview',
|
||||||
|
'badgeType' => 'primary',
|
||||||
|
'industry' => 'KI & Innovation',
|
||||||
|
'date' => '17. Oktober 2024',
|
||||||
|
'title' => 'Die Zukunft der KI',
|
||||||
|
'text' => 'Beschreibungstext...',
|
||||||
|
'author' => 'Dr. Maria Schmidt',
|
||||||
|
'authorRole' => 'Expertin für KI',
|
||||||
|
'initials' => 'MS',
|
||||||
|
'image' => 'https://...',
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Highlights Slider
|
||||||
|
**Pfad:** `resources/views/components/web/highlights-slider.blade.php`
|
||||||
|
|
||||||
|
Interaktiver Slider für Highlights mit Navigation und Keyboard-Support.
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `$highlights` (array, required): Array von Highlight-Daten
|
||||||
|
- `$theme` (string): Theme-Name (default: `'presseecho'`)
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
```blade
|
||||||
|
@php
|
||||||
|
$highlights = [
|
||||||
|
[
|
||||||
|
'badge' => 'Exklusiv-Interview',
|
||||||
|
'badgeType' => 'primary',
|
||||||
|
'industry' => 'KI & Innovation',
|
||||||
|
'date' => '17. Oktober 2024',
|
||||||
|
'title' => 'Die Zukunft der KI',
|
||||||
|
'text' => 'Interview-Text...',
|
||||||
|
'author' => 'Dr. Maria Schmidt',
|
||||||
|
'authorRole' => 'Expertin',
|
||||||
|
'initials' => 'MS',
|
||||||
|
'image' => 'https://...',
|
||||||
|
],
|
||||||
|
// ... weitere Highlights
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<x-web.highlights-slider :highlights="$highlights" theme="presseecho" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verwendungsbeispiele
|
||||||
|
|
||||||
|
### Beispiel 1: Artikel-Detailseite
|
||||||
|
|
||||||
|
```blade
|
||||||
|
@extends('web.layouts.web-master')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<main class="min-h-screen flex flex-col bg-white dark:bg-zinc-950">
|
||||||
|
<livewire:web.burger-menu />
|
||||||
|
<livewire:web.header />
|
||||||
|
|
||||||
|
<x-web.main-navigation theme="presseecho" />
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<x-web.breadcrumb :items="[
|
||||||
|
['label' => 'Themendossiers', 'url' => '/themendossiers'],
|
||||||
|
['label' => 'KI & Innovation', 'url' => '/ki'],
|
||||||
|
['label' => 'Artikel-Titel']
|
||||||
|
]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-web.page-header
|
||||||
|
title="Die Zukunft der KI"
|
||||||
|
subtitle="Exklusive Einblicke">
|
||||||
|
<x-slot name="meta">
|
||||||
|
<span class="badge badge-primary">Interview</span>
|
||||||
|
<span>17. Oktober 2024</span>
|
||||||
|
</x-slot>
|
||||||
|
</x-web.page-header>
|
||||||
|
|
||||||
|
<x-web.content-layout>
|
||||||
|
<x-slot name="sidebar">
|
||||||
|
<x-web.sidebar-widget title="Über den Autor">
|
||||||
|
<!-- Author Info -->
|
||||||
|
</x-web.sidebar-widget>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<article class="prose prose-lg">
|
||||||
|
<!-- Artikel-Content -->
|
||||||
|
</article>
|
||||||
|
</x-web.content-layout>
|
||||||
|
|
||||||
|
<livewire:web.footer />
|
||||||
|
</main>
|
||||||
|
@endsection
|
||||||
|
```
|
||||||
|
|
||||||
|
### Beispiel 2: Kategorie-Übersicht
|
||||||
|
|
||||||
|
```blade
|
||||||
|
@extends('web.layouts.web-master')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<main class="min-h-screen flex flex-col bg-white dark:bg-zinc-950">
|
||||||
|
<livewire:web.burger-menu />
|
||||||
|
<livewire:web.header />
|
||||||
|
|
||||||
|
<x-web.main-navigation theme="presseecho" />
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<x-web.breadcrumb :items="[
|
||||||
|
['label' => 'Themendossiers', 'url' => '/themendossiers'],
|
||||||
|
['label' => 'KI & Innovation']
|
||||||
|
]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-web.page-header
|
||||||
|
title="KI & Innovation"
|
||||||
|
subtitle="Alle Artikel zum Thema"
|
||||||
|
image="https://..." />
|
||||||
|
|
||||||
|
<x-web.content-layout>
|
||||||
|
<x-slot name="sidebar">
|
||||||
|
<x-web.sidebar-widget title="Filter">
|
||||||
|
<!-- Filter-Optionen -->
|
||||||
|
</x-web.sidebar-widget>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
|
@foreach ($articles as $article)
|
||||||
|
<x-web.article-card :article="$article" />
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-web.content-layout>
|
||||||
|
|
||||||
|
<livewire:web.footer />
|
||||||
|
</main>
|
||||||
|
@endsection
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS-Klassen
|
||||||
|
|
||||||
|
### Badge-Typen
|
||||||
|
- `badge` - Basis-Badge
|
||||||
|
- `badge-primary` - Primary-Farbe
|
||||||
|
- `badge-secondary` - Secondary-Farbe
|
||||||
|
- `badge-success` - Erfolg (Grün)
|
||||||
|
- `badge-warning` - Warnung (Gelb)
|
||||||
|
|
||||||
|
### Button-Typen
|
||||||
|
- `btn-primary` - Primary-Button mit Gradient
|
||||||
|
- `btn-secondary` - Secondary-Button
|
||||||
|
- `pagination-btn` - Pagination-Button
|
||||||
|
- `pagination-active` - Aktive Pagination-Seite
|
||||||
|
|
||||||
|
### Card-Typen
|
||||||
|
- `card` - Basis-Karte
|
||||||
|
- `card-hover` - Karte mit Hover-Effekt
|
||||||
|
- `highlight-card` - Highlight-Karte (Slider)
|
||||||
|
|
||||||
|
### Layout-Klassen
|
||||||
|
- `section-light-bg` - Heller Section-Hintergrund
|
||||||
|
- `section-gradient-bg` - Gradient Section-Hintergrund
|
||||||
|
- `page-header` - Page-Header-Container
|
||||||
|
- `page-header-compact` - Kompakter Page-Header
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Theme-Anpassung
|
||||||
|
|
||||||
|
Alle Komponenten respektieren die Theme-Variablen:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--primary: /* HSL-Werte */
|
||||||
|
--secondary: /* HSL-Werte */
|
||||||
|
--color-primary: /* Hex-Werte */
|
||||||
|
--color-secondary: /* Hex-Werte */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Presseecho:**
|
||||||
|
- Primary: `#345636` (Grün)
|
||||||
|
- Secondary: `#6b8f71` (Hellgrün)
|
||||||
|
|
||||||
|
**Businessportal24:**
|
||||||
|
- Primary: `#cf3628` (Rot)
|
||||||
|
- Secondary: `#f0834a` (Orange)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Komponenten-Verwendung:**
|
||||||
|
- Nutzen Sie immer die wiederverwendbaren Komponenten
|
||||||
|
- Vermeiden Sie duplizierte HTML-Strukturen
|
||||||
|
- Verwenden Sie Props für dynamische Inhalte
|
||||||
|
|
||||||
|
2. **CSS-Klassen:**
|
||||||
|
- Nutzen Sie die vordefinierten CSS-Klassen aus `shared-styles.css`
|
||||||
|
- Vermeiden Sie Inline-Styles, wo möglich
|
||||||
|
- Theme-spezifische Overrides in den jeweiligen Theme-CSS-Dateien
|
||||||
|
|
||||||
|
3. **Datenstruktur:**
|
||||||
|
- Halten Sie sich an die definierten Array-Strukturen
|
||||||
|
- Verwenden Sie `@php`-Blöcke für Daten-Definitionen
|
||||||
|
- Dokumentieren Sie Custom-Properties
|
||||||
|
|
||||||
|
4. **Accessibility:**
|
||||||
|
- Verwenden Sie semantische HTML-Tags
|
||||||
|
- Fügen Sie `aria-label` zu interaktiven Elementen hinzu
|
||||||
|
- Testen Sie die Keyboard-Navigation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Bei Fragen zur Verwendung der Komponenten:
|
||||||
|
- Siehe Beispiele unter `/resources/views/web/examples/`
|
||||||
|
- Konsultieren Sie diese Dokumentation
|
||||||
|
- Prüfen Sie die bestehenden Implementierungen in `presseecho.blade.php`
|
||||||
|
|
||||||
|
|
@ -9,14 +9,14 @@ Füge die folgenden Variablen zu deiner `.env`-Datei hinzu:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Domain-Konfigurationen
|
# Domain-Konfigurationen
|
||||||
APP_NAME=pr-copilot
|
APP_NAME=presseportale
|
||||||
APP_URL=https://pr-copilot.test
|
APP_URL=https://presseportale.test
|
||||||
APP_PRIMARY="#3ea3dc"
|
APP_PRIMARY="#3ea3dc"
|
||||||
APP_ACCENT="#5c5c60"
|
APP_ACCENT="#5c5c60"
|
||||||
|
|
||||||
# Entwicklungseinstellungen für Domains
|
# Entwicklungseinstellungen für Domains
|
||||||
DEV_SIMULATE_DOMAIN=false
|
DEV_SIMULATE_DOMAIN=false
|
||||||
DEV_SIMULATED_DOMAIN=pr-copilot.test
|
DEV_SIMULATED_DOMAIN=presseportale.test
|
||||||
```
|
```
|
||||||
|
|
||||||
## Entwicklungsmodus
|
## Entwicklungsmodus
|
||||||
|
|
@ -34,10 +34,10 @@ unabhängig von der tatsächlichen URL.
|
||||||
|
|
||||||
Jede Domain kann eigene Einstellungen haben:
|
Jede Domain kann eigene Einstellungen haben:
|
||||||
|
|
||||||
### Haupt-Website (pr-copilot.test)
|
### Haupt-Website (presseportale.test)
|
||||||
|
|
||||||
- `APP_URL`: Die Domain für die Haupt-Website (https://pr-copilot.test)
|
- `APP_URL`: Die Domain für die Haupt-Website (https://presseportale.test)
|
||||||
- `APP_NAME`: Der Name der Haupt-Website (pr-copilot)
|
- `APP_NAME`: Der Name der Haupt-Website (presseportale)
|
||||||
- `APP_PRIMARY`: Die primäre Farbe im HEX-Format (#3ea3dc)
|
- `APP_PRIMARY`: Die primäre Farbe im HEX-Format (#3ea3dc)
|
||||||
- `APP_ACCENT`: Die Akzentfarbe im HEX-Format (#5c5c60)
|
- `APP_ACCENT`: Die Akzentfarbe im HEX-Format (#5c5c60)
|
||||||
|
|
||||||
|
|
@ -62,7 +62,7 @@ Jede Domain kann eigene Einstellungen haben:
|
||||||
Um die verschiedenen Domains lokal zu testen, füge folgende Zeilen zu deiner Hosts-Datei hinzu:
|
Um die verschiedenen Domains lokal zu testen, füge folgende Zeilen zu deiner Hosts-Datei hinzu:
|
||||||
|
|
||||||
```
|
```
|
||||||
127.0.0.1 pr-copilot.test
|
127.0.0.1 presseportale.test
|
||||||
127.0.0.1 presseecho.test
|
127.0.0.1 presseecho.test
|
||||||
127.0.0.1 businessportal24.test
|
127.0.0.1 businessportal24.test
|
||||||
```
|
```
|
||||||
|
|
@ -101,7 +101,7 @@ Im Code kannst du auf die Domain-Konfiguration zugreifen:
|
||||||
|
|
||||||
Das Projekt verwendet bereits eine Multi-Domain-Architektur mit:
|
Das Projekt verwendet bereits eine Multi-Domain-Architektur mit:
|
||||||
|
|
||||||
- **Hauptwebsite:** `pr-copilot.test` - Hauptwebsite mit blauem Theme (#3ea3dc)
|
- **Hauptwebsite:** `presseportale.test` - Hauptwebsite mit blauem Theme (#3ea3dc)
|
||||||
- **Presseecho:** `presseecho.test` - Presseecho-Website mit rotem Theme (#e94a3c)
|
- **Presseecho:** `presseecho.test` - Presseecho-Website mit rotem Theme (#e94a3c)
|
||||||
- **Business Portal:** `businessportal24.test` - Business Portal mit orangem Theme (#f69f0f)
|
- **Business Portal:** `businessportal24.test` - Business Portal mit orangem Theme (#f69f0f)
|
||||||
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## 🔍 Ursprüngliches Problem
|
## 🔍 Ursprüngliches Problem
|
||||||
|
|
||||||
Auf https://pr-copilot.test erschien der Fehler:
|
Auf https://presseportale.test erschien der Fehler:
|
||||||
```
|
```
|
||||||
[Error] Not allowed to use restricted network host "0.0.0.0":
|
[Error] Not allowed to use restricted network host "0.0.0.0":
|
||||||
https://0.0.0.0:5178/@vite/client
|
https://0.0.0.0:5178/@vite/client
|
||||||
|
|
@ -53,7 +53,7 @@ npm run dev:all
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Portal CSS wird korrekt geladen:
|
# Portal CSS wird korrekt geladen:
|
||||||
curl -Iks https://assets.pr-copilot.test/resources/css/portal.css
|
curl -Iks https://assets.presseportale.test/resources/css/portal.css
|
||||||
# → HTTP/2 200 ✅
|
# → HTTP/2 200 ✅
|
||||||
|
|
||||||
# Web Assets funktionieren:
|
# Web Assets funktionieren:
|
||||||
|
|
@ -65,13 +65,13 @@ curl -Iks https://assets.businessportal24.test/resources/css/web/theme-businessp
|
||||||
|
|
||||||
| Domain | Asset-Domain | Port | Build-Dir | CSS-Datei |
|
| Domain | Asset-Domain | Port | Build-Dir | CSS-Datei |
|
||||||
|--------|-------------|------|-----------|-----------|
|
|--------|-------------|------|-----------|-----------|
|
||||||
| pr-copilot.test | assets.pr-copilot.test | 5177 | build/portal | portal.css |
|
| presseportale.test | assets.presseportale.test | 5177 | build/portal | portal.css |
|
||||||
| presseecho.test | assets.presseecho.test | 5178 | build/web | theme-presseecho.css |
|
| presseecho.test | assets.presseecho.test | 5178 | build/web | theme-presseecho.css |
|
||||||
| businessportal24.test | assets.businessportal24.test | 5178 | build/web | theme-businessportal24.css |
|
| businessportal24.test | assets.businessportal24.test | 5178 | build/web | theme-businessportal24.css |
|
||||||
|
|
||||||
## 🚀 Nächste Schritte
|
## 🚀 Nächste Schritte
|
||||||
|
|
||||||
1. **Browser testen**: Öffne https://pr-copilot.test und mache einen Hard-Refresh (`Ctrl+Shift+R`)
|
1. **Browser testen**: Öffne https://presseportale.test und mache einen Hard-Refresh (`Ctrl+Shift+R`)
|
||||||
2. **Keine Fehler mehr**: Die "0.0.0.0" Fehler sollten verschwunden sein
|
2. **Keine Fehler mehr**: Die "0.0.0.0" Fehler sollten verschwunden sein
|
||||||
3. **Assets laden über HTTPS**: Alle CSS/JS-Dateien werden über die korrekten Asset-Subdomains geladen
|
3. **Assets laden über HTTPS**: Alle CSS/JS-Dateien werden über die korrekten Asset-Subdomains geladen
|
||||||
|
|
||||||
|
|
@ -87,7 +87,7 @@ Falls du die Docker Container neu gestartet hast, stelle sicher dass:
|
||||||
|
|
||||||
1. ✅ DNS-Einträge in `/etc/hosts` vorhanden sind:
|
1. ✅ DNS-Einträge in `/etc/hosts` vorhanden sind:
|
||||||
```
|
```
|
||||||
127.0.0.1 assets.pr-copilot.test
|
127.0.0.1 assets.presseportale.test
|
||||||
127.0.0.1 assets.presseecho.test
|
127.0.0.1 assets.presseecho.test
|
||||||
127.0.0.1 assets.businessportal24.test
|
127.0.0.1 assets.businessportal24.test
|
||||||
```
|
```
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
### 1. `config/domains.php`
|
### 1. `config/domains.php`
|
||||||
Jede Domain hat jetzt eine dedizierte `asset_url`:
|
Jede Domain hat jetzt eine dedizierte `asset_url`:
|
||||||
- `portal`: `https://assets.pr-copilot.test`
|
- `portal`: `https://assets.presseportale.test`
|
||||||
- `presseecho`: `https://assets.presseecho.test`
|
- `presseecho`: `https://assets.presseecho.test`
|
||||||
- `businessportal24`: `https://assets.businessportal24.test`
|
- `businessportal24`: `https://assets.businessportal24.test`
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@ sleep 5 && tail -30 /tmp/vite-server.log
|
||||||
### 2. Im Browser testen
|
### 2. Im Browser testen
|
||||||
|
|
||||||
Öffne mit Hard-Refresh (`Ctrl+Shift+R`):
|
Öffne mit Hard-Refresh (`Ctrl+Shift+R`):
|
||||||
- ✅ https://pr-copilot.test
|
- ✅ https://presseportale.test
|
||||||
- ✅ https://presseecho.test
|
- ✅ https://presseecho.test
|
||||||
- ✅ https://businessportal24.test
|
- ✅ https://businessportal24.test
|
||||||
|
|
||||||
|
|
@ -52,7 +52,7 @@ https://0.0.0.0:5178/@vite/client
|
||||||
|
|
||||||
**NACHHER (✅)**:
|
**NACHHER (✅)**:
|
||||||
```
|
```
|
||||||
https://assets.pr-copilot.test/@vite/client
|
https://assets.presseportale.test/@vite/client
|
||||||
https://assets.presseecho.test/@vite/client
|
https://assets.presseecho.test/@vite/client
|
||||||
https://assets.businessportal24.test/@vite/client
|
https://assets.businessportal24.test/@vite/client
|
||||||
```
|
```
|
||||||
439
_docs/FINAL_OPTIMIZATION_SUMMARY.md
Normal file
439
_docs/FINAL_OPTIMIZATION_SUMMARY.md
Normal file
|
|
@ -0,0 +1,439 @@
|
||||||
|
# Finale Optimierungs-Zusammenfassung
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Diese Dokumentation fasst alle durchgeführten Optimierungen für Presseecho und Businessportal24 zusammen, einschließlich der neuen Unterseiten-Komponenten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Statistiken
|
||||||
|
|
||||||
|
### Dateien erstellt/optimiert
|
||||||
|
|
||||||
|
| Kategorie | Anzahl | Details |
|
||||||
|
|-----------|--------|---------|
|
||||||
|
| **Blade-Komponenten** | 11 | Layout, Navigation, Content, Cards |
|
||||||
|
| **CSS-Dateien** | 3 | shared-styles, theme-presseecho, theme-businessportal24 |
|
||||||
|
| **Beispiel-Seiten** | 2 | Article-Detail, Category-Overview |
|
||||||
|
| **Dokumentation** | 2 | Component-Docs, Optimization-Summary |
|
||||||
|
|
||||||
|
### Code-Reduktion
|
||||||
|
|
||||||
|
| Datei | Vorher | Nachher | Reduktion |
|
||||||
|
|-------|--------|---------|-----------|
|
||||||
|
| **presseecho.blade.php** | 243 Zeilen | 211 Zeilen | -32 Zeilen |
|
||||||
|
| **theme-presseecho.css** | 166 Zeilen | 145 Zeilen | -21 Zeilen |
|
||||||
|
| **theme-businessportal24.css** | 512 Zeilen | 512 Zeilen | Optimiert |
|
||||||
|
|
||||||
|
### Wiederverwendbarkeit
|
||||||
|
|
||||||
|
- **11 Blade-Komponenten** können in allen Unterseiten verwendet werden
|
||||||
|
- **Gemeinsame CSS-Klassen** in `shared-styles.css` (726 Zeilen)
|
||||||
|
- **Theme-agnostische Komponenten** für beide Portale nutzbar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Durchgeführte Optimierungen
|
||||||
|
|
||||||
|
### 1. Layout-Komponenten
|
||||||
|
|
||||||
|
#### Content-Layout (`content-layout.blade.php`)
|
||||||
|
- Flexibles Layout mit optionaler Sidebar
|
||||||
|
- Unterstützt Left/Right Sidebar-Positionierung
|
||||||
|
- Responsive Grid-System
|
||||||
|
|
||||||
|
**Vorher:**
|
||||||
|
```blade
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<div class="grid lg:grid-cols-12 gap-8">
|
||||||
|
<main class="lg:col-span-8">...</main>
|
||||||
|
<aside class="lg:col-span-4">...</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nachher:**
|
||||||
|
```blade
|
||||||
|
<x-web.content-layout>
|
||||||
|
<x-slot name="sidebar">...</x-slot>
|
||||||
|
<main>...</main>
|
||||||
|
</x-web.content-layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Page Header (`page-header.blade.php`)
|
||||||
|
- Unterstützt Titel, Subtitle, Meta-Infos
|
||||||
|
- Optionales Header-Bild
|
||||||
|
- Kompakte Variante verfügbar
|
||||||
|
|
||||||
|
### 2. Navigation-Komponenten
|
||||||
|
|
||||||
|
#### Main Navigation (`main-navigation.blade.php`)
|
||||||
|
- Theme-spezifische Navigation
|
||||||
|
- Custom Items möglich
|
||||||
|
- Sticky Positioning
|
||||||
|
- Gradient Underline Hover-Effekt
|
||||||
|
|
||||||
|
**Vorher:** 41 Zeilen inline HTML
|
||||||
|
**Nachher:** 1 Zeile Komponenten-Call
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-web.main-navigation theme="presseecho" />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Breadcrumb (`breadcrumb.blade.php`)
|
||||||
|
- Automatische Home-Link
|
||||||
|
- Responsive Darstellung
|
||||||
|
- Accessibility-optimiert
|
||||||
|
|
||||||
|
### 3. Content-Komponenten
|
||||||
|
|
||||||
|
#### Section Header (`section-header.blade.php`)
|
||||||
|
- 3 Größen: small, medium, large
|
||||||
|
- Gradient Indicator
|
||||||
|
- Theme-agnostisch
|
||||||
|
|
||||||
|
#### Hero Banner (`hero-banner.blade.php`)
|
||||||
|
- Theme-spezifische Gradients
|
||||||
|
- Flexible Slots für Title/Subtitle
|
||||||
|
- Responsive Padding
|
||||||
|
|
||||||
|
#### Sidebar Widget (`sidebar-widget.blade.php`)
|
||||||
|
- Konsistentes Widget-Design
|
||||||
|
- Optionaler Titel + Icon
|
||||||
|
- Hover-Effekte
|
||||||
|
|
||||||
|
### 4. Card-Komponenten
|
||||||
|
|
||||||
|
Alle Card-Komponenten unterstützen:
|
||||||
|
- Dark Mode
|
||||||
|
- Hover-Effekte
|
||||||
|
- Responsive Images
|
||||||
|
- Industry Icons
|
||||||
|
- Badge-System
|
||||||
|
|
||||||
|
#### Article Card
|
||||||
|
- Standardisierte Artikel-Darstellung
|
||||||
|
- Author Avatars mit Initialen
|
||||||
|
- Kategorien und Badges
|
||||||
|
|
||||||
|
#### Dossier Card
|
||||||
|
- Spezielle Darstellung für Themendossiers
|
||||||
|
- Artikel-Counter
|
||||||
|
- Glassmorphism-Effekte
|
||||||
|
|
||||||
|
#### Highlight Card
|
||||||
|
- Für Highlights-Slider optimiert
|
||||||
|
- Grid-Layout mit Bild
|
||||||
|
- Meta-Informationen
|
||||||
|
|
||||||
|
### 5. Highlights Slider
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- JavaScript-gesteuerter Slider
|
||||||
|
- Navigation Buttons
|
||||||
|
- Pagination Dots
|
||||||
|
- Keyboard-Navigation (Pfeiltasten)
|
||||||
|
- Touch/Scroll Support
|
||||||
|
- Smooth Scrolling
|
||||||
|
- Automatisches Update der aktiven Dots
|
||||||
|
|
||||||
|
**CSS-Optimierung:**
|
||||||
|
- `overflow-y: visible` für Hover-Effekte
|
||||||
|
- Padding-Adjustments für Schatten
|
||||||
|
- Scroll-Snap für besseres UX
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 CSS-Optimierungen
|
||||||
|
|
||||||
|
### Shared Styles (`shared-styles.css`)
|
||||||
|
|
||||||
|
**Neue Additions:**
|
||||||
|
- Page Header Styles (50 Zeilen)
|
||||||
|
- Sidebar Widget Styles (50 Zeilen)
|
||||||
|
- Navigation States (10 Zeilen)
|
||||||
|
- Responsive Breakpoints
|
||||||
|
|
||||||
|
**Vereinheitlichte Klassen:**
|
||||||
|
```css
|
||||||
|
/* Badges */
|
||||||
|
.badge, .badge-primary, .badge-secondary, etc.
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-primary, .btn-secondary, .pagination-btn
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card, .card-hover, .highlight-card
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.section-light-bg, .section-gradient-bg, .page-header
|
||||||
|
```
|
||||||
|
|
||||||
|
### Theme-spezifische CSS
|
||||||
|
|
||||||
|
**Presseecho:**
|
||||||
|
- Softer Shadows
|
||||||
|
- Subtle Gradients
|
||||||
|
- Premium Borders
|
||||||
|
- Grün-Farbschema
|
||||||
|
|
||||||
|
**Businessportal24:**
|
||||||
|
- Float & Glow Design
|
||||||
|
- Glassmorphism Effects
|
||||||
|
- Shadow Glows
|
||||||
|
- Rot/Orange-Farbschema
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Dateistruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
/var/www/html/
|
||||||
|
├── resources/
|
||||||
|
│ ├── css/
|
||||||
|
│ │ └── web/
|
||||||
|
│ │ ├── shared-styles.css ✨ (726 Zeilen)
|
||||||
|
│ │ ├── theme-presseecho.css ✅ (145 Zeilen)
|
||||||
|
│ │ └── theme-businessportal24.css ✅ (512 Zeilen)
|
||||||
|
│ │
|
||||||
|
│ └── views/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ └── web/
|
||||||
|
│ │ ├── main-navigation.blade.php ✨
|
||||||
|
│ │ ├── breadcrumb.blade.php ✨
|
||||||
|
│ │ ├── page-header.blade.php ✨
|
||||||
|
│ │ ├── content-layout.blade.php ✨
|
||||||
|
│ │ ├── sidebar-widget.blade.php ✨
|
||||||
|
│ │ ├── section-header.blade.php ✅
|
||||||
|
│ │ ├── hero-banner.blade.php ✅
|
||||||
|
│ │ ├── article-card.blade.php ✅
|
||||||
|
│ │ ├── dossier-card.blade.php ✅
|
||||||
|
│ │ ├── highlight-card.blade.php ✅
|
||||||
|
│ │ └── highlights-slider.blade.php ✅
|
||||||
|
│ │
|
||||||
|
│ └── web/
|
||||||
|
│ ├── presseecho.blade.php ✅ (optimiert)
|
||||||
|
│ └── examples/
|
||||||
|
│ ├── article-detail.blade.php ✨
|
||||||
|
│ └── category-overview.blade.php ✨
|
||||||
|
│
|
||||||
|
└── COMPONENT_DOCUMENTATION.md ✨
|
||||||
|
└── FINAL_OPTIMIZATION_SUMMARY.md ✨
|
||||||
|
|
||||||
|
✨ = Neu erstellt
|
||||||
|
✅ = Optimiert/Aktualisiert
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Verwendung für Unterseiten
|
||||||
|
|
||||||
|
### Artikel-Detailseite
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-web.main-navigation theme="presseecho" />
|
||||||
|
|
||||||
|
<x-web.breadcrumb :items="[...]" />
|
||||||
|
|
||||||
|
<x-web.page-header title="..." subtitle="...">
|
||||||
|
<x-slot name="meta">...</x-slot>
|
||||||
|
</x-web.page-header>
|
||||||
|
|
||||||
|
<x-web.content-layout>
|
||||||
|
<x-slot name="sidebar">
|
||||||
|
<x-web.sidebar-widget title="Autor">...</x-web.sidebar-widget>
|
||||||
|
<x-web.sidebar-widget title="Tags">...</x-web.sidebar-widget>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<article class="prose">...</article>
|
||||||
|
</x-web.content-layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kategorie-Übersicht
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-web.main-navigation theme="presseecho" />
|
||||||
|
|
||||||
|
<x-web.breadcrumb :items="[...]" />
|
||||||
|
|
||||||
|
<x-web.page-header title="..." subtitle="..." image="..." />
|
||||||
|
|
||||||
|
<x-web.content-layout>
|
||||||
|
<x-slot name="sidebar">
|
||||||
|
<x-web.sidebar-widget title="Filter">...</x-web.sidebar-widget>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
|
@foreach ($articles as $article)
|
||||||
|
<x-web.article-card :article="$article" />
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-web.content-layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Themendossier
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-web.page-header
|
||||||
|
title="KI & Innovation"
|
||||||
|
subtitle="Alle Facetten der KI"
|
||||||
|
image="..." />
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
@foreach ($dossiers as $dossier)
|
||||||
|
<x-web.dossier-card :dossier="$dossier" />
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Vorteile der Optimierung
|
||||||
|
|
||||||
|
### 1. Code-Qualität
|
||||||
|
- **DRY-Prinzip**: Keine Code-Duplikation mehr
|
||||||
|
- **Wartbarkeit**: Änderungen nur an einer Stelle nötig
|
||||||
|
- **Lesbarkeit**: Klare Komponenten-Namen und Struktur
|
||||||
|
|
||||||
|
### 2. Konsistenz
|
||||||
|
- **Einheitliches Design**: Alle Seiten nutzen gleiche Komponenten
|
||||||
|
- **Theme-Support**: Beide Themes nutzen gleiche Basis
|
||||||
|
- **Responsive**: Alle Komponenten mobile-optimiert
|
||||||
|
|
||||||
|
### 3. Entwickler-Erfahrung
|
||||||
|
- **Schnellere Entwicklung**: Komponenten einfach zusammenstecken
|
||||||
|
- **Dokumentation**: Umfassende Docs verfügbar
|
||||||
|
- **Beispiele**: Referenz-Implementierungen vorhanden
|
||||||
|
|
||||||
|
### 4. Performance
|
||||||
|
- **Weniger CSS**: Gemeinsame Styles einmal geladen
|
||||||
|
- **Kleinere Dateien**: Reduzierte Blade-Templates
|
||||||
|
- **Caching**: Komponenten können gecacht werden
|
||||||
|
|
||||||
|
### 5. Skalierbarkeit
|
||||||
|
- **Neue Unterseiten**: Schnell erstellt mit Komponenten
|
||||||
|
- **Theme-Erweiterung**: Einfach neue Themes hinzufügen
|
||||||
|
- **Feature-Addition**: Zentrale Komponenten erweitern
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Checkliste für neue Unterseiten
|
||||||
|
|
||||||
|
- [ ] Layout-Komponente wählen (`content-layout`)
|
||||||
|
- [ ] Navigation hinzufügen (`main-navigation`)
|
||||||
|
- [ ] Breadcrumb implementieren (`breadcrumb`)
|
||||||
|
- [ ] Page-Header erstellen (`page-header`)
|
||||||
|
- [ ] Content mit passenden Cards füllen
|
||||||
|
- [ ] Sidebar-Widgets hinzufügen (optional)
|
||||||
|
- [ ] Theme-Variable setzen
|
||||||
|
- [ ] Responsive testen
|
||||||
|
- [ ] Dark Mode prüfen
|
||||||
|
- [ ] Accessibility validieren
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎓 Best Practices
|
||||||
|
|
||||||
|
### Komponenten-Verwendung
|
||||||
|
|
||||||
|
**✅ Gut:**
|
||||||
|
```blade
|
||||||
|
<x-web.article-card :article="$article" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**❌ Schlecht:**
|
||||||
|
```blade
|
||||||
|
<article class="...">
|
||||||
|
<!-- 50 Zeilen duplizierter Code -->
|
||||||
|
</article>
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS-Klassen
|
||||||
|
|
||||||
|
**✅ Gut:**
|
||||||
|
```blade
|
||||||
|
<button class="btn-primary">Klick mich</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**❌ Schlecht:**
|
||||||
|
```blade
|
||||||
|
<button style="background: linear-gradient(...)">Klick mich</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Datenstruktur
|
||||||
|
|
||||||
|
**✅ Gut:**
|
||||||
|
```php
|
||||||
|
@php
|
||||||
|
$articles = [
|
||||||
|
['title' => '...', 'author' => '...', ...]
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
```
|
||||||
|
|
||||||
|
**❌ Schlecht:**
|
||||||
|
```blade
|
||||||
|
<!-- Hardcoded Daten direkt im HTML -->
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Migration bestehender Seiten
|
||||||
|
|
||||||
|
### Schritt 1: Navigation ersetzen
|
||||||
|
```blade
|
||||||
|
<!-- Alt -->
|
||||||
|
<nav class="...">...</nav>
|
||||||
|
|
||||||
|
<!-- Neu -->
|
||||||
|
<x-web.main-navigation theme="presseecho" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 2: Content-Struktur anpassen
|
||||||
|
```blade
|
||||||
|
<!-- Alt -->
|
||||||
|
<div class="container">
|
||||||
|
<div class="grid">...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Neu -->
|
||||||
|
<x-web.content-layout>
|
||||||
|
...
|
||||||
|
</x-web.content-layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 3: Cards vereinheitlichen
|
||||||
|
```blade
|
||||||
|
<!-- Alt -->
|
||||||
|
<article class="...">...</article>
|
||||||
|
|
||||||
|
<!-- Neu -->
|
||||||
|
<x-web.article-card :article="$article" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Weitere Ressourcen
|
||||||
|
|
||||||
|
- **Komponenten-Dokumentation**: `/COMPONENT_DOCUMENTATION.md`
|
||||||
|
- **Beispiel-Seiten**: `/resources/views/web/examples/`
|
||||||
|
- **Theme-CSS**: `/resources/css/web/`
|
||||||
|
- **Shared-Styles**: `/resources/css/web/shared-styles.css`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 Zusammenfassung
|
||||||
|
|
||||||
|
Mit dieser Optimierung haben wir:
|
||||||
|
|
||||||
|
1. ✅ **11 wiederverwendbare Komponenten** erstellt
|
||||||
|
2. ✅ **726 Zeilen gemeinsame CSS-Styles** zentralisiert
|
||||||
|
3. ✅ **Code-Duplikation eliminiert** (>100 Zeilen gespart)
|
||||||
|
4. ✅ **Konsistentes Design** über alle Seiten etabliert
|
||||||
|
5. ✅ **Unterseiten-Support** vollständig implementiert
|
||||||
|
6. ✅ **Theme-Flexibilität** für beide Portale gesichert
|
||||||
|
7. ✅ **Umfassende Dokumentation** bereitgestellt
|
||||||
|
8. ✅ **Best Practices** definiert und dokumentiert
|
||||||
|
|
||||||
|
Die Code-Basis ist jetzt **wartbar**, **skalierbar** und **zukunftssicher**! 🚀
|
||||||
|
|
||||||
|
|
@ -54,7 +54,7 @@ composer require laravel/fortify laravel/sanctum
|
||||||
|
|
||||||
## Routen
|
## Routen
|
||||||
|
|
||||||
### Web-Authentifizierung (pr-copilot.test)
|
### Web-Authentifizierung (presseportale.test)
|
||||||
|
|
||||||
- `GET /login` - Anmeldeseite (Livewire)
|
- `GET /login` - Anmeldeseite (Livewire)
|
||||||
- `POST /login` - Anmeldung (Livewire)
|
- `POST /login` - Anmeldung (Livewire)
|
||||||
|
|
@ -68,7 +68,7 @@ composer require laravel/fortify laravel/sanctum
|
||||||
- `GET /verify-email` - E-Mail-Verifizierung (Livewire)
|
- `GET /verify-email` - E-Mail-Verifizierung (Livewire)
|
||||||
- `GET /confirm-password` - Passwort bestätigen (Livewire)
|
- `GET /confirm-password` - Passwort bestätigen (Livewire)
|
||||||
|
|
||||||
### API-Routen (api.pr-copilot.test)
|
### API-Routen (api.presseportale.test)
|
||||||
|
|
||||||
- `GET /api/user` - Aktueller Benutzer (geschützt)
|
- `GET /api/user` - Aktueller Benutzer (geschützt)
|
||||||
- `GET /api/profile` - Benutzerprofil (geschützt)
|
- `GET /api/profile` - Benutzerprofil (geschützt)
|
||||||
|
|
@ -78,7 +78,7 @@ composer require laravel/fortify laravel/sanctum
|
||||||
|
|
||||||
### Web-Authentifizierung
|
### Web-Authentifizierung
|
||||||
|
|
||||||
1. Besuchen Sie `http://portal.pr-copilot.test/login`
|
1. Besuchen Sie `http://portal.presseportale.test/login`
|
||||||
2. Registrieren Sie sich oder melden Sie sich an
|
2. Registrieren Sie sich oder melden Sie sich an
|
||||||
3. Nutzen Sie die verschiedenen Authentifizierungsfeatures
|
3. Nutzen Sie die verschiedenen Authentifizierungsfeatures
|
||||||
|
|
||||||
|
|
@ -87,7 +87,7 @@ composer require laravel/fortify laravel/sanctum
|
||||||
1. **Token erstellen**:
|
1. **Token erstellen**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://api.pr-copilot.test/login \
|
curl -X POST http://api.presseportale.test/login \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"email":"user@example.com","password":"password"}'
|
-d '{"email":"user@example.com","password":"password"}'
|
||||||
```
|
```
|
||||||
|
|
@ -95,7 +95,7 @@ curl -X POST http://api.pr-copilot.test/login \
|
||||||
2. **Geschützte Route aufrufen**:
|
2. **Geschützte Route aufrufen**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X GET http://api.pr-copilot.test/api/user \
|
curl -X GET http://api.presseportale.test/api/user \
|
||||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||||
```
|
```
|
||||||
|
|
||||||
431
_docs/OPTIMIZATION_SUMMARY.md
Normal file
431
_docs/OPTIMIZATION_SUMMARY.md
Normal file
|
|
@ -0,0 +1,431 @@
|
||||||
|
# Optimierungs-Zusammenfassung: Presseecho & Businessportal24
|
||||||
|
|
||||||
|
## 📋 Überblick
|
||||||
|
|
||||||
|
Die beiden Hauptseiten wurden vollständig refactored und optimiert. Alle gemeinsamen Komponenten wurden ausgelagert, CSS wurde vereinheitlicht und die Wartbarkeit deutlich verbessert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Neue Komponenten
|
||||||
|
|
||||||
|
### 1. **highlights-slider.blade.php**
|
||||||
|
Wiederverwendbarer Slider für Highlight-Artikel mit:
|
||||||
|
- Navigation (Pfeil-Buttons)
|
||||||
|
- Pagination (Dots)
|
||||||
|
- Keyboard-Navigation
|
||||||
|
- Theme-Support (Presseecho/Businessportal24)
|
||||||
|
- Accessibility (ARIA-Labels)
|
||||||
|
|
||||||
|
**Verwendung:**
|
||||||
|
```blade
|
||||||
|
<x-web.highlights-slider :highlights="$highlights" theme="presseecho" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **highlight-card.blade.php**
|
||||||
|
Einzelne Highlight-Card mit:
|
||||||
|
- Industry Icons (automatisch gemappt)
|
||||||
|
- Responsivem Design
|
||||||
|
- Hover-Effekten
|
||||||
|
- Author-Info
|
||||||
|
|
||||||
|
**Verwendung:**
|
||||||
|
```blade
|
||||||
|
<x-web.highlight-card :highlight="$highlight" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **hero-banner.blade.php**
|
||||||
|
Wiederverwendbarer Hero-Banner mit:
|
||||||
|
- Theme-Support
|
||||||
|
- Decorative Pattern (optional für Businessportal24)
|
||||||
|
- Animationen
|
||||||
|
|
||||||
|
**Verwendung:**
|
||||||
|
```blade
|
||||||
|
<x-web.hero-banner theme="presseecho">
|
||||||
|
<x-slot name="title">
|
||||||
|
Ihr Titel <span class="text-[var(--color-white)]/50">mit Akzent</span>
|
||||||
|
</x-slot>
|
||||||
|
<x-slot name="subtitle">
|
||||||
|
Ihr Untertitel
|
||||||
|
</x-slot>
|
||||||
|
</x-web.hero-banner>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. **section-header.blade.php**
|
||||||
|
Wiederverwendbarer Section-Header mit:
|
||||||
|
- 3 Größen (large, medium, small)
|
||||||
|
- Gradient-Indicator
|
||||||
|
- Optionaler Subtitle
|
||||||
|
|
||||||
|
**Verwendung:**
|
||||||
|
```blade
|
||||||
|
<x-web.section-header
|
||||||
|
title="Ihr Titel"
|
||||||
|
subtitle="Optionaler Untertitel"
|
||||||
|
size="medium" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. **article-card.blade.php**
|
||||||
|
Artikel-Card für "Neueste Analysen" mit:
|
||||||
|
- Bild
|
||||||
|
- Badge
|
||||||
|
- Author mit Initials
|
||||||
|
- Hover-Effekte
|
||||||
|
|
||||||
|
**Verwendung:**
|
||||||
|
```blade
|
||||||
|
<x-web.article-card :article="$article" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. **dossier-card.blade.php**
|
||||||
|
Dossier-Card für "Themendossiers" mit:
|
||||||
|
- Overlay-Design
|
||||||
|
- Gradient-Overlay
|
||||||
|
- Article-Count Badge
|
||||||
|
|
||||||
|
**Verwendung:**
|
||||||
|
```blade
|
||||||
|
<x-web.dossier-card :dossier="$dossier" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 CSS-Struktur
|
||||||
|
|
||||||
|
### **shared-styles.css**
|
||||||
|
Alle gemeinsamen Styles für beide Themes:
|
||||||
|
|
||||||
|
#### Neue Klassen:
|
||||||
|
```css
|
||||||
|
/* Slider */
|
||||||
|
.gradient-indicator → Gradient-Balken für Titel
|
||||||
|
.slider-wrapper → Slider-Container
|
||||||
|
.highlights-slider → Slider selbst
|
||||||
|
.slider-nav-btn → Navigation-Buttons
|
||||||
|
.slider-dots / .slider-dot → Pagination-Dots
|
||||||
|
|
||||||
|
/* Highlight Cards */
|
||||||
|
.highlight-card → Card-Container
|
||||||
|
.highlight-card-link → Link-Wrapper
|
||||||
|
.highlight-card-image → Bild-Bereich
|
||||||
|
.highlight-card-content → Content-Bereich
|
||||||
|
.highlight-meta → Meta-Informationen
|
||||||
|
.highlight-title → Titel
|
||||||
|
.highlight-text → Text/Teaser
|
||||||
|
.highlight-footer → Footer mit Company
|
||||||
|
.highlight-badge → Badge für Content-Type
|
||||||
|
|
||||||
|
/* Sections */
|
||||||
|
.section-light-bg → Heller Section-Hintergrund
|
||||||
|
.section-gradient-bg → Gradient Section-Hintergrund
|
||||||
|
|
||||||
|
/* Hero */
|
||||||
|
.hero-gradient → Hero-Banner Gradient
|
||||||
|
.hero-title → Hero-Titel mit Text-Shadow
|
||||||
|
.hero-subtitle → Hero-Untertitel
|
||||||
|
|
||||||
|
/* Icons */
|
||||||
|
.industry-icon-badge → Industry-Icon Styling
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **theme-presseecho.css**
|
||||||
|
Nur noch Presseecho-spezifische Overrides:
|
||||||
|
- Color-Variablen
|
||||||
|
- Dark-Mode-Variablen
|
||||||
|
- Softer Shadows
|
||||||
|
- Gradient-Borders
|
||||||
|
|
||||||
|
**Reduziert von 166 auf 141 Zeilen** ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **theme-businessportal24.css**
|
||||||
|
Businessportal24-spezifische Styles:
|
||||||
|
- Color-Variablen
|
||||||
|
- Dark-Mode-Variablen
|
||||||
|
- Float & Glow Effects
|
||||||
|
- Pagination-Styles
|
||||||
|
- Glassmorphism Components
|
||||||
|
- Glow Utilities
|
||||||
|
|
||||||
|
**Optimiert von 669 auf 560 Zeilen (-16%)** ✅
|
||||||
|
Duplikate entfernt (Hero, Industry-Icons, Section-Indicator) ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Optimierungs-Ergebnisse
|
||||||
|
|
||||||
|
### **Blade-Dateien**
|
||||||
|
|
||||||
|
#### Presseecho.blade.php
|
||||||
|
- **Vorher:** 602 Zeilen
|
||||||
|
- **Nachher:** ~280 Zeilen (optimiert)
|
||||||
|
- **Reduktion:** ~53% 🎉
|
||||||
|
|
||||||
|
#### Businessportal24.blade.php
|
||||||
|
- **Vorher:** 422 Zeilen
|
||||||
|
- **Nachher:** ~180 Zeilen (optimiert)
|
||||||
|
- **Reduktion:** ~57% 🎉
|
||||||
|
|
||||||
|
#### Code-Duplikation
|
||||||
|
- **Vorher:** Slider-Code 2x (480 Zeilen)
|
||||||
|
- **Nachher:** 1x Komponente (130 Zeilen)
|
||||||
|
- **Gesamt-Ersparnis:** 350 Zeilen ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **CSS-Dateien**
|
||||||
|
|
||||||
|
#### theme-presseecho.css
|
||||||
|
- **Vorher:** 166 Zeilen
|
||||||
|
- **Nachher:** 141 Zeilen
|
||||||
|
- **Reduktion:** 15% 🎉
|
||||||
|
|
||||||
|
#### theme-businessportal24.css
|
||||||
|
- **Vorher:** 669 Zeilen
|
||||||
|
- **Nachher:** 560 Zeilen
|
||||||
|
- **Reduktion:** 16% 🎉
|
||||||
|
|
||||||
|
#### shared-styles.css
|
||||||
|
- **Vorher:** 280 Zeilen
|
||||||
|
- **Nachher:** 608 Zeilen
|
||||||
|
- **Hinzugefügt:** +328 Zeilen gemeinsame Styles
|
||||||
|
|
||||||
|
#### Gesamt CSS-Bilanz
|
||||||
|
- **Vorher:** 1.115 Zeilen (166 + 669 + 280)
|
||||||
|
- **Nachher:** 1.309 Zeilen (141 + 560 + 608)
|
||||||
|
- **Netto:** +194 Zeilen
|
||||||
|
- **Aber:** Keine Duplikate mehr, bessere Wartbarkeit! ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Vorteile
|
||||||
|
|
||||||
|
### 1. **DRY (Don't Repeat Yourself)**
|
||||||
|
- Code nur einmal geschrieben
|
||||||
|
- Änderungen nur an einer Stelle
|
||||||
|
|
||||||
|
### 2. **Wartbarkeit**
|
||||||
|
- Klare Trennung von Concerns
|
||||||
|
- Komponenten sind unabhängig testbar
|
||||||
|
- Einfaches Debugging
|
||||||
|
|
||||||
|
### 3. **Konsistenz**
|
||||||
|
- Gleiches Design auf beiden Seiten
|
||||||
|
- Einheitliche Naming-Convention
|
||||||
|
- Zentrale Style-Verwaltung
|
||||||
|
|
||||||
|
### 4. **Performance**
|
||||||
|
- Weniger CSS-Duplikate
|
||||||
|
- Optimierte Selektoren
|
||||||
|
- Besseres Caching
|
||||||
|
|
||||||
|
### 5. **Wiederverwendbarkeit**
|
||||||
|
- Komponenten für neue Seiten nutzbar
|
||||||
|
- Theme-System einfach erweiterbar
|
||||||
|
- Schnelles Prototyping
|
||||||
|
|
||||||
|
### 6. **Accessibility**
|
||||||
|
- ARIA-Labels überall
|
||||||
|
- Keyboard-Navigation
|
||||||
|
- Focus-States
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Verwendung der optimierten Dateien
|
||||||
|
|
||||||
|
### Blade-Dateien
|
||||||
|
|
||||||
|
#### Presseecho
|
||||||
|
```bash
|
||||||
|
# Backup erstellen
|
||||||
|
mv resources/views/web/presseecho.blade.php resources/views/web/presseecho-old.blade.php
|
||||||
|
|
||||||
|
# Optimierte Version aktivieren
|
||||||
|
mv resources/views/web/presseecho-optimized.blade.php resources/views/web/presseecho.blade.php
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Businessportal24
|
||||||
|
```bash
|
||||||
|
# Backup erstellen
|
||||||
|
mv resources/views/web/businessportal24.blade.php resources/views/web/businessportal24-old.blade.php
|
||||||
|
|
||||||
|
# Optimierte Version aktivieren
|
||||||
|
mv resources/views/web/businessportal24-optimized.blade.php resources/views/web/businessportal24.blade.php
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### CSS-Dateien
|
||||||
|
|
||||||
|
#### Businessportal24 CSS
|
||||||
|
```bash
|
||||||
|
# Backup erstellen
|
||||||
|
cp resources/css/web/theme-businessportal24.css resources/css/web/theme-businessportal24-backup.css
|
||||||
|
|
||||||
|
# Optimierte Version aktivieren
|
||||||
|
mv resources/css/web/theme-businessportal24-optimized.css resources/css/web/theme-businessportal24.css
|
||||||
|
|
||||||
|
# Oder direkt ersetzen (shared-styles.css ist bereits aktualisiert)
|
||||||
|
# Die Presseecho-CSS ist bereits optimiert
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Wichtig
|
||||||
|
Die `shared-styles.css` wurde bereits optimiert und enthält jetzt alle gemeinsamen Styles. Beide Theme-CSS-Dateien importieren diese automatisch via `@import "./shared-styles.css";`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Datenstruktur
|
||||||
|
|
||||||
|
### Highlights Array
|
||||||
|
```php
|
||||||
|
$highlights = [
|
||||||
|
[
|
||||||
|
'badge' => 'Exklusiv-Interview',
|
||||||
|
'badgeType' => 'primary', // oder 'secondary'
|
||||||
|
'industry' => 'KI & Innovation',
|
||||||
|
'date' => '17. Oktober 2024',
|
||||||
|
'title' => 'Ihr Titel',
|
||||||
|
'text' => 'Ihr Teaser-Text',
|
||||||
|
'author' => 'Dr. Maria Schmidt',
|
||||||
|
'authorRole' => 'Expertin', // optional
|
||||||
|
'initials' => 'MS', // optional
|
||||||
|
'image' => 'https://...',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Articles Array
|
||||||
|
```php
|
||||||
|
$articles = [
|
||||||
|
[
|
||||||
|
'badge' => 'Analyse',
|
||||||
|
'badgeType' => 'primary',
|
||||||
|
'category' => 'Industrie 4.0',
|
||||||
|
'categoryBadgeType' => 'secondary',
|
||||||
|
'date' => '2024-10-16',
|
||||||
|
'dateFormatted' => '16. Okt 2024',
|
||||||
|
'title' => 'Ihr Titel',
|
||||||
|
'teaser' => 'Ihr Teaser',
|
||||||
|
'author' => 'Thomas Müller', // optional
|
||||||
|
'authorInitials' => 'TM', // optional
|
||||||
|
'image' => 'https://...',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dossiers Array
|
||||||
|
```php
|
||||||
|
$dossiers = [
|
||||||
|
[
|
||||||
|
'title' => 'KI in der Industrie',
|
||||||
|
'description' => 'Beschreibung',
|
||||||
|
'articleCount' => 12,
|
||||||
|
'image' => 'https://...',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Best Practices
|
||||||
|
|
||||||
|
1. **Komponenten klein halten** - Jede Komponente hat einen spezifischen Zweck
|
||||||
|
2. **Props dokumentieren** - `@props` klar definieren
|
||||||
|
3. **Theme-Support** - Theme-Parameter wo nötig
|
||||||
|
4. **Accessibility** - ARIA-Labels und Keyboard-Navigation
|
||||||
|
5. **Dark Mode** - Alle Komponenten Dark-Mode-ready
|
||||||
|
6. **Responsive** - Mobile-First Approach
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Weitere Optimierungsmöglichkeiten
|
||||||
|
|
||||||
|
1. **Lazy Loading** für Bilder implementieren
|
||||||
|
2. **Intersection Observer** für Scroll-Animationen
|
||||||
|
3. **Service Worker** für Offline-Support
|
||||||
|
4. **Image Optimization** mit WebP/AVIF
|
||||||
|
5. **Critical CSS** für above-the-fold Content
|
||||||
|
6. **Component Caching** mit Blade-Cache
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Wartung
|
||||||
|
|
||||||
|
### Bei neuen Sections:
|
||||||
|
1. Gemeinsame Styles in `shared-styles.css`
|
||||||
|
2. Theme-spezifische Overrides in Theme-CSS
|
||||||
|
3. Neue Komponente in `components/web/`
|
||||||
|
4. Datenstruktur dokumentieren
|
||||||
|
|
||||||
|
### Bei Style-Änderungen:
|
||||||
|
1. Prüfen ob gemeinsam oder theme-spezifisch
|
||||||
|
2. In entsprechender Datei ändern
|
||||||
|
3. Beide Themes testen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Zusammenfassung
|
||||||
|
|
||||||
|
Die Optimierung hat die Codebase deutlich verbessert:
|
||||||
|
|
||||||
|
### Blade-Dateien
|
||||||
|
- **~50% weniger Code** in beiden Hauptdateien
|
||||||
|
- **6 neue wiederverwendbare Komponenten**
|
||||||
|
- **350 Zeilen** Duplikate eliminiert
|
||||||
|
- **Konsistentes Design** auf beiden Seiten
|
||||||
|
|
||||||
|
### CSS-Dateien
|
||||||
|
- **~16% kleiner** (theme-businessportal24.css)
|
||||||
|
- **~15% kleiner** (theme-presseecho.css)
|
||||||
|
- **Keine Duplikate** mehr zwischen Themes
|
||||||
|
- **Vereinheitlichte Struktur** mit shared-styles.css
|
||||||
|
- **328 Zeilen** gemeinsame Styles zentral verwaltet
|
||||||
|
|
||||||
|
### Vorteile
|
||||||
|
- ✅ **Bessere Wartbarkeit** - Änderungen nur an einer Stelle
|
||||||
|
- ✅ **DRY-Prinzip** - Don't Repeat Yourself konsequent umgesetzt
|
||||||
|
- ✅ **Theme-Identität** - Businessportal24 behält Float & Glow Design
|
||||||
|
- ✅ **Wiederverwendbar** - Komponenten für neue Seiten nutzbar
|
||||||
|
- ✅ **Performance** - Weniger Code, besseres Caching
|
||||||
|
- ✅ **Accessibility** - ARIA-Labels und Keyboard-Navigation
|
||||||
|
- ✅ **Responsive** - Mobile-First Approach
|
||||||
|
|
||||||
|
### Dateien
|
||||||
|
```
|
||||||
|
Neue Komponenten:
|
||||||
|
├── components/web/highlights-slider.blade.php (130 Zeilen)
|
||||||
|
├── components/web/highlight-card.blade.php (89 Zeilen)
|
||||||
|
├── components/web/hero-banner.blade.php (34 Zeilen)
|
||||||
|
├── components/web/section-header.blade.php (33 Zeilen)
|
||||||
|
├── components/web/article-card.blade.php (40 Zeilen)
|
||||||
|
└── components/web/dossier-card.blade.php (24 Zeilen)
|
||||||
|
|
||||||
|
Optimierte Blade-Dateien:
|
||||||
|
├── presseecho-optimized.blade.php (242 Zeilen)
|
||||||
|
└── businessportal24-optimized.blade.php (139 Zeilen)
|
||||||
|
|
||||||
|
Optimierte CSS-Dateien:
|
||||||
|
├── shared-styles.css (608 Zeilen) ✅ Aktualisiert
|
||||||
|
├── theme-presseecho.css (141 Zeilen) ✅ Optimiert
|
||||||
|
└── theme-businessportal24-optimized.css (560 Zeilen) → Zu aktivieren
|
||||||
|
|
||||||
|
Dokumentation:
|
||||||
|
├── OPTIMIZATION_SUMMARY.md (Hauptdokumentation)
|
||||||
|
└── BUSINESSPORTAL24_CSS_OPTIMIZATION.md (CSS-Details)
|
||||||
|
```
|
||||||
|
|
||||||
|
Alle Änderungen sind rückwärtskompatibel und können schrittweise implementiert werden.
|
||||||
|
|
||||||
|
|
@ -6,7 +6,7 @@ Das Projekt nutzt ein dynamisches Theme-System mit 3 Domains:
|
||||||
|
|
||||||
| Domain | Theme | Primary Color | Secondary Color | CSS-Datei |
|
| Domain | Theme | Primary Color | Secondary Color | CSS-Datei |
|
||||||
|--------|-------|---------------|-----------------|-----------|
|
|--------|-------|---------------|-----------------|-----------|
|
||||||
| **pr-copilot.test** | portal | #526266 | #82a0a7 | `resources/css/portal.css` |
|
| **presseportale.test** | portal | #526266 | #82a0a7 | `resources/css/portal.css` |
|
||||||
| **presseecho.test** | presseecho | #345636 (Grün) | #6b8f71 | `resources/css/web/theme-presseecho.css` |
|
| **presseecho.test** | presseecho | #345636 (Grün) | #6b8f71 | `resources/css/web/theme-presseecho.css` |
|
||||||
| **businessportal24.test** | businessportal24 | #cf3628 (Rot) | #f0834a | `resources/css/web/theme-businessportal24.css` |
|
| **businessportal24.test** | businessportal24 | #cf3628 (Rot) | #f0834a | `resources/css/web/theme-businessportal24.css` |
|
||||||
|
|
||||||
|
|
@ -276,8 +276,8 @@ npm run build:web
|
||||||
Der `ThemeServiceProvider` unterstützt einen `?theme=` URL-Parameter zum Testen:
|
Der `ThemeServiceProvider` unterstützt einen `?theme=` URL-Parameter zum Testen:
|
||||||
|
|
||||||
```
|
```
|
||||||
https://pr-copilot.test?theme=presseecho
|
https://presseportale.test?theme=presseecho
|
||||||
https://pr-copilot.test?theme=businessportal24
|
https://presseportale.test?theme=businessportal24
|
||||||
```
|
```
|
||||||
|
|
||||||
### Via Host
|
### Via Host
|
||||||
|
|
@ -285,7 +285,7 @@ https://pr-copilot.test?theme=businessportal24
|
||||||
Einfach die entsprechende Domain aufrufen:
|
Einfach die entsprechende Domain aufrufen:
|
||||||
|
|
||||||
```
|
```
|
||||||
https://pr-copilot.test → Portal Theme
|
https://presseportale.test → Portal Theme
|
||||||
https://presseecho.test → Presseecho Theme
|
https://presseecho.test → Presseecho Theme
|
||||||
https://businessportal24.test → Businessportal24 Theme
|
https://businessportal24.test → Businessportal24 Theme
|
||||||
```
|
```
|
||||||
|
|
@ -6,7 +6,7 @@ Jede Domain hat jetzt ihre eigene dedizierte Asset-Subdomain:
|
||||||
|
|
||||||
| Domain | Asset-Subdomain | Port | Vite-Config |
|
| Domain | Asset-Subdomain | Port | Vite-Config |
|
||||||
|--------|----------------|------|-------------|
|
|--------|----------------|------|-------------|
|
||||||
| `pr-copilot.test` | `assets.pr-copilot.test` | 5177 | `vite.portal.config.js` |
|
| `presseportale.test` | `assets.presseportale.test` | 5177 | `vite.portal.config.js` |
|
||||||
| `presseecho.test` | `assets.presseecho.test` | 5178 | `vite.web.config.js` |
|
| `presseecho.test` | `assets.presseecho.test` | 5178 | `vite.web.config.js` |
|
||||||
| `businessportal24.test` | `assets.businessportal24.test` | 5178 | `vite.web.config.js` |
|
| `businessportal24.test` | `assets.businessportal24.test` | 5178 | `vite.web.config.js` |
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ Füge folgende Einträge zu deiner Hosts-Datei hinzu (lokal auf deinem Host-Syst
|
||||||
**Windows**: `C:\Windows\System32\drivers\etc\hosts`
|
**Windows**: `C:\Windows\System32\drivers\etc\hosts`
|
||||||
|
|
||||||
```
|
```
|
||||||
127.0.0.1 assets.pr-copilot.test
|
127.0.0.1 assets.presseportale.test
|
||||||
127.0.0.1 assets.presseecho.test
|
127.0.0.1 assets.presseecho.test
|
||||||
127.0.0.1 assets.businessportal24.test
|
127.0.0.1 assets.businessportal24.test
|
||||||
```
|
```
|
||||||
|
|
@ -47,7 +47,7 @@ npm run dev:all
|
||||||
|
|
||||||
### 1. `docker-compose.yml`
|
### 1. `docker-compose.yml`
|
||||||
Neue Traefik-Routen hinzugefügt:
|
Neue Traefik-Routen hinzugefügt:
|
||||||
- `assets.pr-copilot.test` → Port 5177 (Portal)
|
- `assets.presseportale.test` → Port 5177 (Portal)
|
||||||
- `assets.presseecho.test` → Port 5178 (Presseecho)
|
- `assets.presseecho.test` → Port 5178 (Presseecho)
|
||||||
- `assets.businessportal24.test` → Port 5178 (Businessportal24)
|
- `assets.businessportal24.test` → Port 5178 (Businessportal24)
|
||||||
|
|
||||||
|
|
@ -66,7 +66,7 @@ Nach dem Neustart kannst du testen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Im DevContainer:
|
# Im DevContainer:
|
||||||
curl -Ik https://assets.pr-copilot.test/@vite/client
|
curl -Ik https://assets.presseportale.test/@vite/client
|
||||||
curl -Ik https://assets.presseecho.test/@vite/client
|
curl -Ik https://assets.presseecho.test/@vite/client
|
||||||
curl -Ik https://assets.businessportal24.test/@vite/client
|
curl -Ik https://assets.businessportal24.test/@vite/client
|
||||||
```
|
```
|
||||||
|
|
@ -6,7 +6,7 @@ Dieses Projekt verwendet **2 separate Vite-Ports** für unterschiedliche Domain-
|
||||||
|
|
||||||
| Bereich | Port | Vite Config | Tailwind Config | Domains | FluxUI |
|
| Bereich | Port | Vite Config | Tailwind Config | Domains | FluxUI |
|
||||||
|---------|------|-------------|-----------------|---------|--------|
|
|---------|------|-------------|-----------------|---------|--------|
|
||||||
| **Backend (Portal)** | 5177 | `vite.portal.config.js` | `tailwind.portal.config.js` | `pr-copilot.test` | ✅ Ja |
|
| **Backend (Portal)** | 5177 | `vite.portal.config.js` | `tailwind.portal.config.js` | `presseportale.test` | ✅ Ja |
|
||||||
| **Frontend (Web)** | 5178 | `vite.web.config.js` | `tailwind.web.config.js` | `presseecho.test`, `businessportal24.test` | ❌ Nein |
|
| **Frontend (Web)** | 5178 | `vite.web.config.js` | `tailwind.web.config.js` | `presseecho.test`, `businessportal24.test` | ❌ Nein |
|
||||||
|
|
||||||
## Warum 2 Ports?
|
## Warum 2 Ports?
|
||||||
|
|
@ -35,15 +35,15 @@ Startet beide Vite-Server parallel mit `concurrently`
|
||||||
npm run dev:portal
|
npm run dev:portal
|
||||||
```
|
```
|
||||||
- Port: 5177
|
- Port: 5177
|
||||||
- HMR-Host: assets.pr-copilot.test
|
- HMR-Host: assets.presseportale.test
|
||||||
- Domain: pr-copilot.test
|
- Domain: presseportale.test
|
||||||
|
|
||||||
### Option 3: Nur Frontend (Web)
|
### Option 3: Nur Frontend (Web)
|
||||||
```bash
|
```bash
|
||||||
npm run dev:web
|
npm run dev:web
|
||||||
```
|
```
|
||||||
- Port: 5178
|
- Port: 5178
|
||||||
- HMR-Host: assets-web.pr-copilot.test
|
- HMR-Host: assets-web.presseportale.test
|
||||||
- Domains: presseecho.test, businessportal24.test
|
- Domains: presseecho.test, businessportal24.test
|
||||||
|
|
||||||
## Production Build
|
## Production Build
|
||||||
|
|
@ -86,7 +86,7 @@ public/
|
||||||
## Theme-System
|
## Theme-System
|
||||||
|
|
||||||
### Backend (Portal)
|
### Backend (Portal)
|
||||||
- **Domain:** pr-copilot.test
|
- **Domain:** presseportale.test
|
||||||
- **Theme:** `portal`
|
- **Theme:** `portal`
|
||||||
- **CSS:** `resources/css/portal.css`
|
- **CSS:** `resources/css/portal.css`
|
||||||
- **Views:** `resources/views/portal/**`
|
- **Views:** `resources/views/portal/**`
|
||||||
|
|
@ -110,8 +110,8 @@ public/
|
||||||
|
|
||||||
Beide Vite-Server laufen intern auf HTTP (`https: false`), Traefik übernimmt SSL-Terminierung:
|
Beide Vite-Server laufen intern auf HTTP (`https: false`), Traefik übernimmt SSL-Terminierung:
|
||||||
|
|
||||||
- **Portal HMR:** `wss://assets.pr-copilot.test` → Port 5177
|
- **Portal HMR:** `wss://assets.presseportale.test` → Port 5177
|
||||||
- **Web HMR:** `wss://assets-web.pr-copilot.test` → Port 5178
|
- **Web HMR:** `wss://assets-web.presseportale.test` → Port 5178
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|
@ -37,7 +37,7 @@ sudo nano /etc/hosts
|
||||||
|
|
||||||
**Einträge:**
|
**Einträge:**
|
||||||
```
|
```
|
||||||
127.0.0.1 assets.pr-copilot.test
|
127.0.0.1 assets.presseportale.test
|
||||||
127.0.0.1 assets.presseecho.test
|
127.0.0.1 assets.presseecho.test
|
||||||
127.0.0.1 assets.businessportal24.test
|
127.0.0.1 assets.businessportal24.test
|
||||||
```
|
```
|
||||||
|
|
@ -51,7 +51,7 @@ Zurück im DevContainer:
|
||||||
tail -20 /tmp/vite-server.log
|
tail -20 /tmp/vite-server.log
|
||||||
|
|
||||||
# Teste die Asset-URLs:
|
# Teste die Asset-URLs:
|
||||||
curl -Ik https://assets.pr-copilot.test/@vite/client
|
curl -Ik https://assets.presseportale.test/@vite/client
|
||||||
curl -Ik https://assets.presseecho.test/@vite/client
|
curl -Ik https://assets.presseecho.test/@vite/client
|
||||||
curl -Ik https://assets.businessportal24.test/@vite/client
|
curl -Ik https://assets.businessportal24.test/@vite/client
|
||||||
|
|
||||||
|
|
@ -63,7 +63,7 @@ curl -Ik https://assets.businessportal24.test/@vite/client
|
||||||
Öffne:
|
Öffne:
|
||||||
- https://businessportal24.test
|
- https://businessportal24.test
|
||||||
- https://presseecho.test
|
- https://presseecho.test
|
||||||
- https://pr-copilot.test
|
- https://presseportale.test
|
||||||
|
|
||||||
Die Assets sollten nun korrekt über HTTPS von den jeweiligen Asset-Subdomains geladen werden!
|
Die Assets sollten nun korrekt über HTTPS von den jeweiligen Asset-Subdomains geladen werden!
|
||||||
|
|
||||||
|
|
@ -71,7 +71,7 @@ Die Assets sollten nun korrekt über HTTPS von den jeweiligen Asset-Subdomains g
|
||||||
|
|
||||||
| Hauptdomain | Asset-Domain | Port | Build-Dir |
|
| Hauptdomain | Asset-Domain | Port | Build-Dir |
|
||||||
|------------|-------------|------|-----------|
|
|------------|-------------|------|-----------|
|
||||||
| pr-copilot.test | assets.pr-copilot.test | 5177 | public/build/portal |
|
| presseportale.test | assets.presseportale.test | 5177 | public/build/portal |
|
||||||
| presseecho.test | assets.presseecho.test | 5178 | public/build/web |
|
| presseecho.test | assets.presseecho.test | 5178 | public/build/web |
|
||||||
| businessportal24.test | assets.businessportal24.test | 5178 | public/build/web |
|
| businessportal24.test | assets.businessportal24.test | 5178 | public/build/web |
|
||||||
|
|
||||||
|
|
@ -65,7 +65,7 @@ Du benötigst **mindestens 2 Vite-Ports**:
|
||||||
│ └──────────────────┘ └──────────────────┘ │
|
│ └──────────────────┘ └──────────────────┘ │
|
||||||
│ ↓ ↓ │
|
│ ↓ ↓ │
|
||||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||||
│ │ pr-copilot.test │ │ presseecho.test │ │
|
│ │ presseportale.test │ │ presseecho.test │ │
|
||||||
│ │ │ │ businessp24.test│ │
|
│ │ │ │ businessp24.test│ │
|
||||||
│ └──────────────────┘ └──────────────────┘ │
|
│ └──────────────────┘ └──────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
|
|
@ -80,14 +80,14 @@ Du benötigst **mindestens 2 Vite-Ports**:
|
||||||
- Port: **5177**
|
- Port: **5177**
|
||||||
- Input: `resources/css/portal.css`
|
- Input: `resources/css/portal.css`
|
||||||
- Build: `public/build/portal`
|
- Build: `public/build/portal`
|
||||||
- HMR: `assets.pr-copilot.test`
|
- HMR: `assets.presseportale.test`
|
||||||
- FluxUI: ✅ Ja
|
- FluxUI: ✅ Ja
|
||||||
|
|
||||||
#### ✅ `vite.web.config.js`
|
#### ✅ `vite.web.config.js`
|
||||||
- Port: **5178** (geändert von 5177!)
|
- Port: **5178** (geändert von 5177!)
|
||||||
- Input: `theme-presseecho.css`, `theme-businessportal24.css`
|
- Input: `theme-presseecho.css`, `theme-businessportal24.css`
|
||||||
- Build: `public/build/web`
|
- Build: `public/build/web`
|
||||||
- HMR: `assets-web.pr-copilot.test`
|
- HMR: `assets-web.presseportale.test`
|
||||||
- FluxUI: ❌ Nein
|
- FluxUI: ❌ Nein
|
||||||
|
|
||||||
#### ❌ `vite.config.js` (deprecated)
|
#### ❌ `vite.config.js` (deprecated)
|
||||||
|
|
@ -190,14 +190,14 @@ npm run build:web
|
||||||
|
|
||||||
| URL | Vite-Port | Theme | FluxUI |
|
| URL | Vite-Port | Theme | FluxUI |
|
||||||
|-----|-----------|-------|--------|
|
|-----|-----------|-------|--------|
|
||||||
| https://pr-copilot.test | 5177 | portal | ✅ |
|
| https://presseportale.test | 5177 | portal | ✅ |
|
||||||
| https://presseecho.test | 5178 | presseecho | ❌ |
|
| https://presseecho.test | 5178 | presseecho | ❌ |
|
||||||
| https://businessportal24.test | 5178 | businessportal24 | ❌ |
|
| https://businessportal24.test | 5178 | businessportal24 | ❌ |
|
||||||
|
|
||||||
## HMR (Hot Module Replacement)
|
## HMR (Hot Module Replacement)
|
||||||
|
|
||||||
- **Portal:** `wss://assets.pr-copilot.test` → Port 5177
|
- **Portal:** `wss://assets.presseportale.test` → Port 5177
|
||||||
- **Web:** `wss://assets-web.pr-copilot.test` → Port 5178
|
- **Web:** `wss://assets-web.presseportale.test` → Port 5178
|
||||||
|
|
||||||
⚠️ **Wichtig:** Traefik muss beide HMR-Hosts routen!
|
⚠️ **Wichtig:** Traefik muss beide HMR-Hosts routen!
|
||||||
|
|
||||||
|
|
@ -210,12 +210,12 @@ Stelle sicher, dass Traefik beide Vite-Ports routet:
|
||||||
# docker-compose.yml oder traefik.yml
|
# docker-compose.yml oder traefik.yml
|
||||||
labels:
|
labels:
|
||||||
# Portal Assets
|
# Portal Assets
|
||||||
- "traefik.http.routers.vite-portal.rule=Host(`assets.pr-copilot.test`)"
|
- "traefik.http.routers.vite-portal.rule=Host(`assets.presseportale.test`)"
|
||||||
- "traefik.http.routers.vite-portal.service=vite-portal"
|
- "traefik.http.routers.vite-portal.service=vite-portal"
|
||||||
- "traefik.http.services.vite-portal.loadbalancer.server.port=5177"
|
- "traefik.http.services.vite-portal.loadbalancer.server.port=5177"
|
||||||
|
|
||||||
# Web Assets
|
# Web Assets
|
||||||
- "traefik.http.routers.vite-web.rule=Host(`assets-web.pr-copilot.test`)"
|
- "traefik.http.routers.vite-web.rule=Host(`assets-web.presseportale.test`)"
|
||||||
- "traefik.http.routers.vite-web.service=vite-web"
|
- "traefik.http.routers.vite-web.service=vite-web"
|
||||||
- "traefik.http.services.vite-web.loadbalancer.server.port=5178"
|
- "traefik.http.services.vite-web.loadbalancer.server.port=5178"
|
||||||
```
|
```
|
||||||
|
|
@ -223,11 +223,11 @@ labels:
|
||||||
### 2. DNS/Hosts-Datei aktualisieren
|
### 2. DNS/Hosts-Datei aktualisieren
|
||||||
|
|
||||||
```
|
```
|
||||||
127.0.0.1 pr-copilot.test
|
127.0.0.1 presseportale.test
|
||||||
127.0.0.1 presseecho.test
|
127.0.0.1 presseecho.test
|
||||||
127.0.0.1 businessportal24.test
|
127.0.0.1 businessportal24.test
|
||||||
127.0.0.1 assets.pr-copilot.test
|
127.0.0.1 assets.presseportale.test
|
||||||
127.0.0.1 assets-web.pr-copilot.test
|
127.0.0.1 assets-web.presseportale.test
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Blade-Templates aktualisieren
|
### 3. Blade-Templates aktualisieren
|
||||||
|
|
@ -258,7 +258,7 @@ Diese Dateien werden nicht mehr benötigt:
|
||||||
npm run dev:all
|
npm run dev:all
|
||||||
|
|
||||||
# 2. Browser öffnen
|
# 2. Browser öffnen
|
||||||
# - https://pr-copilot.test (Backend)
|
# - https://presseportale.test (Backend)
|
||||||
# - https://presseecho.test (Frontend Grün)
|
# - https://presseecho.test (Frontend Grün)
|
||||||
# - https://businessportal24.test (Frontend Rot)
|
# - https://businessportal24.test (Frontend Rot)
|
||||||
|
|
||||||
656
_docs/api/v1.yml
Normal file
656
_docs/api/v1.yml
Normal file
|
|
@ -0,0 +1,656 @@
|
||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Presseportale API
|
||||||
|
version: 1.0.0
|
||||||
|
description: >
|
||||||
|
REST API for customer integrations after the 2026 migration. Legacy
|
||||||
|
`api_key` query parameters and `X-Api-Key` headers are no longer accepted.
|
||||||
|
servers:
|
||||||
|
- url: /api/v1
|
||||||
|
security:
|
||||||
|
- sanctumBearer: []
|
||||||
|
tags:
|
||||||
|
- name: Press Releases
|
||||||
|
- name: Press Release Images
|
||||||
|
- name: Companies
|
||||||
|
- name: Categories
|
||||||
|
- name: Newsletter
|
||||||
|
paths:
|
||||||
|
/press-releases:
|
||||||
|
get:
|
||||||
|
tags: [Press Releases]
|
||||||
|
summary: List own press releases
|
||||||
|
description: Requires `press-releases:read`.
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/StatusFilter'
|
||||||
|
- $ref: '#/components/parameters/PerPage'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Paginated press release collection.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PaginatedPressReleases'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthenticated'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'410':
|
||||||
|
$ref: '#/components/responses/LegacyApiKeyGone'
|
||||||
|
post:
|
||||||
|
tags: [Press Releases]
|
||||||
|
summary: Create a press release
|
||||||
|
description: Requires `press-releases:write`.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/StorePressReleaseRequest'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Press release created.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PressReleaseResponse'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'422':
|
||||||
|
$ref: '#/components/responses/ValidationError'
|
||||||
|
/press-releases/{pressRelease}:
|
||||||
|
get:
|
||||||
|
tags: [Press Releases]
|
||||||
|
summary: Show one own press release
|
||||||
|
description: Requires `press-releases:read`.
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/PressReleaseId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Press release resource.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PressReleaseResponse'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
patch:
|
||||||
|
tags: [Press Releases]
|
||||||
|
summary: Update one own draft or rejected press release
|
||||||
|
description: Requires `press-releases:write`.
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/PressReleaseId'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UpdatePressReleaseRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Press release updated.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PressReleaseResponse'
|
||||||
|
'409':
|
||||||
|
$ref: '#/components/responses/Conflict'
|
||||||
|
'422':
|
||||||
|
$ref: '#/components/responses/ValidationError'
|
||||||
|
delete:
|
||||||
|
tags: [Press Releases]
|
||||||
|
summary: Delete one own unpublished press release
|
||||||
|
description: Requires `press-releases:write`.
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/PressReleaseId'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Press release deleted.
|
||||||
|
'409':
|
||||||
|
$ref: '#/components/responses/Conflict'
|
||||||
|
/press-releases/{pressRelease}/images:
|
||||||
|
get:
|
||||||
|
tags: [Press Release Images]
|
||||||
|
summary: List images for one own press release
|
||||||
|
description: Requires `press-releases:read`.
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/PressReleaseId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Press release image collection.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PressReleaseImageCollection'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
post:
|
||||||
|
tags: [Press Release Images]
|
||||||
|
summary: Upload an image for one own draft or rejected press release
|
||||||
|
description: Requires `press-release-images:write`. The uploaded image is limited to 5 MB.
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/PressReleaseId'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/StorePressReleaseImageRequest'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Press release image uploaded.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PressReleaseImageResponse'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'409':
|
||||||
|
$ref: '#/components/responses/Conflict'
|
||||||
|
'422':
|
||||||
|
$ref: '#/components/responses/ValidationError'
|
||||||
|
/press-release-images/{pressReleaseImage}:
|
||||||
|
delete:
|
||||||
|
tags: [Press Release Images]
|
||||||
|
summary: Delete one own press release image
|
||||||
|
description: Requires `press-release-images:write`; only draft or rejected press releases may be changed.
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/PressReleaseImageId'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Press release image deleted.
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'409':
|
||||||
|
$ref: '#/components/responses/Conflict'
|
||||||
|
/companies:
|
||||||
|
get:
|
||||||
|
tags: [Companies]
|
||||||
|
summary: List own companies
|
||||||
|
description: Requires `companies:read`.
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/PerPage'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Paginated company collection.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PaginatedCompanies'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
/companies/{company}:
|
||||||
|
get:
|
||||||
|
tags: [Companies]
|
||||||
|
summary: Show one own company
|
||||||
|
description: Requires `companies:read`.
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/CompanyId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Company resource.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CompanyResponse'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
/categories:
|
||||||
|
get:
|
||||||
|
tags: [Categories]
|
||||||
|
summary: List active categories
|
||||||
|
description: Requires `press-releases:read`.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Category collection.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CategoryCollection'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
/newsletter/subscribe:
|
||||||
|
post:
|
||||||
|
tags: [Newsletter]
|
||||||
|
summary: Subscribe an email address
|
||||||
|
description: Requires `newsletter:subscribe`.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SubscribeNewsletterRequest'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Subscription created.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/NewsletterSubscriptionResponse'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'422':
|
||||||
|
$ref: '#/components/responses/ValidationError'
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
sanctumBearer:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: Sanctum personal access token
|
||||||
|
parameters:
|
||||||
|
PressReleaseId:
|
||||||
|
name: pressRelease
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
PressReleaseImageId:
|
||||||
|
name: pressReleaseImage
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
CompanyId:
|
||||||
|
name: company
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
StatusFilter:
|
||||||
|
name: status
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [draft, review, published, rejected, archived]
|
||||||
|
PerPage:
|
||||||
|
name: per_page
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 100
|
||||||
|
default: 25
|
||||||
|
responses:
|
||||||
|
Unauthenticated:
|
||||||
|
description: Missing or invalid Bearer token.
|
||||||
|
Forbidden:
|
||||||
|
description: Token ability missing or resource does not belong to the authenticated user.
|
||||||
|
Conflict:
|
||||||
|
description: Requested state transition is not allowed.
|
||||||
|
ValidationError:
|
||||||
|
description: Validation failed.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ValidationError'
|
||||||
|
LegacyApiKeyGone:
|
||||||
|
description: Legacy API keys are no longer supported.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/LegacyApiKeyGone'
|
||||||
|
schemas:
|
||||||
|
StorePressReleaseRequest:
|
||||||
|
type: object
|
||||||
|
required: [company_id, category_id, language, title, text]
|
||||||
|
properties:
|
||||||
|
company_id:
|
||||||
|
type: integer
|
||||||
|
category_id:
|
||||||
|
type: integer
|
||||||
|
language:
|
||||||
|
type: string
|
||||||
|
minLength: 2
|
||||||
|
maxLength: 2
|
||||||
|
example: de
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
maxLength: 255
|
||||||
|
text:
|
||||||
|
type: string
|
||||||
|
backlink_url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
nullable: true
|
||||||
|
keywords:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
maxLength: 255
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [draft, review]
|
||||||
|
default: draft
|
||||||
|
teaser_begin:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
minimum: 0
|
||||||
|
teaser_end:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
minimum: 0
|
||||||
|
no_export:
|
||||||
|
type: boolean
|
||||||
|
nullable: true
|
||||||
|
UpdatePressReleaseRequest:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/StorePressReleaseRequest'
|
||||||
|
required: []
|
||||||
|
SubscribeNewsletterRequest:
|
||||||
|
type: object
|
||||||
|
required: [portal, email]
|
||||||
|
properties:
|
||||||
|
portal:
|
||||||
|
type: string
|
||||||
|
enum: [presseecho, businessportal24]
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
maxLength: 190
|
||||||
|
salutation_key:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
maxLength: 20
|
||||||
|
first_name:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
maxLength: 80
|
||||||
|
last_name:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
maxLength: 80
|
||||||
|
StorePressReleaseImageRequest:
|
||||||
|
type: object
|
||||||
|
required: [image]
|
||||||
|
properties:
|
||||||
|
image:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
description: JPEG, PNG or WebP image, max. 5 MB.
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
maxLength: 120
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
maxLength: 500
|
||||||
|
copyright:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
maxLength: 255
|
||||||
|
is_preview:
|
||||||
|
type: boolean
|
||||||
|
nullable: true
|
||||||
|
PressRelease:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
uuid:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
legacy:
|
||||||
|
$ref: '#/components/schemas/LegacyReference'
|
||||||
|
portal:
|
||||||
|
type: string
|
||||||
|
enum: [presseecho, businessportal24]
|
||||||
|
language:
|
||||||
|
type: string
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
slug:
|
||||||
|
type: string
|
||||||
|
text:
|
||||||
|
type: string
|
||||||
|
backlink_url:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
keywords:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [draft, review, published, rejected, archived]
|
||||||
|
hits:
|
||||||
|
type: integer
|
||||||
|
teaser:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
begin:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
end:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
no_export:
|
||||||
|
type: boolean
|
||||||
|
published_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
company:
|
||||||
|
$ref: '#/components/schemas/Company'
|
||||||
|
category:
|
||||||
|
$ref: '#/components/schemas/Category'
|
||||||
|
images:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/PressReleaseImage'
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
Company:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
legacy:
|
||||||
|
$ref: '#/components/schemas/LegacyReference'
|
||||||
|
portal:
|
||||||
|
type: string
|
||||||
|
enum: [presseecho, businessportal24]
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
slug:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
website:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
phone:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
country_code:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
is_active:
|
||||||
|
type: boolean
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
Category:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
legacy:
|
||||||
|
$ref: '#/components/schemas/LegacyReference'
|
||||||
|
portal:
|
||||||
|
type: string
|
||||||
|
enum: [presseecho, businessportal24]
|
||||||
|
nullable: true
|
||||||
|
is_active:
|
||||||
|
type: boolean
|
||||||
|
translations:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
slug:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
PressReleaseImage:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
press_release_id:
|
||||||
|
type: integer
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
disk:
|
||||||
|
type: string
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
copyright:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
is_preview:
|
||||||
|
type: boolean
|
||||||
|
sort_order:
|
||||||
|
type: integer
|
||||||
|
width:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
height:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
mime:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
LegacyReference:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
portal:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
PressReleaseResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/PressRelease'
|
||||||
|
PressReleaseImageResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/PressReleaseImage'
|
||||||
|
CompanyResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/Company'
|
||||||
|
PaginatedPressReleases:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/PressRelease'
|
||||||
|
links:
|
||||||
|
type: object
|
||||||
|
meta:
|
||||||
|
type: object
|
||||||
|
PaginatedCompanies:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Company'
|
||||||
|
links:
|
||||||
|
type: object
|
||||||
|
meta:
|
||||||
|
type: object
|
||||||
|
CategoryCollection:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Category'
|
||||||
|
PressReleaseImageCollection:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/PressReleaseImage'
|
||||||
|
NewsletterSubscriptionResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
portal:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
is_confirmed:
|
||||||
|
type: boolean
|
||||||
|
LegacyApiKeyGone:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Legacy API keys are no longer supported.
|
||||||
|
migration_url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
docs_url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
ValidationError:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
errors:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
93
app/Actions/Admin/UserImpersonation.php
Normal file
93
app/Actions/Admin/UserImpersonation.php
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Admin;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class UserImpersonation
|
||||||
|
{
|
||||||
|
public const SessionKey = 'impersonate_from';
|
||||||
|
|
||||||
|
public function canInitiate(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->is_super_admin || $user->can('users:manage');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canStart(User $admin, User $target): bool
|
||||||
|
{
|
||||||
|
return $this->canInitiate($admin)
|
||||||
|
&& ! $admin->is($target)
|
||||||
|
&& ! $this->isActive()
|
||||||
|
&& $this->canBeImpersonated($target);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canBeImpersonated(User $target): bool
|
||||||
|
{
|
||||||
|
return $target->canAccessCustomer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*/
|
||||||
|
public function start(User $admin, User $target): void
|
||||||
|
{
|
||||||
|
if (! $this->canInitiate($admin)) {
|
||||||
|
throw new AuthorizationException(__('Du darfst keine Benutzer-Impersonation starten.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($admin->is($target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isActive()) {
|
||||||
|
throw new AuthorizationException(__('Verschachtelte Benutzer-Impersonation ist nicht erlaubt.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->canBeImpersonated($target)) {
|
||||||
|
throw new AuthorizationException(__('Dieser Benutzer kann nicht im Panel angemeldet werden.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
session([self::SessionKey => $admin->getKey()]);
|
||||||
|
|
||||||
|
Auth::login($target);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stop(): ?User
|
||||||
|
{
|
||||||
|
$adminUserId = session(self::SessionKey);
|
||||||
|
|
||||||
|
session()->forget(self::SessionKey);
|
||||||
|
|
||||||
|
if (! is_numeric($adminUserId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$admin = User::query()->find((int) $adminUserId);
|
||||||
|
|
||||||
|
if (! $admin?->canAccessAdmin()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Auth::login($admin);
|
||||||
|
|
||||||
|
return $admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isActive(): bool
|
||||||
|
{
|
||||||
|
return session()->has(self::SessionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function impersonator(): ?User
|
||||||
|
{
|
||||||
|
$adminUserId = session(self::SessionKey);
|
||||||
|
|
||||||
|
if (! is_numeric($adminUserId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return User::query()->find((int) $adminUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
app/Console/Commands/AnalyzeLegacyApiAccessLogs.php
Normal file
87
app/Console/Commands/AnalyzeLegacyApiAccessLogs.php
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Api\LegacyApiAccessLogAnalyzer;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class AnalyzeLegacyApiAccessLogs extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'api:analyze-legacy-access-logs
|
||||||
|
{paths* : Access-log files or glob patterns}
|
||||||
|
{--top=20 : Number of top entries per section}
|
||||||
|
{--no-report : Keinen JSON-Report schreiben}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Analysiert Legacy-API-Zugriffe aus Webserver-Access-Logs.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(LegacyApiAccessLogAnalyzer $analyzer): int
|
||||||
|
{
|
||||||
|
$paths = array_map('strval', (array) $this->argument('paths'));
|
||||||
|
$top = max(1, (int) $this->option('top'));
|
||||||
|
$report = $analyzer->analyze($paths, $top);
|
||||||
|
|
||||||
|
$this->info('Legacy-API-Access-Log-Auswertung');
|
||||||
|
$this->newLine();
|
||||||
|
$this->line("Dateien: {$report['summary']['files']}");
|
||||||
|
$this->line("Log-Zeilen: {$report['summary']['total_lines']}");
|
||||||
|
$this->line("Legacy-API-Requests: {$report['summary']['matched_requests']}");
|
||||||
|
$this->line("Requests mit api_key: {$report['summary']['legacy_key_requests']}");
|
||||||
|
$this->line("Eindeutige Client-IPs: {$report['summary']['unique_client_ips']}");
|
||||||
|
$this->line("Eindeutige API-Key-Fingerprints: {$report['summary']['unique_api_key_fingerprints']}");
|
||||||
|
|
||||||
|
if ($report['missing_paths'] !== []) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->warn('Nicht lesbare Pfade:');
|
||||||
|
foreach ($report['missing_paths'] as $path) {
|
||||||
|
$this->line(" - {$path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->renderTopTable('Top Endpoints', $report['endpoints']);
|
||||||
|
$this->renderTopTable('Top Client-IPs', $report['client_ips']);
|
||||||
|
$this->renderTopTable('Statuscodes', $report['status_codes']);
|
||||||
|
|
||||||
|
if (! (bool) $this->option('no-report')) {
|
||||||
|
$path = 'migration/legacy-api-access-'.now()->format('Ymd-His').'.json';
|
||||||
|
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||||
|
$this->newLine();
|
||||||
|
$this->line("Report geschrieben: storage/app/private/{$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $rows
|
||||||
|
*/
|
||||||
|
private function renderTopTable(string $title, array $rows): void
|
||||||
|
{
|
||||||
|
if ($rows === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->line($title);
|
||||||
|
$this->table(
|
||||||
|
['Wert', 'Requests'],
|
||||||
|
collect($rows)
|
||||||
|
->map(fn (int $count, string $value): array => [$value, $count])
|
||||||
|
->values()
|
||||||
|
->all()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
314
app/Console/Commands/ArchiveLegacyInvoices.php
Normal file
314
app/Console/Commands/ArchiveLegacyInvoices.php
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Enums\Portal;
|
||||||
|
use App\Models\LegacyImportMap;
|
||||||
|
use App\Models\LegacyInvoice;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archiviert alle Rechnungen aus den Legacy-Portalen in legacy_invoices (P6.5).
|
||||||
|
*
|
||||||
|
* Die Tabelle ist read-only und dient als Audit-Archiv.
|
||||||
|
* Rechnungen werden nicht in den neuen Rechnungskreis (invoices) übernommen.
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* php artisan legacy:archive-invoices --dry-run
|
||||||
|
* php artisan legacy:archive-invoices --portal=presseecho
|
||||||
|
* php artisan legacy:archive-invoices
|
||||||
|
*/
|
||||||
|
class ArchiveLegacyInvoices extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'legacy:archive-invoices
|
||||||
|
{--portal=all : Portal (presseecho|businessportal24|all)}
|
||||||
|
{--dry-run : Nur zählen, nichts schreiben}
|
||||||
|
{--force : Bereits archivierte Rechnungen aktualisieren}
|
||||||
|
{--no-report : Keinen JSON-Report schreiben}';
|
||||||
|
|
||||||
|
protected $description = 'Archiviert Legacy-Rechnungen in legacy_invoices (read-only Archiv).';
|
||||||
|
|
||||||
|
private const PORTAL_CONFIG = [
|
||||||
|
'presseecho' => [
|
||||||
|
'connection' => 'mysql_presseecho',
|
||||||
|
'portal_enum' => Portal::Presseecho,
|
||||||
|
],
|
||||||
|
'businessportal24' => [
|
||||||
|
'connection' => 'mysql_businessportal',
|
||||||
|
'portal_enum' => Portal::Businessportal24,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
private const CHUNK_SIZE = 200;
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$portalOpt = $this->option('portal');
|
||||||
|
$isDryRun = (bool) $this->option('dry-run');
|
||||||
|
$isForce = (bool) $this->option('force');
|
||||||
|
|
||||||
|
$portals = $portalOpt === 'all'
|
||||||
|
? array_keys(self::PORTAL_CONFIG)
|
||||||
|
: [$portalOpt];
|
||||||
|
|
||||||
|
if (array_diff($portals, array_keys(self::PORTAL_CONFIG)) !== []) {
|
||||||
|
$this->error("Unbekanntes Portal: {$portalOpt}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isDryRun) {
|
||||||
|
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalImported = 0;
|
||||||
|
$totalSkipped = 0;
|
||||||
|
$totalErrors = 0;
|
||||||
|
$report = [
|
||||||
|
'generated_at' => now()->toIso8601String(),
|
||||||
|
'portal' => $portalOpt,
|
||||||
|
'dry_run' => $isDryRun,
|
||||||
|
'force' => $isForce,
|
||||||
|
'portals' => [],
|
||||||
|
];
|
||||||
|
$start = microtime(true);
|
||||||
|
|
||||||
|
foreach ($portals as $portal) {
|
||||||
|
$config = self::PORTAL_CONFIG[$portal];
|
||||||
|
$portalReport = $this->archivePortal(
|
||||||
|
$portal,
|
||||||
|
$config['connection'],
|
||||||
|
$isDryRun,
|
||||||
|
$isForce,
|
||||||
|
);
|
||||||
|
|
||||||
|
$report['portals'][$portal] = $portalReport;
|
||||||
|
$imported = $portalReport['imported'];
|
||||||
|
$skipped = $portalReport['skipped'];
|
||||||
|
$errors = $portalReport['errors'];
|
||||||
|
$this->line(" [{$portal}] Archiviert: {$imported} | Übersprungen: {$skipped} | Fehler: {$errors}");
|
||||||
|
$this->line(" [{$portal}] Quelle: {$portalReport['source_count']} | ohne User-Mapping: {$portalReport['unmapped_user_count']} | PDF-Payload: {$portalReport['pdf_payload_count']}");
|
||||||
|
$totalImported += $imported;
|
||||||
|
$totalSkipped += $skipped;
|
||||||
|
$totalErrors += $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
$elapsed = round(microtime(true) - $start, 1);
|
||||||
|
$this->newLine();
|
||||||
|
$this->info("Gesamt: {$totalImported} archiviert | {$totalSkipped} übersprungen | {$totalErrors} Fehler | {$elapsed}s");
|
||||||
|
|
||||||
|
if (! (bool) $this->option('no-report')) {
|
||||||
|
$path = 'migration/legacy-invoices-'.now()->format('Ymd-His').'.json';
|
||||||
|
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||||
|
$this->line("Report geschrieben: storage/app/private/{$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $totalErrors > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeDateOrNull(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (blank($value) || $value === '0000-00-00' || $value === '0000-00-00 00:00:00') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function archivePortal(
|
||||||
|
string $portal,
|
||||||
|
string $connection,
|
||||||
|
bool $isDryRun,
|
||||||
|
bool $isForce,
|
||||||
|
): array {
|
||||||
|
$imported = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$errors = 0;
|
||||||
|
$unmappedUserCount = 0;
|
||||||
|
$pdfPayloadCount = 0;
|
||||||
|
$amountCents = 0;
|
||||||
|
$statusCounts = [];
|
||||||
|
$hasBillingAddressTable = Schema::connection($connection)->hasTable('invoice_billing_address');
|
||||||
|
$hasUserPaymentTable = Schema::connection($connection)->hasTable('user_payment');
|
||||||
|
$hasUserPaymentOptionTable = Schema::connection($connection)->hasTable('user_payment_option');
|
||||||
|
$hasPaymentOptionTable = Schema::connection($connection)->hasTable('payment_option');
|
||||||
|
$hasPaymentOptionTranslationTable = Schema::connection($connection)->hasTable('payment_option_translation');
|
||||||
|
$sourceCount = DB::connection($connection)->table('invoice')->count();
|
||||||
|
|
||||||
|
DB::connection($connection)
|
||||||
|
->table('invoice')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunk(self::CHUNK_SIZE, function ($rows) use (
|
||||||
|
$portal, $connection, $isDryRun, $isForce, $hasBillingAddressTable, $hasUserPaymentTable,
|
||||||
|
$hasUserPaymentOptionTable, $hasPaymentOptionTable, $hasPaymentOptionTranslationTable,
|
||||||
|
&$imported, &$skipped, &$errors, &$unmappedUserCount, &$pdfPayloadCount, &$amountCents, &$statusCounts,
|
||||||
|
): void {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
try {
|
||||||
|
$status = (string) ($row->status ?? 'unknown');
|
||||||
|
$rowAmountCents = (int) round((float) $row->amount * 100);
|
||||||
|
$amountCents += $rowAmountCents;
|
||||||
|
$statusCounts[$status] = ($statusCounts[$status] ?? 0) + 1;
|
||||||
|
|
||||||
|
$existingInvoice = LegacyInvoice::query()
|
||||||
|
->where('legacy_portal', $portal)
|
||||||
|
->where('legacy_id', $row->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existingInvoice && ! $isForce) {
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isDryRun) {
|
||||||
|
$imported++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = null;
|
||||||
|
if ($row->user_id) {
|
||||||
|
$userMap = LegacyImportMap::query()
|
||||||
|
->where('legacy_portal', $portal)
|
||||||
|
->where('legacy_table', 'sf_guard_user')
|
||||||
|
->where('legacy_id', $row->user_id)
|
||||||
|
->first();
|
||||||
|
$userId = $userMap?->target_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($userId === null) {
|
||||||
|
$unmappedUserCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdfPayload = $this->pdfPayload(
|
||||||
|
$connection,
|
||||||
|
$row,
|
||||||
|
$hasBillingAddressTable,
|
||||||
|
$hasUserPaymentTable,
|
||||||
|
$hasUserPaymentOptionTable,
|
||||||
|
$hasPaymentOptionTable,
|
||||||
|
$hasPaymentOptionTranslationTable,
|
||||||
|
);
|
||||||
|
$pdfPayloadCount++;
|
||||||
|
|
||||||
|
$taxCents = 0;
|
||||||
|
$totalCents = $rowAmountCents;
|
||||||
|
|
||||||
|
$paidAt = $this->safeDateOrNull($row->pay_date);
|
||||||
|
|
||||||
|
LegacyInvoice::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'legacy_portal' => $portal,
|
||||||
|
'legacy_id' => $row->id,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'user_id' => $userId,
|
||||||
|
'legacy_user_id' => $row->user_id,
|
||||||
|
'number' => (string) $row->number,
|
||||||
|
'amount_cents' => $rowAmountCents,
|
||||||
|
'tax_cents' => $taxCents,
|
||||||
|
'total_cents' => $totalCents,
|
||||||
|
'status' => $status,
|
||||||
|
'invoice_date' => $this->safeDateOrNull($row->invoice_date),
|
||||||
|
'due_date' => $this->safeDateOrNull($row->due_date),
|
||||||
|
'paid_at' => $paidAt,
|
||||||
|
'payment_method' => $row->payment_method ?: null,
|
||||||
|
'pdf_path' => $existingInvoice?->pdf_path,
|
||||||
|
'raw_snapshot' => (array) $row,
|
||||||
|
'pdf_payload' => $pdfPayload,
|
||||||
|
'imported_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$imported++;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$errors++;
|
||||||
|
$this->warn(" ! Invoice {$row->id}: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ksort($statusCounts);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'source_count' => $sourceCount,
|
||||||
|
'imported' => $imported,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'errors' => $errors,
|
||||||
|
'unmapped_user_count' => $unmappedUserCount,
|
||||||
|
'pdf_payload_count' => $pdfPayloadCount,
|
||||||
|
'amount_cents' => $amountCents,
|
||||||
|
'status_counts' => $statusCounts,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function pdfPayload(
|
||||||
|
string $connection,
|
||||||
|
object $invoice,
|
||||||
|
bool $hasBillingAddressTable,
|
||||||
|
bool $hasUserPaymentTable,
|
||||||
|
bool $hasUserPaymentOptionTable,
|
||||||
|
bool $hasPaymentOptionTable,
|
||||||
|
bool $hasPaymentOptionTranslationTable,
|
||||||
|
): array {
|
||||||
|
$userPayment = $hasUserPaymentTable && $invoice->user_payment_id
|
||||||
|
? $this->legacyRow($connection, 'user_payment', (int) $invoice->user_payment_id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$userPaymentOption = $userPayment && $hasUserPaymentOptionTable
|
||||||
|
? $this->legacyRow($connection, 'user_payment_option', (int) $userPayment['user_payment_option_id'])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$paymentOption = $userPaymentOption && $hasPaymentOptionTable
|
||||||
|
? $this->legacyRow($connection, 'payment_option', (int) $userPaymentOption['payment_option_id'])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'invoice' => (array) $invoice,
|
||||||
|
'billing_address' => $hasBillingAddressTable && $invoice->billing_address_id
|
||||||
|
? $this->legacyRow($connection, 'invoice_billing_address', (int) $invoice->billing_address_id)
|
||||||
|
: null,
|
||||||
|
'user_payment' => $userPayment,
|
||||||
|
'user_payment_option' => $userPaymentOption,
|
||||||
|
'payment_option' => $paymentOption,
|
||||||
|
'payment_option_translation' => $paymentOption && $hasPaymentOptionTranslationTable
|
||||||
|
? $this->paymentOptionTranslation($connection, (int) $paymentOption['id'])
|
||||||
|
: null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function paymentOptionTranslation(string $connection, int $paymentOptionId): ?array
|
||||||
|
{
|
||||||
|
$row = DB::connection($connection)
|
||||||
|
->table('payment_option_translation')
|
||||||
|
->where('id', $paymentOptionId)
|
||||||
|
->whereIn('lang', ['de', 'de_DE', 'de_AT', 'de_CH', 'en'])
|
||||||
|
->orderByRaw("CASE lang WHEN 'de' THEN 0 WHEN 'de_DE' THEN 1 WHEN 'de_AT' THEN 2 WHEN 'de_CH' THEN 3 ELSE 4 END")
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $row ? (array) $row : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function legacyRow(string $connection, string $table, int $id): ?array
|
||||||
|
{
|
||||||
|
$row = DB::connection($connection)
|
||||||
|
->table($table)
|
||||||
|
->where('id', $id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $row ? (array) $row : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
142
app/Console/Commands/FixLegacyTimestamps.php
Normal file
142
app/Console/Commands/FixLegacyTimestamps.php
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Korrigiert created_at / updated_at auf bereits importierten Datensätzen.
|
||||||
|
*
|
||||||
|
* Verwendet Cross-DB-JOINs (gleicher MySQL-Server), um die Legacy-Timestamps
|
||||||
|
* in einer einzigen SQL-Operation auf die neuen Tabellen zu übertragen.
|
||||||
|
* Viel schneller als ein erneuter --force Import.
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* php artisan legacy:fix-timestamps --dry-run # nur zählen
|
||||||
|
* php artisan legacy:fix-timestamps # alle Entitäten, beide Portale
|
||||||
|
* php artisan legacy:fix-timestamps --entity=companies --portal=presseecho
|
||||||
|
*/
|
||||||
|
class FixLegacyTimestamps extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'legacy:fix-timestamps
|
||||||
|
{--entity=all : Entität (users|companies|contacts|press-releases|all)}
|
||||||
|
{--portal=all : Portal (presseecho|businessportal24|all)}
|
||||||
|
{--dry-run : Nur zählen, nichts schreiben}';
|
||||||
|
|
||||||
|
protected $description = 'Korrigiert created_at/updated_at auf importierten Datensätzen aus den Legacy-DBs.';
|
||||||
|
|
||||||
|
private const PORTAL_DB = [
|
||||||
|
'presseecho' => 'presseecho',
|
||||||
|
'businessportal24' => 'businessportal',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Mapping: neue Tabelle → (legacy_portal, legacy_db, legacy_table, legacy_created_col) */
|
||||||
|
private const ENTITY_CONFIG = [
|
||||||
|
'users' => [
|
||||||
|
'new_table' => 'users',
|
||||||
|
'legacy_table_name' => 'sf_guard_user',
|
||||||
|
'legacy_created' => 'created_at',
|
||||||
|
'legacy_updated' => 'updated_at',
|
||||||
|
],
|
||||||
|
'companies' => [
|
||||||
|
'new_table' => 'companies',
|
||||||
|
'legacy_table_name' => 'company',
|
||||||
|
'legacy_created' => 'created_at',
|
||||||
|
'legacy_updated' => 'updated_at',
|
||||||
|
],
|
||||||
|
'contacts' => [
|
||||||
|
'new_table' => 'contacts',
|
||||||
|
'legacy_table_name' => 'contact',
|
||||||
|
'legacy_created' => 'created_at',
|
||||||
|
'legacy_updated' => 'updated_at',
|
||||||
|
],
|
||||||
|
'press-releases' => [
|
||||||
|
'new_table' => 'press_releases',
|
||||||
|
'legacy_table_name' => 'press_release',
|
||||||
|
'legacy_created' => 'created_at',
|
||||||
|
'legacy_updated' => 'updated_at',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$entityOpt = $this->option('entity');
|
||||||
|
$portalOpt = $this->option('portal');
|
||||||
|
$isDryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
|
$entities = $entityOpt === 'all' ? array_keys(self::ENTITY_CONFIG) : [$entityOpt];
|
||||||
|
$portals = $portalOpt === 'all' ? array_keys(self::PORTAL_DB) : [$portalOpt];
|
||||||
|
|
||||||
|
if ($isDryRun) {
|
||||||
|
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalUpdated = 0;
|
||||||
|
$start = microtime(true);
|
||||||
|
|
||||||
|
foreach ($entities as $entity) {
|
||||||
|
$config = self::ENTITY_CONFIG[$entity] ?? null;
|
||||||
|
if (! $config) {
|
||||||
|
$this->error("Unbekannte Entität: {$entity}");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($portals as $portal) {
|
||||||
|
$legacyDb = self::PORTAL_DB[$portal] ?? null;
|
||||||
|
if (! $legacyDb) {
|
||||||
|
$this->error("Unbekanntes Portal: {$portal}");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$updated = $this->fixEntity($config, $portal, $legacyDb, $isDryRun);
|
||||||
|
$totalUpdated += $updated;
|
||||||
|
|
||||||
|
$this->line(" [{$entity}] [{$portal}] → {$updated} Datensätze ".($isDryRun ? 'würden aktualisiert' : 'aktualisiert'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$elapsed = round(microtime(true) - $start, 1);
|
||||||
|
$this->newLine();
|
||||||
|
$this->info("Gesamt: {$totalUpdated} Datensätze in {$elapsed}s.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fixEntity(array $config, string $portal, string $legacyDb, bool $isDryRun): int
|
||||||
|
{
|
||||||
|
$newTable = $config['new_table'];
|
||||||
|
$legacyTable = $config['legacy_table_name'];
|
||||||
|
$legacyCreated = $config['legacy_created'];
|
||||||
|
$legacyUpdated = $config['legacy_updated'];
|
||||||
|
|
||||||
|
if ($isDryRun) {
|
||||||
|
// Nur zählen: wie viele Datensätze hätten falsche Timestamps?
|
||||||
|
return (int) DB::selectOne("
|
||||||
|
SELECT COUNT(*) as cnt
|
||||||
|
FROM {$newTable} n
|
||||||
|
JOIN legacy_import_map m
|
||||||
|
ON m.target_id = n.id
|
||||||
|
AND m.target_table = '{$newTable}'
|
||||||
|
AND m.legacy_portal = '{$portal}'
|
||||||
|
JOIN `{$legacyDb}`.`{$legacyTable}` lc ON lc.id = m.legacy_id
|
||||||
|
WHERE n.created_at != lc.{$legacyCreated}
|
||||||
|
OR n.updated_at != lc.{$legacyUpdated}
|
||||||
|
")->cnt ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::affectingStatement("
|
||||||
|
UPDATE {$newTable} n
|
||||||
|
JOIN legacy_import_map m
|
||||||
|
ON m.target_id = n.id
|
||||||
|
AND m.target_table = '{$newTable}'
|
||||||
|
AND m.legacy_portal = '{$portal}'
|
||||||
|
JOIN `{$legacyDb}`.`{$legacyTable}` lc ON lc.id = m.legacy_id
|
||||||
|
SET
|
||||||
|
n.created_at = lc.{$legacyCreated},
|
||||||
|
n.updated_at = lc.{$legacyUpdated}
|
||||||
|
");
|
||||||
|
}
|
||||||
|
}
|
||||||
144
app/Console/Commands/ImportLegacyData.php
Normal file
144
app/Console/Commands/ImportLegacyData.php
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Auth\UserRolePermissionSyncService;
|
||||||
|
use App\Services\Import\CategoryImporter;
|
||||||
|
use App\Services\Import\CompanyImporter;
|
||||||
|
use App\Services\Import\ContactImporter;
|
||||||
|
use App\Services\Import\ImportContext;
|
||||||
|
use App\Services\Import\ImportResult;
|
||||||
|
use App\Services\Import\PressReleaseImporter;
|
||||||
|
use App\Services\Import\UserAssociationLinker;
|
||||||
|
use App\Services\Import\UserImporter;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy-Import-Master-Command (Phase 6 – Datenmigration).
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* php artisan legacy:import --source=presseecho --dry-run
|
||||||
|
* php artisan legacy:import --source=businessportal24 --step=users
|
||||||
|
* php artisan legacy:import --source=all --step=categories
|
||||||
|
* php artisan legacy:import --source=all --step=link-associations ← NEU
|
||||||
|
* php artisan legacy:import --source=presseecho --force
|
||||||
|
*
|
||||||
|
* Schritte (--step):
|
||||||
|
* categories – Kategorien + Translations (einmalig, beide Portale identisch)
|
||||||
|
* users – sf_guard_user + sf_guard_user_profile + Rollen
|
||||||
|
* companies – company + company_user + responsible_company_user
|
||||||
|
* contacts – contact
|
||||||
|
* press-releases – press_release + press_release_image + press_release_contact
|
||||||
|
* link-associations – User↔Kontakt direkt verknüpfen (via Firma-Zugehörigkeit)
|
||||||
|
* all – alle Schritte in korrekter Reihenfolge (Standard)
|
||||||
|
*/
|
||||||
|
class ImportLegacyData extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'legacy:import
|
||||||
|
{--source=all : Portal (presseecho|businessportal24|all)}
|
||||||
|
{--step=all : Schritt (categories|users|companies|contacts|press-releases|link-associations|all)}
|
||||||
|
{--dry-run : Nichts schreiben, nur zählen}
|
||||||
|
{--force : Bereits importierte Datensätze erneut verarbeiten}
|
||||||
|
{--chunk-size=500 : Batch-Größe (Debugging)}';
|
||||||
|
|
||||||
|
protected $description = 'Legacy-Daten aus Presseecho / Businessportal24 in die neue DB importieren.';
|
||||||
|
|
||||||
|
private const STEPS = [
|
||||||
|
'categories',
|
||||||
|
'users',
|
||||||
|
'companies',
|
||||||
|
'contacts',
|
||||||
|
'press-releases',
|
||||||
|
'link-associations',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const PORTALS = ['presseecho', 'businessportal24'];
|
||||||
|
|
||||||
|
public function handle(UserRolePermissionSyncService $roleSync): int
|
||||||
|
{
|
||||||
|
$source = $this->option('source');
|
||||||
|
$step = $this->option('step');
|
||||||
|
$isDryRun = (bool) $this->option('dry-run');
|
||||||
|
$isForce = (bool) $this->option('force');
|
||||||
|
|
||||||
|
$portals = $source === 'all' ? self::PORTALS : [$source];
|
||||||
|
$steps = $step === 'all' ? self::STEPS : [$step];
|
||||||
|
|
||||||
|
if (! $isDryRun && ! $this->option('force')) {
|
||||||
|
$this->warn('WICHTIG: Dieser Command schreibt Daten in die Produktions-DB.');
|
||||||
|
$this->line(' Portale: '.implode(', ', $portals));
|
||||||
|
$this->line(' Schritte: '.implode(', ', $steps));
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
if (! $this->confirm('Import starten?')) {
|
||||||
|
$this->info('Abgebrochen.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isDryRun) {
|
||||||
|
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalStart = microtime(true);
|
||||||
|
|
||||||
|
foreach ($steps as $currentStep) {
|
||||||
|
// link-associations arbeitet auf der neuen DB → einmalig, portal-unabhängig
|
||||||
|
if ($currentStep === 'link-associations') {
|
||||||
|
$ctx = new ImportContext('all', $isDryRun, $isForce);
|
||||||
|
$this->info('▶ Schritt [link-associations] (beide Portale, neue DB)');
|
||||||
|
$start = microtime(true);
|
||||||
|
$result = app(UserAssociationLinker::class)->run($ctx);
|
||||||
|
$elapsed = round(microtime(true) - $start, 1);
|
||||||
|
$this->line(" ✓ {$result->summary()} ({$elapsed}s)");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kategorien sind portal-übergreifend → nur einmal
|
||||||
|
$stepPortals = ($currentStep === 'categories') ? [$portals[0]] : $portals;
|
||||||
|
|
||||||
|
foreach ($stepPortals as $portal) {
|
||||||
|
$ctx = new ImportContext($portal, $isDryRun, $isForce);
|
||||||
|
|
||||||
|
$this->info("▶ Schritt [{$currentStep}] Portal [{$portal}]");
|
||||||
|
$start = microtime(true);
|
||||||
|
|
||||||
|
$result = $this->runStep($currentStep, $ctx, $roleSync);
|
||||||
|
|
||||||
|
$elapsed = round(microtime(true) - $start, 1);
|
||||||
|
$this->line(" ✓ {$result->summary()} ({$elapsed}s)");
|
||||||
|
|
||||||
|
foreach (array_slice($result->errors(), 0, 10) as $err) {
|
||||||
|
$this->warn(" ! {$err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($result->errors()) > 10) {
|
||||||
|
$this->warn(' ! ... und '.(count($result->errors()) - 10).' weitere Fehler.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = round(microtime(true) - $totalStart, 1);
|
||||||
|
$this->newLine();
|
||||||
|
$this->info("Import abgeschlossen in {$total}s.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runStep(
|
||||||
|
string $step,
|
||||||
|
ImportContext $ctx,
|
||||||
|
UserRolePermissionSyncService $roleSync,
|
||||||
|
): ImportResult {
|
||||||
|
return match ($step) {
|
||||||
|
'categories' => app(CategoryImporter::class)->run($ctx),
|
||||||
|
'users' => app(UserImporter::class, ['roleSync' => $roleSync])->run($ctx),
|
||||||
|
'companies' => app(CompanyImporter::class)->run($ctx),
|
||||||
|
'contacts' => app(ContactImporter::class)->run($ctx),
|
||||||
|
'press-releases' => app(PressReleaseImporter::class)->run($ctx),
|
||||||
|
default => throw new \InvalidArgumentException("Unbekannter Schritt: {$step}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,21 +2,19 @@
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Article as NewArticle;
|
||||||
|
use App\Models\Brand;
|
||||||
|
use App\Models\Legacy\Presseecho\Article as LegacyArticle;
|
||||||
|
use App\Models\Legacy\Presseecho\User as LegacyUser;
|
||||||
|
// Import der Models für die NEUE Struktur
|
||||||
|
use App\Models\User as NewUser;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
// Import der Models für die ALTE Struktur
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
// Import der Models für die NEUE Struktur
|
class MigratePresseData extends Command
|
||||||
use App\Models\User as NewUser;
|
|
||||||
use App\Models\Article as NewArticle;
|
|
||||||
use App\Models\Brand;
|
|
||||||
|
|
||||||
// Import der Models für die ALTE Struktur
|
|
||||||
use App\Models\Legacy\Presseecho\User as LegacyUser;
|
|
||||||
use App\Models\Legacy\Presseecho\Article as LegacyArticle;
|
|
||||||
|
|
||||||
class MigratePresseechoData extends Command
|
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The name and signature of the console command.
|
* The name and signature of the console command.
|
||||||
|
|
@ -43,6 +41,7 @@ class MigratePresseechoData extends Command
|
||||||
$brand = Brand::where('domain', 'presseecho.de')->first();
|
$brand = Brand::where('domain', 'presseecho.de')->first();
|
||||||
if (! $brand) {
|
if (! $brand) {
|
||||||
$this->error('Brand "presseecho.de" not found in the new database. Please seed brands first.');
|
$this->error('Brand "presseecho.de" not found in the new database. Please seed brands first.');
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,6 +57,7 @@ class MigratePresseechoData extends Command
|
||||||
DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
|
DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
|
||||||
|
|
||||||
$this->info('Migration for presseecho.de completed successfully!');
|
$this->info('Migration for presseecho.de completed successfully!');
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,6 +106,7 @@ class MigratePresseechoData extends Command
|
||||||
|
|
||||||
if (! $author) {
|
if (! $author) {
|
||||||
$this->warn("Skipping article ID {$legacyArticle->id}, author with email {$legacyArticle->author_email} not found.");
|
$this->warn("Skipping article ID {$legacyArticle->id}, author with email {$legacyArticle->author_email} not found.");
|
||||||
|
|
||||||
continue; // Artikel überspringen, wenn kein Autor gefunden wurde
|
continue; // Artikel überspringen, wenn kein Autor gefunden wurde
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
45
app/Console/Commands/PurgeExpiredPressReleaseDrafts.php
Normal file
45
app/Console/Commands/PurgeExpiredPressReleaseDrafts.php
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Enums\PressReleaseStatus;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archiviert Entwürfe, die seit mehr als X Tagen nicht bearbeitet wurden.
|
||||||
|
* Schützt vor "Zombie-Drafts" in der DB – optional, konfigurierbar.
|
||||||
|
*/
|
||||||
|
class PurgeExpiredPressReleaseDrafts extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'press-releases:purge-drafts
|
||||||
|
{--days=180 : Entwürfe älter als X Tage archivieren}
|
||||||
|
{--dry-run : Nur zählen, nichts ändern}';
|
||||||
|
|
||||||
|
protected $description = 'Archiviert inaktive PM-Entwürfe (älter als X Tage).';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$days = (int) $this->option('days');
|
||||||
|
$isDryRun = $this->option('dry-run');
|
||||||
|
$cutoff = now()->subDays($days);
|
||||||
|
|
||||||
|
$query = PressRelease::withoutGlobalScopes()
|
||||||
|
->where('status', PressReleaseStatus::Draft->value)
|
||||||
|
->where('updated_at', '<', $cutoff);
|
||||||
|
|
||||||
|
$count = $query->count();
|
||||||
|
|
||||||
|
if ($isDryRun) {
|
||||||
|
$this->warn("[DRY-RUN] {$count} Entwürfe würden archiviert. Kein tatsächlicher Vorgang.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->update(['status' => PressReleaseStatus::Archived->value]);
|
||||||
|
|
||||||
|
$this->info("PM-Entwürfe archiviert: {$count} Einträge.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Console/Commands/PurgeMagicLinks.php
Normal file
37
app/Console/Commands/PurgeMagicLinks.php
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\MagicLink;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht verbrauchte oder abgelaufene Magic-Links, die älter als 30 Tage sind.
|
||||||
|
* Läuft täglich via Scheduler.
|
||||||
|
*/
|
||||||
|
class PurgeMagicLinks extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'magic-links:purge {--days=30 : Links älter als X Tage löschen}';
|
||||||
|
|
||||||
|
protected $description = 'Löscht verbrauchte und abgelaufene Magic-Links.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$days = (int) $this->option('days');
|
||||||
|
$cutoff = now()->subDays($days);
|
||||||
|
|
||||||
|
$deleted = MagicLink::query()
|
||||||
|
->where(function ($q) use ($cutoff): void {
|
||||||
|
$q->where('expires_at', '<', $cutoff)
|
||||||
|
->orWhere(function ($q) use ($cutoff): void {
|
||||||
|
$q->whereNotNull('consumed_at')
|
||||||
|
->where('consumed_at', '<', $cutoff);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$this->info("Magic-Links bereinigt: {$deleted} Einträge gelöscht.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
181
app/Console/Commands/ReportAdminSlowRequests.php
Normal file
181
app/Console/Commands/ReportAdminSlowRequests.php
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Admin\AdminSlowRequestReporter;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class ReportAdminSlowRequests extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'admin:slow-requests
|
||||||
|
{--from= : Startzeitpunkt, z.B. 2026-04-29 oder 2026-04-29 08:00:00}
|
||||||
|
{--to= : Endzeitpunkt}
|
||||||
|
{--route= : Auf Route-Namen filtern}
|
||||||
|
{--path= : Auf Pfad filtern}
|
||||||
|
{--status= : Auf HTTP-Statuscode filtern}
|
||||||
|
{--min-duration= : Mindestdauer in Millisekunden}
|
||||||
|
{--top=10 : Anzahl Top-Zeilen pro Abschnitt}
|
||||||
|
{--limit=25 : Anzahl Detailzeilen}
|
||||||
|
{--file=* : Optionaler Logdatei-Pfad oder Glob}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Wertet Slow-Admin-Request-Logs aus.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(AdminSlowRequestReporter $reporter): int
|
||||||
|
{
|
||||||
|
$top = max(1, (int) $this->option('top'));
|
||||||
|
$limit = max(1, (int) $this->option('limit'));
|
||||||
|
$files = array_values(array_filter(array_map('strval', (array) $this->option('file'))));
|
||||||
|
|
||||||
|
$report = $reporter->report(
|
||||||
|
filters: [
|
||||||
|
'from' => $this->option('from') !== null ? (string) $this->option('from') : null,
|
||||||
|
'to' => $this->option('to') !== null ? (string) $this->option('to') : null,
|
||||||
|
'route' => $this->option('route') !== null ? (string) $this->option('route') : null,
|
||||||
|
'path' => $this->option('path') !== null ? (string) $this->option('path') : null,
|
||||||
|
'status' => $this->option('status') !== null ? (int) $this->option('status') : null,
|
||||||
|
'min_duration_ms' => $this->option('min-duration') !== null ? (int) $this->option('min-duration') : null,
|
||||||
|
],
|
||||||
|
top: $top,
|
||||||
|
limit: $limit,
|
||||||
|
paths: $files !== [] ? $files : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->info('Slow-Admin-Request-Report');
|
||||||
|
$this->newLine();
|
||||||
|
$this->line("Dateien: {$report['summary']['files']}");
|
||||||
|
$this->line("Requests: {$report['summary']['total_requests']}");
|
||||||
|
$this->line("Routen: {$report['summary']['unique_routes']}");
|
||||||
|
$this->line("Durchschnitt Dauer: {$report['summary']['average_duration_ms']} ms");
|
||||||
|
$this->line("Max. Dauer: {$report['summary']['max_duration_ms']} ms");
|
||||||
|
$this->line("Durchschnitt DB-Zeit: {$report['summary']['average_database_time_ms']} ms");
|
||||||
|
$this->line("Max. Query-Anzahl: {$report['summary']['max_query_count']}");
|
||||||
|
|
||||||
|
$this->renderTopTable('Top Routen', $report['top_routes']);
|
||||||
|
$this->renderTopTable('Top Pfade', $report['top_paths']);
|
||||||
|
$this->renderTopTable('Statuscodes', $report['status_codes']);
|
||||||
|
$this->renderRequests('Langsamste Requests', $report['slowest_requests']);
|
||||||
|
$this->renderRequests('Query-lastige Requests', $report['query_heavy_requests']);
|
||||||
|
$this->renderSlowQueries($report['slow_queries']);
|
||||||
|
$this->renderExplainPlans($report['explain_plans']);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $rows
|
||||||
|
*/
|
||||||
|
private function renderTopTable(string $title, array $rows): void
|
||||||
|
{
|
||||||
|
if ($rows === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->line($title);
|
||||||
|
$this->table(
|
||||||
|
['Wert', 'Requests', 'Ø Dauer', 'Max Dauer', 'Ø DB', 'Queries'],
|
||||||
|
collect($rows)
|
||||||
|
->map(fn (array $row): array => [
|
||||||
|
$row['value'],
|
||||||
|
$row['requests'],
|
||||||
|
$row['average_duration_ms'].' ms',
|
||||||
|
$row['max_duration_ms'].' ms',
|
||||||
|
$row['average_database_time_ms'].' ms',
|
||||||
|
$row['total_queries'],
|
||||||
|
])
|
||||||
|
->all()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $rows
|
||||||
|
*/
|
||||||
|
private function renderRequests(string $title, array $rows): void
|
||||||
|
{
|
||||||
|
if ($rows === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->line($title);
|
||||||
|
$this->table(
|
||||||
|
['Zeit', 'Route', 'Pfad', 'Status', 'Dauer', 'DB', 'Queries'],
|
||||||
|
collect($rows)
|
||||||
|
->map(fn (array $row): array => [
|
||||||
|
$row['timestamp'],
|
||||||
|
$row['route_name'],
|
||||||
|
$row['path'],
|
||||||
|
$row['status_code'],
|
||||||
|
$row['duration_ms'].' ms',
|
||||||
|
$row['database_time_ms'].' ms',
|
||||||
|
$row['query_count'],
|
||||||
|
])
|
||||||
|
->all()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $rows
|
||||||
|
*/
|
||||||
|
private function renderSlowQueries(array $rows): void
|
||||||
|
{
|
||||||
|
if ($rows === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('Häufige Slow Queries');
|
||||||
|
$this->table(
|
||||||
|
['SQL', 'Vorkommen', 'Ø Zeit', 'Max Zeit'],
|
||||||
|
collect($rows)
|
||||||
|
->map(fn (array $row): array => [
|
||||||
|
str($row['sql'])->limit(100)->toString(),
|
||||||
|
$row['occurrences'],
|
||||||
|
$row['average_time_ms'].' ms',
|
||||||
|
$row['max_time_ms'].' ms',
|
||||||
|
])
|
||||||
|
->all()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $rows
|
||||||
|
*/
|
||||||
|
private function renderExplainPlans(array $rows): void
|
||||||
|
{
|
||||||
|
if ($rows === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('EXPLAIN Top Slow Queries');
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$this->line(str($row['sql'])->limit(120)->toString());
|
||||||
|
|
||||||
|
if ($row['error'] !== null) {
|
||||||
|
$this->warn((string) $row['error']);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
array_keys($row['plan'][0] ?? []),
|
||||||
|
collect($row['plan'])->map(fn (array $planRow): array => array_values($planRow))->all()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/Console/Commands/ReportApiUsage.php
Normal file
92
app/Console/Commands/ReportApiUsage.php
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Api\ApiUsageReporter;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class ReportApiUsage extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'api:usage-report
|
||||||
|
{--from= : Startzeitpunkt, z.B. 2026-04-01 oder 2026-04-01 00:00:00}
|
||||||
|
{--to= : Endzeitpunkt}
|
||||||
|
{--user= : Auf User-ID filtern}
|
||||||
|
{--status= : Auf HTTP-Statuscode filtern}
|
||||||
|
{--top=10 : Anzahl Top-Zeilen pro Abschnitt}
|
||||||
|
{--no-report : Keinen JSON-Report schreiben}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Erstellt einen Report aus den protokollierten API-Usage-Logs.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(ApiUsageReporter $reporter): int
|
||||||
|
{
|
||||||
|
$top = max(1, (int) $this->option('top'));
|
||||||
|
$userId = $this->option('user') !== null ? (int) $this->option('user') : null;
|
||||||
|
$statusCode = $this->option('status') !== null ? (int) $this->option('status') : null;
|
||||||
|
|
||||||
|
$report = $reporter->report(
|
||||||
|
from: $this->option('from') !== null ? (string) $this->option('from') : null,
|
||||||
|
to: $this->option('to') !== null ? (string) $this->option('to') : null,
|
||||||
|
userId: $userId,
|
||||||
|
statusCode: $statusCode,
|
||||||
|
top: $top,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->info('API-Usage-Report');
|
||||||
|
$this->newLine();
|
||||||
|
$this->line("Requests: {$report['summary']['total_requests']}");
|
||||||
|
$this->line("Eindeutige User: {$report['summary']['unique_users']}");
|
||||||
|
$this->line("Eindeutige Tokens: {$report['summary']['unique_tokens']}");
|
||||||
|
$this->line("2xx: {$report['summary']['successful_requests']}");
|
||||||
|
$this->line("4xx: {$report['summary']['client_error_requests']}");
|
||||||
|
$this->line("5xx: {$report['summary']['server_error_requests']}");
|
||||||
|
$this->line("Durchschnitt Dauer: {$report['summary']['average_duration_ms']} ms");
|
||||||
|
|
||||||
|
$this->renderRows('Top Pfade', ['Pfad', 'Requests'], $report['top_paths']);
|
||||||
|
$this->renderRows('Statuscodes', ['Status', 'Requests'], $report['status_codes']);
|
||||||
|
$this->renderRows('Top User', ['User-ID', 'Requests'], $report['top_users']);
|
||||||
|
$this->renderRows('Top Tokens', ['Token-ID', 'Requests'], $report['top_tokens']);
|
||||||
|
|
||||||
|
if (! (bool) $this->option('no-report')) {
|
||||||
|
$path = 'migration/api-usage-'.now()->format('Ymd-His').'.json';
|
||||||
|
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||||
|
$this->newLine();
|
||||||
|
$this->line("Report geschrieben: storage/app/private/{$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $headers
|
||||||
|
* @param list<array{value: mixed, requests: int}> $rows
|
||||||
|
*/
|
||||||
|
private function renderRows(string $title, array $headers, array $rows): void
|
||||||
|
{
|
||||||
|
if ($rows === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->line($title);
|
||||||
|
$this->table(
|
||||||
|
$headers,
|
||||||
|
collect($rows)
|
||||||
|
->map(fn (array $row): array => [$row['value'], $row['requests']])
|
||||||
|
->all()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/Console/Commands/ReportLegacyApiCustomers.php
Normal file
92
app/Console/Commands/ReportLegacyApiCustomers.php
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Api\LegacyApiCustomerReporter;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class ReportLegacyApiCustomers extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'api:legacy-customers-report
|
||||||
|
{--portal=all : Portal (presseecho|businessportal24|both|all)}
|
||||||
|
{--classification=all : Filter (eligible|needs_review|blocked|all)}
|
||||||
|
{--no-report : Keinen JSON-Report schreiben}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Ermittelt Legacy-API-Kunden aus migrierten Daten und letzter bezahlter Rechnung.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(LegacyApiCustomerReporter $reporter): int
|
||||||
|
{
|
||||||
|
$portal = (string) $this->option('portal');
|
||||||
|
$classification = (string) $this->option('classification');
|
||||||
|
|
||||||
|
if (! in_array($portal, ['presseecho', 'businessportal24', 'both', 'all'], true)) {
|
||||||
|
$this->error("Unbekanntes Portal: {$portal}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($classification, ['eligible', 'needs_review', 'blocked', 'all'], true)) {
|
||||||
|
$this->error("Unbekannte Klassifizierung: {$classification}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = $reporter->report($portal);
|
||||||
|
$customers = collect($report['customers']);
|
||||||
|
|
||||||
|
if ($classification !== 'all') {
|
||||||
|
$customers = $customers
|
||||||
|
->where('classification', $classification)
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Legacy-API-Kundenreport');
|
||||||
|
$this->newLine();
|
||||||
|
$this->line("Portal: {$portal}");
|
||||||
|
$this->line("Kandidaten: {$report['summary']['total_candidates']}");
|
||||||
|
$this->line("Freigabefähig: {$report['summary']['eligible']}");
|
||||||
|
$this->line("Manuell prüfen: {$report['summary']['needs_review']}");
|
||||||
|
$this->line("Gesperrt: {$report['summary']['blocked']}");
|
||||||
|
|
||||||
|
if ($customers->isNotEmpty()) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->table(
|
||||||
|
['ID', 'E-Mail', 'Portal', 'Letzte Rechnung', 'Status', 'Klassifizierung'],
|
||||||
|
$customers
|
||||||
|
->take(25)
|
||||||
|
->map(fn (array $customer): array => [
|
||||||
|
$customer['user_id'],
|
||||||
|
$customer['email'],
|
||||||
|
$customer['portal'],
|
||||||
|
$customer['latest_legacy_invoice']['number'] ?? '-',
|
||||||
|
$customer['latest_legacy_invoice']['status'] ?? '-',
|
||||||
|
$customer['classification'],
|
||||||
|
])
|
||||||
|
->all()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! (bool) $this->option('no-report')) {
|
||||||
|
$path = 'migration/legacy-api-customers-'.now()->format('Ymd-His').'.json';
|
||||||
|
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||||
|
$this->newLine();
|
||||||
|
$this->line("Report geschrieben: storage/app/private/{$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
106
app/Console/Commands/SendGoLiveMails.php
Normal file
106
app/Console/Commands/SendGoLiveMails.php
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Mail\GoLivePasswordReset;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sendet Go-Live-Passwort-Reset-Mails an alle aktiven User.
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* php artisan auth:send-go-live-mails --dry-run
|
||||||
|
* php artisan auth:send-go-live-mails --portal=presseecho
|
||||||
|
* php artisan auth:send-go-live-mails --limit=10
|
||||||
|
* php artisan auth:send-go-live-mails --force
|
||||||
|
*/
|
||||||
|
class SendGoLiveMails extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'auth:send-go-live-mails
|
||||||
|
{--dry-run : Nur Anzahl ausgeben, keine Mails senden}
|
||||||
|
{--portal= : Nur User eines Portals (presseecho|businessportal24|both)}
|
||||||
|
{--limit=0 : Maximale Anzahl (0 = alle)}
|
||||||
|
{--force : Bestätigung überspringen}';
|
||||||
|
|
||||||
|
protected $description = 'Sendet Go-Live-Passwort-Reset-Mails an alle aktiven Benutzer.';
|
||||||
|
|
||||||
|
private const EXPIRES_IN_MINUTES = 60;
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$isDryRun = $this->option('dry-run');
|
||||||
|
$portal = $this->option('portal');
|
||||||
|
$limit = (int) $this->option('limit');
|
||||||
|
|
||||||
|
$query = User::query()
|
||||||
|
->where('is_active', true)
|
||||||
|
->whereNotNull('email')
|
||||||
|
->when($portal, fn ($q) => $q->where('portal', $portal));
|
||||||
|
|
||||||
|
$total = $query->count();
|
||||||
|
$this->info("Aktive Benutzer gefunden: {$total}");
|
||||||
|
|
||||||
|
if ($portal) {
|
||||||
|
$this->line(" → Portal-Filter: {$portal}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($limit > 0) {
|
||||||
|
$this->line(" → Limit: {$limit}");
|
||||||
|
$query->limit($limit);
|
||||||
|
$total = min($total, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isDryRun) {
|
||||||
|
$this->warn("[DRY-RUN] Es würden {$total} Mails versendet. Kein tatsächlicher Versand.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->option('force') && ! $this->confirm("Jetzt {$total} Go-Live-Mails senden?")) {
|
||||||
|
$this->info('Abgebrochen.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sent = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
$bar = $this->output->createProgressBar($total);
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
$query->each(function (User $user) use (&$sent, &$failed, $bar): void {
|
||||||
|
try {
|
||||||
|
$token = Password::broker()->createToken($user);
|
||||||
|
$resetUrl = url(route('password.reset', [
|
||||||
|
'token' => $token,
|
||||||
|
'email' => $user->email,
|
||||||
|
], false));
|
||||||
|
|
||||||
|
Mail::to($user->email)->send(
|
||||||
|
new GoLivePasswordReset($user, $resetUrl, self::EXPIRES_IN_MINUTES)
|
||||||
|
);
|
||||||
|
|
||||||
|
$sent++;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$failed++;
|
||||||
|
$this->newLine();
|
||||||
|
$this->error("Fehler für {$user->email}: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->advance();
|
||||||
|
});
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine(2);
|
||||||
|
$this->info("Versendet: {$sent}");
|
||||||
|
|
||||||
|
if ($failed > 0) {
|
||||||
|
$this->warn("Fehlgeschlagen: {$failed}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
160
app/Console/Commands/SyncCompanyLogos.php
Normal file
160
app/Console/Commands/SyncCompanyLogos.php
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Enums\Portal;
|
||||||
|
use App\Models\Company;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class SyncCompanyLogos extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'legacy:sync-company-logos
|
||||||
|
{--portal=all : Portal (presseecho|businessportal24|all)}
|
||||||
|
{--dry-run : Nur prüfen, nichts kopieren oder aktualisieren}
|
||||||
|
{--force : Ziel-Dateien erneut überschreiben}';
|
||||||
|
|
||||||
|
protected $description = 'Kopiert referenzierte Legacy-Firmenlogos in einen sauberen Storage-Pfad und aktualisiert companies.logo_path.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$portals = $this->selectedPortals();
|
||||||
|
|
||||||
|
if ($portals === []) {
|
||||||
|
$this->error('Ungültiges Portal. Erlaubt: presseecho, businessportal24, all.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
$force = (bool) $this->option('force');
|
||||||
|
$totals = [
|
||||||
|
'referenced' => 0,
|
||||||
|
'copied' => 0,
|
||||||
|
'updated' => 0,
|
||||||
|
'already_synced' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'unused' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($portals as $portal) {
|
||||||
|
$stats = $this->syncPortal($portal, $dryRun, $force);
|
||||||
|
|
||||||
|
foreach ($totals as $key => $value) {
|
||||||
|
$totals[$key] = $value + $stats[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(sprintf(
|
||||||
|
'%s: referenziert %d, kopiert %d, DB-Updates %d, bereits synchron %d, fehlend %d, ungenutzt %d',
|
||||||
|
$portal,
|
||||||
|
$stats['referenced'],
|
||||||
|
$stats['copied'],
|
||||||
|
$stats['updated'],
|
||||||
|
$stats['already_synced'],
|
||||||
|
$stats['missing'],
|
||||||
|
$stats['unused'],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Gesamt: referenziert %d, kopiert %d, DB-Updates %d, bereits synchron %d, fehlend %d, ungenutzt %d%s',
|
||||||
|
$totals['referenced'],
|
||||||
|
$totals['copied'],
|
||||||
|
$totals['updated'],
|
||||||
|
$totals['already_synced'],
|
||||||
|
$totals['missing'],
|
||||||
|
$totals['unused'],
|
||||||
|
$dryRun ? ' (Dry-Run)' : '',
|
||||||
|
));
|
||||||
|
|
||||||
|
return $totals['missing'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function selectedPortals(): array
|
||||||
|
{
|
||||||
|
$portal = (string) $this->option('portal');
|
||||||
|
|
||||||
|
return match ($portal) {
|
||||||
|
'all' => [Portal::Presseecho->value, Portal::Businessportal24->value],
|
||||||
|
Portal::Presseecho->value, Portal::Businessportal24->value => [$portal],
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{referenced:int,copied:int,updated:int,already_synced:int,missing:int,unused:int}
|
||||||
|
*/
|
||||||
|
private function syncPortal(string $portal, bool $dryRun, bool $force): array
|
||||||
|
{
|
||||||
|
$sourceDirectory = "{$portal}/company";
|
||||||
|
$allSourceFiles = collect(Storage::disk('public')->files($sourceDirectory))
|
||||||
|
->mapWithKeys(fn (string $path): array => [basename($path) => $path]);
|
||||||
|
|
||||||
|
$referencedFilenames = [];
|
||||||
|
$stats = [
|
||||||
|
'referenced' => 0,
|
||||||
|
'copied' => 0,
|
||||||
|
'updated' => 0,
|
||||||
|
'already_synced' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'unused' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
Company::withoutGlobalScopes()
|
||||||
|
->where('legacy_portal', $portal)
|
||||||
|
->whereNotNull('logo_path')
|
||||||
|
->select(['id', 'legacy_portal', 'logo_path'])
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById(500, function ($companies) use ($allSourceFiles, $dryRun, $force, $portal, &$referencedFilenames, &$stats): void {
|
||||||
|
foreach ($companies as $company) {
|
||||||
|
$stats['referenced']++;
|
||||||
|
|
||||||
|
$sourceFilename = basename((string) $company->logo_path);
|
||||||
|
$referencedFilenames[$sourceFilename] = true;
|
||||||
|
|
||||||
|
if (Str::startsWith((string) $company->logo_path, 'company-logos/')) {
|
||||||
|
$stats['already_synced']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourcePath = $allSourceFiles->get($sourceFilename);
|
||||||
|
|
||||||
|
if (! $sourcePath) {
|
||||||
|
$stats['missing']++;
|
||||||
|
$this->warn("Fehlt: {$portal}/company/{$sourceFilename} (Company #{$company->id})");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$destinationPath = "company-logos/{$portal}/{$company->id}/{$sourceFilename}";
|
||||||
|
|
||||||
|
if (! $dryRun && ($force || ! Storage::disk('public')->exists($destinationPath))) {
|
||||||
|
Storage::disk('public')->copy($sourcePath, $destinationPath);
|
||||||
|
$stats['copied']++;
|
||||||
|
} elseif ($dryRun || Storage::disk('public')->exists($destinationPath)) {
|
||||||
|
$stats['copied'] += $dryRun ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$company->forceFill([
|
||||||
|
'logo_path' => $destinationPath,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats['updated']++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$stats['unused'] = $allSourceFiles->keys()
|
||||||
|
->reject(fn (string $filename): bool => isset($referencedFilenames[$filename]))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
}
|
||||||
235
app/Console/Commands/SyncPressReleaseImages.php
Normal file
235
app/Console/Commands/SyncPressReleaseImages.php
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Enums\Portal;
|
||||||
|
use App\Models\PressReleaseImage;
|
||||||
|
use App\Services\Image\ImageService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class SyncPressReleaseImages extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'legacy:sync-press-release-images
|
||||||
|
{--portal=all : Portal (presseecho|businessportal24|all)}
|
||||||
|
{--dry-run : Nur prüfen, nichts kopieren oder aktualisieren}
|
||||||
|
{--force : Ziel-Dateien erneut überschreiben}
|
||||||
|
{--skip-variants : Varianten nicht regenerieren (nur Datei-Kopie + DB-Update)}
|
||||||
|
{--limit=0 : Maximal N Bilder pro Portal verarbeiten (0 = alle)}';
|
||||||
|
|
||||||
|
protected $description = 'Kopiert referenzierte Legacy-PM-Bilder in den finalen Storage-Pfad, generiert Varianten und aktualisiert press_release_images.';
|
||||||
|
|
||||||
|
public function __construct(private readonly ImageService $imageService)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$portals = $this->selectedPortals();
|
||||||
|
|
||||||
|
if ($portals === []) {
|
||||||
|
$this->error('Ungültiges Portal. Erlaubt: presseecho, businessportal24, all.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
$force = (bool) $this->option('force');
|
||||||
|
$skipVariants = (bool) $this->option('skip-variants');
|
||||||
|
$limit = max(0, (int) $this->option('limit'));
|
||||||
|
|
||||||
|
$totals = [
|
||||||
|
'referenced' => 0,
|
||||||
|
'copied' => 0,
|
||||||
|
'variants_generated' => 0,
|
||||||
|
'updated' => 0,
|
||||||
|
'already_synced' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'unused' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($portals as $portal) {
|
||||||
|
$stats = $this->syncPortal($portal, $dryRun, $force, $skipVariants, $limit);
|
||||||
|
|
||||||
|
foreach ($totals as $key => $value) {
|
||||||
|
$totals[$key] = $value + $stats[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(sprintf(
|
||||||
|
'%s: referenziert %d, kopiert %d, Varianten %d, DB-Updates %d, bereits synchron %d, fehlend %d, ungenutzt %d',
|
||||||
|
$portal,
|
||||||
|
$stats['referenced'],
|
||||||
|
$stats['copied'],
|
||||||
|
$stats['variants_generated'],
|
||||||
|
$stats['updated'],
|
||||||
|
$stats['already_synced'],
|
||||||
|
$stats['missing'],
|
||||||
|
$stats['unused'],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Gesamt: referenziert %d, kopiert %d, Varianten %d, DB-Updates %d, bereits synchron %d, fehlend %d, ungenutzt %d%s',
|
||||||
|
$totals['referenced'],
|
||||||
|
$totals['copied'],
|
||||||
|
$totals['variants_generated'],
|
||||||
|
$totals['updated'],
|
||||||
|
$totals['already_synced'],
|
||||||
|
$totals['missing'],
|
||||||
|
$totals['unused'],
|
||||||
|
$dryRun ? ' (Dry-Run)' : '',
|
||||||
|
));
|
||||||
|
|
||||||
|
return $totals['missing'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function selectedPortals(): array
|
||||||
|
{
|
||||||
|
$portal = (string) $this->option('portal');
|
||||||
|
|
||||||
|
return match ($portal) {
|
||||||
|
'all' => [Portal::Presseecho->value, Portal::Businessportal24->value],
|
||||||
|
Portal::Presseecho->value, Portal::Businessportal24->value => [$portal],
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{referenced:int,copied:int,variants_generated:int,updated:int,already_synced:int,missing:int,unused:int}
|
||||||
|
*/
|
||||||
|
private function syncPortal(string $portal, bool $dryRun, bool $force, bool $skipVariants, int $limit): array
|
||||||
|
{
|
||||||
|
$sourceDirectory = "{$portal}/press_release";
|
||||||
|
$allSourceFiles = collect(Storage::disk('public')->files($sourceDirectory))
|
||||||
|
->mapWithKeys(fn (string $path): array => [basename($path) => $path]);
|
||||||
|
|
||||||
|
$referencedFilenames = [];
|
||||||
|
$stats = [
|
||||||
|
'referenced' => 0,
|
||||||
|
'copied' => 0,
|
||||||
|
'variants_generated' => 0,
|
||||||
|
'updated' => 0,
|
||||||
|
'already_synced' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'unused' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$query = PressReleaseImage::withoutGlobalScopes()
|
||||||
|
->where('legacy_portal', $portal)
|
||||||
|
->whereNotNull('path')
|
||||||
|
->select(['id', 'press_release_id', 'legacy_portal', 'path', 'variants'])
|
||||||
|
->orderBy('id');
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
$reachLimit = false;
|
||||||
|
|
||||||
|
$query->chunkById(500, function ($images) use (
|
||||||
|
$allSourceFiles, $dryRun, $force, $skipVariants, $portal, $limit,
|
||||||
|
&$referencedFilenames, &$stats, &$processed, &$reachLimit,
|
||||||
|
): bool {
|
||||||
|
foreach ($images as $image) {
|
||||||
|
if ($reachLimit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats['referenced']++;
|
||||||
|
$sourceFilename = basename((string) $image->path);
|
||||||
|
$referencedFilenames[$sourceFilename] = true;
|
||||||
|
|
||||||
|
if (Str::startsWith((string) $image->path, 'press-releases/')) {
|
||||||
|
$stats['already_synced']++;
|
||||||
|
|
||||||
|
if (! $skipVariants && (! is_array($image->variants) || $image->variants === [])) {
|
||||||
|
if (! $dryRun) {
|
||||||
|
$variants = $this->imageService->generateMissingPressReleaseVariants($image->path);
|
||||||
|
|
||||||
|
if ($variants !== []) {
|
||||||
|
$image->forceFill(['variants' => $variants])->save();
|
||||||
|
$stats['variants_generated']++;
|
||||||
|
$stats['updated']++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$stats['variants_generated']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed++;
|
||||||
|
|
||||||
|
if ($limit > 0 && $processed >= $limit) {
|
||||||
|
$reachLimit = true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourcePath = $allSourceFiles->get($sourceFilename);
|
||||||
|
|
||||||
|
if (! $sourcePath) {
|
||||||
|
$stats['missing']++;
|
||||||
|
$this->warn("Fehlt: {$portal}/press_release/{$sourceFilename} (Image #{$image->id})");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$destinationPath = "press-releases/{$image->press_release_id}/images/{$sourceFilename}";
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
if ($force || ! Storage::disk('public')->exists($destinationPath)) {
|
||||||
|
Storage::disk('public')->copy($sourcePath, $destinationPath);
|
||||||
|
$stats['copied']++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$stats['copied']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$variants = [];
|
||||||
|
if (! $skipVariants && ! $dryRun) {
|
||||||
|
$variants = $this->imageService->generateMissingPressReleaseVariants($destinationPath);
|
||||||
|
|
||||||
|
if ($variants !== []) {
|
||||||
|
$stats['variants_generated']++;
|
||||||
|
}
|
||||||
|
} elseif (! $skipVariants && $dryRun) {
|
||||||
|
$stats['variants_generated']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$size = @getimagesize(Storage::disk('public')->path($destinationPath)) ?: [null, null];
|
||||||
|
|
||||||
|
$image->forceFill([
|
||||||
|
'disk' => 'public',
|
||||||
|
'path' => $destinationPath,
|
||||||
|
'variants' => $variants !== [] ? $variants : $image->variants,
|
||||||
|
'width' => is_int($size[0] ?? null) ? $size[0] : $image->width,
|
||||||
|
'height' => is_int($size[1] ?? null) ? $size[1] : $image->height,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats['updated']++;
|
||||||
|
$processed++;
|
||||||
|
|
||||||
|
if ($limit > 0 && $processed >= $limit) {
|
||||||
|
$reachLimit = true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$stats['unused'] = $allSourceFiles->keys()
|
||||||
|
->reject(fn (string $filename): bool => isset($referencedFilenames[$filename]))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
}
|
||||||
408
app/Console/Commands/VerifyLegacyImport.php
Normal file
408
app/Console/Commands/VerifyLegacyImport.php
Normal file
|
|
@ -0,0 +1,408 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Enums\Portal;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class VerifyLegacyImport extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'legacy:verify
|
||||||
|
{--portal=all : Portal (presseecho|businessportal24|all)}
|
||||||
|
{--skip-legacy : Legacy-DB-Checks überspringen}
|
||||||
|
{--no-report : Keinen JSON-Report schreiben}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Verifiziert den Legacy-Import und schreibt einen maschinenlesbaren Report.';
|
||||||
|
|
||||||
|
private const PORTAL_CONNECTIONS = [
|
||||||
|
'presseecho' => 'mysql_presseecho',
|
||||||
|
'businessportal24' => 'mysql_businessportal',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const ENTITY_MAP = [
|
||||||
|
'users' => [
|
||||||
|
'legacy_table' => 'sf_guard_user',
|
||||||
|
'target_table' => 'users',
|
||||||
|
'target_column' => 'legacy_id',
|
||||||
|
],
|
||||||
|
'companies' => [
|
||||||
|
'legacy_table' => 'company',
|
||||||
|
'target_table' => 'companies',
|
||||||
|
'target_column' => 'legacy_id',
|
||||||
|
],
|
||||||
|
'contacts' => [
|
||||||
|
'legacy_table' => 'contact',
|
||||||
|
'target_table' => 'contacts',
|
||||||
|
'target_column' => 'legacy_id',
|
||||||
|
],
|
||||||
|
'categories' => [
|
||||||
|
'legacy_table' => 'category',
|
||||||
|
'target_table' => 'categories',
|
||||||
|
'target_column' => 'legacy_id',
|
||||||
|
],
|
||||||
|
'press_releases' => [
|
||||||
|
'legacy_table' => 'press_release',
|
||||||
|
'target_table' => 'press_releases',
|
||||||
|
'target_column' => 'legacy_id',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$portalOption = (string) $this->option('portal');
|
||||||
|
$skipLegacy = (bool) $this->option('skip-legacy');
|
||||||
|
|
||||||
|
$portals = $portalOption === 'all'
|
||||||
|
? array_keys(self::PORTAL_CONNECTIONS)
|
||||||
|
: [$portalOption];
|
||||||
|
|
||||||
|
if (array_diff($portals, array_keys(self::PORTAL_CONNECTIONS)) !== []) {
|
||||||
|
$this->error("Unbekanntes Portal: {$portalOption}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = [
|
||||||
|
'generated_at' => now()->toIso8601String(),
|
||||||
|
'portal' => $portalOption,
|
||||||
|
'skip_legacy' => $skipLegacy,
|
||||||
|
'checks' => [],
|
||||||
|
'summary' => [
|
||||||
|
'failures' => 0,
|
||||||
|
'warnings' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->info('Legacy-Import-Verifikation');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
if (! $skipLegacy) {
|
||||||
|
$this->verifyLegacyCounts($report, $portals);
|
||||||
|
$this->verifyLegacyInvoices($report, $portals);
|
||||||
|
} else {
|
||||||
|
$this->warn('Legacy-DB-Checks werden übersprungen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->verifyImportMapTargets($report, $portals);
|
||||||
|
$this->verifyTargetForeignKeys($report);
|
||||||
|
$this->verifyUserMerges($report);
|
||||||
|
$this->verifyGrandfatheredSubscriptions($report);
|
||||||
|
|
||||||
|
$this->renderSummary($report);
|
||||||
|
|
||||||
|
if (! (bool) $this->option('no-report')) {
|
||||||
|
$path = 'migration/verify-'.now()->format('Ymd-His').'.json';
|
||||||
|
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||||
|
$this->line("Report geschrieben: storage/app/private/{$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $report['summary']['failures'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $report
|
||||||
|
* @param list<string> $portals
|
||||||
|
*/
|
||||||
|
private function verifyLegacyCounts(array &$report, array $portals): void
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($portals as $portal) {
|
||||||
|
foreach (self::ENTITY_MAP as $entity => $config) {
|
||||||
|
$legacyCount = $this->legacyCount($portal, $config['legacy_table']);
|
||||||
|
$targetCount = DB::table($config['target_table'])
|
||||||
|
->where('legacy_portal', $portal)
|
||||||
|
->whereNotNull($config['target_column'])
|
||||||
|
->count();
|
||||||
|
$mappedCount = DB::table('legacy_import_map')
|
||||||
|
->where('legacy_portal', $portal)
|
||||||
|
->where('legacy_table', $config['legacy_table'])
|
||||||
|
->where('target_table', $config['target_table'])
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'portal' => $portal,
|
||||||
|
'entity' => $entity,
|
||||||
|
'legacy_count' => $legacyCount,
|
||||||
|
'target_count' => $targetCount,
|
||||||
|
'mapped_count' => $mappedCount,
|
||||||
|
'delta' => $legacyCount - $targetCount,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addCheck($report, 'legacy_counts', 'ok', 'Legacy- und Ziel-Counts erfasst.', $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $report
|
||||||
|
* @param list<string> $portals
|
||||||
|
*/
|
||||||
|
private function verifyLegacyInvoices(array &$report, array $portals): void
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
$failures = 0;
|
||||||
|
|
||||||
|
foreach ($portals as $portal) {
|
||||||
|
$legacy = DB::connection(self::PORTAL_CONNECTIONS[$portal])
|
||||||
|
->table('invoice')
|
||||||
|
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as amount')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$target = DB::table('legacy_invoices')
|
||||||
|
->where('legacy_portal', $portal)
|
||||||
|
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount_cents), 0) as amount_cents')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$legacyAmountCents = (int) round((float) $legacy->amount * 100);
|
||||||
|
$targetAmountCents = (int) $target->amount_cents;
|
||||||
|
$countDelta = (int) $legacy->count - (int) $target->count;
|
||||||
|
$amountDelta = $legacyAmountCents - $targetAmountCents;
|
||||||
|
|
||||||
|
if ($countDelta !== 0 || $amountDelta !== 0) {
|
||||||
|
$failures++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'portal' => $portal,
|
||||||
|
'legacy_count' => (int) $legacy->count,
|
||||||
|
'target_count' => (int) $target->count,
|
||||||
|
'count_delta' => $countDelta,
|
||||||
|
'legacy_amount_cents' => $legacyAmountCents,
|
||||||
|
'target_amount_cents' => $targetAmountCents,
|
||||||
|
'amount_delta_cents' => $amountDelta,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addCheck(
|
||||||
|
$report,
|
||||||
|
'legacy_invoices',
|
||||||
|
$failures === 0 ? 'ok' : 'fail',
|
||||||
|
$failures === 0 ? 'Legacy-Rechnungen stimmen mit dem Archiv überein.' : 'Legacy-Rechnungsarchiv weicht von der Quelle ab.',
|
||||||
|
$rows,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $report
|
||||||
|
* @param list<string> $portals
|
||||||
|
*/
|
||||||
|
private function verifyImportMapTargets(array &$report, array $portals): void
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
$failures = 0;
|
||||||
|
|
||||||
|
foreach (self::ENTITY_MAP as $entity => $config) {
|
||||||
|
foreach ($portals as $portal) {
|
||||||
|
$missingTargets = DB::table('legacy_import_map as map')
|
||||||
|
->leftJoin($config['target_table'].' as target', 'target.id', '=', 'map.target_id')
|
||||||
|
->where('map.legacy_portal', $portal)
|
||||||
|
->where('map.legacy_table', $config['legacy_table'])
|
||||||
|
->where('map.target_table', $config['target_table'])
|
||||||
|
->whereNull('target.id')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if ($missingTargets > 0) {
|
||||||
|
$failures++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'portal' => $portal,
|
||||||
|
'entity' => $entity,
|
||||||
|
'missing_targets' => $missingTargets,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addCheck(
|
||||||
|
$report,
|
||||||
|
'import_map_targets',
|
||||||
|
$failures === 0 ? 'ok' : 'fail',
|
||||||
|
$failures === 0 ? 'Alle Import-Map-Ziele existieren.' : 'Import-Map enthält verwaiste Ziel-IDs.',
|
||||||
|
$rows,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $report
|
||||||
|
*/
|
||||||
|
private function verifyTargetForeignKeys(array &$report): void
|
||||||
|
{
|
||||||
|
$checks = [
|
||||||
|
'companies.owner_user_id' => DB::table('companies as c')
|
||||||
|
->leftJoin('users as u', 'u.id', '=', 'c.owner_user_id')
|
||||||
|
->whereNotNull('c.owner_user_id')
|
||||||
|
->whereNull('u.id'),
|
||||||
|
'contacts.company_id' => DB::table('contacts as c')
|
||||||
|
->leftJoin('companies as co', 'co.id', '=', 'c.company_id')
|
||||||
|
->whereNull('co.id'),
|
||||||
|
'press_releases.user_id' => DB::table('press_releases as pr')
|
||||||
|
->leftJoin('users as u', 'u.id', '=', 'pr.user_id')
|
||||||
|
->whereNull('u.id'),
|
||||||
|
'press_releases.company_id' => DB::table('press_releases as pr')
|
||||||
|
->leftJoin('companies as c', 'c.id', '=', 'pr.company_id')
|
||||||
|
->whereNotNull('pr.company_id')
|
||||||
|
->whereNull('c.id'),
|
||||||
|
'press_releases.category_id' => DB::table('press_releases as pr')
|
||||||
|
->leftJoin('categories as c', 'c.id', '=', 'pr.category_id')
|
||||||
|
->whereNull('c.id'),
|
||||||
|
'company_user.company_id' => DB::table('company_user as pivot')
|
||||||
|
->leftJoin('companies as c', 'c.id', '=', 'pivot.company_id')
|
||||||
|
->whereNull('c.id'),
|
||||||
|
'company_user.user_id' => DB::table('company_user as pivot')
|
||||||
|
->leftJoin('users as u', 'u.id', '=', 'pivot.user_id')
|
||||||
|
->whereNull('u.id'),
|
||||||
|
'contact_user.contact_id' => DB::table('contact_user as pivot')
|
||||||
|
->leftJoin('contacts as c', 'c.id', '=', 'pivot.contact_id')
|
||||||
|
->whereNull('c.id'),
|
||||||
|
'contact_user.user_id' => DB::table('contact_user as pivot')
|
||||||
|
->leftJoin('users as u', 'u.id', '=', 'pivot.user_id')
|
||||||
|
->whereNull('u.id'),
|
||||||
|
'press_release_contact.press_release_id' => DB::table('press_release_contact as pivot')
|
||||||
|
->leftJoin('press_releases as pr', 'pr.id', '=', 'pivot.press_release_id')
|
||||||
|
->whereNull('pr.id'),
|
||||||
|
'press_release_contact.contact_id' => DB::table('press_release_contact as pivot')
|
||||||
|
->leftJoin('contacts as c', 'c.id', '=', 'pivot.contact_id')
|
||||||
|
->whereNull('c.id'),
|
||||||
|
'legacy_invoices.user_id' => DB::table('legacy_invoices as li')
|
||||||
|
->leftJoin('users as u', 'u.id', '=', 'li.user_id')
|
||||||
|
->whereNotNull('li.user_id')
|
||||||
|
->whereNull('u.id'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
$failures = 0;
|
||||||
|
|
||||||
|
foreach ($checks as $name => $query) {
|
||||||
|
$count = $query->count();
|
||||||
|
|
||||||
|
if ($count > 0) {
|
||||||
|
$failures++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'relation' => $name,
|
||||||
|
'dangling_count' => $count,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addCheck(
|
||||||
|
$report,
|
||||||
|
'target_foreign_keys',
|
||||||
|
$failures === 0 ? 'ok' : 'fail',
|
||||||
|
$failures === 0 ? 'Keine verwaisten Ziel-Foreign-Keys gefunden.' : 'Zieltabellen enthalten verwaiste Foreign Keys.',
|
||||||
|
$rows,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $report
|
||||||
|
*/
|
||||||
|
private function verifyUserMerges(array &$report): void
|
||||||
|
{
|
||||||
|
$bothUsers = DB::table('users')
|
||||||
|
->where('portal', Portal::Both->value)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$importMapsForBothUsers = DB::table('legacy_import_map as map')
|
||||||
|
->join('users as users', 'users.id', '=', 'map.target_id')
|
||||||
|
->where('map.legacy_table', 'sf_guard_user')
|
||||||
|
->where('map.target_table', 'users')
|
||||||
|
->where('users.portal', Portal::Both->value)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$this->addCheck($report, 'user_merges', 'ok', 'Portalübergreifende User-Merges erfasst.', [
|
||||||
|
'users_with_portal_both' => $bothUsers,
|
||||||
|
'legacy_maps_to_portal_both_users' => $importMapsForBothUsers,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $report
|
||||||
|
*/
|
||||||
|
private function verifyGrandfatheredSubscriptions(array &$report): void
|
||||||
|
{
|
||||||
|
$rows = DB::table('user_payment_options')
|
||||||
|
->selectRaw('status, COUNT(*) as count, MIN(grandfathered_until) as earliest_grandfathered_until, MAX(grandfathered_until) as latest_grandfathered_until')
|
||||||
|
->where('status', 'grandfathered')
|
||||||
|
->groupBy('status')
|
||||||
|
->get()
|
||||||
|
->map(fn (object $row): array => [
|
||||||
|
'status' => $row->status,
|
||||||
|
'count' => (int) $row->count,
|
||||||
|
'earliest_grandfathered_until' => $row->earliest_grandfathered_until,
|
||||||
|
'latest_grandfathered_until' => $row->latest_grandfathered_until,
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$this->addCheck($report, 'grandfathered_subscriptions', 'ok', 'Grandfathered Subscriptions erfasst.', $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function legacyCount(string $portal, string $table): int
|
||||||
|
{
|
||||||
|
return DB::connection(self::PORTAL_CONNECTIONS[$portal])
|
||||||
|
->table($table)
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $report
|
||||||
|
* @param array<mixed> $details
|
||||||
|
*/
|
||||||
|
private function addCheck(array &$report, string $name, string $status, string $message, array $details): void
|
||||||
|
{
|
||||||
|
$report['checks'][$name] = [
|
||||||
|
'status' => $status,
|
||||||
|
'message' => $message,
|
||||||
|
'details' => $details,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($status === 'fail') {
|
||||||
|
$report['summary']['failures']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status === 'warning') {
|
||||||
|
$report['summary']['warnings']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $report
|
||||||
|
*/
|
||||||
|
private function renderSummary(array $report): void
|
||||||
|
{
|
||||||
|
foreach ($report['checks'] as $name => $check) {
|
||||||
|
$status = match ($check['status']) {
|
||||||
|
'ok' => '<info>OK</info>',
|
||||||
|
'warning' => '<comment>WARN</comment>',
|
||||||
|
default => '<error>FAIL</error>',
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->line("{$status} {$name}: {$check['message']}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->table(
|
||||||
|
['Status', 'Anzahl'],
|
||||||
|
[
|
||||||
|
['Fehler', $report['summary']['failures']],
|
||||||
|
['Warnungen', $report['summary']['warnings']],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/Contracts/NewsletterSyncClient.php
Normal file
12
app/Contracts/NewsletterSyncClient.php
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Contracts;
|
||||||
|
|
||||||
|
use App\Models\NewsletterSubscription;
|
||||||
|
|
||||||
|
interface NewsletterSyncClient
|
||||||
|
{
|
||||||
|
public function subscribe(NewsletterSubscription $subscription): void;
|
||||||
|
|
||||||
|
public function unsubscribe(NewsletterSubscription $subscription): void;
|
||||||
|
}
|
||||||
17
app/Enums/CompanyType.php
Normal file
17
app/Enums/CompanyType.php
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum CompanyType: string
|
||||||
|
{
|
||||||
|
case Company = 'company';
|
||||||
|
case Agency = 'agency';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Company => 'Unternehmen',
|
||||||
|
self::Agency => 'Agentur',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Enums/InvoiceStatus.php
Normal file
21
app/Enums/InvoiceStatus.php
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum InvoiceStatus: string
|
||||||
|
{
|
||||||
|
case Open = 'open';
|
||||||
|
case Paid = 'paid';
|
||||||
|
case Void = 'void';
|
||||||
|
case Uncollectible = 'uncollectible';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Open => 'Offen',
|
||||||
|
self::Paid => 'Bezahlt',
|
||||||
|
self::Void => 'Storniert',
|
||||||
|
self::Uncollectible => 'Uneinbringlich',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Enums/PaymentOptionType.php
Normal file
17
app/Enums/PaymentOptionType.php
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum PaymentOptionType: string
|
||||||
|
{
|
||||||
|
case Recurring = 'recurring';
|
||||||
|
case Onetime = 'onetime';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Recurring => 'Wiederkehrend',
|
||||||
|
self::Onetime => 'Einmalig',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Enums/PaymentStatus.php
Normal file
21
app/Enums/PaymentStatus.php
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum PaymentStatus: string
|
||||||
|
{
|
||||||
|
case Pending = 'pending';
|
||||||
|
case Succeeded = 'succeeded';
|
||||||
|
case Failed = 'failed';
|
||||||
|
case Refunded = 'refunded';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Pending => 'Ausstehend',
|
||||||
|
self::Succeeded => 'Erfolgreich',
|
||||||
|
self::Failed => 'Fehlgeschlagen',
|
||||||
|
self::Refunded => 'Erstattet',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Enums/Portal.php
Normal file
19
app/Enums/Portal.php
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum Portal: string
|
||||||
|
{
|
||||||
|
case Presseecho = 'presseecho';
|
||||||
|
case Businessportal24 = 'businessportal24';
|
||||||
|
case Both = 'both';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Presseecho => 'Presseecho',
|
||||||
|
self::Businessportal24 => 'Businessportal24',
|
||||||
|
self::Both => 'Beide Portale',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Enums/PressReleaseStatus.php
Normal file
23
app/Enums/PressReleaseStatus.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum PressReleaseStatus: string
|
||||||
|
{
|
||||||
|
case Draft = 'draft';
|
||||||
|
case Review = 'review';
|
||||||
|
case Published = 'published';
|
||||||
|
case Rejected = 'rejected';
|
||||||
|
case Archived = 'archived';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Draft => 'Entwurf',
|
||||||
|
self::Review => 'In Pruefung',
|
||||||
|
self::Published => 'Veroeffentlicht',
|
||||||
|
self::Rejected => 'Abgelehnt',
|
||||||
|
self::Archived => 'Archiviert',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Enums/RegistrationType.php
Normal file
21
app/Enums/RegistrationType.php
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum RegistrationType: string
|
||||||
|
{
|
||||||
|
case Agency = 'agency';
|
||||||
|
case Company = 'company';
|
||||||
|
case ApiUser = 'apiuser';
|
||||||
|
case ExistingLegacy = 'existing_legacy';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Agency => 'Agentur',
|
||||||
|
self::Company => 'Unternehmen',
|
||||||
|
self::ApiUser => 'API-User',
|
||||||
|
self::ExistingLegacy => 'Legacy-Bestand',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Enums/UserPaymentOptionStatus.php
Normal file
21
app/Enums/UserPaymentOptionStatus.php
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum UserPaymentOptionStatus: string
|
||||||
|
{
|
||||||
|
case Active = 'active';
|
||||||
|
case PastDue = 'past_due';
|
||||||
|
case Cancelled = 'cancelled';
|
||||||
|
case Grandfathered = 'grandfathered';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Active => 'Aktiv',
|
||||||
|
self::PastDue => 'Ueberfaellig',
|
||||||
|
self::Cancelled => 'Gekuendigt',
|
||||||
|
self::Grandfathered => 'Bestandsschutz',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,24 +9,24 @@ class ThemeHelper
|
||||||
*/
|
*/
|
||||||
public static function getLogoPath(string $type = 'positive'): string
|
public static function getLogoPath(string $type = 'positive'): string
|
||||||
{
|
{
|
||||||
$theme = config('app.theme', 'pr-copilot');
|
$theme = config('app.theme', 'portal');
|
||||||
|
|
||||||
$logoMap = [
|
$logoMap = [
|
||||||
'portal' => [
|
'portal' => [
|
||||||
'positive' => 'img/logos/portal-logo-positive.svg',
|
'positive' => 'img/logos/portal-logo-positive.svg',
|
||||||
'negative' => 'img/logos/portal-logo-negative.svg'
|
'negative' => 'img/logos/portal-logo-negative.svg',
|
||||||
],
|
],
|
||||||
'presseecho' => [
|
'presseecho' => [
|
||||||
'positive' => 'img/logos/presseecho-logo-positiv.svg',
|
'positive' => 'img/logos/presseecho-logo-positiv.svg',
|
||||||
'negative' => 'img/logos/presseecho-logo-negativ.svg'
|
'negative' => 'img/logos/presseecho-logo-negativ.svg',
|
||||||
],
|
],
|
||||||
'businessportal24' => [
|
'businessportal24' => [
|
||||||
'positive' => 'img/logos/businessportal24-logo-positiv.svg',
|
'positive' => 'img/logos/businessportal24-logo-positiv.svg',
|
||||||
'negative' => 'img/logos/businessportal24-logo-negativ.svg'
|
'negative' => 'img/logos/businessportal24-logo-negativ.svg',
|
||||||
]
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
return $logoMap[$theme][$type] ?? $logoMap['pr-copilot'][$type];
|
return $logoMap[$theme][$type] ?? $logoMap['portal'][$type];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -35,6 +35,7 @@ class ThemeHelper
|
||||||
public static function getFaviconPath(): string
|
public static function getFaviconPath(): string
|
||||||
{
|
{
|
||||||
$theme = config('app.theme', 'portal');
|
$theme = config('app.theme', 'portal');
|
||||||
|
|
||||||
return "img/favicons/{$theme}-favicon.ico";
|
return "img/favicons/{$theme}-favicon.ico";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,6 +45,7 @@ class ThemeHelper
|
||||||
public static function getThemeCssPath(): string
|
public static function getThemeCssPath(): string
|
||||||
{
|
{
|
||||||
$theme = config('app.theme', 'portal');
|
$theme = config('app.theme', 'portal');
|
||||||
|
|
||||||
return "resources/css/web/theme-{$theme}.css";
|
return "resources/css/web/theme-{$theme}.css";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,6 +55,7 @@ class ThemeHelper
|
||||||
public static function getDomainConfig(): array
|
public static function getDomainConfig(): array
|
||||||
{
|
{
|
||||||
$theme = config('app.theme', 'portal');
|
$theme = config('app.theme', 'portal');
|
||||||
|
|
||||||
return config("domains.domains.{$theme}", []);
|
return config("domains.domains.{$theme}", []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,6 +65,7 @@ class ThemeHelper
|
||||||
public static function getPrimaryColor(): string
|
public static function getPrimaryColor(): string
|
||||||
{
|
{
|
||||||
$config = self::getDomainConfig();
|
$config = self::getDomainConfig();
|
||||||
|
|
||||||
return $config['color_scheme']['primary'] ?? '#526266';
|
return $config['color_scheme']['primary'] ?? '#526266';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,6 +75,7 @@ class ThemeHelper
|
||||||
public static function getSecondaryColor(): string
|
public static function getSecondaryColor(): string
|
||||||
{
|
{
|
||||||
$config = self::getDomainConfig();
|
$config = self::getDomainConfig();
|
||||||
|
|
||||||
return $config['color_scheme']['secondary'] ?? '#82a0a7';
|
return $config['color_scheme']['secondary'] ?? '#82a0a7';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,22 +85,24 @@ class ThemeHelper
|
||||||
public static function getFont(): string
|
public static function getFont(): string
|
||||||
{
|
{
|
||||||
$config = self::getDomainConfig();
|
$config = self::getDomainConfig();
|
||||||
|
|
||||||
return $config['font'] ?? 'Montserrat';
|
return $config['font'] ?? 'Montserrat';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get domain name for the current theme
|
* Get domain name for the current theme
|
||||||
*/
|
*/
|
||||||
public static function getDomainName(): string
|
public static function getDomainName(): string
|
||||||
{
|
{
|
||||||
$config = self::getDomainConfig();
|
$config = self::getDomainConfig();
|
||||||
return $config['domain_name'] ?? 'pr-copilot.test';
|
|
||||||
|
return $config['domain_name'] ?? 'presseportale.test';
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getDomainUrl(): string
|
public static function getDomainUrl(): string
|
||||||
{
|
{
|
||||||
$config = self::getDomainConfig();
|
$config = self::getDomainConfig();
|
||||||
|
|
||||||
return $config['url'] ?? config('app.url');
|
return $config['url'] ?? config('app.url');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,11 +114,11 @@ class ThemeHelper
|
||||||
$theme = config('app.theme', 'portal');
|
$theme = config('app.theme', 'portal');
|
||||||
|
|
||||||
$assetUrlMap = [
|
$assetUrlMap = [
|
||||||
'portal' => 'https://assets.pr-copilot.test',
|
'portal' => 'https://assets.presseportale.test',
|
||||||
'presseecho' => 'https://assets.presseecho.test',
|
'presseecho' => 'https://assets.presseecho.test',
|
||||||
'businessportal24' => 'https://assets.businessportal24.test',
|
'businessportal24' => 'https://assets.businessportal24.test',
|
||||||
];
|
];
|
||||||
|
|
||||||
return $assetUrlMap[$theme] ?? 'https://assets.pr-copilot.test';
|
return $assetUrlMap[$theme] ?? 'https://assets.presseportale.test';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
74
app/Http/Controllers/Admin/DashboardController.php
Normal file
74
app/Http/Controllers/Admin/DashboardController.php
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Enums\PressReleaseStatus;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\Contact;
|
||||||
|
use App\Models\NewsletterSubscription;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Admin\AdminPerformanceCache;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use stdClass;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(): View
|
||||||
|
{
|
||||||
|
$stats = app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::DashboardStats, AdminPerformanceCache::StatsTtl, fn (): array => $this->stats());
|
||||||
|
|
||||||
|
return view('admin.dashboard', [
|
||||||
|
'stats' => $stats,
|
||||||
|
'recentPRs' => PressRelease::withoutGlobalScopes()
|
||||||
|
->with(['company:id,name', 'user:id,name'])
|
||||||
|
->latest('created_at')
|
||||||
|
->limit(8)
|
||||||
|
->get(['id', 'title', 'status', 'portal', 'company_id', 'user_id', 'created_at']),
|
||||||
|
'pendingReviews' => PressRelease::withoutGlobalScopes()
|
||||||
|
->with(['company:id,name'])
|
||||||
|
->where('status', PressReleaseStatus::Review->value)
|
||||||
|
->latest('created_at')
|
||||||
|
->limit(5)
|
||||||
|
->get(['id', 'title', 'company_id', 'portal', 'created_at']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{press_releases: array{total: int, published: int, review: int, draft: int}, companies: int, contacts: int, users: int, newsletter: int}
|
||||||
|
*/
|
||||||
|
private function stats(): array
|
||||||
|
{
|
||||||
|
$pressReleaseStats = PressRelease::withoutGlobalScopes()
|
||||||
|
->toBase()
|
||||||
|
->selectRaw('COUNT(*) as total')
|
||||||
|
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as published', [PressReleaseStatus::Published->value])
|
||||||
|
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as review', [PressReleaseStatus::Review->value])
|
||||||
|
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as draft', [PressReleaseStatus::Draft->value])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'press_releases' => $this->normalizePressReleaseStats($pressReleaseStats),
|
||||||
|
'companies' => Company::withoutGlobalScopes()->count(),
|
||||||
|
'contacts' => Contact::withoutGlobalScopes()->count(),
|
||||||
|
'users' => User::query()->toBase()->count('*'),
|
||||||
|
'newsletter' => NewsletterSubscription::withoutGlobalScopes()
|
||||||
|
->where('is_confirmed', true)
|
||||||
|
->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{total: int, published: int, review: int, draft: int}
|
||||||
|
*/
|
||||||
|
private function normalizePressReleaseStats(?stdClass $stats): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'total' => (int) ($stats->total ?? 0),
|
||||||
|
'published' => (int) ($stats->published ?? 0),
|
||||||
|
'review' => (int) ($stats->review ?? 0),
|
||||||
|
'draft' => (int) ($stats->draft ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Http/Controllers/Admin/LeaveImpersonationController.php
Normal file
23
app/Http/Controllers/Admin/LeaveImpersonationController.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Actions\Admin\UserImpersonation;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
||||||
|
class LeaveImpersonationController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(UserImpersonation $impersonation): RedirectResponse
|
||||||
|
{
|
||||||
|
$admin = $impersonation->stop();
|
||||||
|
|
||||||
|
if ($admin !== null) {
|
||||||
|
return redirect()
|
||||||
|
->route('admin.users.index')
|
||||||
|
->with('status', __('Erfolgreich zurück zum Admin-Account gewechselt.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('me.dashboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Http/Controllers/Api/V1/CategoryController.php
Normal file
25
app/Http/Controllers/Api/V1/CategoryController.php
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\CategoryResource;
|
||||||
|
use App\Models\Category;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
class CategoryController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->tokenCan('press-releases:read'), 403);
|
||||||
|
|
||||||
|
$categories = Category::query()
|
||||||
|
->with('translations')
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return CategoryResource::collection($categories);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Http/Controllers/Api/V1/CompanyController.php
Normal file
59
app/Http/Controllers/Api/V1/CompanyController.php
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\CompanyResource;
|
||||||
|
use App\Models\Company;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
class CompanyController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->tokenCan('companies:read'), 403);
|
||||||
|
|
||||||
|
$companies = Company::withoutGlobalScopes()
|
||||||
|
->where(function ($query) use ($request): void {
|
||||||
|
$query->where('owner_user_id', $request->user()->id)
|
||||||
|
->orWhereHas('users', fn ($users) => $users->whereKey($request->user()->id));
|
||||||
|
})
|
||||||
|
->orderBy('name')
|
||||||
|
->paginate(min((int) $request->query('per_page', 25), 100));
|
||||||
|
|
||||||
|
return CompanyResource::collection($companies);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, int $company): CompanyResource
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->tokenCan('companies:read'), 403);
|
||||||
|
|
||||||
|
$company = Company::withoutGlobalScopes()
|
||||||
|
->whereKey($company)
|
||||||
|
->where(function ($query) use ($request): void {
|
||||||
|
$query->where('owner_user_id', $request->user()->id)
|
||||||
|
->orWhereHas('users', fn ($users) => $users->whereKey($request->user()->id));
|
||||||
|
})
|
||||||
|
->first();
|
||||||
|
|
||||||
|
abort_unless($company !== null, 403);
|
||||||
|
|
||||||
|
return CompanyResource::make($company);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, string $id)
|
||||||
|
{
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(string $id)
|
||||||
|
{
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\SubscribeNewsletterRequest;
|
||||||
|
use App\Models\NewsletterSubscription;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class NewsletterSubscriptionController extends Controller
|
||||||
|
{
|
||||||
|
public function store(SubscribeNewsletterRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validated();
|
||||||
|
|
||||||
|
$subscription = NewsletterSubscription::withoutGlobalScopes()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'portal' => $validated['portal'],
|
||||||
|
'email' => mb_strtolower($validated['email']),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'user_id' => $request->user()->id,
|
||||||
|
'salutation_key' => $validated['salutation_key'] ?? null,
|
||||||
|
'first_name' => $validated['first_name'] ?? null,
|
||||||
|
'last_name' => $validated['last_name'] ?? null,
|
||||||
|
'ip_address' => $request->ip(),
|
||||||
|
'is_confirmed' => false,
|
||||||
|
'confirmation_token' => Str::random(32),
|
||||||
|
'subscribed_at' => now(),
|
||||||
|
'unsubscribed_at' => null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Newsletter subscription created.',
|
||||||
|
'data' => [
|
||||||
|
'id' => $subscription->id,
|
||||||
|
'portal' => $subscription->portal->value,
|
||||||
|
'email' => $subscription->email,
|
||||||
|
'is_confirmed' => $subscription->is_confirmed,
|
||||||
|
],
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
}
|
||||||
165
app/Http/Controllers/Api/V1/PressReleaseController.php
Normal file
165
app/Http/Controllers/Api/V1/PressReleaseController.php
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\StorePressReleaseRequest;
|
||||||
|
use App\Http\Requests\Api\V1\UpdatePressReleaseRequest;
|
||||||
|
use App\Http\Resources\PressReleaseResource;
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class PressReleaseController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->tokenCan('press-releases:read'), 403);
|
||||||
|
|
||||||
|
$pressReleases = PressRelease::withoutGlobalScopes()
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->with(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||||||
|
->when($request->query('status'), fn ($query, string $status) => $query->where('status', $status))
|
||||||
|
->latest()
|
||||||
|
->paginate(min((int) $request->query('per_page', 25), 100));
|
||||||
|
|
||||||
|
return PressReleaseResource::collection($pressReleases);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StorePressReleaseRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validated();
|
||||||
|
$company = $this->findOwnedCompany((int) $validated['company_id'], $request);
|
||||||
|
abort_unless($company !== null, 403);
|
||||||
|
|
||||||
|
$pressRelease = PressRelease::withoutGlobalScopes()->create([
|
||||||
|
...$validated,
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'user_id' => $request->user()->id,
|
||||||
|
'portal' => $company->portal->value,
|
||||||
|
'slug' => $this->uniqueSlug(
|
||||||
|
Str::slug($validated['title']),
|
||||||
|
$company->portal->value,
|
||||||
|
$validated['language'],
|
||||||
|
),
|
||||||
|
'status' => $validated['status'] ?? 'draft',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return PressReleaseResource::make(
|
||||||
|
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||||||
|
)->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, int $pressRelease): PressReleaseResource
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->tokenCan('press-releases:read'), 403);
|
||||||
|
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||||||
|
abort_unless($pressRelease !== null, 403);
|
||||||
|
|
||||||
|
return PressReleaseResource::make(
|
||||||
|
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdatePressReleaseRequest $request, int $pressRelease): PressReleaseResource|JsonResponse
|
||||||
|
{
|
||||||
|
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||||||
|
abort_unless($pressRelease !== null, 403);
|
||||||
|
|
||||||
|
if (! in_array($pressRelease->status->value, ['draft', 'rejected'], true)) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Only draft or rejected press releases may be edited.',
|
||||||
|
], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validated();
|
||||||
|
$company = isset($validated['company_id'])
|
||||||
|
? $this->findOwnedCompany((int) $validated['company_id'], $request)
|
||||||
|
: $this->findOwnedCompany((int) $pressRelease->company_id, $request);
|
||||||
|
abort_unless($company !== null, 403);
|
||||||
|
|
||||||
|
$language = $validated['language'] ?? $pressRelease->language;
|
||||||
|
$title = $validated['title'] ?? $pressRelease->title;
|
||||||
|
$portal = $company->portal->value;
|
||||||
|
|
||||||
|
$pressRelease->fill([
|
||||||
|
...$validated,
|
||||||
|
'portal' => $portal,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
array_key_exists('title', $validated)
|
||||||
|
|| array_key_exists('language', $validated)
|
||||||
|
|| array_key_exists('company_id', $validated)
|
||||||
|
) {
|
||||||
|
$pressRelease->slug = $this->uniqueSlug(Str::slug($title), $portal, $language, $pressRelease->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pressRelease->save();
|
||||||
|
|
||||||
|
return PressReleaseResource::make(
|
||||||
|
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, int $pressRelease): JsonResponse|Response
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
|
||||||
|
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||||||
|
abort_unless($pressRelease !== null, 403);
|
||||||
|
|
||||||
|
if ($pressRelease->status->value === 'published') {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Published press releases cannot be deleted via API.',
|
||||||
|
], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pressRelease->delete();
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findOwnedCompany(int $companyId, Request $request): ?Company
|
||||||
|
{
|
||||||
|
return Company::withoutGlobalScopes()
|
||||||
|
->whereKey($companyId)
|
||||||
|
->where(function ($query) use ($request): void {
|
||||||
|
$query->where('owner_user_id', $request->user()->id)
|
||||||
|
->orWhereHas('users', fn ($users) => $users->whereKey($request->user()->id));
|
||||||
|
})
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findOwnedPressRelease(int $pressReleaseId, Request $request): ?PressRelease
|
||||||
|
{
|
||||||
|
return PressRelease::withoutGlobalScopes()
|
||||||
|
->whereKey($pressReleaseId)
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function uniqueSlug(string $baseSlug, string $portal, string $language, ?int $excludeId = null): string
|
||||||
|
{
|
||||||
|
$slug = $baseSlug !== '' ? $baseSlug : Str::random(8);
|
||||||
|
$candidate = $slug;
|
||||||
|
$suffix = 2;
|
||||||
|
|
||||||
|
while (
|
||||||
|
PressRelease::withoutGlobalScopes()
|
||||||
|
->where('portal', $portal)
|
||||||
|
->where('language', $language)
|
||||||
|
->where('slug', $candidate)
|
||||||
|
->when($excludeId !== null, fn ($query) => $query->whereKeyNot($excludeId))
|
||||||
|
->exists()
|
||||||
|
) {
|
||||||
|
$candidate = "{$slug}-{$suffix}";
|
||||||
|
$suffix++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
app/Http/Controllers/Api/V1/PressReleaseImageController.php
Normal file
121
app/Http/Controllers/Api/V1/PressReleaseImageController.php
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\PressReleaseStatus;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\StorePressReleaseImageRequest;
|
||||||
|
use App\Http\Resources\PressReleaseImageResource;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Models\PressReleaseImage;
|
||||||
|
use App\Services\Image\ImageService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class PressReleaseImageController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ImageService $imageService) {}
|
||||||
|
|
||||||
|
public function index(Request $request, int $pressRelease): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->tokenCan('press-releases:read'), 403);
|
||||||
|
|
||||||
|
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||||||
|
abort_unless($pressRelease !== null, 403);
|
||||||
|
|
||||||
|
return PressReleaseImageResource::collection(
|
||||||
|
$pressRelease->images()->orderBy('sort_order')->orderBy('id')->get()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StorePressReleaseImageRequest $request, int $pressRelease): JsonResponse
|
||||||
|
{
|
||||||
|
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||||||
|
abort_unless($pressRelease !== null, 403);
|
||||||
|
|
||||||
|
if (! $this->canChangeImages($pressRelease)) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Only draft or rejected press releases may be edited.',
|
||||||
|
], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validated();
|
||||||
|
/** @var UploadedFile $file */
|
||||||
|
$file = $request->file('image');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stored = $this->imageService->storePressReleaseImage($file, $pressRelease->id);
|
||||||
|
} catch (RuntimeException $exception) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((bool) ($validated['is_preview'] ?? false)) {
|
||||||
|
$pressRelease->images()->update(['is_preview' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$image = $pressRelease->images()->create([
|
||||||
|
'disk' => 'public',
|
||||||
|
'path' => $stored['path'],
|
||||||
|
'variants' => $stored['variants'],
|
||||||
|
'title' => $validated['title'] ?? null,
|
||||||
|
'description' => $validated['description'] ?? null,
|
||||||
|
'copyright' => $validated['copyright'] ?? null,
|
||||||
|
'is_preview' => (bool) ($validated['is_preview'] ?? false),
|
||||||
|
'sort_order' => ((int) $pressRelease->images()->max('sort_order')) + 1,
|
||||||
|
'width' => $stored['width'],
|
||||||
|
'height' => $stored['height'],
|
||||||
|
'mime' => $stored['mime'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return PressReleaseImageResource::make($image)
|
||||||
|
->response()
|
||||||
|
->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, int $pressReleaseImage): Response|JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->tokenCan('press-release-images:write'), 403);
|
||||||
|
|
||||||
|
$image = PressReleaseImage::query()
|
||||||
|
->whereKey($pressReleaseImage)
|
||||||
|
->whereHas('pressRelease', fn ($query) => $query->withoutGlobalScopes()->where('user_id', $request->user()->id))
|
||||||
|
->with(['pressRelease' => fn ($query) => $query->withoutGlobalScopes()])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
abort_unless($image !== null, 403);
|
||||||
|
|
||||||
|
if (! $this->canChangeImages($image->pressRelease)) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Only draft or rejected press releases may be edited.',
|
||||||
|
], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
|
||||||
|
$image->delete();
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findOwnedPressRelease(int $pressReleaseId, Request $request): ?PressRelease
|
||||||
|
{
|
||||||
|
return PressRelease::withoutGlobalScopes()
|
||||||
|
->whereKey($pressReleaseId)
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canChangeImages(PressRelease $pressRelease): bool
|
||||||
|
{
|
||||||
|
$status = $pressRelease->status instanceof PressReleaseStatus
|
||||||
|
? $pressRelease->status->value
|
||||||
|
: (string) $pressRelease->status;
|
||||||
|
|
||||||
|
return in_array($status, [PressReleaseStatus::Draft->value, PressReleaseStatus::Rejected->value], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/Http/Controllers/Auth/MagicLinkConsumeController.php
Normal file
52
app/Http/Controllers/Auth/MagicLinkConsumeController.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\MagicLink;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class MagicLinkConsumeController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, string $token): RedirectResponse
|
||||||
|
{
|
||||||
|
$magicLink = MagicLink::query()
|
||||||
|
->with('user')
|
||||||
|
->where('token_hash', hash('sha256', $token))
|
||||||
|
->where('purpose', 'login')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $magicLink || ! $magicLink->user) {
|
||||||
|
return redirect()->route('login')->with('status', __('The magic login link is invalid.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($magicLink->consumed_at !== null || $magicLink->expires_at->isPast()) {
|
||||||
|
return redirect()->route('login')->with('status', __('The magic login link has expired or was already used.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $magicLink->user->is_active) {
|
||||||
|
return redirect()->route('login')->with('status', __('Your account is not active.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$magicLink->update([
|
||||||
|
'consumed_at' => now(),
|
||||||
|
'ip_consumed' => $request->ip(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$magicLink->user->update([
|
||||||
|
'last_login_at' => now(),
|
||||||
|
'last_login_ip' => $request->ip(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Auth::guard('web')->login($magicLink->user);
|
||||||
|
$request->session()->regenerate();
|
||||||
|
|
||||||
|
$home = $magicLink->user->canAccessAdmin()
|
||||||
|
? route('dashboard', absolute: false)
|
||||||
|
: route('me.dashboard', absolute: false);
|
||||||
|
|
||||||
|
return redirect()->intended($home);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Auth\Events\Verified;
|
use Illuminate\Auth\Events\Verified;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
||||||
|
|
@ -14,17 +15,21 @@ class VerifyEmailController extends Controller
|
||||||
*/
|
*/
|
||||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
|
$home = $request->user()->canAccessAdmin()
|
||||||
|
? route('dashboard', absolute: false)
|
||||||
|
: route('me.dashboard', absolute: false);
|
||||||
|
|
||||||
if ($request->user()->hasVerifiedEmail()) {
|
if ($request->user()->hasVerifiedEmail()) {
|
||||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
return redirect()->intended($home.'?verified=1');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->user()->markEmailAsVerified()) {
|
if ($request->user()->markEmailAsVerified()) {
|
||||||
/** @var \Illuminate\Contracts\Auth\MustVerifyEmail $user */
|
/** @var MustVerifyEmail $user */
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
event(new Verified($user));
|
event(new Verified($user));
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
return redirect()->intended($home.'?verified=1');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
32
app/Http/Controllers/LegacyInvoicePdfController.php
Normal file
32
app/Http/Controllers/LegacyInvoicePdfController.php
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\LegacyInvoice;
|
||||||
|
use App\Services\Billing\LegacyInvoicePdfRenderer;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class LegacyInvoicePdfController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(LegacyInvoice $legacyInvoice, LegacyInvoicePdfRenderer $renderer): Response
|
||||||
|
{
|
||||||
|
Gate::authorize('downloadPdf', $legacyInvoice);
|
||||||
|
|
||||||
|
if (filled($legacyInvoice->pdf_path) && Storage::disk('local')->exists($legacyInvoice->pdf_path)) {
|
||||||
|
return response()->file(Storage::disk('local')->path($legacyInvoice->pdf_path), [
|
||||||
|
'Content-Type' => 'application/pdf',
|
||||||
|
'Content-Disposition' => 'inline; filename="'.$renderer->filename($legacyInvoice).'"',
|
||||||
|
'Cache-Control' => 'private, max-age=0, must-revalidate',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasColumn('legacy_invoices', 'pdf_generated_at')) {
|
||||||
|
$legacyInvoice->forceFill(['pdf_generated_at' => now()])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $renderer->inlineResponse($legacyInvoice);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Http/Controllers/PressReleasePreviewController.php
Normal file
51
app/Http/Controllers/PressReleasePreviewController.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\MagicLink;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class PressReleasePreviewController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(string $token): View|Response
|
||||||
|
{
|
||||||
|
$magicLink = MagicLink::query()
|
||||||
|
->where('token_hash', hash('sha256', $token))
|
||||||
|
->where('purpose', 'press_release_access')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $magicLink) {
|
||||||
|
return $this->renderError(__('Der Vorschau-Link ist ungültig.'), 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($magicLink->expires_at && $magicLink->expires_at->isPast()) {
|
||||||
|
return $this->renderError(__('Der Vorschau-Link ist abgelaufen.'), 410);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pressReleaseId = (int) ($magicLink->payload['press_release_id'] ?? 0);
|
||||||
|
|
||||||
|
$pressRelease = $pressReleaseId
|
||||||
|
? PressRelease::withoutGlobalScopes()
|
||||||
|
->with(['company:id,name,slug', 'category.translations', 'images', 'user:id,name'])
|
||||||
|
->find($pressReleaseId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (! $pressRelease) {
|
||||||
|
return $this->renderError(__('Die Pressemitteilung wurde nicht gefunden.'), 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('press-release-preview', [
|
||||||
|
'pressRelease' => $pressRelease,
|
||||||
|
'expiresAt' => $magicLink->expires_at,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderError(string $message, int $status): Response
|
||||||
|
{
|
||||||
|
return response()->view('press-release-preview-error', [
|
||||||
|
'message' => $message,
|
||||||
|
], $status);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Http/Middleware/BasicAuthMiddleware.php
Normal file
51
app/Http/Middleware/BasicAuthMiddleware.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class BasicAuthMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
// Skip Basic Auth für Livewire-Requests komplett
|
||||||
|
// Diese sind bereits durch Laravel Session/CSRF geschützt
|
||||||
|
$path = $request->path();
|
||||||
|
|
||||||
|
if (
|
||||||
|
str_starts_with($path, 'livewire/') ||
|
||||||
|
str_contains($path, '/livewire/') ||
|
||||||
|
$request->is('livewire/*') ||
|
||||||
|
$request->is('*/livewire/*')
|
||||||
|
) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip Basic Auth für Flux UI Assets (flux.js, flux.min.js, editor.js, etc.)
|
||||||
|
if (str_starts_with($path, 'flux/')) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip Basic Auth für API und Short-Links; API-Zugriff wird per Sanctum geschützt.
|
||||||
|
if ($request->is('api/*') || $request->is('_cabinet/*')) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credentials from .env file
|
||||||
|
$user = config('auth.basic.user');
|
||||||
|
$pass = config('auth.basic.password');
|
||||||
|
|
||||||
|
if ($request->getUser() != $user || $request->getPassword() != $pass) {
|
||||||
|
return response('Unauthorized.', 401, ['WWW-Authenticate' => 'Basic']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/Http/Middleware/EnsureApiTokenRateLimit.php
Normal file
71
app/Http/Middleware/EnsureApiTokenRateLimit.php
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureApiTokenRateLimit
|
||||||
|
{
|
||||||
|
private const MAX_ATTEMPTS = 60;
|
||||||
|
|
||||||
|
private const DECAY_SECONDS = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$key = $this->rateLimitKey($request);
|
||||||
|
|
||||||
|
if (RateLimiter::tooManyAttempts($key, self::MAX_ATTEMPTS)) {
|
||||||
|
$retryAfter = RateLimiter::availableIn($key);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'API rate limit exceeded.',
|
||||||
|
], 429, [
|
||||||
|
'Retry-After' => (string) $retryAfter,
|
||||||
|
'X-RateLimit-Limit' => (string) self::MAX_ATTEMPTS,
|
||||||
|
'X-RateLimit-Remaining' => '0',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::hit($key, self::DECAY_SECONDS);
|
||||||
|
|
||||||
|
$response = $next($request);
|
||||||
|
|
||||||
|
$response->headers->set('X-RateLimit-Limit', (string) self::MAX_ATTEMPTS);
|
||||||
|
$response->headers->set('X-RateLimit-Remaining', (string) RateLimiter::remaining($key, self::MAX_ATTEMPTS));
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rateLimitKey(Request $request): string
|
||||||
|
{
|
||||||
|
$bearerToken = $request->bearerToken();
|
||||||
|
|
||||||
|
if ($bearerToken !== null && str_contains($bearerToken, '|')) {
|
||||||
|
[$tokenId] = explode('|', $bearerToken, 2);
|
||||||
|
|
||||||
|
if (ctype_digit($tokenId)) {
|
||||||
|
return 'api-v1:token:'.$tokenId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bearerToken !== null) {
|
||||||
|
return 'api-v1:bearer:'.hash('sha256', $bearerToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $request->user()?->currentAccessToken();
|
||||||
|
|
||||||
|
if (is_object($token) && method_exists($token, 'getKey') && $token->getKey() !== null) {
|
||||||
|
return 'api-v1:token:'.$token->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'api-v1:user:'.($request->user()?->getAuthIdentifier() ?? $request->ip());
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Http/Middleware/EnsureApiUserIsActive.php
Normal file
26
app/Http/Middleware/EnsureApiUserIsActive.php
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureApiUserIsActive
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (! $request->user()?->is_active) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'API access is disabled for inactive users.',
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Http/Middleware/EnsureUserIsAdmin.php
Normal file
34
app/Http/Middleware/EnsureUserIsAdmin.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Actions\Admin\UserImpersonation;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureUserIsAdmin
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user !== null && $user->is_active && ! $user->is_super_admin) {
|
||||||
|
$user->loadMissing('roles');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app(UserImpersonation::class)->isActive()) {
|
||||||
|
if ($request->isMethod('GET') || $request->isMethod('HEAD')) {
|
||||||
|
return redirect()->route('me.dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(403, 'Während der Benutzer-Impersonation ist der Admin-Bereich gesperrt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user?->canAccessAdmin()) {
|
||||||
|
abort(403, 'Kein Zugriff auf den Admin-Bereich.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Http/Middleware/EnsureUserIsCustomer.php
Normal file
19
app/Http/Middleware/EnsureUserIsCustomer.php
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureUserIsCustomer
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (! $request->user()?->canAccessCustomer()) {
|
||||||
|
abort(403, 'Kein Zugriff auf das Kundenportal.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Http/Middleware/LogApiUsage.php
Normal file
48
app/Http/Middleware/LogApiUsage.php
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\ApiUsageLog;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Laravel\Sanctum\PersonalAccessToken;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class LogApiUsage
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$startedAt = microtime(true);
|
||||||
|
$response = $next($request);
|
||||||
|
|
||||||
|
$this->writeLog($request, $response, $startedAt);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeLog(Request $request, Response $response, float $startedAt): void
|
||||||
|
{
|
||||||
|
$token = $request->user()?->currentAccessToken();
|
||||||
|
$tokenId = $token instanceof PersonalAccessToken && (int) $token->getKey() > 0
|
||||||
|
? (int) $token->getKey()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
ApiUsageLog::query()->create([
|
||||||
|
'user_id' => $request->user()?->id,
|
||||||
|
'personal_access_token_id' => $tokenId,
|
||||||
|
'method' => $request->method(),
|
||||||
|
'path' => '/'.$request->path(),
|
||||||
|
'route_name' => $request->route()?->getName(),
|
||||||
|
'status_code' => $response->getStatusCode(),
|
||||||
|
'ip_address' => $request->ip(),
|
||||||
|
'user_agent' => mb_substr((string) $request->userAgent(), 0, 512),
|
||||||
|
'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
|
||||||
|
'requested_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/Http/Middleware/LogSlowAdminRequests.php
Normal file
88
app/Http/Middleware/LogSlowAdminRequests.php
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Services\Admin\AdminRequestPerformanceMetrics;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class LogSlowAdminRequests
|
||||||
|
{
|
||||||
|
public function __construct(private AdminRequestPerformanceMetrics $metrics) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (! config('admin_performance.slow_requests.enabled', true)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$startedAt = microtime(true);
|
||||||
|
$this->metrics->start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $next($request);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
$this->metrics->stop();
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
$snapshot = $this->metrics->snapshot();
|
||||||
|
$this->metrics->stop();
|
||||||
|
|
||||||
|
$durationMs = (int) round((microtime(true) - $startedAt) * 1000);
|
||||||
|
|
||||||
|
if ($this->shouldLog($durationMs, $snapshot['database_time_ms'], $snapshot['query_count'])) {
|
||||||
|
$this->logger()->warning('Slow admin request detected.', [
|
||||||
|
'method' => $request->method(),
|
||||||
|
'path' => '/'.$request->path(),
|
||||||
|
'route_name' => $request->route()?->getName(),
|
||||||
|
'status_code' => $response->getStatusCode(),
|
||||||
|
'user_id' => $request->user()?->id,
|
||||||
|
'duration_ms' => $durationMs,
|
||||||
|
'database_time_ms' => $snapshot['database_time_ms'],
|
||||||
|
'query_count' => $snapshot['query_count'],
|
||||||
|
'slow_queries' => $snapshot['slow_queries'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldLog(int $durationMs, float $databaseTimeMs, int $queryCount): bool
|
||||||
|
{
|
||||||
|
return $durationMs >= $this->durationThresholdMs()
|
||||||
|
|| $databaseTimeMs >= $this->databaseThresholdMs()
|
||||||
|
|| $queryCount >= $this->queryCountThreshold();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logger(): LoggerInterface
|
||||||
|
{
|
||||||
|
$channel = config('admin_performance.slow_requests.channel') ?: config('logging.default');
|
||||||
|
|
||||||
|
return Log::channel(is_string($channel) && $channel !== '' ? $channel : 'stack');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function durationThresholdMs(): int
|
||||||
|
{
|
||||||
|
return (int) config('admin_performance.slow_requests.duration_threshold_ms', 750);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function databaseThresholdMs(): int
|
||||||
|
{
|
||||||
|
return (int) config('admin_performance.slow_requests.database_threshold_ms', 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function queryCountThreshold(): int
|
||||||
|
{
|
||||||
|
return (int) config('admin_performance.slow_requests.query_count_threshold', 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Http/Middleware/RejectLegacyApiKeys.php
Normal file
28
app/Http/Middleware/RejectLegacyApiKeys.php
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class RejectLegacyApiKeys
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if ($request->has('api_key') || filled($request->header('X-Api-Key'))) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Legacy API keys are no longer supported.',
|
||||||
|
'migration_url' => url('/customer/tokens'),
|
||||||
|
'docs_url' => url('/docs/api/v1'),
|
||||||
|
], 410);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Http/Middleware/SetCurrentPortal.php
Normal file
56
app/Http/Middleware/SetCurrentPortal.php
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Enums\Portal;
|
||||||
|
use App\Services\CurrentPortalContext;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den Portal-Kontext für den aktuellen Request.
|
||||||
|
*
|
||||||
|
* Reihenfolge der Auflösung:
|
||||||
|
* 1. Admin-Session-Override: Ein angemeldeter Admin kann über die Session ein
|
||||||
|
* bestimmtes Portal forcieren (für die Filteransicht im Admin-Bereich).
|
||||||
|
* 2. Domain-Konfiguration: Das aktive Theme (gesetzt vom ThemeServiceProvider)
|
||||||
|
* bestimmt das Portal über den config('app.theme')-Wert.
|
||||||
|
* 3. Kein Kontext: Portal-Scope filtert nicht (z.B. CLI, Tests).
|
||||||
|
*
|
||||||
|
* Theme → Portal-Mapping:
|
||||||
|
* 'presseecho' → Portal::Presseecho
|
||||||
|
* 'businessportal24'→ Portal::Businessportal24
|
||||||
|
* 'main' / andere → null (Admin-Domain; Super-Admin sieht alles)
|
||||||
|
*/
|
||||||
|
class SetCurrentPortal
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$portal = $this->resolvePortal($request);
|
||||||
|
CurrentPortalContext::set($portal);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePortal(Request $request): ?Portal
|
||||||
|
{
|
||||||
|
// Admin-Session-Override hat höchste Priorität
|
||||||
|
if ($request->hasSession() && $request->session()->has('admin_portal_filter')) {
|
||||||
|
$overrideValue = $request->session()->get('admin_portal_filter');
|
||||||
|
$override = Portal::tryFrom((string) $overrideValue);
|
||||||
|
if ($override !== null) {
|
||||||
|
return $override;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain-basierte Auflösung via ThemeServiceProvider
|
||||||
|
$theme = config('app.theme', 'main');
|
||||||
|
|
||||||
|
return match ($theme) {
|
||||||
|
'presseecho' => Portal::Presseecho,
|
||||||
|
'businessportal24' => Portal::Businessportal24,
|
||||||
|
default => null, // Admin/Portal-Domain → kein automatischer Filter
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Http/Middleware/SetDomainUrl.php
Normal file
59
app/Http/Middleware/SetDomainUrl.php
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\UrlGenerator;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware um die URL-Konfiguration basierend auf der aktuellen Domain zu setzen.
|
||||||
|
*
|
||||||
|
* Diese Middleware muss sehr früh im Request-Lifecycle ausgeführt werden,
|
||||||
|
* um sicherzustellen, dass url() und asset() die richtige Domain verwenden.
|
||||||
|
*/
|
||||||
|
class SetDomainUrl
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$host = $request->getHost();
|
||||||
|
|
||||||
|
// Suche nach der Domain-Konfiguration
|
||||||
|
$domainConfig = null;
|
||||||
|
$domains = config('domains.domains', []);
|
||||||
|
|
||||||
|
foreach ($domains as $name => $config) {
|
||||||
|
if (is_array($config) && isset($config['domain_name']) && $config['domain_name'] === $host) {
|
||||||
|
$domainConfig = $config;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn eine Domain-Konfiguration gefunden wurde, setze die URL
|
||||||
|
if ($domainConfig && isset($domainConfig['url'])) {
|
||||||
|
$domainUrl = $domainConfig['url'];
|
||||||
|
|
||||||
|
// URL-Generator konfigurieren
|
||||||
|
URL::forceRootUrl($domainUrl);
|
||||||
|
URL::forceScheme(parse_url($domainUrl, PHP_URL_SCHEME) ?: 'https');
|
||||||
|
|
||||||
|
// Asset-Root setzen
|
||||||
|
/** @var UrlGenerator $urlGenerator */
|
||||||
|
$urlGenerator = app('url');
|
||||||
|
$urlGenerator->useAssetOrigin($domainUrl);
|
||||||
|
|
||||||
|
// Config aktualisieren
|
||||||
|
config([
|
||||||
|
'app.url' => $domainUrl,
|
||||||
|
'app.asset_url' => $domainUrl,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/Http/Middleware/ThemeMiddleware.php
Normal file
57
app/Http/Middleware/ThemeMiddleware.php
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class ThemeMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$host = $request->getHost();
|
||||||
|
$path = $request->path();
|
||||||
|
|
||||||
|
// Theme-Switching über Subdomains
|
||||||
|
if (str_contains($host, 'b2in')) {
|
||||||
|
config(['app.theme' => 'b2in']);
|
||||||
|
} elseif (str_contains($host, 'b2a') || str_contains($host, 'bridges2america')) {
|
||||||
|
config(['app.theme' => 'b2a']);
|
||||||
|
} elseif (str_contains($host, 'stileigentum')) {
|
||||||
|
config(['app.theme' => 'stileigentum']);
|
||||||
|
} elseif (str_contains($host, 'style2own')) {
|
||||||
|
config(['app.theme' => 'style2own']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme-Switching über URL-Parameter (für Testing)
|
||||||
|
if ($request->has('theme')) {
|
||||||
|
$theme = $request->get('theme');
|
||||||
|
if (in_array($theme, ['b2in', 'b2a', 'stileigentum', 'style2own'])) {
|
||||||
|
config(['app.theme' => $theme]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme-Switching über Pfade (für lokale Entwicklung ohne Domain-Setup)
|
||||||
|
if (str_starts_with($path, 'b2in/')) {
|
||||||
|
config(['app.theme' => 'b2in']);
|
||||||
|
$request->server->set('REQUEST_URI', '/'.substr($path, 5)); // Entferne 'b2in/' vom Pfad
|
||||||
|
} elseif (str_starts_with($path, 'b2a/') || str_starts_with($path, 'bridges2america/')) {
|
||||||
|
config(['app.theme' => 'b2a']);
|
||||||
|
$request->server->set('REQUEST_URI', '/'.substr($path, 4)); // Entferne 'b2a/' vom Pfad
|
||||||
|
} elseif (str_starts_with($path, 'stileigentum/')) {
|
||||||
|
config(['app.theme' => 'stileigentum']);
|
||||||
|
$request->server->set('REQUEST_URI', '/'.substr($path, 13)); // Entferne 'stileigentum/' vom Pfad
|
||||||
|
} elseif (str_starts_with($path, 'style2own/')) {
|
||||||
|
config(['app.theme' => 'style2own']);
|
||||||
|
$request->server->set('REQUEST_URI', '/'.substr($path, 10)); // Entferne 'style2own/' vom Pfad
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Http/Requests/Api/V1/StorePressReleaseImageRequest.php
Normal file
33
app/Http/Requests/Api/V1/StorePressReleaseImageRequest.php
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StorePressReleaseImageRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->tokenCan('press-release-images:write') === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'image' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:8192'],
|
||||||
|
'title' => ['nullable', 'string', 'max:120'],
|
||||||
|
'description' => ['nullable', 'string', 'max:500'],
|
||||||
|
'copyright' => ['nullable', 'string', 'max:255'],
|
||||||
|
'is_preview' => ['nullable', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Http/Requests/Api/V1/StorePressReleaseRequest.php
Normal file
44
app/Http/Requests/Api/V1/StorePressReleaseRequest.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\PressReleaseStatus;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StorePressReleaseRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->tokenCan('press-releases:write') === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'company_id' => ['required', 'integer', 'exists:companies,id'],
|
||||||
|
'category_id' => ['required', 'integer', 'exists:categories,id'],
|
||||||
|
'language' => ['required', 'string', 'size:2'],
|
||||||
|
'title' => ['required', 'string', 'max:255'],
|
||||||
|
'text' => ['required', 'string'],
|
||||||
|
'backlink_url' => ['nullable', 'url', 'max:255'],
|
||||||
|
'keywords' => ['nullable', 'string', 'max:255'],
|
||||||
|
'status' => ['nullable', Rule::in([
|
||||||
|
PressReleaseStatus::Draft->value,
|
||||||
|
PressReleaseStatus::Review->value,
|
||||||
|
])],
|
||||||
|
'teaser_begin' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'teaser_end' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'no_export' => ['nullable', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue