initial commit

This commit is contained in:
2026-02-11 23:37:50 +01:00
commit 908cf42ad9
128 changed files with 17831 additions and 0 deletions

18
.editorconfig Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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/

View 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

View 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
View File

65
README.md Normal file
View 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
---

View 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,
];
}
}

View 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
View 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']);
}
}
}

View 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,
) {
}
}

View 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,
) {
}
}

View 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,
) {
}
}

View File

@@ -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,
) {
}
}

View 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
);
}
}

View 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,
) {
}
}

View 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,
) {
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Data\Intent\ProcessImport;
class ImportContactsIntent
{
public function __construct(
public readonly string $path
) {
}
}

View 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,
) {
}
}

View 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);
}
}

View 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';
}

View 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,
]);
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View 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());
}
}

View 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.',
];
}
}

View 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,
);
}
}

View 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
View 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 doesnt 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
View 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',
];
}
}

View 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,
];
}
}

View 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);
});
}
}

View 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;
});
}
}

View 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,
);
}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}

View 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,
);
}
}

View 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,
];
}
}

View 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,
];
}
}

View File

@@ -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);
}
}

View 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,
]
);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Services\ProcessImportMonitor;
use Ramsey\Uuid\UuidInterface;
interface ProcessImportIdentifiable
{
public UuidInterface $uuid { get; }
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Services\ProcessImportMonitor;
interface ProcessImportMonitor
{
public function newImport(string $file): ProcessImportState&ProcessImportIdentifiable;
}

View 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;
}

View 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);
}
}

View 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(),
);
}
}

View 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);
}
}

View 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;
}

View 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;
}

View 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());
}
}

View 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());
}
}

View 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;
}

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
*
!.gitignore

6
bootstrap/providers.php Normal file
View File

@@ -0,0 +1,6 @@
<?php
return [
App\Providers\AppServiceProvider::class,
App\Providers\RouteBindingServiceProvider::class,
];

99
composer.json Normal file
View 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

File diff suppressed because it is too large Load Diff

126
config/app.php Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
<?php
return [
'max_file_size' => 100 * 1024,
];

132
config/logging.php Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
*.sqlite*

View 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(),
];
}
}

View 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']),
];
}
}

View 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');
}
};

View 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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View 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();
}
}

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

17
package.json Normal file
View 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
View 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
View File

@@ -0,0 +1,4 @@
parameters:
level: max
paths:
- app/

40
phpunit.xml Normal file
View 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
View File

@@ -0,0 +1,6 @@
{
"preset": "laravel",
"rules": {
"php_unit_method_casing": false
}
}

25
public/.htaccess Normal file
View 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
View File

20
public/index.php Normal file
View 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
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow:

11
resources/css/app.css Normal file
View 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
View File

@@ -0,0 +1 @@
import './bootstrap';

Some files were not shown because too many files have changed in this diff Show More