initial commit
This commit is contained in:
18
.editorconfig
Normal file
18
.editorconfig
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{yml,yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[compose.yaml]
|
||||||
|
indent_size = 4
|
||||||
60
.env.example
Normal file
60
.env.example
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
APP_NAME=Laravel
|
||||||
|
APP_ENV=local
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_URL=http://localhost:8000
|
||||||
|
|
||||||
|
APP_LOCALE=en
|
||||||
|
APP_FALLBACK_LOCALE=en
|
||||||
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
|
APP_MAINTENANCE_DRIVER=file
|
||||||
|
# APP_MAINTENANCE_STORE=database
|
||||||
|
|
||||||
|
# PHP_CLI_SERVER_WORKERS=4
|
||||||
|
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
|
LOG_CHANNEL=stack
|
||||||
|
LOG_STACK=single
|
||||||
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
DB_CONNECTION=pgsql
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_DATABASE=ecomail
|
||||||
|
DB_USERNAME=laravel
|
||||||
|
DB_PASSWORD=laravel
|
||||||
|
DB_URL=pgsql://laravel:laravel@postgresql:5432/ecomail
|
||||||
|
|
||||||
|
|
||||||
|
SESSION_DRIVER=database
|
||||||
|
SESSION_LIFETIME=120
|
||||||
|
SESSION_ENCRYPT=false
|
||||||
|
SESSION_PATH=/
|
||||||
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
|
BROADCAST_CONNECTION=log
|
||||||
|
FILESYSTEM_DISK=local
|
||||||
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
|
CACHE_STORE=database
|
||||||
|
# CACHE_PREFIX=
|
||||||
|
|
||||||
|
MAIL_MAILER=log
|
||||||
|
MAIL_SCHEME=null
|
||||||
|
MAIL_HOST=127.0.0.1
|
||||||
|
MAIL_PORT=2525
|
||||||
|
MAIL_USERNAME=null
|
||||||
|
MAIL_PASSWORD=null
|
||||||
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
|
AWS_BUCKET=
|
||||||
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
16
.env.testing
Normal file
16
.env.testing
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
APP_ENV=testing
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=true
|
||||||
|
|
||||||
|
CACHE_STORE=array
|
||||||
|
SESSION_DRIVER=array
|
||||||
|
QUEUE_CONNECTION=sync
|
||||||
|
MAIL_MAILER=array
|
||||||
|
|
||||||
|
DB_CONNECTION=pgsql
|
||||||
|
DB_HOST=postgresql-test
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_DATABASE=ecomail
|
||||||
|
DB_USERNAME=laravel
|
||||||
|
DB_PASSWORD=laravel
|
||||||
|
DB_URL=pgsql://laravel:laravel@postgresql-test:5432/ecomail
|
||||||
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.blade.php diff=html
|
||||||
|
*.css diff=css
|
||||||
|
*.html diff=html
|
||||||
|
*.md diff=markdown
|
||||||
|
*.php diff=php
|
||||||
|
|
||||||
|
/.github export-ignore
|
||||||
|
CHANGELOG.md export-ignore
|
||||||
|
.styleci.yml export-ignore
|
||||||
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.mcp.json
|
||||||
|
.env
|
||||||
|
.env.backup
|
||||||
|
.env.production
|
||||||
|
.phpactor.json
|
||||||
|
.ai
|
||||||
|
.codex
|
||||||
|
.junie
|
||||||
|
docker-compose.override.yml
|
||||||
|
.phpcs-cache
|
||||||
|
.phpunit.result.cache
|
||||||
|
/.fleet
|
||||||
|
/.idea
|
||||||
|
/.nova
|
||||||
|
/.phpunit.cache
|
||||||
|
/.vscode
|
||||||
|
/.zed
|
||||||
|
/auth.json
|
||||||
|
/node_modules
|
||||||
|
/public/build
|
||||||
|
/public/hot
|
||||||
|
/public/storage
|
||||||
|
/storage/*.key
|
||||||
|
/storage/pail
|
||||||
|
/vendor
|
||||||
|
Homestead.json
|
||||||
|
Homestead.yaml
|
||||||
|
Thumbs.db
|
||||||
235
AGENTS.md
Normal file
235
AGENTS.md
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<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.3
|
||||||
|
- laravel/framework (LARAVEL) - v12
|
||||||
|
- laravel/prompts (PROMPTS) - v0
|
||||||
|
- laravel/mcp (MCP) - v0
|
||||||
|
- laravel/pint (PINT) - v1
|
||||||
|
- laravel/sail (SAIL) - v1
|
||||||
|
- phpunit/phpunit (PHPUNIT) - v11
|
||||||
|
- tailwindcss (TAILWINDCSS) - v4
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||||
|
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||||
|
- Check for existing components to reuse before writing a new one.
|
||||||
|
|
||||||
|
## Verification Scripts
|
||||||
|
|
||||||
|
- Do not create verification scripts or tinker when tests cover that functionality and prove 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 `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||||
|
|
||||||
|
## Documentation Files
|
||||||
|
|
||||||
|
- You must only create documentation files if explicitly requested by the user.
|
||||||
|
|
||||||
|
## Replies
|
||||||
|
|
||||||
|
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||||
|
|
||||||
|
=== boost rules ===
|
||||||
|
|
||||||
|
# Laravel Boost
|
||||||
|
|
||||||
|
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||||
|
|
||||||
|
## Artisan
|
||||||
|
|
||||||
|
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
|
||||||
|
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||||
|
|
||||||
|
## Tinker / Debugging
|
||||||
|
|
||||||
|
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||||
|
- Use the `database-query` tool when you only need to read from the database.
|
||||||
|
|
||||||
|
## Reading Browser Logs With the `browser-logs` Tool
|
||||||
|
|
||||||
|
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||||
|
- Only recent browser logs will be useful - ignore old logs.
|
||||||
|
|
||||||
|
## Searching Documentation (Critically Important)
|
||||||
|
|
||||||
|
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||||
|
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||||
|
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
|
||||||
|
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||||
|
|
||||||
|
### Available Search Syntax
|
||||||
|
|
||||||
|
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||||
|
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||||
|
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
||||||
|
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||||
|
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||||
|
|
||||||
|
=== php rules ===
|
||||||
|
|
||||||
|
# PHP
|
||||||
|
|
||||||
|
- Always use curly braces for control structures, even for single-line bodies.
|
||||||
|
|
||||||
|
## Constructors
|
||||||
|
|
||||||
|
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||||
|
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||||
|
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||||
|
|
||||||
|
## Type Declarations
|
||||||
|
|
||||||
|
- Always use explicit return type declarations for methods and functions.
|
||||||
|
- Use appropriate PHP type hints for method parameters.
|
||||||
|
|
||||||
|
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||||
|
protected function isAccessible(User $user, ?string $path = null): bool
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
## Enums
|
||||||
|
|
||||||
|
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
|
||||||
|
|
||||||
|
## PHPDoc Blocks
|
||||||
|
|
||||||
|
- Add useful array shape type definitions when appropriate.
|
||||||
|
|
||||||
|
=== laravel/core rules ===
|
||||||
|
|
||||||
|
# Do Things the Laravel Way
|
||||||
|
|
||||||
|
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||||
|
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||||
|
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||||
|
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||||
|
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||||
|
- Generate code that prevents N+1 query problems by using eager loading.
|
||||||
|
- Use Laravel's query builder for very complex database operations.
|
||||||
|
|
||||||
|
### Model Creation
|
||||||
|
|
||||||
|
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||||
|
|
||||||
|
### APIs & Eloquent Resources
|
||||||
|
|
||||||
|
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||||
|
|
||||||
|
## Controllers & Validation
|
||||||
|
|
||||||
|
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||||
|
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||||
|
|
||||||
|
## Authentication & Authorization
|
||||||
|
|
||||||
|
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||||
|
|
||||||
|
## URL Generation
|
||||||
|
|
||||||
|
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||||
|
|
||||||
|
## Queues
|
||||||
|
|
||||||
|
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||||
|
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||||
|
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||||
|
|
||||||
|
## Vite Error
|
||||||
|
|
||||||
|
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||||
|
|
||||||
|
=== 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.
|
||||||
|
|
||||||
|
=== pint/core rules ===
|
||||||
|
|
||||||
|
# Laravel Pint Code Formatter
|
||||||
|
|
||||||
|
- You must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||||
|
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
|
||||||
|
|
||||||
|
=== phpunit/core rules ===
|
||||||
|
|
||||||
|
# PHPUnit
|
||||||
|
|
||||||
|
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
|
||||||
|
- If you see a test using "Pest", convert it to PHPUnit.
|
||||||
|
- Every time a test has been updated, run that singular test.
|
||||||
|
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
|
||||||
|
- Tests should cover all happy paths, failure paths, and edge cases.
|
||||||
|
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
- Run the minimal number of tests, using an appropriate filter, before finalizing.
|
||||||
|
- To run all tests: `php artisan test --compact`.
|
||||||
|
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||||
|
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||||
|
|
||||||
|
=== tailwindcss/core rules ===
|
||||||
|
|
||||||
|
# Tailwind CSS
|
||||||
|
|
||||||
|
- Always use existing Tailwind conventions; check project patterns before adding new ones.
|
||||||
|
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
|
||||||
|
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
|
||||||
|
</laravel-boost-guidelines>
|
||||||
235
CLAUDE.md
Normal file
235
CLAUDE.md
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<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.3
|
||||||
|
- laravel/framework (LARAVEL) - v12
|
||||||
|
- laravel/prompts (PROMPTS) - v0
|
||||||
|
- laravel/mcp (MCP) - v0
|
||||||
|
- laravel/pint (PINT) - v1
|
||||||
|
- laravel/sail (SAIL) - v1
|
||||||
|
- phpunit/phpunit (PHPUNIT) - v11
|
||||||
|
- tailwindcss (TAILWINDCSS) - v4
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||||
|
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||||
|
- Check for existing components to reuse before writing a new one.
|
||||||
|
|
||||||
|
## Verification Scripts
|
||||||
|
|
||||||
|
- Do not create verification scripts or tinker when tests cover that functionality and prove 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 `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||||
|
|
||||||
|
## Documentation Files
|
||||||
|
|
||||||
|
- You must only create documentation files if explicitly requested by the user.
|
||||||
|
|
||||||
|
## Replies
|
||||||
|
|
||||||
|
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||||
|
|
||||||
|
=== boost rules ===
|
||||||
|
|
||||||
|
# Laravel Boost
|
||||||
|
|
||||||
|
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||||
|
|
||||||
|
## Artisan
|
||||||
|
|
||||||
|
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
|
||||||
|
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||||
|
|
||||||
|
## Tinker / Debugging
|
||||||
|
|
||||||
|
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||||
|
- Use the `database-query` tool when you only need to read from the database.
|
||||||
|
|
||||||
|
## Reading Browser Logs With the `browser-logs` Tool
|
||||||
|
|
||||||
|
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||||
|
- Only recent browser logs will be useful - ignore old logs.
|
||||||
|
|
||||||
|
## Searching Documentation (Critically Important)
|
||||||
|
|
||||||
|
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||||
|
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||||
|
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
|
||||||
|
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||||
|
|
||||||
|
### Available Search Syntax
|
||||||
|
|
||||||
|
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||||
|
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||||
|
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
||||||
|
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||||
|
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||||
|
|
||||||
|
=== php rules ===
|
||||||
|
|
||||||
|
# PHP
|
||||||
|
|
||||||
|
- Always use curly braces for control structures, even for single-line bodies.
|
||||||
|
|
||||||
|
## Constructors
|
||||||
|
|
||||||
|
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||||
|
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||||
|
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||||
|
|
||||||
|
## Type Declarations
|
||||||
|
|
||||||
|
- Always use explicit return type declarations for methods and functions.
|
||||||
|
- Use appropriate PHP type hints for method parameters.
|
||||||
|
|
||||||
|
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||||
|
protected function isAccessible(User $user, ?string $path = null): bool
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
## Enums
|
||||||
|
|
||||||
|
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
|
||||||
|
|
||||||
|
## PHPDoc Blocks
|
||||||
|
|
||||||
|
- Add useful array shape type definitions when appropriate.
|
||||||
|
|
||||||
|
=== laravel/core rules ===
|
||||||
|
|
||||||
|
# Do Things the Laravel Way
|
||||||
|
|
||||||
|
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||||
|
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||||
|
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||||
|
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||||
|
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||||
|
- Generate code that prevents N+1 query problems by using eager loading.
|
||||||
|
- Use Laravel's query builder for very complex database operations.
|
||||||
|
|
||||||
|
### Model Creation
|
||||||
|
|
||||||
|
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||||
|
|
||||||
|
### APIs & Eloquent Resources
|
||||||
|
|
||||||
|
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||||
|
|
||||||
|
## Controllers & Validation
|
||||||
|
|
||||||
|
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||||
|
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||||
|
|
||||||
|
## Authentication & Authorization
|
||||||
|
|
||||||
|
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||||
|
|
||||||
|
## URL Generation
|
||||||
|
|
||||||
|
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||||
|
|
||||||
|
## Queues
|
||||||
|
|
||||||
|
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||||
|
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||||
|
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||||
|
|
||||||
|
## Vite Error
|
||||||
|
|
||||||
|
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||||
|
|
||||||
|
=== 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.
|
||||||
|
|
||||||
|
=== pint/core rules ===
|
||||||
|
|
||||||
|
# Laravel Pint Code Formatter
|
||||||
|
|
||||||
|
- You must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||||
|
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
|
||||||
|
|
||||||
|
=== phpunit/core rules ===
|
||||||
|
|
||||||
|
# PHPUnit
|
||||||
|
|
||||||
|
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
|
||||||
|
- If you see a test using "Pest", convert it to PHPUnit.
|
||||||
|
- Every time a test has been updated, run that singular test.
|
||||||
|
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
|
||||||
|
- Tests should cover all happy paths, failure paths, and edge cases.
|
||||||
|
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
- Run the minimal number of tests, using an appropriate filter, before finalizing.
|
||||||
|
- To run all tests: `php artisan test --compact`.
|
||||||
|
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||||
|
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||||
|
|
||||||
|
=== tailwindcss/core rules ===
|
||||||
|
|
||||||
|
# Tailwind CSS
|
||||||
|
|
||||||
|
- Always use existing Tailwind conventions; check project patterns before adding new ones.
|
||||||
|
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
|
||||||
|
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
|
||||||
|
</laravel-boost-guidelines>
|
||||||
22
Dockerfile-fpm.dockerfile
Normal file
22
Dockerfile-fpm.dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
FROM git.nanobyte.cz/ovlach-public/php-docker:php-fpm-dev-8.4.17-cad44e8
|
||||||
|
ARG UID=1000
|
||||||
|
ARG GID=1000
|
||||||
|
|
||||||
|
USER 0:0
|
||||||
|
|
||||||
|
RUN usermod -u $UID www-data && \
|
||||||
|
groupmod -g $GID www-data && \
|
||||||
|
echo "UID=$UID GID=$GID"
|
||||||
|
|
||||||
|
RUN chown www-data:www-data /var/www
|
||||||
|
|
||||||
|
RUN echo "post_max_size = 100M\nupload_max_filesize = 100M\n" > /usr/local/etc/php/conf.d/uploads-fpm.ini
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y postgresql-client && rm -rf /var/lib/apt/cache/*
|
||||||
|
|
||||||
|
USER $UID:$GID
|
||||||
|
|
||||||
|
RUN composer global require laravel/installer
|
||||||
|
ENV PATH="/var/www/.composer/vendor/bin:${PATH}"
|
||||||
|
|
||||||
|
WORKDIR /var/www/html/
|
||||||
14
Dockerfile-nginx.dockerfile
Normal file
14
Dockerfile-nginx.dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM docker.io/nginx:1.25.3-alpine
|
||||||
|
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
ARG UID=1000
|
||||||
|
ARG GID=1000
|
||||||
|
|
||||||
|
USER 0:0
|
||||||
|
|
||||||
|
RUN apk add shadow
|
||||||
|
RUN usermod -u $UID nginx && \
|
||||||
|
groupmod -g $GID nginx && \
|
||||||
|
echo "UID=$UID GID=$GID"
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
20
Dockerfile-nodejs.dockerfile
Normal file
20
Dockerfile-nodejs.dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
ARG UID=1000
|
||||||
|
ARG GID=1000
|
||||||
|
|
||||||
|
RUN usermod -u $UID node && \
|
||||||
|
groupmod -g $GID node && \
|
||||||
|
echo "UID=$UID GID=$GID"
|
||||||
|
|
||||||
|
RUN mkdir -p /var/www/html && chown node:node /var/www/html
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
COPY package*.json /var/www/html/
|
||||||
|
RUN chown -R node:node /var/www/html
|
||||||
|
|
||||||
|
USER node:node
|
||||||
|
|
||||||
|
RUN ls -lah /var/www/html && id && npm install
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
0
Dockerfile.static
Normal file
0
Dockerfile.static
Normal file
65
README.md
Normal file
65
README.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
Getting started
|
||||||
|
===
|
||||||
|
|
||||||
|
* **Requirements:**
|
||||||
|
* bash
|
||||||
|
* Docker with Docker Compose v2
|
||||||
|
|
||||||
|
* **Run:**
|
||||||
|
```bash
|
||||||
|
develop.sh
|
||||||
|
```
|
||||||
|
* Enjoy your coffee :)
|
||||||
|
* Laravel will serve the app at http://localhost:8000
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Tests
|
||||||
|
---
|
||||||
|
Run tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec php-fpm vendor/bin/phpunit
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
PHPStan & Code Style
|
||||||
|
---
|
||||||
|
|
||||||
|
* Run PHP CodeSniffer:
|
||||||
|
```bash
|
||||||
|
docker compose exec php-fpm vendor/bin/phpcs
|
||||||
|
```
|
||||||
|
* Run PHPStan with increased memory limit:
|
||||||
|
```bash
|
||||||
|
docker compose exec php-fpm vendor/bin/phpstan --memory-limit=2G
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Containers
|
||||||
|
---
|
||||||
|
|
||||||
|
* `php-fpm` - FPM and additional tooling
|
||||||
|
* `nginx` - Serves static content, proxies requests to FPM
|
||||||
|
* `postgresql` - Database
|
||||||
|
* `postgresql-test` - Database for testing purposes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Future directions
|
||||||
|
---
|
||||||
|
|
||||||
|
* [ ] Sessions in Redis (or a better storage)
|
||||||
|
* [ ] Send XML by parts from frontend to importer
|
||||||
|
* [ ] The current tests are very basic. Add more scenarios.
|
||||||
|
* [ ] Notifications via websockets
|
||||||
|
* [ ] Store import state in Redis (no database polling)
|
||||||
|
* [ ] Implement observability (especially Prometheus for import time and flow, OTel, Loki, Grafana, etc.)
|
||||||
|
* [ ] Integrate a CI/CD pipeline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Notes
|
||||||
|
---
|
||||||
37
app/Data/Api/ContactImport.php
Normal file
37
app/Data/Api/ContactImport.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Api;
|
||||||
|
|
||||||
|
final class ContactImport
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $id,
|
||||||
|
public readonly string $queueAt,
|
||||||
|
public readonly ?string $startedAt,
|
||||||
|
public readonly ?string $finishedAt,
|
||||||
|
public readonly int $totalProcessed,
|
||||||
|
public readonly int $errors,
|
||||||
|
public readonly int $duplicates,
|
||||||
|
public readonly string $state,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'queue_at' => $this->queueAt,
|
||||||
|
'started_at' => $this->startedAt,
|
||||||
|
'finished_at' => $this->finishedAt,
|
||||||
|
'total_processed' => $this->totalProcessed,
|
||||||
|
'errors' => $this->errors,
|
||||||
|
'duplicates' => $this->duplicates,
|
||||||
|
'state' => $this->state,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/Data/Command/BatchResult.php
Normal file
15
app/Data/Command/BatchResult.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Command;
|
||||||
|
|
||||||
|
class BatchResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $fails,
|
||||||
|
public readonly int $duplicates,
|
||||||
|
public readonly int $success,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Data/Contact.php
Normal file
38
app/Data/Contact.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
final readonly class Contact
|
||||||
|
{
|
||||||
|
public string $email;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public UuidInterface $uuid,
|
||||||
|
string $email,
|
||||||
|
public ?string $firstName,
|
||||||
|
public ?string $lastName,
|
||||||
|
) {
|
||||||
|
$this->email = strtolower($email);
|
||||||
|
|
||||||
|
$validator = Validator::make(['email' => $this->email], [
|
||||||
|
'email' => ['required', 'email:rfc', 'max:254'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($this->firstName !== null && strlen($this->firstName) > 100) {
|
||||||
|
throw new InvariantException('First name cannot be longer than 100 characters', ['first_name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->lastName !== null && strlen($this->lastName) > 100) {
|
||||||
|
throw new InvariantException('Last name cannot be longer than 100 characters', ['last_name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
throw new InvariantException($validator->errors()->first('email'), ['email']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Data/ContactImport.php
Normal file
23
app/Data/ContactImport.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class ContactImport
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $id,
|
||||||
|
public readonly Carbon $queueAt,
|
||||||
|
public readonly ?Carbon $startedAt,
|
||||||
|
public readonly ?Carbon $finishedAt,
|
||||||
|
public readonly int $totalProcessed,
|
||||||
|
public readonly int $errors,
|
||||||
|
public readonly int $duplicates,
|
||||||
|
public readonly string $state,
|
||||||
|
public readonly ?string $file,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/Data/Intent/Contact/CreateContactIntent.php
Normal file
15
app/Data/Intent/Contact/CreateContactIntent.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Intent\Contact;
|
||||||
|
|
||||||
|
final class CreateContactIntent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $email,
|
||||||
|
public ?string $firstName,
|
||||||
|
public ?string $lastName,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/Data/Intent/Contact/DeleteContactIntent.php
Normal file
15
app/Data/Intent/Contact/DeleteContactIntent.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Intent\Contact;
|
||||||
|
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
final class DeleteContactIntent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public UuidInterface $uuid,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Intent\Contact\ProcessImport;
|
||||||
|
|
||||||
|
final class ImportContactIntent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ?string $email = null,
|
||||||
|
public ?string $firstName = null,
|
||||||
|
public ?string $lastName = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Data/Intent/Contact/SearchContactIntent.php
Normal file
27
app/Data/Intent/Contact/SearchContactIntent.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Intent\Contact;
|
||||||
|
|
||||||
|
final class SearchContactIntent
|
||||||
|
{
|
||||||
|
private const QUERY_ALL = '*';
|
||||||
|
|
||||||
|
public readonly string $query;
|
||||||
|
public readonly int $resultsPerPage;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
string $query,
|
||||||
|
) {
|
||||||
|
$this->query = strtolower($query);
|
||||||
|
$this->resultsPerPage = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function queryAll(): SearchContactIntent
|
||||||
|
{
|
||||||
|
return new SearchContactIntent(
|
||||||
|
self::QUERY_ALL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/Data/Intent/Contact/SearchContactIntentByUuid.php
Normal file
15
app/Data/Intent/Contact/SearchContactIntentByUuid.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Intent\Contact;
|
||||||
|
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
class SearchContactIntentByUuid
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly UuidInterface $uuid,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/Data/Intent/Contact/UpdateContactIntent.php
Normal file
18
app/Data/Intent/Contact/UpdateContactIntent.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Intent\Contact;
|
||||||
|
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
final class UpdateContactIntent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public UuidInterface $uuid,
|
||||||
|
public string $email,
|
||||||
|
public ?string $firstName,
|
||||||
|
public ?string $lastName,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/Data/Intent/ProcessImport/ImportContactsIntent.php
Normal file
13
app/Data/Intent/ProcessImport/ImportContactsIntent.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Intent\ProcessImport;
|
||||||
|
|
||||||
|
class ImportContactsIntent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $path
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/Data/Intent/ProcessImport/SearchImportContactsIntent.php
Normal file
15
app/Data/Intent/ProcessImport/SearchImportContactsIntent.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Intent\ProcessImport;
|
||||||
|
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
class SearchImportContactsIntent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly UuidInterface $uuid,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/Data/InvariantException.php
Normal file
18
app/Data/InvariantException.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data;
|
||||||
|
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class InvariantException extends \InvalidArgumentException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string> $fields
|
||||||
|
*/
|
||||||
|
public function __construct(string $message, public readonly array $fields, int $code = 0, ?Throwable $previous = null)
|
||||||
|
{
|
||||||
|
parent::__construct($message, $code, $previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/Enums/ContactImportStateEnum.php
Normal file
13
app/Enums/ContactImportStateEnum.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum ContactImportStateEnum: string
|
||||||
|
{
|
||||||
|
case Running = 'RUNNING';
|
||||||
|
case Done = 'DONE';
|
||||||
|
case Pending = 'PENDING';
|
||||||
|
case Fail = 'FAILED';
|
||||||
|
}
|
||||||
203
app/Http/Controllers/ContactController.php
Normal file
203
app/Http/Controllers/ContactController.php
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Data\Contact;
|
||||||
|
use App\Data\Intent\Contact\DeleteContactIntent;
|
||||||
|
use App\Data\Intent\Contact\SearchContactIntent;
|
||||||
|
use App\Data\InvariantException;
|
||||||
|
use App\Http\Requests\StoreContactRequest;
|
||||||
|
use App\Http\Requests\UpdateContactRequest;
|
||||||
|
use App\Services\Command\CreateContactCommand;
|
||||||
|
use App\Services\Command\DeleteContactCommand;
|
||||||
|
use App\Services\Command\UpdateContactCommand;
|
||||||
|
use App\Services\Query\SearchContactQuery;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class ContactController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UpdateContactCommand $updateContactCommand,
|
||||||
|
private readonly DeleteContactCommand $deleteContactCommand,
|
||||||
|
private readonly CreateContactCommand $createContactCommand,
|
||||||
|
private readonly SearchContactQuery $searchContactQuery,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a listing of the resource.
|
||||||
|
*/
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
$contacts = $this->searchContactQuery->execute(SearchContactIntent::queryAll());
|
||||||
|
|
||||||
|
return view('contacts.index', [
|
||||||
|
'contacts' => $contacts,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for creating a new resource.
|
||||||
|
*/
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
return view('contacts.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created resource in storage.
|
||||||
|
*/
|
||||||
|
public function store(StoreContactRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$query = $request->query('q');
|
||||||
|
$searchQueryParams = is_string($query) && $query !== '' ? ['q' => $query] : [];
|
||||||
|
try {
|
||||||
|
$contact = $this->createContactCommand->execute($request->toIntent());
|
||||||
|
} catch (InvariantException $exception) {
|
||||||
|
Log::error(
|
||||||
|
'Cannot store contact: {message}',
|
||||||
|
['message' => $exception->getMessage()]
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('contacts.index', $searchQueryParams)
|
||||||
|
->with('status', 'Contact create failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($contact === null) {
|
||||||
|
Log::error(
|
||||||
|
'Cannot store contact: {message}',
|
||||||
|
['intent' => $request->toIntent()]
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('contacts.index', $searchQueryParams)
|
||||||
|
->with('status', 'Contact create failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('contacts.show', ['contact' => $contact->uuid->toString(), ...$searchQueryParams])
|
||||||
|
->with('status', 'Contact created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified resource.
|
||||||
|
*/
|
||||||
|
public function show(Contact $contact): View
|
||||||
|
{
|
||||||
|
return view('contacts.show', [
|
||||||
|
'contact' => $contact,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for editing the specified resource.
|
||||||
|
*/
|
||||||
|
public function edit(Contact $contact): View
|
||||||
|
{
|
||||||
|
return view('contacts.edit', [
|
||||||
|
'contact' => $contact,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified resource in storage.
|
||||||
|
*/
|
||||||
|
public function update(UpdateContactRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$intent = $request->toIntent();
|
||||||
|
$query = $request->query('q');
|
||||||
|
$searchQueryParams = is_string($query) && $query !== '' ? ['q' => $query] : [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$contact = $this->updateContactCommand->execute($intent);
|
||||||
|
} catch (InvariantException $exception) {
|
||||||
|
Log::error(
|
||||||
|
'Cannot update contact: {message}',
|
||||||
|
['message' => $exception->getMessage()]
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('contacts.index', $searchQueryParams)
|
||||||
|
->with('status', 'Contact update failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($contact === null) {
|
||||||
|
Log::error("Failed to update contact '{uuid}'", [
|
||||||
|
'uuid' => $intent->uuid,
|
||||||
|
'intent' => $intent,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('contacts.show', ['contact' => $intent->uuid, ...$searchQueryParams])
|
||||||
|
->with('status', 'Contact update failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('contacts.show', ['contact' => $contact->uuid->toString(), ...$searchQueryParams])
|
||||||
|
->with('status', 'Contact updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified resource from storage.
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$route = $request->route('contact');
|
||||||
|
if ($route === null) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
/** @var Contact $route */
|
||||||
|
$uuid = $route->uuid;
|
||||||
|
$query = $request->query('q');
|
||||||
|
$searchQueryParams = is_string($query) && $query !== '' ? ['q' => $query] : [];
|
||||||
|
|
||||||
|
$result = $this->deleteContactCommand->execute(
|
||||||
|
new DeleteContactIntent($uuid)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result === false) {
|
||||||
|
Log::error('Failed to delete contact {uuid}.', [
|
||||||
|
'uuid' => $uuid,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('contacts.show', ['contact' => $uuid, ...$searchQueryParams])
|
||||||
|
->with('status', 'Contact can\'t be deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('contacts.index', $searchQueryParams)
|
||||||
|
->with('status', 'Contact deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function search(Request $request): View
|
||||||
|
{
|
||||||
|
$q = $request->query('q');
|
||||||
|
|
||||||
|
if ($q === null) {
|
||||||
|
Log::warning('No search query provided.');
|
||||||
|
$searchIntent = SearchContactIntent::queryAll();
|
||||||
|
} else {
|
||||||
|
if (is_string($q)) {
|
||||||
|
$searchIntent = new SearchContactIntent($q);
|
||||||
|
} else {
|
||||||
|
abort(400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$contacts = $this->searchContactQuery->execute($searchIntent);
|
||||||
|
if ($request->filled('q')) {
|
||||||
|
$contacts->appends(['q' => $request->query('q')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('contacts.index', [
|
||||||
|
'contacts' => $contacts,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/Http/Controllers/Controller.php
Normal file
10
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
60
app/Http/Controllers/ImportController.php
Normal file
60
app/Http/Controllers/ImportController.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Data\ContactImport;
|
||||||
|
use App\Data\Intent\ProcessImport\ImportContactsIntent;
|
||||||
|
use App\Http\Requests\ImportContactsRequest;
|
||||||
|
use App\Services\Command\ImportContactsCommand;
|
||||||
|
use App\Services\Mapper\Api\ContactImportMapper;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
|
||||||
|
class ImportController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ImportContactsCommand $command,
|
||||||
|
private readonly ContactImportMapper $apiMapper,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
return view('import.show', [
|
||||||
|
'importUuid' => session('importUuid'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(ImportContactsRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$file = $request->file('file');
|
||||||
|
if (! $file instanceof UploadedFile) {
|
||||||
|
abort(422, 'Missing import file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $file->store('imports');
|
||||||
|
if ($path === false) {
|
||||||
|
abort(500, 'Failed to store import file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$import = new ImportContactsIntent(
|
||||||
|
$path
|
||||||
|
);
|
||||||
|
|
||||||
|
$process = $this->command->execute($import);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('import.index')
|
||||||
|
->with('status', 'Import queued.')
|
||||||
|
->with('importUuid', $process->uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(ContactImport $contactImport): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json($this->apiMapper->fromModel($contactImport)->toArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Http/Requests/ImportContactsRequest.php
Normal file
47
app/Http/Requests/ImportContactsRequest.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class ImportContactsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$fileSize = config('imports.max_file_size');
|
||||||
|
if (!is_numeric($fileSize)) {
|
||||||
|
throw new \InvalidArgumentException("imports.max_file_size config value must be number");
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'file' => ['required', 'file', 'mimes:xml', 'max:' . $fileSize],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'file.required' => 'File is required.',
|
||||||
|
'file.file' => 'File must be a valid upload.',
|
||||||
|
'file.mimes' => 'File must be an XML file.',
|
||||||
|
'file.max' => 'File may not be greater than 100 MB.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/Http/Requests/StoreContactRequest.php
Normal file
60
app/Http/Requests/StoreContactRequest.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Data\Intent\Contact\CreateContactIntent;
|
||||||
|
use App\Services\Validators\ContactValidator;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreContactRequest extends FormRequest
|
||||||
|
{
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$email = $this->input('email');
|
||||||
|
if (is_string($email)) {
|
||||||
|
$this->merge([
|
||||||
|
'email' => strtolower(trim($email)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return app(ContactValidator::class)->rulesForStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return app(ContactValidator::class)->messages();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toIntent(): CreateContactIntent
|
||||||
|
{
|
||||||
|
$data = $this->validated();
|
||||||
|
/** @var array{email: string, first_name?: string|null, last_name?: string|null} $data */
|
||||||
|
|
||||||
|
return new CreateContactIntent(
|
||||||
|
$data['email'],
|
||||||
|
$data['first_name'] ?? null,
|
||||||
|
$data['last_name'] ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/Http/Requests/UpdateContactRequest.php
Normal file
75
app/Http/Requests/UpdateContactRequest.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Data\Contact as ContactData;
|
||||||
|
use App\Data\Intent\Contact\UpdateContactIntent;
|
||||||
|
use App\Services\Validators\ContactValidator;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateContactRequest extends FormRequest
|
||||||
|
{
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$email = $this->input('email');
|
||||||
|
if (is_string($email)) {
|
||||||
|
$this->merge([
|
||||||
|
'email' => strtolower(trim($email)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$contact = $this->resolveContact();
|
||||||
|
|
||||||
|
return app(ContactValidator::class)->rulesForUpdate($contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return app(ContactValidator::class)->messages();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toIntent(): UpdateContactIntent
|
||||||
|
{
|
||||||
|
$data = $this->validated();
|
||||||
|
/** @var array{email: string, first_name?: string|null, last_name?: string|null} $data */
|
||||||
|
$contact = $this->resolveContact();
|
||||||
|
|
||||||
|
return new UpdateContactIntent(
|
||||||
|
$contact->uuid,
|
||||||
|
$data['email'],
|
||||||
|
$data['first_name'] ?? null,
|
||||||
|
$data['last_name'] ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveContact(): ContactData
|
||||||
|
{
|
||||||
|
$routeContact = $this->route('contact');
|
||||||
|
if ($routeContact instanceof ContactData) {
|
||||||
|
return $routeContact;
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
275
app/Jobs/ProcessImport.php
Normal file
275
app/Jobs/ProcessImport.php
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Data\Intent\Contact\CreateContactIntent;
|
||||||
|
use App\Data\Intent\Contact\ProcessImport\ImportContactIntent;
|
||||||
|
use App\Services\Command\CreateContactCommand;
|
||||||
|
use App\Services\ProcessImportMonitor\ProcessImportState;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use XMLReader;
|
||||||
|
|
||||||
|
class ProcessImport implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
private const STATE_DATA = 1 << 0;
|
||||||
|
|
||||||
|
private const STATE_ITEM = 1 << 1;
|
||||||
|
|
||||||
|
private const MAX_BATCH_SIZE = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job instance.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $file,
|
||||||
|
private readonly ProcessImportState $processImportState,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(
|
||||||
|
CreateContactCommand $createContactCommand,
|
||||||
|
): void {
|
||||||
|
$prev = libxml_use_internal_errors(true);
|
||||||
|
libxml_clear_errors();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->runImport($createContactCommand);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->processImportState->fail();
|
||||||
|
throw $e;
|
||||||
|
} finally {
|
||||||
|
libxml_clear_errors();
|
||||||
|
libxml_use_internal_errors($prev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*/
|
||||||
|
private function runImport(
|
||||||
|
CreateContactCommand $createContactCommand,
|
||||||
|
): void {
|
||||||
|
$importPath = Storage::disk('local')->path($this->file);
|
||||||
|
$reader = \XMLReader::open($importPath);
|
||||||
|
Log::debug('job {uuid} - start', [
|
||||||
|
'uuid' => $this->processImportState->uuid()->toString(),
|
||||||
|
]);
|
||||||
|
if ($reader === false) {
|
||||||
|
Log::error('job {uuid} - cannot create reader {file}', [
|
||||||
|
'uuid' => $this->processImportState->uuid()->toString(),
|
||||||
|
'file' => $importPath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->processImportState->fail();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yeah, I could use XSD validation -- but would that really help if the XML is only formally correct?
|
||||||
|
// The CS requirements doesn’t mention it, so I chose the ‘import whatever we can, no matter what’ approach.
|
||||||
|
|
||||||
|
$elementDepth = 0;
|
||||||
|
$actualState = 0;
|
||||||
|
$actualBatch = [];
|
||||||
|
|
||||||
|
$actualContact = null;
|
||||||
|
$activeField = null;
|
||||||
|
|
||||||
|
$this->processImportState->start();
|
||||||
|
|
||||||
|
while ($reader->read()) {
|
||||||
|
switch ($reader->nodeType) {
|
||||||
|
case XMLReader::ELEMENT:
|
||||||
|
$activeField = null;
|
||||||
|
$elementDepth++;
|
||||||
|
if (
|
||||||
|
$elementDepth === 1 && $reader->name === 'data' // data
|
||||||
|
) {
|
||||||
|
$actualState |= self::STATE_DATA;
|
||||||
|
} elseif (
|
||||||
|
$elementDepth === 2 && $reader->name == 'item' // data/item
|
||||||
|
&& ($actualState & self::STATE_DATA) === self::STATE_DATA // we must be in data
|
||||||
|
) {
|
||||||
|
$actualContact = new ImportContactIntent();
|
||||||
|
$actualState |= self::STATE_ITEM;
|
||||||
|
libxml_clear_errors();
|
||||||
|
} elseif (
|
||||||
|
$elementDepth === 3 // data/item/element
|
||||||
|
&& ($actualState & self::STATE_ITEM) === self::STATE_ITEM // We must be in item
|
||||||
|
&& $actualContact !== null // This never happen, but OK
|
||||||
|
) {
|
||||||
|
switch ($reader->name) {
|
||||||
|
case 'first_name':
|
||||||
|
$activeField = 'first_name';
|
||||||
|
break;
|
||||||
|
case 'last_name':
|
||||||
|
$activeField = 'last_name';
|
||||||
|
break;
|
||||||
|
case 'email':
|
||||||
|
$activeField = 'email';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case XMLReader::TEXT:
|
||||||
|
case XMLReader::CDATA:
|
||||||
|
if ($activeField !== null && $actualContact !== null) {
|
||||||
|
switch ($activeField) {
|
||||||
|
case 'first_name':
|
||||||
|
$value = $reader->value;
|
||||||
|
if ($actualContact->firstName !== null) { // Handles multiple text nodes
|
||||||
|
$value = $actualContact->firstName . $value;
|
||||||
|
}
|
||||||
|
$actualContact->firstName = $value;
|
||||||
|
break;
|
||||||
|
case 'last_name':
|
||||||
|
$value = $reader->value;
|
||||||
|
if ($actualContact->lastName !== null) { // Handles multiple text nodes
|
||||||
|
$value = $actualContact->lastName . $value;
|
||||||
|
}
|
||||||
|
$actualContact->lastName = $value;
|
||||||
|
break;
|
||||||
|
case 'email':
|
||||||
|
$value = $reader->value;
|
||||||
|
if ($actualContact->email !== null) { // Handles multiple text nodes
|
||||||
|
$value = $actualContact->email . $value;
|
||||||
|
}
|
||||||
|
$actualContact->email = $value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case XMLReader::END_ELEMENT:
|
||||||
|
$error = libxml_get_last_error();
|
||||||
|
if ($elementDepth === 1 && $reader->name === 'data') {
|
||||||
|
$actualState &= ~self::STATE_DATA;
|
||||||
|
}
|
||||||
|
if ($elementDepth === 2 && $reader->name === 'item') {
|
||||||
|
$itemHasError = false;
|
||||||
|
if ($error !== false) {
|
||||||
|
$itemHasError = true;
|
||||||
|
Log::error(
|
||||||
|
'job {uuid} - importing email failed',
|
||||||
|
[
|
||||||
|
'uuid' => $this->processImportState->uuid()->toString(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
libxml_clear_errors();
|
||||||
|
}
|
||||||
|
if (! $itemHasError) {
|
||||||
|
Log::debug('job {uuid} - importing {email}', [
|
||||||
|
'uuid' => $this->processImportState->uuid()->toString(),
|
||||||
|
'email' => $actualContact?->email,
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
if ($actualContact === null) {
|
||||||
|
Log::error(
|
||||||
|
'job {uuid} - importing email failed',
|
||||||
|
[
|
||||||
|
'uuid' => $this->processImportState->uuid()->toString(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$itemHasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $itemHasError && $actualContact->email === null) {
|
||||||
|
$itemHasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $itemHasError) {
|
||||||
|
// Omg, PHPStan - this should never happen
|
||||||
|
$email = $actualContact?->email;
|
||||||
|
if ($email === null || $actualContact === null) {
|
||||||
|
$itemHasError = true;
|
||||||
|
} else {
|
||||||
|
$intent = new CreateContactIntent(
|
||||||
|
$email,
|
||||||
|
$actualContact->firstName,
|
||||||
|
$actualContact->lastName,
|
||||||
|
);
|
||||||
|
|
||||||
|
$actualBatch[] = $intent;
|
||||||
|
if (count($actualBatch) >= self::MAX_BATCH_SIZE) {
|
||||||
|
$this->storeBatch($createContactCommand, $actualBatch);
|
||||||
|
$actualBatch = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\InvalidArgumentException $exception) {
|
||||||
|
$itemHasError = true;
|
||||||
|
Log::debug(
|
||||||
|
'job {uuid} - importing email {email} failed - validation errors',
|
||||||
|
[
|
||||||
|
'email' => $actualContact?->email,
|
||||||
|
'exception' => $exception,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (\Exception $exception) {
|
||||||
|
$itemHasError = true;
|
||||||
|
$this->processImportState->contactFail(count($actualBatch));
|
||||||
|
Log::error(
|
||||||
|
'job {uuid} - importing email {email} failed critically. skipping batch',
|
||||||
|
[
|
||||||
|
'exception' => $exception,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$actualBatch = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($itemHasError) {
|
||||||
|
$this->processImportState->contactFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
$actualState &= ~self::STATE_ITEM;
|
||||||
|
$activeField = null;
|
||||||
|
$actualContact = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$elementDepth--;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->storeBatch($createContactCommand, $actualBatch);
|
||||||
|
} catch (\Exception $exception) {
|
||||||
|
Log::error(
|
||||||
|
'job {uuid} - importing email {email} failed critically. skipping batch',
|
||||||
|
[
|
||||||
|
'email' => $actualContact?->email,
|
||||||
|
'exception' => $exception,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$this->processImportState->contactFail(count($actualBatch));
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::debug('job {uuid} - finish', [
|
||||||
|
'uuid' => $this->processImportState->uuid()->toString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->processImportState->finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<CreateContactIntent> $actualBatch
|
||||||
|
*/
|
||||||
|
private function storeBatch(CreateContactCommand $createContactCommand, array $actualBatch): void
|
||||||
|
{
|
||||||
|
Log::info('job {uuid} - storing batch', [
|
||||||
|
'uuid' => $this->processImportState->uuid()->toString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$multipleUpdateResult = $createContactCommand->multipleExecute($actualBatch);
|
||||||
|
$this->processImportState->contactDuplicate($multipleUpdateResult->duplicates);
|
||||||
|
$this->processImportState->contactSuccess($multipleUpdateResult->success);
|
||||||
|
$this->processImportState->contactFail($multipleUpdateResult->fails);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Models/Contact.php
Normal file
42
app/Models/Contact.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Contact extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\ContactFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'id',
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
|
'email',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => 'string',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Models/ContactImport.php
Normal file
61
app/Models/ContactImport.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\ContactImportStateEnum;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property string $id
|
||||||
|
* @property \Illuminate\Support\Carbon $queue_at
|
||||||
|
* @property \Illuminate\Support\Carbon|null $started_at
|
||||||
|
* @property \Illuminate\Support\Carbon|null $finished_at
|
||||||
|
* @property int $total_processed
|
||||||
|
* @property int $errors
|
||||||
|
* @property int $duplicates
|
||||||
|
* @property string $file
|
||||||
|
* @property \App\Enums\ContactImportStateEnum $state
|
||||||
|
*/
|
||||||
|
class ContactImport extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\ContactImportFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'id',
|
||||||
|
'queue_at',
|
||||||
|
'started_at',
|
||||||
|
'finished_at',
|
||||||
|
'total_processed',
|
||||||
|
'errors',
|
||||||
|
'duplicates',
|
||||||
|
'state',
|
||||||
|
'file',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => 'string',
|
||||||
|
'queue_at' => 'datetime',
|
||||||
|
'started_at' => 'datetime',
|
||||||
|
'finished_at' => 'datetime',
|
||||||
|
'state' => ContactImportStateEnum::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/Providers/AppServiceProvider.php
Normal file
57
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Services\ProcessImportMonitor\DatabaseProcessImportMonitor;
|
||||||
|
use App\Services\ProcessImportMonitor\ProcessImportMonitor;
|
||||||
|
use App\Services\SearchProvider\DatabaseSearchProvider;
|
||||||
|
use App\Services\SearchProvider\SearchProvider;
|
||||||
|
use App\Services\Storage\ContactStorageProvider;
|
||||||
|
use App\Services\Storage\DatabaseContactStorageProvider;
|
||||||
|
use App\Services\Storage\DatabaseImportStorageProvider;
|
||||||
|
use App\Services\Storage\ProcessImportStorageProvider;
|
||||||
|
use Illuminate\Support\Facades\View;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class AppServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register any application services.
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->app->bind(
|
||||||
|
SearchProvider::class,
|
||||||
|
DatabaseSearchProvider::class,
|
||||||
|
);
|
||||||
|
$this->app->bind(
|
||||||
|
ContactStorageProvider::class,
|
||||||
|
DatabaseContactStorageProvider::class
|
||||||
|
);
|
||||||
|
$this->app->bind(
|
||||||
|
ProcessImportStorageProvider::class,
|
||||||
|
DatabaseImportStorageProvider::class,
|
||||||
|
);
|
||||||
|
$this->app->bind(
|
||||||
|
ProcessImportMonitor::class,
|
||||||
|
DatabaseProcessImportMonitor::class,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap any application services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
View::composer('contacts.*', function (\Illuminate\View\View $view): void {
|
||||||
|
$q = request()->query('q');
|
||||||
|
$searchQuery = is_string($q) ? $q : null;
|
||||||
|
$searchQueryParams = $searchQuery !== null && $searchQuery !== '' ? ['q' => $searchQuery] : [];
|
||||||
|
|
||||||
|
$view->with('searchQuery', $searchQuery);
|
||||||
|
$view->with('searchQueryParams', $searchQueryParams);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/Providers/RouteBindingServiceProvider.php
Normal file
62
app/Providers/RouteBindingServiceProvider.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Data\Contact;
|
||||||
|
use App\Data\ContactImport;
|
||||||
|
use App\Data\Intent\Contact\SearchContactIntentByUuid;
|
||||||
|
use App\Data\Intent\ProcessImport\SearchImportContactsIntent;
|
||||||
|
use App\Services\Query\ImportContactsStateQuery;
|
||||||
|
use App\Services\Query\SearchContactQuery;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Ramsey\Uuid\Uuid;
|
||||||
|
|
||||||
|
class RouteBindingServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register services.
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
Route::bind('contact', function (string $value): Contact {
|
||||||
|
if (! Uuid::isValid($value)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$searchIntent = new SearchContactIntentByUuid(Uuid::fromString($value));
|
||||||
|
$contact = app(SearchContactQuery::class)->executeOne($searchIntent);
|
||||||
|
|
||||||
|
if ($contact === null) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $contact;
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::bind('contactImport', function (string $value): ContactImport {
|
||||||
|
if (! Uuid::isValid($value)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$searchIntent = new SearchImportContactsIntent(Uuid::fromString($value));
|
||||||
|
$contactImport = app(ImportContactsStateQuery::class)->executeOne($searchIntent);
|
||||||
|
|
||||||
|
if ($contactImport === null) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $contactImport;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Services/Command/CreateContactCommand.php
Normal file
54
app/Services/Command/CreateContactCommand.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Command;
|
||||||
|
|
||||||
|
use App\Data\Command\BatchResult;
|
||||||
|
use App\Data\Contact;
|
||||||
|
use App\Data\Intent\Contact\CreateContactIntent;
|
||||||
|
use App\Data\InvariantException;
|
||||||
|
use App\Services\Mapper\ContactMapper;
|
||||||
|
use App\Services\Storage\ContactStorageProvider;
|
||||||
|
|
||||||
|
class CreateContactCommand
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContactMapper $contactMapper,
|
||||||
|
private readonly ContactStorageProvider $contactStorageProvider,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(CreateContactIntent $command): ?Contact
|
||||||
|
{
|
||||||
|
$entity = $this->contactMapper->tryToCreateFromIntent($command);
|
||||||
|
|
||||||
|
return $this->contactStorageProvider->create($entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<CreateContactIntent> $actualBatch
|
||||||
|
* @return BatchResult
|
||||||
|
*/
|
||||||
|
public function multipleExecute(array $actualBatch): BatchResult
|
||||||
|
{
|
||||||
|
$contacts = [];
|
||||||
|
|
||||||
|
foreach ($actualBatch as $intent) {
|
||||||
|
try {
|
||||||
|
$entity = $this->contactMapper->tryToCreateFromIntent($intent);
|
||||||
|
$contacts[] = $entity;
|
||||||
|
} catch (InvariantException) {
|
||||||
|
// Do nothing -> mark later as failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->contactStorageProvider->batchCreate($contacts);
|
||||||
|
|
||||||
|
return new BatchResult(
|
||||||
|
count($actualBatch) - count($contacts),
|
||||||
|
count($contacts) - $result,
|
||||||
|
$result,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Services/Command/DeleteContactCommand.php
Normal file
21
app/Services/Command/DeleteContactCommand.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Command;
|
||||||
|
|
||||||
|
use App\Data\Intent\Contact\DeleteContactIntent;
|
||||||
|
use App\Services\Storage\ContactStorageProvider;
|
||||||
|
|
||||||
|
class DeleteContactCommand
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContactStorageProvider $contactStorageProvider,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(DeleteContactIntent $command): bool
|
||||||
|
{
|
||||||
|
return $this->contactStorageProvider->delete($command->uuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Services/Command/ImportContactsCommand.php
Normal file
25
app/Services/Command/ImportContactsCommand.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Command;
|
||||||
|
|
||||||
|
use App\Data\Intent\ProcessImport\ImportContactsIntent;
|
||||||
|
use App\Jobs\ProcessImport;
|
||||||
|
use App\Services\ProcessImportMonitor\ProcessImportIdentifiable;
|
||||||
|
use App\Services\ProcessImportMonitor\ProcessImportMonitor;
|
||||||
|
|
||||||
|
class ImportContactsCommand
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ProcessImportMonitor $importMonitor)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(ImportContactsIntent $command): ProcessImportIdentifiable
|
||||||
|
{
|
||||||
|
$import = $this->importMonitor->newImport($command->path);
|
||||||
|
ProcessImport::dispatch($command->path, $import);
|
||||||
|
|
||||||
|
return $import;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Services/Command/UpdateContactCommand.php
Normal file
26
app/Services/Command/UpdateContactCommand.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Command;
|
||||||
|
|
||||||
|
use App\Data\Contact;
|
||||||
|
use App\Data\Intent\Contact\UpdateContactIntent;
|
||||||
|
use App\Services\Mapper\ContactMapper;
|
||||||
|
use App\Services\Storage\ContactStorageProvider;
|
||||||
|
|
||||||
|
class UpdateContactCommand
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContactMapper $contactMapper,
|
||||||
|
private readonly ContactStorageProvider $contactStorageProvider,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(UpdateContactIntent $command): ?Contact
|
||||||
|
{
|
||||||
|
$entity = $this->contactMapper->tryToCreateFromIntent($command);
|
||||||
|
|
||||||
|
return $this->contactStorageProvider->update($entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Services/Mapper/Api/ContactImportMapper.php
Normal file
25
app/Services/Mapper/Api/ContactImportMapper.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Mapper\Api;
|
||||||
|
|
||||||
|
use App\Data\Api\ContactImport as ContactImportData;
|
||||||
|
use App\Data\ContactImport;
|
||||||
|
|
||||||
|
final class ContactImportMapper
|
||||||
|
{
|
||||||
|
public function fromModel(ContactImport $contactImport): ContactImportData
|
||||||
|
{
|
||||||
|
return new ContactImportData(
|
||||||
|
$contactImport->id,
|
||||||
|
$contactImport->queueAt->toIso8601String(),
|
||||||
|
$contactImport->startedAt?->toIso8601String(),
|
||||||
|
$contactImport->finishedAt?->toIso8601String(),
|
||||||
|
(int) $contactImport->totalProcessed,
|
||||||
|
(int) $contactImport->errors,
|
||||||
|
(int) $contactImport->duplicates,
|
||||||
|
$contactImport->state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Services/Mapper/ContactImportMapper.php
Normal file
44
app/Services/Mapper/ContactImportMapper.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Mapper;
|
||||||
|
|
||||||
|
use App\Data\ContactImport as ContactImportData;
|
||||||
|
use App\Models\ContactImport;
|
||||||
|
|
||||||
|
final class ContactImportMapper
|
||||||
|
{
|
||||||
|
public function fromModel(ContactImport $contactImport): ContactImportData
|
||||||
|
{
|
||||||
|
return new ContactImportData(
|
||||||
|
$contactImport->id,
|
||||||
|
$contactImport->queue_at,
|
||||||
|
$contactImport->started_at,
|
||||||
|
$contactImport->finished_at,
|
||||||
|
(int) $contactImport->total_processed,
|
||||||
|
(int) $contactImport->errors,
|
||||||
|
(int) $contactImport->duplicates,
|
||||||
|
$contactImport->state->value,
|
||||||
|
$contactImport->file,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toModelAttributes(ContactImportData $contactImport): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $contactImport->id,
|
||||||
|
'queue_at' => $contactImport->queueAt,
|
||||||
|
'started_at' => $contactImport->startedAt,
|
||||||
|
'finished_at' => $contactImport->finishedAt,
|
||||||
|
'total_processed' => $contactImport->totalProcessed,
|
||||||
|
'errors' => $contactImport->errors,
|
||||||
|
'duplicates' => $contactImport->duplicates,
|
||||||
|
'state' => $contactImport->state,
|
||||||
|
'file' => $contactImport->file,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Services/Mapper/ContactMapper.php
Normal file
58
app/Services/Mapper/ContactMapper.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Mapper;
|
||||||
|
|
||||||
|
use App\Data\Contact as ContactData;
|
||||||
|
use App\Data\Intent\Contact\CreateContactIntent;
|
||||||
|
use App\Data\Intent\Contact\UpdateContactIntent;
|
||||||
|
use App\Models\Contact;
|
||||||
|
use Ramsey\Uuid\Uuid;
|
||||||
|
|
||||||
|
final class ContactMapper
|
||||||
|
{
|
||||||
|
public function fromModel(Contact $contact): ContactData
|
||||||
|
{
|
||||||
|
return new ContactData(
|
||||||
|
// @phpstan-ignore-next-line argument.type
|
||||||
|
Uuid::fromString((string) $contact->id),
|
||||||
|
// @phpstan-ignore-next-line argument.type
|
||||||
|
$contact->email,
|
||||||
|
// @phpstan-ignore-next-line argument.type
|
||||||
|
$contact->first_name,
|
||||||
|
// @phpstan-ignore-next-line argument.type
|
||||||
|
$contact->last_name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tryToCreateFromIntent(UpdateContactIntent|CreateContactIntent $intent): ContactData
|
||||||
|
{
|
||||||
|
if ($intent instanceof CreateContactIntent) {
|
||||||
|
$uuid = Uuid::uuid7();
|
||||||
|
} else {
|
||||||
|
$uuid = $intent->uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ContactData(
|
||||||
|
$uuid,
|
||||||
|
$intent->email,
|
||||||
|
$intent->firstName,
|
||||||
|
$intent->lastName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toModelAttributes(
|
||||||
|
ContactData $contactData,
|
||||||
|
): array {
|
||||||
|
return [
|
||||||
|
'id' => $contactData->uuid->toString(),
|
||||||
|
'first_name' => $contactData->firstName,
|
||||||
|
'last_name' => $contactData->lastName,
|
||||||
|
'email' => $contactData->email,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\ProcessImportMonitor;
|
||||||
|
|
||||||
|
use Ramsey\Uuid\Rfc4122\UuidV7;
|
||||||
|
|
||||||
|
class DatabaseProcessImportMonitor implements ProcessImportMonitor
|
||||||
|
{
|
||||||
|
public function newImport(string $file): ProcessImportState&ProcessImportIdentifiable
|
||||||
|
{
|
||||||
|
return new DatabaseProcessImportState(UuidV7::uuid7(), $file);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
app/Services/ProcessImportMonitor/DatabaseProcessImportState.php
Normal file
115
app/Services/ProcessImportMonitor/DatabaseProcessImportState.php
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\ProcessImportMonitor;
|
||||||
|
|
||||||
|
use App\Enums\ContactImportStateEnum;
|
||||||
|
use App\Models\ContactImport;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
class DatabaseProcessImportState implements ProcessImportIdentifiable, ProcessImportState
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly UuidInterface $uuid,
|
||||||
|
string $file
|
||||||
|
) {
|
||||||
|
ContactImport::query()->create(
|
||||||
|
[
|
||||||
|
'id' => $this->uuid()->toString(),
|
||||||
|
'state' => ContactImportStateEnum::Pending,
|
||||||
|
'file' => $file,
|
||||||
|
'queue_at' => Carbon::now(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uuid(): UuidInterface
|
||||||
|
{
|
||||||
|
return $this->uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start(): void
|
||||||
|
{
|
||||||
|
ContactImport::query()->where(
|
||||||
|
[
|
||||||
|
'id' => $this->uuid()->toString(),
|
||||||
|
]
|
||||||
|
)->update(
|
||||||
|
[
|
||||||
|
'started_at' => now(),
|
||||||
|
'state' => ContactImportStateEnum::Running,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fail(): void
|
||||||
|
{
|
||||||
|
ContactImport::query()->where(
|
||||||
|
[
|
||||||
|
'id' => $this->uuid()->toString(),
|
||||||
|
],
|
||||||
|
)->update(
|
||||||
|
[
|
||||||
|
'finished_at' => now(),
|
||||||
|
'state' => ContactImportStateEnum::Fail,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function finish(): void
|
||||||
|
{
|
||||||
|
ContactImport::query()->where(
|
||||||
|
[
|
||||||
|
'id' => $this->uuid()->toString(),
|
||||||
|
],
|
||||||
|
)->update(
|
||||||
|
[
|
||||||
|
'finished_at' => now(),
|
||||||
|
'state' => ContactImportStateEnum::Done,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function contactDuplicate(int $processed = 1): void
|
||||||
|
{
|
||||||
|
ContactImport::query()->where(
|
||||||
|
[
|
||||||
|
'id' => $this->uuid()->toString(),
|
||||||
|
],
|
||||||
|
)->incrementEach(
|
||||||
|
[
|
||||||
|
'total_processed' => $processed,
|
||||||
|
'duplicates' => $processed,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function contactFail(int $processed = 1): void
|
||||||
|
{
|
||||||
|
ContactImport::query()->where(
|
||||||
|
[
|
||||||
|
'id' => $this->uuid()->toString(),
|
||||||
|
],
|
||||||
|
)->incrementEach(
|
||||||
|
[
|
||||||
|
'total_processed' => $processed,
|
||||||
|
'errors' => $processed,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function contactSuccess(int $processed = 1): void
|
||||||
|
{
|
||||||
|
ContactImport::query()->where(
|
||||||
|
[
|
||||||
|
'id' => $this->uuid()->toString(),
|
||||||
|
],
|
||||||
|
)->incrementEach(
|
||||||
|
[
|
||||||
|
'total_processed' => $processed,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\ProcessImportMonitor;
|
||||||
|
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
interface ProcessImportIdentifiable
|
||||||
|
{
|
||||||
|
public UuidInterface $uuid { get; }
|
||||||
|
}
|
||||||
10
app/Services/ProcessImportMonitor/ProcessImportMonitor.php
Normal file
10
app/Services/ProcessImportMonitor/ProcessImportMonitor.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\ProcessImportMonitor;
|
||||||
|
|
||||||
|
interface ProcessImportMonitor
|
||||||
|
{
|
||||||
|
public function newImport(string $file): ProcessImportState&ProcessImportIdentifiable;
|
||||||
|
}
|
||||||
29
app/Services/ProcessImportMonitor/ProcessImportState.php
Normal file
29
app/Services/ProcessImportMonitor/ProcessImportState.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\ProcessImportMonitor;
|
||||||
|
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handling actual state of import
|
||||||
|
*
|
||||||
|
* Never use not-serializable values in implementations!
|
||||||
|
*/
|
||||||
|
interface ProcessImportState
|
||||||
|
{
|
||||||
|
public function uuid(): UuidInterface;
|
||||||
|
|
||||||
|
public function start(): void;
|
||||||
|
|
||||||
|
public function finish(): void;
|
||||||
|
|
||||||
|
public function fail(): void;
|
||||||
|
|
||||||
|
public function contactFail(int $processed = 1): void;
|
||||||
|
|
||||||
|
public function contactDuplicate(int $processed = 1): void;
|
||||||
|
|
||||||
|
public function contactSuccess(int $processed = 1): void;
|
||||||
|
}
|
||||||
29
app/Services/Query/ImportContactsStateQuery.php
Normal file
29
app/Services/Query/ImportContactsStateQuery.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Query;
|
||||||
|
|
||||||
|
use App\Data\ContactImport;
|
||||||
|
use App\Data\Intent\ProcessImport\SearchImportContactsIntent;
|
||||||
|
use App\Services\Mapper\ContactImportMapper;
|
||||||
|
use App\Services\Storage\ProcessImportStorageProvider;
|
||||||
|
|
||||||
|
class ImportContactsStateQuery
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContactImportMapper $contactImportMapper,
|
||||||
|
private readonly ProcessImportStorageProvider $processImportStorageProvider,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function executeOne(SearchImportContactsIntent $search): ?ContactImport
|
||||||
|
{
|
||||||
|
$modelResult = $this->processImportStorageProvider->fetchByUuid($search->uuid);
|
||||||
|
if ($modelResult === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->contactImportMapper->fromModel($modelResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Services/Query/SearchContactQuery.php
Normal file
59
app/Services/Query/SearchContactQuery.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Query;
|
||||||
|
|
||||||
|
use App\Data\Intent\Contact\SearchContactIntent;
|
||||||
|
use App\Data\Intent\Contact\SearchContactIntentByUuid;
|
||||||
|
use App\Models\Contact;
|
||||||
|
use App\Services\Mapper\ContactMapper;
|
||||||
|
use App\Services\SearchProvider\SearchProvider;
|
||||||
|
use App\Services\Storage\ContactStorageProvider;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
|
class SearchContactQuery
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SearchProvider $searchProvider,
|
||||||
|
private readonly ContactStorageProvider $contactStorageProvider,
|
||||||
|
private readonly ContactMapper $contactMapper,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function executeOne(SearchContactIntentByUuid $search): ?\App\Data\Contact
|
||||||
|
{
|
||||||
|
$modelResult = $this->contactStorageProvider->fetchByUuid($search->uuid);
|
||||||
|
if ($modelResult === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->contactMapper->fromModel($modelResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return LengthAwarePaginator<int, \App\Data\Contact>
|
||||||
|
*/
|
||||||
|
public function execute(SearchContactIntent $searchIntent): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
if ($searchIntent->query == SearchContactIntent::queryAll()->query) {
|
||||||
|
/** @var LengthAwarePaginator<int, Contact> $modelResult */
|
||||||
|
$modelResult = $this->contactStorageProvider->fetchPaginatedAll($searchIntent->resultsPerPage);
|
||||||
|
} else {
|
||||||
|
/** @var LengthAwarePaginator<int, Contact> $modelResult */
|
||||||
|
$modelResult = $this->searchProvider->search($searchIntent->query, $searchIntent->resultsPerPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
$collection = $modelResult->getCollection()->map(
|
||||||
|
fn (Contact $contact) => $this->contactMapper->fromModel($contact),
|
||||||
|
);
|
||||||
|
|
||||||
|
return new LengthAwarePaginator(
|
||||||
|
$collection,
|
||||||
|
$modelResult->total(),
|
||||||
|
$modelResult->perPage(),
|
||||||
|
$modelResult->currentPage(),
|
||||||
|
$modelResult->getOptions(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Services/SearchProvider/DatabaseSearchProvider.php
Normal file
34
app/Services/SearchProvider/DatabaseSearchProvider.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\SearchProvider;
|
||||||
|
|
||||||
|
use App\Models\Contact;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
|
final class DatabaseSearchProvider implements SearchProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return LengthAwarePaginator<int, \App\Models\Contact>
|
||||||
|
*/
|
||||||
|
public function search(string $query, int $paginate): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
return Contact::query()
|
||||||
|
->select('*')
|
||||||
|
->selectRaw(
|
||||||
|
"CASE
|
||||||
|
WHEN email LIKE CONCAT('%', ?::text, '%')
|
||||||
|
THEN 1.0
|
||||||
|
ELSE ts_rank(ts_name, plainto_tsquery('simple', ?::text))
|
||||||
|
END AS rank",
|
||||||
|
[$query, $query]
|
||||||
|
)
|
||||||
|
->whereRaw(
|
||||||
|
"ts_name @@ plainto_tsquery('simple', ?::text) OR email LIKE CONCAT('%', ?::text, '%')",
|
||||||
|
[$query, $query]
|
||||||
|
)
|
||||||
|
->orderByDesc('rank')
|
||||||
|
->paginate($paginate);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Services/SearchProvider/SearchProvider.php
Normal file
16
app/Services/SearchProvider/SearchProvider.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\SearchProvider;
|
||||||
|
|
||||||
|
use App\Data\Contact;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
|
interface SearchProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return LengthAwarePaginator<int, \App\Models\Contact>
|
||||||
|
*/
|
||||||
|
public function search(string $query, int $paginate): LengthAwarePaginator;
|
||||||
|
}
|
||||||
30
app/Services/Storage/ContactStorageProvider.php
Normal file
30
app/Services/Storage/ContactStorageProvider.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Storage;
|
||||||
|
|
||||||
|
use App\Data\Contact;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
interface ContactStorageProvider
|
||||||
|
{
|
||||||
|
public function create(Contact $contact): ?Contact;
|
||||||
|
|
||||||
|
public function update(Contact $contact): ?Contact;
|
||||||
|
|
||||||
|
public function delete(UuidInterface $uuid): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<Contact> $intents
|
||||||
|
*/
|
||||||
|
public function batchCreate(array $intents): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return LengthAwarePaginator<int, \App\Models\Contact>
|
||||||
|
*/
|
||||||
|
public function fetchPaginatedAll(int $paginate): LengthAwarePaginator;
|
||||||
|
|
||||||
|
public function fetchByUuid(UuidInterface $uuid): ?\App\Models\Contact;
|
||||||
|
}
|
||||||
72
app/Services/Storage/DatabaseContactStorageProvider.php
Normal file
72
app/Services/Storage/DatabaseContactStorageProvider.php
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Storage;
|
||||||
|
|
||||||
|
use App\Data\Contact;
|
||||||
|
use App\Services\Mapper\ContactMapper;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
class DatabaseContactStorageProvider implements ContactStorageProvider
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContactMapper $contactMapper,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Contact $contact): ?Contact
|
||||||
|
{
|
||||||
|
$contact = \App\Models\Contact::query()->create(
|
||||||
|
$this->contactMapper->toModelAttributes($contact),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->contactMapper->fromModel($contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Contact $contact): ?Contact
|
||||||
|
{
|
||||||
|
$existingContact = \App\Models\Contact::query()->findOrFail($contact->uuid->toString());
|
||||||
|
$result = $existingContact->update($this->contactMapper->toModelAttributes($contact));
|
||||||
|
if ($result === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->contactMapper->fromModel($existingContact);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(UuidInterface $uuid): bool
|
||||||
|
{
|
||||||
|
$contact = \App\Models\Contact::query()->findOrFail($uuid->toString());
|
||||||
|
|
||||||
|
return (bool) $contact->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<Contact> $intents
|
||||||
|
*/
|
||||||
|
public function batchCreate(array $intents): int
|
||||||
|
{
|
||||||
|
return \App\Models\Contact::query()->insertOrIgnore(
|
||||||
|
array_map(function (Contact $intent): array {
|
||||||
|
return $this->contactMapper->toModelAttributes($intent);
|
||||||
|
}, $intents)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return LengthAwarePaginator<int, \App\Models\Contact>
|
||||||
|
*/
|
||||||
|
public function fetchPaginatedAll(int $paginate): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
return \App\Models\Contact::query()
|
||||||
|
->orderBy('id')
|
||||||
|
->paginate($paginate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetchByUuid(UuidInterface $uuid): ?\App\Models\Contact
|
||||||
|
{
|
||||||
|
return \App\Models\Contact::query()->find($uuid->toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/Services/Storage/DatabaseImportStorageProvider.php
Normal file
15
app/Services/Storage/DatabaseImportStorageProvider.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Storage;
|
||||||
|
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
class DatabaseImportStorageProvider implements ProcessImportStorageProvider
|
||||||
|
{
|
||||||
|
public function fetchByUuid(UuidInterface $uuid): ?\App\Models\ContactImport
|
||||||
|
{
|
||||||
|
return \App\Models\ContactImport::query()->find($uuid->toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/Services/Storage/ProcessImportStorageProvider.php
Normal file
13
app/Services/Storage/ProcessImportStorageProvider.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Storage;
|
||||||
|
|
||||||
|
use App\Models\ContactImport;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
interface ProcessImportStorageProvider
|
||||||
|
{
|
||||||
|
public function fetchByUuid(UuidInterface $uuid): ?ContactImport;
|
||||||
|
}
|
||||||
83
app/Services/Validators/ContactValidator.php
Normal file
83
app/Services/Validators/ContactValidator.php
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Validators;
|
||||||
|
|
||||||
|
use App\Data\Contact;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
final class ContactValidator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'string', 'email:rfc', 'max:254'],
|
||||||
|
'first_name' => ['nullable', 'string', 'max:100'],
|
||||||
|
'last_name' => ['nullable', 'string', 'max:100'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rulesForStore(): array
|
||||||
|
{
|
||||||
|
return $this->rulesForRequest(unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rulesForUpdate(Contact $contact): array
|
||||||
|
{
|
||||||
|
return $this->rulesForRequest(unique: true, ignore: $contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'first_name.max' => 'First name may not be greater than 100 characters.',
|
||||||
|
'last_name.max' => 'Last name may not be greater than 100 characters.',
|
||||||
|
'email.required' => 'Email is required.',
|
||||||
|
'email.email' => 'Email must be a valid email address.',
|
||||||
|
'email.max' => 'Email may not be greater than 254 characters.',
|
||||||
|
'email.unique' => 'Email has already been taken.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
private function rulesForRequest(bool $unique, ?Contact $ignore = null): array
|
||||||
|
{
|
||||||
|
$rules = $this->rules();
|
||||||
|
/**
|
||||||
|
* @var array<string> $emailRules
|
||||||
|
*/
|
||||||
|
$emailRules = Arr::pull($rules, 'email');
|
||||||
|
|
||||||
|
if ($unique) {
|
||||||
|
$rule = Rule::unique('contacts', 'email');
|
||||||
|
if ($ignore !== null) {
|
||||||
|
$rule = $rule->ignore($ignore->uuid->toString());
|
||||||
|
}
|
||||||
|
$emailRules[] = $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string> $result */
|
||||||
|
$result = [
|
||||||
|
...$rules,
|
||||||
|
'email' => $emailRules,
|
||||||
|
];
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
artisan
Executable file
18
artisan
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Symfony\Component\Console\Input\ArgvInput;
|
||||||
|
|
||||||
|
define('LARAVEL_START', microtime(true));
|
||||||
|
|
||||||
|
// Register the Composer autoloader...
|
||||||
|
require __DIR__.'/vendor/autoload.php';
|
||||||
|
|
||||||
|
// Bootstrap Laravel and handle the command...
|
||||||
|
/** @var Application $app */
|
||||||
|
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||||
|
|
||||||
|
$status = $app->handleCommand(new ArgvInput);
|
||||||
|
|
||||||
|
exit($status);
|
||||||
11
boost.json
Normal file
11
boost.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"agents": [
|
||||||
|
"junie",
|
||||||
|
"claude_code",
|
||||||
|
"codex"
|
||||||
|
],
|
||||||
|
"guidelines": true,
|
||||||
|
"herd_mcp": false,
|
||||||
|
"mcp": true,
|
||||||
|
"sail": false
|
||||||
|
}
|
||||||
19
bootstrap/app.php
Normal file
19
bootstrap/app.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
|
||||||
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
|
->withRouting(
|
||||||
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
|
commands: __DIR__.'/../routes/console.php',
|
||||||
|
health: '/up',
|
||||||
|
)
|
||||||
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
//
|
||||||
|
})
|
||||||
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
|
//
|
||||||
|
})->create();
|
||||||
2
bootstrap/cache/.gitignore
vendored
Normal file
2
bootstrap/cache/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
6
bootstrap/providers.php
Normal file
6
bootstrap/providers.php
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
App\Providers\AppServiceProvider::class,
|
||||||
|
App\Providers\RouteBindingServiceProvider::class,
|
||||||
|
];
|
||||||
99
composer.json
Normal file
99
composer.json
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://getcomposer.org/schema.json",
|
||||||
|
"name": "laravel/laravel",
|
||||||
|
"type": "project",
|
||||||
|
"description": "The skeleton application for the Laravel framework.",
|
||||||
|
"keywords": [
|
||||||
|
"laravel",
|
||||||
|
"framework"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"require": {
|
||||||
|
"php": "^8.4",
|
||||||
|
"ext-xmlreader": "*",
|
||||||
|
"laravel/framework": "^12.0",
|
||||||
|
"laravel/tinker": "^2.10.1",
|
||||||
|
"ramsey/uuid": "^4.9",
|
||||||
|
"tpetry/laravel-postgresql-enhanced": "^3.5",
|
||||||
|
"ext-libxml": "*"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"fakerphp/faker": "^1.23",
|
||||||
|
"laravel/boost": "^2.1",
|
||||||
|
"laravel/pail": "^1.2.2",
|
||||||
|
"laravel/pint": "^1.24",
|
||||||
|
"laravel/sail": "^1.41",
|
||||||
|
"mockery/mockery": "^1.6",
|
||||||
|
"nunomaduro/collision": "^8.6",
|
||||||
|
"phpstan/phpstan": "^2.1",
|
||||||
|
"phpunit/phpunit": "^11.5.3",
|
||||||
|
"slevomat/coding-standard": "^8.27",
|
||||||
|
"squizlabs/php_codesniffer": "^4.0"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "app/",
|
||||||
|
"Database\\Factories\\": "database/factories/",
|
||||||
|
"Database\\Seeders\\": "database/seeders/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"setup": [
|
||||||
|
"composer install",
|
||||||
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||||
|
"@php artisan key:generate",
|
||||||
|
"@php artisan migrate --force",
|
||||||
|
"npm install",
|
||||||
|
"npm run build"
|
||||||
|
],
|
||||||
|
"dev": [
|
||||||
|
"Composer\\Config::disableProcessTimeout",
|
||||||
|
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||||
|
],
|
||||||
|
"test": [
|
||||||
|
"@php artisan config:clear --ansi",
|
||||||
|
"@php artisan test"
|
||||||
|
],
|
||||||
|
"post-autoload-dump": [
|
||||||
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
|
"@php artisan package:discover --ansi"
|
||||||
|
],
|
||||||
|
"post-update-cmd": [
|
||||||
|
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
|
||||||
|
"@php artisan boost:update --ansi"
|
||||||
|
],
|
||||||
|
"post-root-package-install": [
|
||||||
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||||
|
],
|
||||||
|
"post-create-project-cmd": [
|
||||||
|
"@php artisan key:generate --ansi",
|
||||||
|
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||||
|
"@php artisan migrate --graceful --ansi"
|
||||||
|
],
|
||||||
|
"pre-package-uninstall": [
|
||||||
|
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"dont-discover": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"optimize-autoloader": true,
|
||||||
|
"preferred-install": "dist",
|
||||||
|
"sort-packages": true,
|
||||||
|
"allow-plugins": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": true,
|
||||||
|
"pestphp/pest-plugin": true,
|
||||||
|
"php-http/discovery": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true
|
||||||
|
}
|
||||||
9266
composer.lock
generated
Normal file
9266
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
126
config/app.php
Normal file
126
config/app.php
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value is the name of your application, which will be used when the
|
||||||
|
| framework needs to place the application's name in a notification or
|
||||||
|
| other UI elements where an application name needs to be displayed.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'name' => env('APP_NAME', 'Laravel'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Environment
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value determines the "environment" your application is currently
|
||||||
|
| running in. This may determine how you prefer to configure various
|
||||||
|
| services the application utilizes. Set this in your ".env" file.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'env' => env('APP_ENV', 'production'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Debug Mode
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When your application is in debug mode, detailed error messages with
|
||||||
|
| stack traces will be shown on every error that occurs within your
|
||||||
|
| application. If disabled, a simple generic error page is shown.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'debug' => (bool) env('APP_DEBUG', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application URL
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This URL is used by the console to properly generate URLs when using
|
||||||
|
| the Artisan command line tool. You should set this to the root of
|
||||||
|
| the application so that it's available within Artisan commands.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'url' => env('APP_URL', 'http://localhost'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Timezone
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the default timezone for your application, which
|
||||||
|
| will be used by the PHP date and date-time functions. The timezone
|
||||||
|
| is set to "UTC" by default as it is suitable for most use cases.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Locale Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The application locale determines the default locale that will be used
|
||||||
|
| by Laravel's translation / localization methods. This option can be
|
||||||
|
| set to any locale for which you plan to have translation strings.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'locale' => env('APP_LOCALE', 'en'),
|
||||||
|
|
||||||
|
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||||
|
|
||||||
|
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Encryption Key
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This key is utilized by Laravel's encryption services and should be set
|
||||||
|
| to a random, 32 character string to ensure that all encrypted values
|
||||||
|
| are secure. You should do this prior to deploying the application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'cipher' => 'AES-256-CBC',
|
||||||
|
|
||||||
|
'key' => env('APP_KEY'),
|
||||||
|
|
||||||
|
'previous_keys' => [
|
||||||
|
...array_filter(
|
||||||
|
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Maintenance Mode Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These configuration options determine the driver used to determine and
|
||||||
|
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||||
|
| allow maintenance mode to be controlled across multiple machines.
|
||||||
|
|
|
||||||
|
| Supported drivers: "file", "cache"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'maintenance' => [
|
||||||
|
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||||
|
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
93
config/auth.php
Normal file
93
config/auth.php
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Defaults
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option defines the default authentication "guard" and password
|
||||||
|
| reset "broker" for your application. You may change these values
|
||||||
|
| as required, but they're a perfect start for most applications.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'defaults' => [
|
||||||
|
'guard' => null,
|
||||||
|
'passwords' => null,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Guards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Next, you may define every authentication guard for your application.
|
||||||
|
| Of course, a great default configuration has been defined for you
|
||||||
|
| which utilizes session storage plus the Eloquent user provider.
|
||||||
|
|
|
||||||
|
| All authentication guards have a user provider, which defines how the
|
||||||
|
| users are actually retrieved out of your database or other storage
|
||||||
|
| system used by the application. Typically, Eloquent is utilized.
|
||||||
|
|
|
||||||
|
| Supported: "session"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'guards' => [],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| User Providers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| All authentication guards have a user provider, which defines how the
|
||||||
|
| users are actually retrieved out of your database or other storage
|
||||||
|
| system used by the application. Typically, Eloquent is utilized.
|
||||||
|
|
|
||||||
|
| If you have multiple user tables or models you may configure multiple
|
||||||
|
| providers to represent the model / table. These providers may then
|
||||||
|
| be assigned to any extra authentication guards you have defined.
|
||||||
|
|
|
||||||
|
| Supported: "database", "eloquent"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'providers' => [],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Resetting Passwords
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These configuration options specify the behavior of Laravel's password
|
||||||
|
| reset functionality, including the table utilized for token storage
|
||||||
|
| and the user provider that is invoked to actually retrieve users.
|
||||||
|
|
|
||||||
|
| The expiry time is the number of minutes that each reset token will be
|
||||||
|
| considered valid. This security feature keeps tokens short-lived so
|
||||||
|
| they have less time to be guessed. You may change this as needed.
|
||||||
|
|
|
||||||
|
| The throttle setting is the number of seconds a user must wait before
|
||||||
|
| generating more password reset tokens. This prevents the user from
|
||||||
|
| quickly generating a very large amount of password reset tokens.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'passwords' => [],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Password Confirmation Timeout
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define the number of seconds before a password confirmation
|
||||||
|
| window expires and users are asked to re-enter their password via the
|
||||||
|
| confirmation screen. By default, the timeout lasts for three hours.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||||
|
|
||||||
|
];
|
||||||
117
config/cache.php
Normal file
117
config/cache.php
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Cache Store
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default cache store that will be used by the
|
||||||
|
| framework. This connection is utilized if another isn't explicitly
|
||||||
|
| specified when running a cache operation inside the application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('CACHE_STORE', 'database'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cache Stores
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define all of the cache "stores" for your application as
|
||||||
|
| well as their drivers. You may even define multiple stores for the
|
||||||
|
| same cache driver to group types of items stored in your caches.
|
||||||
|
|
|
||||||
|
| Supported drivers: "array", "database", "file", "memcached",
|
||||||
|
| "redis", "dynamodb", "octane",
|
||||||
|
| "failover", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'stores' => [
|
||||||
|
|
||||||
|
'array' => [
|
||||||
|
'driver' => 'array',
|
||||||
|
'serialize' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'database' => [
|
||||||
|
'driver' => 'database',
|
||||||
|
'connection' => env('DB_CACHE_CONNECTION'),
|
||||||
|
'table' => env('DB_CACHE_TABLE', 'cache'),
|
||||||
|
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
|
||||||
|
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'file' => [
|
||||||
|
'driver' => 'file',
|
||||||
|
'path' => storage_path('framework/cache/data'),
|
||||||
|
'lock_path' => storage_path('framework/cache/data'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'memcached' => [
|
||||||
|
'driver' => 'memcached',
|
||||||
|
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||||
|
'sasl' => [
|
||||||
|
env('MEMCACHED_USERNAME'),
|
||||||
|
env('MEMCACHED_PASSWORD'),
|
||||||
|
],
|
||||||
|
'options' => [
|
||||||
|
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||||
|
],
|
||||||
|
'servers' => [
|
||||||
|
[
|
||||||
|
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('MEMCACHED_PORT', 11211),
|
||||||
|
'weight' => 100,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
'driver' => 'redis',
|
||||||
|
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
|
||||||
|
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'dynamodb' => [
|
||||||
|
'driver' => 'dynamodb',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||||
|
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||||
|
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'octane' => [
|
||||||
|
'driver' => 'octane',
|
||||||
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'driver' => 'failover',
|
||||||
|
'stores' => [
|
||||||
|
'database',
|
||||||
|
'array',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cache Key Prefix
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
|
||||||
|
| stores, there might be other applications using the same cache. For
|
||||||
|
| that reason, you may prefix every cache key to avoid collisions.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||||
|
|
||||||
|
];
|
||||||
183
config/database.php
Normal file
183
config/database.php
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Database Connection Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which of the database connections below you wish
|
||||||
|
| to use as your default connection for database operations. This is
|
||||||
|
| the connection which will be utilized unless another connection
|
||||||
|
| is explicitly specified when you execute a query / statement.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Database Connections
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Below are all of the database connections defined for your application.
|
||||||
|
| An example configuration is provided for each database system which
|
||||||
|
| is supported by Laravel. You're free to add / remove connections.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
'sqlite' => [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||||
|
'prefix' => '',
|
||||||
|
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||||
|
'busy_timeout' => null,
|
||||||
|
'journal_mode' => null,
|
||||||
|
'synchronous' => null,
|
||||||
|
'transaction_mode' => 'DEFERRED',
|
||||||
|
],
|
||||||
|
|
||||||
|
'mysql' => [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '3306'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'unix_socket' => env('DB_SOCKET', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||||
|
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
|
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||||
|
]) : [],
|
||||||
|
],
|
||||||
|
|
||||||
|
'mariadb' => [
|
||||||
|
'driver' => 'mariadb',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '3306'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'unix_socket' => env('DB_SOCKET', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||||
|
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
|
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||||
|
]) : [],
|
||||||
|
],
|
||||||
|
|
||||||
|
'pgsql' => [
|
||||||
|
'driver' => 'pgsql',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '5432'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'search_path' => 'public',
|
||||||
|
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'sqlsrv' => [
|
||||||
|
'driver' => 'sqlsrv',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', 'localhost'),
|
||||||
|
'port' => env('DB_PORT', '1433'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||||
|
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Migration Repository Table
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This table keeps track of all the migrations that have already run for
|
||||||
|
| your application. Using this information, we can determine which of
|
||||||
|
| the migrations on disk haven't actually been run on the database.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'migrations' => [
|
||||||
|
'table' => 'migrations',
|
||||||
|
'update_date_on_publish' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Redis Databases
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Redis is an open source, fast, and advanced key-value store that also
|
||||||
|
| provides a richer body of commands than a typical key-value system
|
||||||
|
| such as Memcached. You may define your connection settings here.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
|
||||||
|
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||||
|
|
||||||
|
'options' => [
|
||||||
|
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||||
|
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||||
|
'persistent' => env('REDIS_PERSISTENT', false),
|
||||||
|
],
|
||||||
|
|
||||||
|
'default' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'database' => env('REDIS_DB', '0'),
|
||||||
|
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||||
|
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||||
|
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||||
|
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||||
|
],
|
||||||
|
|
||||||
|
'cache' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'database' => env('REDIS_CACHE_DB', '1'),
|
||||||
|
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||||
|
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||||
|
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||||
|
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
80
config/filesystems.php
Normal file
80
config/filesystems.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Filesystem Disk
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the default filesystem disk that should be used
|
||||||
|
| by the framework. The "local" disk, as well as a variety of cloud
|
||||||
|
| based disks are available to your application for file storage.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Filesystem Disks
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Below you may configure as many filesystem disks as necessary, and you
|
||||||
|
| may even configure multiple disks for the same driver. Examples for
|
||||||
|
| most supported storage drivers are configured here for reference.
|
||||||
|
|
|
||||||
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'disks' => [
|
||||||
|
|
||||||
|
'local' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app/private'),
|
||||||
|
'serve' => true,
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'public' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app/public'),
|
||||||
|
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||||
|
'visibility' => 'public',
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
's3' => [
|
||||||
|
'driver' => 's3',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION'),
|
||||||
|
'bucket' => env('AWS_BUCKET'),
|
||||||
|
'url' => env('AWS_URL'),
|
||||||
|
'endpoint' => env('AWS_ENDPOINT'),
|
||||||
|
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Symbolic Links
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the symbolic links that will be created when the
|
||||||
|
| `storage:link` Artisan command is executed. The array keys should be
|
||||||
|
| the locations of the links and the values should be their targets.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'links' => [
|
||||||
|
public_path('storage') => storage_path('app/public'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
5
config/imports.php
Normal file
5
config/imports.php
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'max_file_size' => 100 * 1024,
|
||||||
|
];
|
||||||
132
config/logging.php
Normal file
132
config/logging.php
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Monolog\Handler\NullHandler;
|
||||||
|
use Monolog\Handler\StreamHandler;
|
||||||
|
use Monolog\Handler\SyslogUdpHandler;
|
||||||
|
use Monolog\Processor\PsrLogMessageProcessor;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Log Channel
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option defines the default log channel that is utilized to write
|
||||||
|
| messages to your logs. The value provided here should match one of
|
||||||
|
| the channels present in the list of "channels" configured below.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('LOG_CHANNEL', 'stack'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Deprecations Log Channel
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the log channel that should be used to log warnings
|
||||||
|
| regarding deprecated PHP and library features. This allows you to get
|
||||||
|
| your application ready for upcoming major versions of dependencies.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'deprecations' => [
|
||||||
|
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||||
|
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Log Channels
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the log channels for your application. Laravel
|
||||||
|
| utilizes the Monolog PHP logging library, which includes a variety
|
||||||
|
| of powerful log handlers and formatters that you're free to use.
|
||||||
|
|
|
||||||
|
| Available drivers: "single", "daily", "slack", "syslog",
|
||||||
|
| "errorlog", "monolog", "custom", "stack"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'channels' => [
|
||||||
|
|
||||||
|
'stack' => [
|
||||||
|
'driver' => 'stack',
|
||||||
|
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||||
|
'ignore_exceptions' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'single' => [
|
||||||
|
'driver' => 'single',
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'daily' => [
|
||||||
|
'driver' => 'daily',
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'days' => env('LOG_DAILY_DAYS', 14),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'slack' => [
|
||||||
|
'driver' => 'slack',
|
||||||
|
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||||
|
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
|
||||||
|
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
|
||||||
|
'level' => env('LOG_LEVEL', 'critical'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'papertrail' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||||
|
'handler_with' => [
|
||||||
|
'host' => env('PAPERTRAIL_URL'),
|
||||||
|
'port' => env('PAPERTRAIL_PORT'),
|
||||||
|
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||||
|
],
|
||||||
|
'processors' => [PsrLogMessageProcessor::class],
|
||||||
|
],
|
||||||
|
|
||||||
|
'stderr' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'handler' => StreamHandler::class,
|
||||||
|
'handler_with' => [
|
||||||
|
'stream' => 'php://stderr',
|
||||||
|
],
|
||||||
|
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||||
|
'processors' => [PsrLogMessageProcessor::class],
|
||||||
|
],
|
||||||
|
|
||||||
|
'syslog' => [
|
||||||
|
'driver' => 'syslog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'errorlog' => [
|
||||||
|
'driver' => 'errorlog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'null' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'handler' => NullHandler::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
'emergency' => [
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
118
config/mail.php
Normal file
118
config/mail.php
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Mailer
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default mailer that is used to send all email
|
||||||
|
| messages unless another mailer is explicitly specified when sending
|
||||||
|
| the message. All additional mailers can be configured within the
|
||||||
|
| "mailers" array. Examples of each type of mailer are provided.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('MAIL_MAILER', 'log'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Mailer Configurations
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure all of the mailers used by your application plus
|
||||||
|
| their respective settings. Several examples have been configured for
|
||||||
|
| you and you are free to add your own as your application requires.
|
||||||
|
|
|
||||||
|
| Laravel supports a variety of mail "transport" drivers that can be used
|
||||||
|
| when delivering an email. You may specify which one you're using for
|
||||||
|
| your mailers below. You may also add additional mailers if needed.
|
||||||
|
|
|
||||||
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
||||||
|
| "postmark", "resend", "log", "array",
|
||||||
|
| "failover", "roundrobin"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'mailers' => [
|
||||||
|
|
||||||
|
'smtp' => [
|
||||||
|
'transport' => 'smtp',
|
||||||
|
'scheme' => env('MAIL_SCHEME'),
|
||||||
|
'url' => env('MAIL_URL'),
|
||||||
|
'host' => env('MAIL_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('MAIL_PORT', 2525),
|
||||||
|
'username' => env('MAIL_USERNAME'),
|
||||||
|
'password' => env('MAIL_PASSWORD'),
|
||||||
|
'timeout' => null,
|
||||||
|
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||||
|
],
|
||||||
|
|
||||||
|
'ses' => [
|
||||||
|
'transport' => 'ses',
|
||||||
|
],
|
||||||
|
|
||||||
|
'postmark' => [
|
||||||
|
'transport' => 'postmark',
|
||||||
|
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
|
||||||
|
// 'client' => [
|
||||||
|
// 'timeout' => 5,
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
|
||||||
|
'resend' => [
|
||||||
|
'transport' => 'resend',
|
||||||
|
],
|
||||||
|
|
||||||
|
'sendmail' => [
|
||||||
|
'transport' => 'sendmail',
|
||||||
|
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'log' => [
|
||||||
|
'transport' => 'log',
|
||||||
|
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'array' => [
|
||||||
|
'transport' => 'array',
|
||||||
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'transport' => 'failover',
|
||||||
|
'mailers' => [
|
||||||
|
'smtp',
|
||||||
|
'log',
|
||||||
|
],
|
||||||
|
'retry_after' => 60,
|
||||||
|
],
|
||||||
|
|
||||||
|
'roundrobin' => [
|
||||||
|
'transport' => 'roundrobin',
|
||||||
|
'mailers' => [
|
||||||
|
'ses',
|
||||||
|
'postmark',
|
||||||
|
],
|
||||||
|
'retry_after' => 60,
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Global "From" Address
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| You may wish for all emails sent by your application to be sent from
|
||||||
|
| the same address. Here you may specify a name and address that is
|
||||||
|
| used globally for all emails that are sent by your application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'from' => [
|
||||||
|
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||||
|
'name' => env('MAIL_FROM_NAME', 'Example'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
129
config/queue.php
Normal file
129
config/queue.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Queue Connection Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Laravel's queue supports a variety of backends via a single, unified
|
||||||
|
| API, giving you convenient access to each backend using identical
|
||||||
|
| syntax for each. The default queue connection is defined below.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('QUEUE_CONNECTION', 'database'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Queue Connections
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the connection options for every queue backend
|
||||||
|
| used by your application. An example configuration is provided for
|
||||||
|
| each backend supported by Laravel. You're also free to add more.
|
||||||
|
|
|
||||||
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
|
||||||
|
| "deferred", "background", "failover", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
'sync' => [
|
||||||
|
'driver' => 'sync',
|
||||||
|
],
|
||||||
|
|
||||||
|
'database' => [
|
||||||
|
'driver' => 'database',
|
||||||
|
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||||
|
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||||
|
'queue' => env('DB_QUEUE', 'default'),
|
||||||
|
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'beanstalkd' => [
|
||||||
|
'driver' => 'beanstalkd',
|
||||||
|
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||||
|
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||||
|
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||||
|
'block_for' => 0,
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'sqs' => [
|
||||||
|
'driver' => 'sqs',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
|
||||||
|
'queue' => env('SQS_QUEUE', 'default'),
|
||||||
|
'suffix' => env('SQS_SUFFIX'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
'driver' => 'redis',
|
||||||
|
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||||
|
'queue' => env('REDIS_QUEUE', 'default'),
|
||||||
|
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||||
|
'block_for' => null,
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'deferred' => [
|
||||||
|
'driver' => 'deferred',
|
||||||
|
],
|
||||||
|
|
||||||
|
'background' => [
|
||||||
|
'driver' => 'background',
|
||||||
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'driver' => 'failover',
|
||||||
|
'connections' => [
|
||||||
|
'database',
|
||||||
|
'deferred',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Job Batching
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following options configure the database and table that store job
|
||||||
|
| batching information. These options can be updated to any database
|
||||||
|
| connection and table which has been defined by your application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'batching' => [
|
||||||
|
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||||
|
'table' => 'job_batches',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Failed Queue Jobs
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These options configure the behavior of failed queue job logging so you
|
||||||
|
| can control how and where failed jobs are stored. Laravel ships with
|
||||||
|
| support for storing failed jobs in a simple file or in a database.
|
||||||
|
|
|
||||||
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'failed' => [
|
||||||
|
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
|
||||||
|
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||||
|
'table' => 'failed_jobs',
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
38
config/services.php
Normal file
38
config/services.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Third Party Services
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This file is for storing the credentials for third party services such
|
||||||
|
| as Mailgun, Postmark, AWS and more. This file provides the de facto
|
||||||
|
| location for this type of information, allowing packages to have
|
||||||
|
| a conventional file to locate the various service credentials.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'postmark' => [
|
||||||
|
'key' => env('POSTMARK_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'resend' => [
|
||||||
|
'key' => env('RESEND_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'ses' => [
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'slack' => [
|
||||||
|
'notifications' => [
|
||||||
|
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
|
||||||
|
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
217
config/session.php
Normal file
217
config/session.php
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Session Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option determines the default session driver that is utilized for
|
||||||
|
| incoming requests. Laravel supports a variety of storage options to
|
||||||
|
| persist session data. Database storage is a great default choice.
|
||||||
|
|
|
||||||
|
| Supported: "file", "cookie", "database", "memcached",
|
||||||
|
| "redis", "dynamodb", "array"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'driver' => env('SESSION_DRIVER', 'database'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Lifetime
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the number of minutes that you wish the session
|
||||||
|
| to be allowed to remain idle before it expires. If you want them
|
||||||
|
| to expire immediately when the browser is closed then you may
|
||||||
|
| indicate that via the expire_on_close configuration option.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'lifetime' => (int) env('SESSION_LIFETIME', 120),
|
||||||
|
|
||||||
|
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Encryption
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option allows you to easily specify that all of your session data
|
||||||
|
| should be encrypted before it's stored. All encryption is performed
|
||||||
|
| automatically by Laravel and you may use the session like normal.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'encrypt' => env('SESSION_ENCRYPT', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session File Location
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When utilizing the "file" session driver, the session files are placed
|
||||||
|
| on disk. The default storage location is defined here; however, you
|
||||||
|
| are free to provide another location where they should be stored.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'files' => storage_path('framework/sessions'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Database Connection
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When using the "database" or "redis" session drivers, you may specify a
|
||||||
|
| connection that should be used to manage these sessions. This should
|
||||||
|
| correspond to a connection in your database configuration options.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connection' => env('SESSION_CONNECTION'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Database Table
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When using the "database" session driver, you may specify the table to
|
||||||
|
| be used to store sessions. Of course, a sensible default is defined
|
||||||
|
| for you; however, you're welcome to change this to another table.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'table' => env('SESSION_TABLE', 'sessions'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cache Store
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When using one of the framework's cache driven session backends, you may
|
||||||
|
| define the cache store which should be used to store the session data
|
||||||
|
| between requests. This must match one of your defined cache stores.
|
||||||
|
|
|
||||||
|
| Affects: "dynamodb", "memcached", "redis"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'store' => env('SESSION_STORE'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Sweeping Lottery
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Some session drivers must manually sweep their storage location to get
|
||||||
|
| rid of old sessions from storage. Here are the chances that it will
|
||||||
|
| happen on a given request. By default, the odds are 2 out of 100.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'lottery' => [2, 100],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cookie Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may change the name of the session cookie that is created by
|
||||||
|
| the framework. Typically, you should not need to change this value
|
||||||
|
| since doing so does not grant a meaningful security improvement.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'cookie' => env(
|
||||||
|
'SESSION_COOKIE',
|
||||||
|
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||||
|
),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cookie Path
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The session cookie path determines the path for which the cookie will
|
||||||
|
| be regarded as available. Typically, this will be the root path of
|
||||||
|
| your application, but you're free to change this when necessary.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'path' => env('SESSION_PATH', '/'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cookie Domain
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value determines the domain and subdomains the session cookie is
|
||||||
|
| available to. By default, the cookie will be available to the root
|
||||||
|
| domain without subdomains. Typically, this shouldn't be changed.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'domain' => env('SESSION_DOMAIN'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| HTTPS Only Cookies
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| By setting this option to true, session cookies will only be sent back
|
||||||
|
| to the server if the browser has a HTTPS connection. This will keep
|
||||||
|
| the cookie from being sent to you when it can't be done securely.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| HTTP Access Only
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Setting this value to true will prevent JavaScript from accessing the
|
||||||
|
| value of the cookie and the cookie will only be accessible through
|
||||||
|
| the HTTP protocol. It's unlikely you should disable this option.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'http_only' => env('SESSION_HTTP_ONLY', true),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Same-Site Cookies
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option determines how your cookies behave when cross-site requests
|
||||||
|
| take place, and can be used to mitigate CSRF attacks. By default, we
|
||||||
|
| will set this value to "lax" to permit secure cross-site requests.
|
||||||
|
|
|
||||||
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
||||||
|
|
|
||||||
|
| Supported: "lax", "strict", "none", null
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'same_site' => env('SESSION_SAME_SITE', 'lax'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Partitioned Cookies
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Setting this value to true will tie the cookie to the top-level site for
|
||||||
|
| a cross-site context. Partitioned cookies are accepted by the browser
|
||||||
|
| when flagged "secure" and the Same-Site attribute is set to "none".
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
|
||||||
|
|
||||||
|
];
|
||||||
1
database/.gitignore
vendored
Normal file
1
database/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.sqlite*
|
||||||
26
database/factories/ContactFactory.php
Normal file
26
database/factories/ContactFactory.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Contact>
|
||||||
|
*/
|
||||||
|
class ContactFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'first_name' => $this->faker->firstName(),
|
||||||
|
'last_name' => $this->faker->lastName(),
|
||||||
|
'email' => $this->faker->unique()->safeEmail(),
|
||||||
|
'id' => $this->faker->uuid(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
database/factories/ContactImportFactory.php
Normal file
31
database/factories/ContactImportFactory.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ContactImport>
|
||||||
|
*/
|
||||||
|
class ContactImportFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->faker->uuid(),
|
||||||
|
'queue_at' => $this->faker->dateTime(),
|
||||||
|
'started_at' => $this->faker->optional()->dateTime(),
|
||||||
|
'finished_at' => $this->faker->optional()->dateTime(),
|
||||||
|
'total_processed' => $this->faker->numberBetween(0, 1000),
|
||||||
|
'errors' => $this->faker->numberBetween(0, 100),
|
||||||
|
'duplicates' => $this->faker->numberBetween(0, 100),
|
||||||
|
'file' => $this->faker->filePath(),
|
||||||
|
'state' => $this->faker->randomElement(['PENDING', 'RUNNING', 'DONE']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('cache', function (Blueprint $table) {
|
||||||
|
$table->string('key')->primary();
|
||||||
|
$table->mediumText('value');
|
||||||
|
$table->integer('expiration')->index();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('cache_locks', function (Blueprint $table) {
|
||||||
|
$table->string('key')->primary();
|
||||||
|
$table->string('owner');
|
||||||
|
$table->integer('expiration')->index();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('cache');
|
||||||
|
Schema::dropIfExists('cache_locks');
|
||||||
|
}
|
||||||
|
};
|
||||||
57
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
57
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('jobs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('queue')->index();
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->unsignedTinyInteger('attempts');
|
||||||
|
$table->unsignedInteger('reserved_at')->nullable();
|
||||||
|
$table->unsignedInteger('available_at');
|
||||||
|
$table->unsignedInteger('created_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('job_batches', function (Blueprint $table) {
|
||||||
|
$table->string('id')->primary();
|
||||||
|
$table->string('name');
|
||||||
|
$table->integer('total_jobs');
|
||||||
|
$table->integer('pending_jobs');
|
||||||
|
$table->integer('failed_jobs');
|
||||||
|
$table->longText('failed_job_ids');
|
||||||
|
$table->mediumText('options')->nullable();
|
||||||
|
$table->integer('cancelled_at')->nullable();
|
||||||
|
$table->integer('created_at');
|
||||||
|
$table->integer('finished_at')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('uuid')->unique();
|
||||||
|
$table->text('connection');
|
||||||
|
$table->text('queue');
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->longText('exception');
|
||||||
|
$table->timestamp('failed_at')->useCurrent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('jobs');
|
||||||
|
Schema::dropIfExists('job_batches');
|
||||||
|
Schema::dropIfExists('failed_jobs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Tpetry\PostgresqlEnhanced\Schema\Blueprint;
|
||||||
|
use Tpetry\PostgresqlEnhanced\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::createFunctionOrReplace(
|
||||||
|
name: 'update_contacts_ts_name',
|
||||||
|
parameters: [],
|
||||||
|
return: 'trigger',
|
||||||
|
language: 'plpgsql',
|
||||||
|
body: <<<'SQL'
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'INSERT'
|
||||||
|
OR NEW.first_name IS DISTINCT FROM OLD.first_name
|
||||||
|
OR NEW.last_name IS DISTINCT FROM OLD.last_name THEN
|
||||||
|
NEW.ts_name =
|
||||||
|
to_tsvector('simple',
|
||||||
|
coalesce(NEW.first_name, '') || ' ' || coalesce(NEW.last_name, '')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
Schema::createExtensionIfNotExists('pg_trgm');
|
||||||
|
|
||||||
|
Schema::create('contacts', function (Blueprint $table) {
|
||||||
|
$table->uuid('id')->primary();
|
||||||
|
$table->string('first_name', 100)->nullable();
|
||||||
|
$table->string('last_name', 100)->nullable();
|
||||||
|
$table->string('email', 254)->unique();
|
||||||
|
$table->tsvector('ts_name');
|
||||||
|
$table->trigger('ts_name_contacts_build_vector', 'update_contacts_ts_name()', 'BEFORE INSERT OR UPDATE')->forEachRow(); // mising indexes (GIN!!! etc.)
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::statement('CREATE INDEX idx_contacts_email_trgm ON contacts USING GIN (email gin_trgm_ops)');
|
||||||
|
DB::statement('CREATE INDEX idx_contacts_ts_name ON contacts USING GIN (ts_name);');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('contacts');
|
||||||
|
Schema::dropFunctionIfExists('update_contacts_ts_name');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('contact_imports', function (Blueprint $table) {
|
||||||
|
$table->uuid('id')->primary();
|
||||||
|
$table->timestamp('queue_at')->default(DB::raw('CURRENT_TIMESTAMP'));
|
||||||
|
$table->timestamp('started_at')->nullable();
|
||||||
|
$table->timestamp('finished_at')->nullable();
|
||||||
|
$table->unsignedInteger('total_processed')->default(0);
|
||||||
|
$table->unsignedInteger('errors')->default(0);
|
||||||
|
$table->unsignedInteger('duplicates')->default(0);
|
||||||
|
$table->string('file');
|
||||||
|
$table->enum('state', ['PENDING', 'RUNNING', 'DONE', 'FAILED'])->default('PENDING');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('contact_imports');
|
||||||
|
}
|
||||||
|
};
|
||||||
19
database/seeders/ContactSeeder.php
Normal file
19
database/seeders/ContactSeeder.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Contact;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class ContactSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
Contact::factory()
|
||||||
|
->count(100)
|
||||||
|
->create();
|
||||||
|
}
|
||||||
|
}
|
||||||
16
database/seeders/DatabaseSeeder.php
Normal file
16
database/seeders/DatabaseSeeder.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class DatabaseSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Seed the application's database.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->call(ContactSeeder::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
develop.sh
Executable file
45
develop.sh
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
set -ex
|
||||||
|
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
cat > docker-compose.override.yml <<EOF
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
build:
|
||||||
|
args:
|
||||||
|
UID: $(id -u)
|
||||||
|
GID: $(id -g)
|
||||||
|
php-fpm:
|
||||||
|
build:
|
||||||
|
args:
|
||||||
|
UID: $(id -u)
|
||||||
|
GID: $(id -g)
|
||||||
|
queue-worker:
|
||||||
|
build:
|
||||||
|
args:
|
||||||
|
UID: $(id -u)
|
||||||
|
GID: $(id -g)
|
||||||
|
nodejs:
|
||||||
|
build:
|
||||||
|
args:
|
||||||
|
UID: $(id -u)
|
||||||
|
GID: $(id -g)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
# composer
|
||||||
|
docker compose run --rm -it php-fpm composer install
|
||||||
|
|
||||||
|
# composer
|
||||||
|
docker compose run --rm -it nodejs npm install
|
||||||
|
|
||||||
|
# database
|
||||||
|
docker compose run --rm -it php-fpm /var/www/html/artisan migrate
|
||||||
|
|
||||||
|
# run containers (and build again)
|
||||||
|
if [ "$NO_EXECUTE" != "1" ]; then
|
||||||
|
docker compose up --build
|
||||||
|
fi
|
||||||
115
docker-compose.yml
Normal file
115
docker-compose.yml
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
x-mapped-user-container: &x-mapped-user-container
|
||||||
|
args:
|
||||||
|
UID: ERR
|
||||||
|
GID: ERR
|
||||||
|
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile-nginx.dockerfile
|
||||||
|
<<: *x-mapped-user-container
|
||||||
|
ports:
|
||||||
|
- "8000:80"
|
||||||
|
volumes:
|
||||||
|
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
|
||||||
|
- ./:/var/www/html/
|
||||||
|
links:
|
||||||
|
- php-fpm
|
||||||
|
php-fpm:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile-fpm.dockerfile
|
||||||
|
<<: *x-mapped-user-container
|
||||||
|
volumes:
|
||||||
|
- ./:/var/www/html/
|
||||||
|
environment:
|
||||||
|
- DB_CONNECTION=pgsql
|
||||||
|
- SESSION_DRIVER=file
|
||||||
|
- APP_DEBUG=true
|
||||||
|
- APP_ENV=local
|
||||||
|
- APP_KEY=base64:yFEh+jTepLsusyVKLmFY3ukDfJrshbB3J6jVzVk1guw=
|
||||||
|
links:
|
||||||
|
- postgresql
|
||||||
|
depends_on:
|
||||||
|
postgresql:
|
||||||
|
condition: service_healthy
|
||||||
|
nodejs:
|
||||||
|
condition: service_started
|
||||||
|
postgresql-test:
|
||||||
|
condition: service_healthy
|
||||||
|
dns:
|
||||||
|
- 8.8.8.8
|
||||||
|
queue-worker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile-fpm.dockerfile
|
||||||
|
<<: *x-mapped-user-container
|
||||||
|
volumes:
|
||||||
|
- ./:/var/www/html/
|
||||||
|
environment:
|
||||||
|
- SESSION_DRIVER=file
|
||||||
|
- APP_DEBUG=true
|
||||||
|
- APP_ENV=local
|
||||||
|
- APP_KEY=base64:yFEh+jTepLsusyVKLmFY3ukDfJrshbB3J6jVzVk1guw=
|
||||||
|
links:
|
||||||
|
- postgresql
|
||||||
|
depends_on:
|
||||||
|
postgresql:
|
||||||
|
condition: service_healthy
|
||||||
|
dns:
|
||||||
|
- 8.8.8.8
|
||||||
|
command:
|
||||||
|
- /usr/local/bin/php
|
||||||
|
- /var/www/html/artisan
|
||||||
|
- queue:work
|
||||||
|
nodejs:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile-nodejs.dockerfile
|
||||||
|
<<: *x-mapped-user-container
|
||||||
|
volumes:
|
||||||
|
- ./:/var/www/html/
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
postgresql:
|
||||||
|
image: docker.io/postgres:18.1
|
||||||
|
environment:
|
||||||
|
- POSTGRES_PASSWORD=laravel
|
||||||
|
- POSTGRES_DB=ecomail
|
||||||
|
- POSTGRES_USER=laravel
|
||||||
|
volumes:
|
||||||
|
- postgresql-data:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"pg_isready -U $POSTGRES_USER -d $POSTGRES_DB -h 127.0.0.1",
|
||||||
|
]
|
||||||
|
start_period: 10s
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 10
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
postgresql-test:
|
||||||
|
image: docker.io/postgres:18.1
|
||||||
|
environment:
|
||||||
|
- POSTGRES_PASSWORD=laravel
|
||||||
|
- POSTGRES_DB=ecomail
|
||||||
|
- POSTGRES_USER=laravel
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"pg_isready -U $POSTGRES_USER -d $POSTGRES_DB -h 127.0.0.1",
|
||||||
|
]
|
||||||
|
start_period: 10s
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 10
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgresql-data:
|
||||||
40
docker/nginx/default.conf
Normal file
40
docker/nginx/default.conf
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
limit_req_zone $binary_remote_addr zone=limiter:10m rate=60r/m;
|
||||||
|
|
||||||
|
server {
|
||||||
|
index index.php index.html;
|
||||||
|
server_name symfony_example_app;
|
||||||
|
error_log stderr debug;
|
||||||
|
access_log stderr;
|
||||||
|
listen 80;
|
||||||
|
root /var/www/html/public;
|
||||||
|
client_max_body_size 100m;
|
||||||
|
|
||||||
|
|
||||||
|
location = /errors/rate-limiting.json {
|
||||||
|
root /var/www/html/public;
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
limit_req zone=limiter burst=60 nodelay;
|
||||||
|
limit_req_status 429;
|
||||||
|
try_files $uri /index.php$is_args$args;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.php$is_args$args;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /build {
|
||||||
|
try_files $uri /$uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 429 /errors/rate-limiting.json;
|
||||||
|
|
||||||
|
|
||||||
|
location ~ ^/.+\.php(/|$) {
|
||||||
|
fastcgi_pass php-fpm:9000;
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
2421
package-lock.json
generated
Normal file
2421
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
Normal file
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://www.schemastore.org/package.json",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"dev": "vite"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"axios": "^1.11.0",
|
||||||
|
"concurrently": "^9.0.1",
|
||||||
|
"laravel-vite-plugin": "^2.0.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"vite": "^7.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
phpcs.xml.dist
Normal file
30
phpcs.xml.dist
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<ruleset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/squizlabs/php_codesniffer/phpcs.xsd">
|
||||||
|
|
||||||
|
<arg name="basepath" value="."/>
|
||||||
|
<arg name="cache" value=".phpcs-cache"/>
|
||||||
|
<arg name="colors"/>
|
||||||
|
<arg name="extensions" value="php"/>
|
||||||
|
|
||||||
|
<config name="installed_paths" value="vendor/slevomat/coding-standard"/>
|
||||||
|
|
||||||
|
<rule ref="SlevomatCodingStandard.TypeHints.DeclareStrictTypes">
|
||||||
|
<properties>
|
||||||
|
<property name="spacesCountAroundEqualsSign" value="0"/>
|
||||||
|
</properties>
|
||||||
|
</rule>
|
||||||
|
|
||||||
|
<rule ref="PSR12"/>
|
||||||
|
<rule ref="Generic.Files.LineLength">
|
||||||
|
<properties>
|
||||||
|
<property name="lineLimit" value="N"/>
|
||||||
|
<property name="absoluteLineLimit" value="M"/>
|
||||||
|
</properties>
|
||||||
|
</rule>
|
||||||
|
|
||||||
|
<file>app/</file>
|
||||||
|
<file>tests/</file>
|
||||||
|
|
||||||
|
</ruleset>
|
||||||
4
phpstan.neon.dist
Normal file
4
phpstan.neon.dist
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
parameters:
|
||||||
|
level: max
|
||||||
|
paths:
|
||||||
|
- app/
|
||||||
40
phpunit.xml
Normal file
40
phpunit.xml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
colors="true"
|
||||||
|
>
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Unit">
|
||||||
|
<directory>tests/Unit</directory>
|
||||||
|
</testsuite>
|
||||||
|
<testsuite name="Feature">
|
||||||
|
<directory>tests/Feature</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory>app</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
<php>
|
||||||
|
<env name="APP_ENV" value="testing" force="true"/>
|
||||||
|
<env name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
|
||||||
|
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
|
||||||
|
<env name="BROADCAST_CONNECTION" value="null" force="true"/>
|
||||||
|
<env name="CACHE_STORE" value="array" force="true"/>
|
||||||
|
<env name="DB_CONNECTION" value="pgsql" force="true"/>
|
||||||
|
<env name="DB_URL" value="pgsql://laravel:laravel@postgresql-test:5432/ecomail" force="true"/>
|
||||||
|
<env name="DB_HOST" value="postgresql-test" force="true"/>
|
||||||
|
<env name="DB_PORT" value="5432" force="true"/>
|
||||||
|
<env name="DB_DATABASE" value="ecomail" force="true"/>
|
||||||
|
<env name="DB_USERNAME" value="laravel" force="true"/>
|
||||||
|
<env name="DB_PASSWORD" value="laravel" force="true"/>
|
||||||
|
<env name="MAIL_MAILER" value="array" force="true"/>
|
||||||
|
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
|
||||||
|
<env name="SESSION_DRIVER" value="array" force="true"/>
|
||||||
|
<env name="PULSE_ENABLED" value="false" force="true"/>
|
||||||
|
<env name="TELESCOPE_ENABLED" value="false" force="true"/>
|
||||||
|
<env name="NIGHTWATCH_ENABLED" value="false" force="true"/>
|
||||||
|
</php>
|
||||||
|
</phpunit>
|
||||||
6
pint.json
Normal file
6
pint.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"preset": "laravel",
|
||||||
|
"rules": {
|
||||||
|
"php_unit_method_casing": false
|
||||||
|
}
|
||||||
|
}
|
||||||
25
public/.htaccess
Normal file
25
public/.htaccess
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
<IfModule mod_negotiation.c>
|
||||||
|
Options -MultiViews -Indexes
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# Handle Authorization Header
|
||||||
|
RewriteCond %{HTTP:Authorization} .
|
||||||
|
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||||
|
|
||||||
|
# Handle X-XSRF-Token Header
|
||||||
|
RewriteCond %{HTTP:x-xsrf-token} .
|
||||||
|
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
|
||||||
|
|
||||||
|
# Redirect Trailing Slashes If Not A Folder...
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteCond %{REQUEST_URI} (.+)/$
|
||||||
|
RewriteRule ^ %1 [L,R=301]
|
||||||
|
|
||||||
|
# Send Requests To Front Controller...
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteRule ^ index.php [L]
|
||||||
|
</IfModule>
|
||||||
0
public/favicon.ico
Normal file
0
public/favicon.ico
Normal file
20
public/index.php
Normal file
20
public/index.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
define('LARAVEL_START', microtime(true));
|
||||||
|
|
||||||
|
// Determine if the application is in maintenance mode...
|
||||||
|
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
||||||
|
require $maintenance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the Composer autoloader...
|
||||||
|
require __DIR__.'/../vendor/autoload.php';
|
||||||
|
|
||||||
|
// Bootstrap Laravel and handle the request...
|
||||||
|
/** @var Application $app */
|
||||||
|
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||||
|
|
||||||
|
$app->handleRequest(Request::capture());
|
||||||
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
11
resources/css/app.css
Normal file
11
resources/css/app.css
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||||
|
@source '../../storage/framework/views/*.php';
|
||||||
|
@source '../**/*.blade.php';
|
||||||
|
@source '../**/*.js';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||||
|
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
|
}
|
||||||
1
resources/js/app.js
Normal file
1
resources/js/app.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import './bootstrap';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user