initial commit
This commit is contained in:
37
app/Data/Api/ContactImport.php
Normal file
37
app/Data/Api/ContactImport.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Api;
|
||||
|
||||
final class ContactImport
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $id,
|
||||
public readonly string $queueAt,
|
||||
public readonly ?string $startedAt,
|
||||
public readonly ?string $finishedAt,
|
||||
public readonly int $totalProcessed,
|
||||
public readonly int $errors,
|
||||
public readonly int $duplicates,
|
||||
public readonly string $state,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'queue_at' => $this->queueAt,
|
||||
'started_at' => $this->startedAt,
|
||||
'finished_at' => $this->finishedAt,
|
||||
'total_processed' => $this->totalProcessed,
|
||||
'errors' => $this->errors,
|
||||
'duplicates' => $this->duplicates,
|
||||
'state' => $this->state,
|
||||
];
|
||||
}
|
||||
}
|
||||
15
app/Data/Command/BatchResult.php
Normal file
15
app/Data/Command/BatchResult.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Command;
|
||||
|
||||
class BatchResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $fails,
|
||||
public readonly int $duplicates,
|
||||
public readonly int $success,
|
||||
) {
|
||||
}
|
||||
}
|
||||
38
app/Data/Contact.php
Normal file
38
app/Data/Contact.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data;
|
||||
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class Contact
|
||||
{
|
||||
public string $email;
|
||||
|
||||
public function __construct(
|
||||
public UuidInterface $uuid,
|
||||
string $email,
|
||||
public ?string $firstName,
|
||||
public ?string $lastName,
|
||||
) {
|
||||
$this->email = strtolower($email);
|
||||
|
||||
$validator = Validator::make(['email' => $this->email], [
|
||||
'email' => ['required', 'email:rfc', 'max:254'],
|
||||
]);
|
||||
|
||||
if ($this->firstName !== null && strlen($this->firstName) > 100) {
|
||||
throw new InvariantException('First name cannot be longer than 100 characters', ['first_name']);
|
||||
}
|
||||
|
||||
if ($this->lastName !== null && strlen($this->lastName) > 100) {
|
||||
throw new InvariantException('Last name cannot be longer than 100 characters', ['last_name']);
|
||||
}
|
||||
|
||||
if ($validator->fails()) {
|
||||
throw new InvariantException($validator->errors()->first('email'), ['email']);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
app/Data/ContactImport.php
Normal file
23
app/Data/ContactImport.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
class ContactImport
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $id,
|
||||
public readonly Carbon $queueAt,
|
||||
public readonly ?Carbon $startedAt,
|
||||
public readonly ?Carbon $finishedAt,
|
||||
public readonly int $totalProcessed,
|
||||
public readonly int $errors,
|
||||
public readonly int $duplicates,
|
||||
public readonly string $state,
|
||||
public readonly ?string $file,
|
||||
) {
|
||||
}
|
||||
}
|
||||
15
app/Data/Intent/Contact/CreateContactIntent.php
Normal file
15
app/Data/Intent/Contact/CreateContactIntent.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Intent\Contact;
|
||||
|
||||
final class CreateContactIntent
|
||||
{
|
||||
public function __construct(
|
||||
public string $email,
|
||||
public ?string $firstName,
|
||||
public ?string $lastName,
|
||||
) {
|
||||
}
|
||||
}
|
||||
15
app/Data/Intent/Contact/DeleteContactIntent.php
Normal file
15
app/Data/Intent/Contact/DeleteContactIntent.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Intent\Contact;
|
||||
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final class DeleteContactIntent
|
||||
{
|
||||
public function __construct(
|
||||
public UuidInterface $uuid,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Intent\Contact\ProcessImport;
|
||||
|
||||
final class ImportContactIntent
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $email = null,
|
||||
public ?string $firstName = null,
|
||||
public ?string $lastName = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
27
app/Data/Intent/Contact/SearchContactIntent.php
Normal file
27
app/Data/Intent/Contact/SearchContactIntent.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Intent\Contact;
|
||||
|
||||
final class SearchContactIntent
|
||||
{
|
||||
private const QUERY_ALL = '*';
|
||||
|
||||
public readonly string $query;
|
||||
public readonly int $resultsPerPage;
|
||||
|
||||
public function __construct(
|
||||
string $query,
|
||||
) {
|
||||
$this->query = strtolower($query);
|
||||
$this->resultsPerPage = 20;
|
||||
}
|
||||
|
||||
public static function queryAll(): SearchContactIntent
|
||||
{
|
||||
return new SearchContactIntent(
|
||||
self::QUERY_ALL
|
||||
);
|
||||
}
|
||||
}
|
||||
15
app/Data/Intent/Contact/SearchContactIntentByUuid.php
Normal file
15
app/Data/Intent/Contact/SearchContactIntentByUuid.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Intent\Contact;
|
||||
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
class SearchContactIntentByUuid
|
||||
{
|
||||
public function __construct(
|
||||
public readonly UuidInterface $uuid,
|
||||
) {
|
||||
}
|
||||
}
|
||||
18
app/Data/Intent/Contact/UpdateContactIntent.php
Normal file
18
app/Data/Intent/Contact/UpdateContactIntent.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Intent\Contact;
|
||||
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final class UpdateContactIntent
|
||||
{
|
||||
public function __construct(
|
||||
public UuidInterface $uuid,
|
||||
public string $email,
|
||||
public ?string $firstName,
|
||||
public ?string $lastName,
|
||||
) {
|
||||
}
|
||||
}
|
||||
13
app/Data/Intent/ProcessImport/ImportContactsIntent.php
Normal file
13
app/Data/Intent/ProcessImport/ImportContactsIntent.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Intent\ProcessImport;
|
||||
|
||||
class ImportContactsIntent
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $path
|
||||
) {
|
||||
}
|
||||
}
|
||||
15
app/Data/Intent/ProcessImport/SearchImportContactsIntent.php
Normal file
15
app/Data/Intent/ProcessImport/SearchImportContactsIntent.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Intent\ProcessImport;
|
||||
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
class SearchImportContactsIntent
|
||||
{
|
||||
public function __construct(
|
||||
public readonly UuidInterface $uuid,
|
||||
) {
|
||||
}
|
||||
}
|
||||
18
app/Data/InvariantException.php
Normal file
18
app/Data/InvariantException.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data;
|
||||
|
||||
use Throwable;
|
||||
|
||||
class InvariantException extends \InvalidArgumentException
|
||||
{
|
||||
/**
|
||||
* @param array<string> $fields
|
||||
*/
|
||||
public function __construct(string $message, public readonly array $fields, int $code = 0, ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
13
app/Enums/ContactImportStateEnum.php
Normal file
13
app/Enums/ContactImportStateEnum.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ContactImportStateEnum: string
|
||||
{
|
||||
case Running = 'RUNNING';
|
||||
case Done = 'DONE';
|
||||
case Pending = 'PENDING';
|
||||
case Fail = 'FAILED';
|
||||
}
|
||||
203
app/Http/Controllers/ContactController.php
Normal file
203
app/Http/Controllers/ContactController.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Data\Contact;
|
||||
use App\Data\Intent\Contact\DeleteContactIntent;
|
||||
use App\Data\Intent\Contact\SearchContactIntent;
|
||||
use App\Data\InvariantException;
|
||||
use App\Http\Requests\StoreContactRequest;
|
||||
use App\Http\Requests\UpdateContactRequest;
|
||||
use App\Services\Command\CreateContactCommand;
|
||||
use App\Services\Command\DeleteContactCommand;
|
||||
use App\Services\Command\UpdateContactCommand;
|
||||
use App\Services\Query\SearchContactQuery;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ContactController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UpdateContactCommand $updateContactCommand,
|
||||
private readonly DeleteContactCommand $deleteContactCommand,
|
||||
private readonly CreateContactCommand $createContactCommand,
|
||||
private readonly SearchContactQuery $searchContactQuery,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$contacts = $this->searchContactQuery->execute(SearchContactIntent::queryAll());
|
||||
|
||||
return view('contacts.index', [
|
||||
'contacts' => $contacts,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('contacts.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(StoreContactRequest $request): RedirectResponse
|
||||
{
|
||||
$query = $request->query('q');
|
||||
$searchQueryParams = is_string($query) && $query !== '' ? ['q' => $query] : [];
|
||||
try {
|
||||
$contact = $this->createContactCommand->execute($request->toIntent());
|
||||
} catch (InvariantException $exception) {
|
||||
Log::error(
|
||||
'Cannot store contact: {message}',
|
||||
['message' => $exception->getMessage()]
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('contacts.index', $searchQueryParams)
|
||||
->with('status', 'Contact create failed.');
|
||||
}
|
||||
|
||||
if ($contact === null) {
|
||||
Log::error(
|
||||
'Cannot store contact: {message}',
|
||||
['intent' => $request->toIntent()]
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('contacts.index', $searchQueryParams)
|
||||
->with('status', 'Contact create failed.');
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('contacts.show', ['contact' => $contact->uuid->toString(), ...$searchQueryParams])
|
||||
->with('status', 'Contact created.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(Contact $contact): View
|
||||
{
|
||||
return view('contacts.show', [
|
||||
'contact' => $contact,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*/
|
||||
public function edit(Contact $contact): View
|
||||
{
|
||||
return view('contacts.edit', [
|
||||
'contact' => $contact,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(UpdateContactRequest $request): RedirectResponse
|
||||
{
|
||||
$intent = $request->toIntent();
|
||||
$query = $request->query('q');
|
||||
$searchQueryParams = is_string($query) && $query !== '' ? ['q' => $query] : [];
|
||||
|
||||
try {
|
||||
$contact = $this->updateContactCommand->execute($intent);
|
||||
} catch (InvariantException $exception) {
|
||||
Log::error(
|
||||
'Cannot update contact: {message}',
|
||||
['message' => $exception->getMessage()]
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('contacts.index', $searchQueryParams)
|
||||
->with('status', 'Contact update failed.');
|
||||
}
|
||||
|
||||
if ($contact === null) {
|
||||
Log::error("Failed to update contact '{uuid}'", [
|
||||
'uuid' => $intent->uuid,
|
||||
'intent' => $intent,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('contacts.show', ['contact' => $intent->uuid, ...$searchQueryParams])
|
||||
->with('status', 'Contact update failed.');
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('contacts.show', ['contact' => $contact->uuid->toString(), ...$searchQueryParams])
|
||||
->with('status', 'Contact updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
$route = $request->route('contact');
|
||||
if ($route === null) {
|
||||
abort(404);
|
||||
}
|
||||
/** @var Contact $route */
|
||||
$uuid = $route->uuid;
|
||||
$query = $request->query('q');
|
||||
$searchQueryParams = is_string($query) && $query !== '' ? ['q' => $query] : [];
|
||||
|
||||
$result = $this->deleteContactCommand->execute(
|
||||
new DeleteContactIntent($uuid)
|
||||
);
|
||||
|
||||
if ($result === false) {
|
||||
Log::error('Failed to delete contact {uuid}.', [
|
||||
'uuid' => $uuid,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('contacts.show', ['contact' => $uuid, ...$searchQueryParams])
|
||||
->with('status', 'Contact can\'t be deleted.');
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('contacts.index', $searchQueryParams)
|
||||
->with('status', 'Contact deleted.');
|
||||
}
|
||||
|
||||
public function search(Request $request): View
|
||||
{
|
||||
$q = $request->query('q');
|
||||
|
||||
if ($q === null) {
|
||||
Log::warning('No search query provided.');
|
||||
$searchIntent = SearchContactIntent::queryAll();
|
||||
} else {
|
||||
if (is_string($q)) {
|
||||
$searchIntent = new SearchContactIntent($q);
|
||||
} else {
|
||||
abort(400);
|
||||
}
|
||||
}
|
||||
|
||||
$contacts = $this->searchContactQuery->execute($searchIntent);
|
||||
if ($request->filled('q')) {
|
||||
$contacts->appends(['q' => $request->query('q')]);
|
||||
}
|
||||
|
||||
return view('contacts.index', [
|
||||
'contacts' => $contacts,
|
||||
]);
|
||||
}
|
||||
}
|
||||
10
app/Http/Controllers/Controller.php
Normal file
10
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
60
app/Http/Controllers/ImportController.php
Normal file
60
app/Http/Controllers/ImportController.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Data\ContactImport;
|
||||
use App\Data\Intent\ProcessImport\ImportContactsIntent;
|
||||
use App\Http\Requests\ImportContactsRequest;
|
||||
use App\Services\Command\ImportContactsCommand;
|
||||
use App\Services\Mapper\Api\ContactImportMapper;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class ImportController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ImportContactsCommand $command,
|
||||
private readonly ContactImportMapper $apiMapper,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
return view('import.show', [
|
||||
'importUuid' => session('importUuid'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(ImportContactsRequest $request): RedirectResponse
|
||||
{
|
||||
$file = $request->file('file');
|
||||
if (! $file instanceof UploadedFile) {
|
||||
abort(422, 'Missing import file.');
|
||||
}
|
||||
|
||||
$path = $file->store('imports');
|
||||
if ($path === false) {
|
||||
abort(500, 'Failed to store import file.');
|
||||
}
|
||||
|
||||
$import = new ImportContactsIntent(
|
||||
$path
|
||||
);
|
||||
|
||||
$process = $this->command->execute($import);
|
||||
|
||||
return redirect()
|
||||
->route('import.index')
|
||||
->with('status', 'Import queued.')
|
||||
->with('importUuid', $process->uuid);
|
||||
}
|
||||
|
||||
public function show(ContactImport $contactImport): JsonResponse
|
||||
{
|
||||
return response()->json($this->apiMapper->fromModel($contactImport)->toArray());
|
||||
}
|
||||
}
|
||||
47
app/Http/Requests/ImportContactsRequest.php
Normal file
47
app/Http/Requests/ImportContactsRequest.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ImportContactsRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$fileSize = config('imports.max_file_size');
|
||||
if (!is_numeric($fileSize)) {
|
||||
throw new \InvalidArgumentException("imports.max_file_size config value must be number");
|
||||
}
|
||||
return [
|
||||
'file' => ['required', 'file', 'mimes:xml', 'max:' . $fileSize],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'file.required' => 'File is required.',
|
||||
'file.file' => 'File must be a valid upload.',
|
||||
'file.mimes' => 'File must be an XML file.',
|
||||
'file.max' => 'File may not be greater than 100 MB.',
|
||||
];
|
||||
}
|
||||
}
|
||||
60
app/Http/Requests/StoreContactRequest.php
Normal file
60
app/Http/Requests/StoreContactRequest.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Data\Intent\Contact\CreateContactIntent;
|
||||
use App\Services\Validators\ContactValidator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreContactRequest extends FormRequest
|
||||
{
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$email = $this->input('email');
|
||||
if (is_string($email)) {
|
||||
$this->merge([
|
||||
'email' => strtolower(trim($email)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return app(ContactValidator::class)->rulesForStore();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return app(ContactValidator::class)->messages();
|
||||
}
|
||||
|
||||
public function toIntent(): CreateContactIntent
|
||||
{
|
||||
$data = $this->validated();
|
||||
/** @var array{email: string, first_name?: string|null, last_name?: string|null} $data */
|
||||
|
||||
return new CreateContactIntent(
|
||||
$data['email'],
|
||||
$data['first_name'] ?? null,
|
||||
$data['last_name'] ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
75
app/Http/Requests/UpdateContactRequest.php
Normal file
75
app/Http/Requests/UpdateContactRequest.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Data\Contact as ContactData;
|
||||
use App\Data\Intent\Contact\UpdateContactIntent;
|
||||
use App\Services\Validators\ContactValidator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateContactRequest extends FormRequest
|
||||
{
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$email = $this->input('email');
|
||||
if (is_string($email)) {
|
||||
$this->merge([
|
||||
'email' => strtolower(trim($email)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$contact = $this->resolveContact();
|
||||
|
||||
return app(ContactValidator::class)->rulesForUpdate($contact);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return app(ContactValidator::class)->messages();
|
||||
}
|
||||
|
||||
public function toIntent(): UpdateContactIntent
|
||||
{
|
||||
$data = $this->validated();
|
||||
/** @var array{email: string, first_name?: string|null, last_name?: string|null} $data */
|
||||
$contact = $this->resolveContact();
|
||||
|
||||
return new UpdateContactIntent(
|
||||
$contact->uuid,
|
||||
$data['email'],
|
||||
$data['first_name'] ?? null,
|
||||
$data['last_name'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveContact(): ContactData
|
||||
{
|
||||
$routeContact = $this->route('contact');
|
||||
if ($routeContact instanceof ContactData) {
|
||||
return $routeContact;
|
||||
}
|
||||
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
275
app/Jobs/ProcessImport.php
Normal file
275
app/Jobs/ProcessImport.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Data\Intent\Contact\CreateContactIntent;
|
||||
use App\Data\Intent\Contact\ProcessImport\ImportContactIntent;
|
||||
use App\Services\Command\CreateContactCommand;
|
||||
use App\Services\ProcessImportMonitor\ProcessImportState;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use XMLReader;
|
||||
|
||||
class ProcessImport implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
private const STATE_DATA = 1 << 0;
|
||||
|
||||
private const STATE_ITEM = 1 << 1;
|
||||
|
||||
private const MAX_BATCH_SIZE = 5000;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $file,
|
||||
private readonly ProcessImportState $processImportState,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(
|
||||
CreateContactCommand $createContactCommand,
|
||||
): void {
|
||||
$prev = libxml_use_internal_errors(true);
|
||||
libxml_clear_errors();
|
||||
|
||||
try {
|
||||
$this->runImport($createContactCommand);
|
||||
} catch (\Exception $e) {
|
||||
$this->processImportState->fail();
|
||||
throw $e;
|
||||
} finally {
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($prev);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
private function runImport(
|
||||
CreateContactCommand $createContactCommand,
|
||||
): void {
|
||||
$importPath = Storage::disk('local')->path($this->file);
|
||||
$reader = \XMLReader::open($importPath);
|
||||
Log::debug('job {uuid} - start', [
|
||||
'uuid' => $this->processImportState->uuid()->toString(),
|
||||
]);
|
||||
if ($reader === false) {
|
||||
Log::error('job {uuid} - cannot create reader {file}', [
|
||||
'uuid' => $this->processImportState->uuid()->toString(),
|
||||
'file' => $importPath,
|
||||
]);
|
||||
|
||||
$this->processImportState->fail();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Yeah, I could use XSD validation -- but would that really help if the XML is only formally correct?
|
||||
// The CS requirements doesn’t mention it, so I chose the ‘import whatever we can, no matter what’ approach.
|
||||
|
||||
$elementDepth = 0;
|
||||
$actualState = 0;
|
||||
$actualBatch = [];
|
||||
|
||||
$actualContact = null;
|
||||
$activeField = null;
|
||||
|
||||
$this->processImportState->start();
|
||||
|
||||
while ($reader->read()) {
|
||||
switch ($reader->nodeType) {
|
||||
case XMLReader::ELEMENT:
|
||||
$activeField = null;
|
||||
$elementDepth++;
|
||||
if (
|
||||
$elementDepth === 1 && $reader->name === 'data' // data
|
||||
) {
|
||||
$actualState |= self::STATE_DATA;
|
||||
} elseif (
|
||||
$elementDepth === 2 && $reader->name == 'item' // data/item
|
||||
&& ($actualState & self::STATE_DATA) === self::STATE_DATA // we must be in data
|
||||
) {
|
||||
$actualContact = new ImportContactIntent();
|
||||
$actualState |= self::STATE_ITEM;
|
||||
libxml_clear_errors();
|
||||
} elseif (
|
||||
$elementDepth === 3 // data/item/element
|
||||
&& ($actualState & self::STATE_ITEM) === self::STATE_ITEM // We must be in item
|
||||
&& $actualContact !== null // This never happen, but OK
|
||||
) {
|
||||
switch ($reader->name) {
|
||||
case 'first_name':
|
||||
$activeField = 'first_name';
|
||||
break;
|
||||
case 'last_name':
|
||||
$activeField = 'last_name';
|
||||
break;
|
||||
case 'email':
|
||||
$activeField = 'email';
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case XMLReader::TEXT:
|
||||
case XMLReader::CDATA:
|
||||
if ($activeField !== null && $actualContact !== null) {
|
||||
switch ($activeField) {
|
||||
case 'first_name':
|
||||
$value = $reader->value;
|
||||
if ($actualContact->firstName !== null) { // Handles multiple text nodes
|
||||
$value = $actualContact->firstName . $value;
|
||||
}
|
||||
$actualContact->firstName = $value;
|
||||
break;
|
||||
case 'last_name':
|
||||
$value = $reader->value;
|
||||
if ($actualContact->lastName !== null) { // Handles multiple text nodes
|
||||
$value = $actualContact->lastName . $value;
|
||||
}
|
||||
$actualContact->lastName = $value;
|
||||
break;
|
||||
case 'email':
|
||||
$value = $reader->value;
|
||||
if ($actualContact->email !== null) { // Handles multiple text nodes
|
||||
$value = $actualContact->email . $value;
|
||||
}
|
||||
$actualContact->email = $value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case XMLReader::END_ELEMENT:
|
||||
$error = libxml_get_last_error();
|
||||
if ($elementDepth === 1 && $reader->name === 'data') {
|
||||
$actualState &= ~self::STATE_DATA;
|
||||
}
|
||||
if ($elementDepth === 2 && $reader->name === 'item') {
|
||||
$itemHasError = false;
|
||||
if ($error !== false) {
|
||||
$itemHasError = true;
|
||||
Log::error(
|
||||
'job {uuid} - importing email failed',
|
||||
[
|
||||
'uuid' => $this->processImportState->uuid()->toString(),
|
||||
]
|
||||
);
|
||||
libxml_clear_errors();
|
||||
}
|
||||
if (! $itemHasError) {
|
||||
Log::debug('job {uuid} - importing {email}', [
|
||||
'uuid' => $this->processImportState->uuid()->toString(),
|
||||
'email' => $actualContact?->email,
|
||||
]);
|
||||
try {
|
||||
if ($actualContact === null) {
|
||||
Log::error(
|
||||
'job {uuid} - importing email failed',
|
||||
[
|
||||
'uuid' => $this->processImportState->uuid()->toString(),
|
||||
]
|
||||
);
|
||||
$itemHasError = true;
|
||||
}
|
||||
|
||||
if (! $itemHasError && $actualContact->email === null) {
|
||||
$itemHasError = true;
|
||||
}
|
||||
|
||||
if (! $itemHasError) {
|
||||
// Omg, PHPStan - this should never happen
|
||||
$email = $actualContact?->email;
|
||||
if ($email === null || $actualContact === null) {
|
||||
$itemHasError = true;
|
||||
} else {
|
||||
$intent = new CreateContactIntent(
|
||||
$email,
|
||||
$actualContact->firstName,
|
||||
$actualContact->lastName,
|
||||
);
|
||||
|
||||
$actualBatch[] = $intent;
|
||||
if (count($actualBatch) >= self::MAX_BATCH_SIZE) {
|
||||
$this->storeBatch($createContactCommand, $actualBatch);
|
||||
$actualBatch = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
$itemHasError = true;
|
||||
Log::debug(
|
||||
'job {uuid} - importing email {email} failed - validation errors',
|
||||
[
|
||||
'email' => $actualContact?->email,
|
||||
'exception' => $exception,
|
||||
]
|
||||
);
|
||||
} catch (\Exception $exception) {
|
||||
$itemHasError = true;
|
||||
$this->processImportState->contactFail(count($actualBatch));
|
||||
Log::error(
|
||||
'job {uuid} - importing email {email} failed critically. skipping batch',
|
||||
[
|
||||
'exception' => $exception,
|
||||
]
|
||||
);
|
||||
$actualBatch = [];
|
||||
}
|
||||
}
|
||||
|
||||
if ($itemHasError) {
|
||||
$this->processImportState->contactFail();
|
||||
}
|
||||
|
||||
$actualState &= ~self::STATE_ITEM;
|
||||
$activeField = null;
|
||||
$actualContact = null;
|
||||
}
|
||||
|
||||
$elementDepth--;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$this->storeBatch($createContactCommand, $actualBatch);
|
||||
} catch (\Exception $exception) {
|
||||
Log::error(
|
||||
'job {uuid} - importing email {email} failed critically. skipping batch',
|
||||
[
|
||||
'email' => $actualContact?->email,
|
||||
'exception' => $exception,
|
||||
]
|
||||
);
|
||||
$this->processImportState->contactFail(count($actualBatch));
|
||||
}
|
||||
|
||||
Log::debug('job {uuid} - finish', [
|
||||
'uuid' => $this->processImportState->uuid()->toString(),
|
||||
]);
|
||||
|
||||
$this->processImportState->finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<CreateContactIntent> $actualBatch
|
||||
*/
|
||||
private function storeBatch(CreateContactCommand $createContactCommand, array $actualBatch): void
|
||||
{
|
||||
Log::info('job {uuid} - storing batch', [
|
||||
'uuid' => $this->processImportState->uuid()->toString(),
|
||||
]);
|
||||
|
||||
$multipleUpdateResult = $createContactCommand->multipleExecute($actualBatch);
|
||||
$this->processImportState->contactDuplicate($multipleUpdateResult->duplicates);
|
||||
$this->processImportState->contactSuccess($multipleUpdateResult->success);
|
||||
$this->processImportState->contactFail($multipleUpdateResult->fails);
|
||||
}
|
||||
}
|
||||
42
app/Models/Contact.php
Normal file
42
app/Models/Contact.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Contact extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\ContactFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'id',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'email',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'string',
|
||||
];
|
||||
}
|
||||
}
|
||||
61
app/Models/ContactImport.php
Normal file
61
app/Models/ContactImport.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ContactImportStateEnum;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property \Illuminate\Support\Carbon $queue_at
|
||||
* @property \Illuminate\Support\Carbon|null $started_at
|
||||
* @property \Illuminate\Support\Carbon|null $finished_at
|
||||
* @property int $total_processed
|
||||
* @property int $errors
|
||||
* @property int $duplicates
|
||||
* @property string $file
|
||||
* @property \App\Enums\ContactImportStateEnum $state
|
||||
*/
|
||||
class ContactImport extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\ContactImportFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'id',
|
||||
'queue_at',
|
||||
'started_at',
|
||||
'finished_at',
|
||||
'total_processed',
|
||||
'errors',
|
||||
'duplicates',
|
||||
'state',
|
||||
'file',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'string',
|
||||
'queue_at' => 'datetime',
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
'state' => ContactImportStateEnum::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
57
app/Providers/AppServiceProvider.php
Normal file
57
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\ProcessImportMonitor\DatabaseProcessImportMonitor;
|
||||
use App\Services\ProcessImportMonitor\ProcessImportMonitor;
|
||||
use App\Services\SearchProvider\DatabaseSearchProvider;
|
||||
use App\Services\SearchProvider\SearchProvider;
|
||||
use App\Services\Storage\ContactStorageProvider;
|
||||
use App\Services\Storage\DatabaseContactStorageProvider;
|
||||
use App\Services\Storage\DatabaseImportStorageProvider;
|
||||
use App\Services\Storage\ProcessImportStorageProvider;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->bind(
|
||||
SearchProvider::class,
|
||||
DatabaseSearchProvider::class,
|
||||
);
|
||||
$this->app->bind(
|
||||
ContactStorageProvider::class,
|
||||
DatabaseContactStorageProvider::class
|
||||
);
|
||||
$this->app->bind(
|
||||
ProcessImportStorageProvider::class,
|
||||
DatabaseImportStorageProvider::class,
|
||||
);
|
||||
$this->app->bind(
|
||||
ProcessImportMonitor::class,
|
||||
DatabaseProcessImportMonitor::class,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
View::composer('contacts.*', function (\Illuminate\View\View $view): void {
|
||||
$q = request()->query('q');
|
||||
$searchQuery = is_string($q) ? $q : null;
|
||||
$searchQueryParams = $searchQuery !== null && $searchQuery !== '' ? ['q' => $searchQuery] : [];
|
||||
|
||||
$view->with('searchQuery', $searchQuery);
|
||||
$view->with('searchQueryParams', $searchQueryParams);
|
||||
});
|
||||
}
|
||||
}
|
||||
62
app/Providers/RouteBindingServiceProvider.php
Normal file
62
app/Providers/RouteBindingServiceProvider.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Data\Contact;
|
||||
use App\Data\ContactImport;
|
||||
use App\Data\Intent\Contact\SearchContactIntentByUuid;
|
||||
use App\Data\Intent\ProcessImport\SearchImportContactsIntent;
|
||||
use App\Services\Query\ImportContactsStateQuery;
|
||||
use App\Services\Query\SearchContactQuery;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
class RouteBindingServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Route::bind('contact', function (string $value): Contact {
|
||||
if (! Uuid::isValid($value)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$searchIntent = new SearchContactIntentByUuid(Uuid::fromString($value));
|
||||
$contact = app(SearchContactQuery::class)->executeOne($searchIntent);
|
||||
|
||||
if ($contact === null) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $contact;
|
||||
});
|
||||
|
||||
Route::bind('contactImport', function (string $value): ContactImport {
|
||||
if (! Uuid::isValid($value)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$searchIntent = new SearchImportContactsIntent(Uuid::fromString($value));
|
||||
$contactImport = app(ImportContactsStateQuery::class)->executeOne($searchIntent);
|
||||
|
||||
if ($contactImport === null) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $contactImport;
|
||||
});
|
||||
}
|
||||
}
|
||||
54
app/Services/Command/CreateContactCommand.php
Normal file
54
app/Services/Command/CreateContactCommand.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Command;
|
||||
|
||||
use App\Data\Command\BatchResult;
|
||||
use App\Data\Contact;
|
||||
use App\Data\Intent\Contact\CreateContactIntent;
|
||||
use App\Data\InvariantException;
|
||||
use App\Services\Mapper\ContactMapper;
|
||||
use App\Services\Storage\ContactStorageProvider;
|
||||
|
||||
class CreateContactCommand
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContactMapper $contactMapper,
|
||||
private readonly ContactStorageProvider $contactStorageProvider,
|
||||
) {
|
||||
}
|
||||
|
||||
public function execute(CreateContactIntent $command): ?Contact
|
||||
{
|
||||
$entity = $this->contactMapper->tryToCreateFromIntent($command);
|
||||
|
||||
return $this->contactStorageProvider->create($entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<CreateContactIntent> $actualBatch
|
||||
* @return BatchResult
|
||||
*/
|
||||
public function multipleExecute(array $actualBatch): BatchResult
|
||||
{
|
||||
$contacts = [];
|
||||
|
||||
foreach ($actualBatch as $intent) {
|
||||
try {
|
||||
$entity = $this->contactMapper->tryToCreateFromIntent($intent);
|
||||
$contacts[] = $entity;
|
||||
} catch (InvariantException) {
|
||||
// Do nothing -> mark later as failed
|
||||
}
|
||||
}
|
||||
|
||||
$result = $this->contactStorageProvider->batchCreate($contacts);
|
||||
|
||||
return new BatchResult(
|
||||
count($actualBatch) - count($contacts),
|
||||
count($contacts) - $result,
|
||||
$result,
|
||||
);
|
||||
}
|
||||
}
|
||||
21
app/Services/Command/DeleteContactCommand.php
Normal file
21
app/Services/Command/DeleteContactCommand.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Command;
|
||||
|
||||
use App\Data\Intent\Contact\DeleteContactIntent;
|
||||
use App\Services\Storage\ContactStorageProvider;
|
||||
|
||||
class DeleteContactCommand
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContactStorageProvider $contactStorageProvider,
|
||||
) {
|
||||
}
|
||||
|
||||
public function execute(DeleteContactIntent $command): bool
|
||||
{
|
||||
return $this->contactStorageProvider->delete($command->uuid);
|
||||
}
|
||||
}
|
||||
25
app/Services/Command/ImportContactsCommand.php
Normal file
25
app/Services/Command/ImportContactsCommand.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Command;
|
||||
|
||||
use App\Data\Intent\ProcessImport\ImportContactsIntent;
|
||||
use App\Jobs\ProcessImport;
|
||||
use App\Services\ProcessImportMonitor\ProcessImportIdentifiable;
|
||||
use App\Services\ProcessImportMonitor\ProcessImportMonitor;
|
||||
|
||||
class ImportContactsCommand
|
||||
{
|
||||
public function __construct(private readonly ProcessImportMonitor $importMonitor)
|
||||
{
|
||||
}
|
||||
|
||||
public function execute(ImportContactsIntent $command): ProcessImportIdentifiable
|
||||
{
|
||||
$import = $this->importMonitor->newImport($command->path);
|
||||
ProcessImport::dispatch($command->path, $import);
|
||||
|
||||
return $import;
|
||||
}
|
||||
}
|
||||
26
app/Services/Command/UpdateContactCommand.php
Normal file
26
app/Services/Command/UpdateContactCommand.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Command;
|
||||
|
||||
use App\Data\Contact;
|
||||
use App\Data\Intent\Contact\UpdateContactIntent;
|
||||
use App\Services\Mapper\ContactMapper;
|
||||
use App\Services\Storage\ContactStorageProvider;
|
||||
|
||||
class UpdateContactCommand
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContactMapper $contactMapper,
|
||||
private readonly ContactStorageProvider $contactStorageProvider,
|
||||
) {
|
||||
}
|
||||
|
||||
public function execute(UpdateContactIntent $command): ?Contact
|
||||
{
|
||||
$entity = $this->contactMapper->tryToCreateFromIntent($command);
|
||||
|
||||
return $this->contactStorageProvider->update($entity);
|
||||
}
|
||||
}
|
||||
25
app/Services/Mapper/Api/ContactImportMapper.php
Normal file
25
app/Services/Mapper/Api/ContactImportMapper.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Mapper\Api;
|
||||
|
||||
use App\Data\Api\ContactImport as ContactImportData;
|
||||
use App\Data\ContactImport;
|
||||
|
||||
final class ContactImportMapper
|
||||
{
|
||||
public function fromModel(ContactImport $contactImport): ContactImportData
|
||||
{
|
||||
return new ContactImportData(
|
||||
$contactImport->id,
|
||||
$contactImport->queueAt->toIso8601String(),
|
||||
$contactImport->startedAt?->toIso8601String(),
|
||||
$contactImport->finishedAt?->toIso8601String(),
|
||||
(int) $contactImport->totalProcessed,
|
||||
(int) $contactImport->errors,
|
||||
(int) $contactImport->duplicates,
|
||||
$contactImport->state,
|
||||
);
|
||||
}
|
||||
}
|
||||
44
app/Services/Mapper/ContactImportMapper.php
Normal file
44
app/Services/Mapper/ContactImportMapper.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Mapper;
|
||||
|
||||
use App\Data\ContactImport as ContactImportData;
|
||||
use App\Models\ContactImport;
|
||||
|
||||
final class ContactImportMapper
|
||||
{
|
||||
public function fromModel(ContactImport $contactImport): ContactImportData
|
||||
{
|
||||
return new ContactImportData(
|
||||
$contactImport->id,
|
||||
$contactImport->queue_at,
|
||||
$contactImport->started_at,
|
||||
$contactImport->finished_at,
|
||||
(int) $contactImport->total_processed,
|
||||
(int) $contactImport->errors,
|
||||
(int) $contactImport->duplicates,
|
||||
$contactImport->state->value,
|
||||
$contactImport->file,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toModelAttributes(ContactImportData $contactImport): array
|
||||
{
|
||||
return [
|
||||
'id' => $contactImport->id,
|
||||
'queue_at' => $contactImport->queueAt,
|
||||
'started_at' => $contactImport->startedAt,
|
||||
'finished_at' => $contactImport->finishedAt,
|
||||
'total_processed' => $contactImport->totalProcessed,
|
||||
'errors' => $contactImport->errors,
|
||||
'duplicates' => $contactImport->duplicates,
|
||||
'state' => $contactImport->state,
|
||||
'file' => $contactImport->file,
|
||||
];
|
||||
}
|
||||
}
|
||||
58
app/Services/Mapper/ContactMapper.php
Normal file
58
app/Services/Mapper/ContactMapper.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Mapper;
|
||||
|
||||
use App\Data\Contact as ContactData;
|
||||
use App\Data\Intent\Contact\CreateContactIntent;
|
||||
use App\Data\Intent\Contact\UpdateContactIntent;
|
||||
use App\Models\Contact;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
final class ContactMapper
|
||||
{
|
||||
public function fromModel(Contact $contact): ContactData
|
||||
{
|
||||
return new ContactData(
|
||||
// @phpstan-ignore-next-line argument.type
|
||||
Uuid::fromString((string) $contact->id),
|
||||
// @phpstan-ignore-next-line argument.type
|
||||
$contact->email,
|
||||
// @phpstan-ignore-next-line argument.type
|
||||
$contact->first_name,
|
||||
// @phpstan-ignore-next-line argument.type
|
||||
$contact->last_name,
|
||||
);
|
||||
}
|
||||
|
||||
public function tryToCreateFromIntent(UpdateContactIntent|CreateContactIntent $intent): ContactData
|
||||
{
|
||||
if ($intent instanceof CreateContactIntent) {
|
||||
$uuid = Uuid::uuid7();
|
||||
} else {
|
||||
$uuid = $intent->uuid;
|
||||
}
|
||||
|
||||
return new ContactData(
|
||||
$uuid,
|
||||
$intent->email,
|
||||
$intent->firstName,
|
||||
$intent->lastName,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toModelAttributes(
|
||||
ContactData $contactData,
|
||||
): array {
|
||||
return [
|
||||
'id' => $contactData->uuid->toString(),
|
||||
'first_name' => $contactData->firstName,
|
||||
'last_name' => $contactData->lastName,
|
||||
'email' => $contactData->email,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\ProcessImportMonitor;
|
||||
|
||||
use Ramsey\Uuid\Rfc4122\UuidV7;
|
||||
|
||||
class DatabaseProcessImportMonitor implements ProcessImportMonitor
|
||||
{
|
||||
public function newImport(string $file): ProcessImportState&ProcessImportIdentifiable
|
||||
{
|
||||
return new DatabaseProcessImportState(UuidV7::uuid7(), $file);
|
||||
}
|
||||
}
|
||||
115
app/Services/ProcessImportMonitor/DatabaseProcessImportState.php
Normal file
115
app/Services/ProcessImportMonitor/DatabaseProcessImportState.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\ProcessImportMonitor;
|
||||
|
||||
use App\Enums\ContactImportStateEnum;
|
||||
use App\Models\ContactImport;
|
||||
use Carbon\Carbon;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
class DatabaseProcessImportState implements ProcessImportIdentifiable, ProcessImportState
|
||||
{
|
||||
public function __construct(
|
||||
public readonly UuidInterface $uuid,
|
||||
string $file
|
||||
) {
|
||||
ContactImport::query()->create(
|
||||
[
|
||||
'id' => $this->uuid()->toString(),
|
||||
'state' => ContactImportStateEnum::Pending,
|
||||
'file' => $file,
|
||||
'queue_at' => Carbon::now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function uuid(): UuidInterface
|
||||
{
|
||||
return $this->uuid;
|
||||
}
|
||||
|
||||
public function start(): void
|
||||
{
|
||||
ContactImport::query()->where(
|
||||
[
|
||||
'id' => $this->uuid()->toString(),
|
||||
]
|
||||
)->update(
|
||||
[
|
||||
'started_at' => now(),
|
||||
'state' => ContactImportStateEnum::Running,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function fail(): void
|
||||
{
|
||||
ContactImport::query()->where(
|
||||
[
|
||||
'id' => $this->uuid()->toString(),
|
||||
],
|
||||
)->update(
|
||||
[
|
||||
'finished_at' => now(),
|
||||
'state' => ContactImportStateEnum::Fail,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function finish(): void
|
||||
{
|
||||
ContactImport::query()->where(
|
||||
[
|
||||
'id' => $this->uuid()->toString(),
|
||||
],
|
||||
)->update(
|
||||
[
|
||||
'finished_at' => now(),
|
||||
'state' => ContactImportStateEnum::Done,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function contactDuplicate(int $processed = 1): void
|
||||
{
|
||||
ContactImport::query()->where(
|
||||
[
|
||||
'id' => $this->uuid()->toString(),
|
||||
],
|
||||
)->incrementEach(
|
||||
[
|
||||
'total_processed' => $processed,
|
||||
'duplicates' => $processed,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function contactFail(int $processed = 1): void
|
||||
{
|
||||
ContactImport::query()->where(
|
||||
[
|
||||
'id' => $this->uuid()->toString(),
|
||||
],
|
||||
)->incrementEach(
|
||||
[
|
||||
'total_processed' => $processed,
|
||||
'errors' => $processed,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function contactSuccess(int $processed = 1): void
|
||||
{
|
||||
ContactImport::query()->where(
|
||||
[
|
||||
'id' => $this->uuid()->toString(),
|
||||
],
|
||||
)->incrementEach(
|
||||
[
|
||||
'total_processed' => $processed,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\ProcessImportMonitor;
|
||||
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
interface ProcessImportIdentifiable
|
||||
{
|
||||
public UuidInterface $uuid { get; }
|
||||
}
|
||||
10
app/Services/ProcessImportMonitor/ProcessImportMonitor.php
Normal file
10
app/Services/ProcessImportMonitor/ProcessImportMonitor.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\ProcessImportMonitor;
|
||||
|
||||
interface ProcessImportMonitor
|
||||
{
|
||||
public function newImport(string $file): ProcessImportState&ProcessImportIdentifiable;
|
||||
}
|
||||
29
app/Services/ProcessImportMonitor/ProcessImportState.php
Normal file
29
app/Services/ProcessImportMonitor/ProcessImportState.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\ProcessImportMonitor;
|
||||
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Handling actual state of import
|
||||
*
|
||||
* Never use not-serializable values in implementations!
|
||||
*/
|
||||
interface ProcessImportState
|
||||
{
|
||||
public function uuid(): UuidInterface;
|
||||
|
||||
public function start(): void;
|
||||
|
||||
public function finish(): void;
|
||||
|
||||
public function fail(): void;
|
||||
|
||||
public function contactFail(int $processed = 1): void;
|
||||
|
||||
public function contactDuplicate(int $processed = 1): void;
|
||||
|
||||
public function contactSuccess(int $processed = 1): void;
|
||||
}
|
||||
29
app/Services/Query/ImportContactsStateQuery.php
Normal file
29
app/Services/Query/ImportContactsStateQuery.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Query;
|
||||
|
||||
use App\Data\ContactImport;
|
||||
use App\Data\Intent\ProcessImport\SearchImportContactsIntent;
|
||||
use App\Services\Mapper\ContactImportMapper;
|
||||
use App\Services\Storage\ProcessImportStorageProvider;
|
||||
|
||||
class ImportContactsStateQuery
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContactImportMapper $contactImportMapper,
|
||||
private readonly ProcessImportStorageProvider $processImportStorageProvider,
|
||||
) {
|
||||
}
|
||||
|
||||
public function executeOne(SearchImportContactsIntent $search): ?ContactImport
|
||||
{
|
||||
$modelResult = $this->processImportStorageProvider->fetchByUuid($search->uuid);
|
||||
if ($modelResult === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->contactImportMapper->fromModel($modelResult);
|
||||
}
|
||||
}
|
||||
59
app/Services/Query/SearchContactQuery.php
Normal file
59
app/Services/Query/SearchContactQuery.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Query;
|
||||
|
||||
use App\Data\Intent\Contact\SearchContactIntent;
|
||||
use App\Data\Intent\Contact\SearchContactIntentByUuid;
|
||||
use App\Models\Contact;
|
||||
use App\Services\Mapper\ContactMapper;
|
||||
use App\Services\SearchProvider\SearchProvider;
|
||||
use App\Services\Storage\ContactStorageProvider;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class SearchContactQuery
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SearchProvider $searchProvider,
|
||||
private readonly ContactStorageProvider $contactStorageProvider,
|
||||
private readonly ContactMapper $contactMapper,
|
||||
) {
|
||||
}
|
||||
|
||||
public function executeOne(SearchContactIntentByUuid $search): ?\App\Data\Contact
|
||||
{
|
||||
$modelResult = $this->contactStorageProvider->fetchByUuid($search->uuid);
|
||||
if ($modelResult === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->contactMapper->fromModel($modelResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return LengthAwarePaginator<int, \App\Data\Contact>
|
||||
*/
|
||||
public function execute(SearchContactIntent $searchIntent): LengthAwarePaginator
|
||||
{
|
||||
if ($searchIntent->query == SearchContactIntent::queryAll()->query) {
|
||||
/** @var LengthAwarePaginator<int, Contact> $modelResult */
|
||||
$modelResult = $this->contactStorageProvider->fetchPaginatedAll($searchIntent->resultsPerPage);
|
||||
} else {
|
||||
/** @var LengthAwarePaginator<int, Contact> $modelResult */
|
||||
$modelResult = $this->searchProvider->search($searchIntent->query, $searchIntent->resultsPerPage);
|
||||
}
|
||||
|
||||
$collection = $modelResult->getCollection()->map(
|
||||
fn (Contact $contact) => $this->contactMapper->fromModel($contact),
|
||||
);
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
$collection,
|
||||
$modelResult->total(),
|
||||
$modelResult->perPage(),
|
||||
$modelResult->currentPage(),
|
||||
$modelResult->getOptions(),
|
||||
);
|
||||
}
|
||||
}
|
||||
34
app/Services/SearchProvider/DatabaseSearchProvider.php
Normal file
34
app/Services/SearchProvider/DatabaseSearchProvider.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\SearchProvider;
|
||||
|
||||
use App\Models\Contact;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
final class DatabaseSearchProvider implements SearchProvider
|
||||
{
|
||||
/**
|
||||
* @return LengthAwarePaginator<int, \App\Models\Contact>
|
||||
*/
|
||||
public function search(string $query, int $paginate): LengthAwarePaginator
|
||||
{
|
||||
return Contact::query()
|
||||
->select('*')
|
||||
->selectRaw(
|
||||
"CASE
|
||||
WHEN email LIKE CONCAT('%', ?::text, '%')
|
||||
THEN 1.0
|
||||
ELSE ts_rank(ts_name, plainto_tsquery('simple', ?::text))
|
||||
END AS rank",
|
||||
[$query, $query]
|
||||
)
|
||||
->whereRaw(
|
||||
"ts_name @@ plainto_tsquery('simple', ?::text) OR email LIKE CONCAT('%', ?::text, '%')",
|
||||
[$query, $query]
|
||||
)
|
||||
->orderByDesc('rank')
|
||||
->paginate($paginate);
|
||||
}
|
||||
}
|
||||
16
app/Services/SearchProvider/SearchProvider.php
Normal file
16
app/Services/SearchProvider/SearchProvider.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\SearchProvider;
|
||||
|
||||
use App\Data\Contact;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
interface SearchProvider
|
||||
{
|
||||
/**
|
||||
* @return LengthAwarePaginator<int, \App\Models\Contact>
|
||||
*/
|
||||
public function search(string $query, int $paginate): LengthAwarePaginator;
|
||||
}
|
||||
30
app/Services/Storage/ContactStorageProvider.php
Normal file
30
app/Services/Storage/ContactStorageProvider.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Storage;
|
||||
|
||||
use App\Data\Contact;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
interface ContactStorageProvider
|
||||
{
|
||||
public function create(Contact $contact): ?Contact;
|
||||
|
||||
public function update(Contact $contact): ?Contact;
|
||||
|
||||
public function delete(UuidInterface $uuid): bool;
|
||||
|
||||
/**
|
||||
* @param array<Contact> $intents
|
||||
*/
|
||||
public function batchCreate(array $intents): int;
|
||||
|
||||
/**
|
||||
* @return LengthAwarePaginator<int, \App\Models\Contact>
|
||||
*/
|
||||
public function fetchPaginatedAll(int $paginate): LengthAwarePaginator;
|
||||
|
||||
public function fetchByUuid(UuidInterface $uuid): ?\App\Models\Contact;
|
||||
}
|
||||
72
app/Services/Storage/DatabaseContactStorageProvider.php
Normal file
72
app/Services/Storage/DatabaseContactStorageProvider.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Storage;
|
||||
|
||||
use App\Data\Contact;
|
||||
use App\Services\Mapper\ContactMapper;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
class DatabaseContactStorageProvider implements ContactStorageProvider
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContactMapper $contactMapper,
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(Contact $contact): ?Contact
|
||||
{
|
||||
$contact = \App\Models\Contact::query()->create(
|
||||
$this->contactMapper->toModelAttributes($contact),
|
||||
);
|
||||
|
||||
return $this->contactMapper->fromModel($contact);
|
||||
}
|
||||
|
||||
public function update(Contact $contact): ?Contact
|
||||
{
|
||||
$existingContact = \App\Models\Contact::query()->findOrFail($contact->uuid->toString());
|
||||
$result = $existingContact->update($this->contactMapper->toModelAttributes($contact));
|
||||
if ($result === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->contactMapper->fromModel($existingContact);
|
||||
}
|
||||
|
||||
public function delete(UuidInterface $uuid): bool
|
||||
{
|
||||
$contact = \App\Models\Contact::query()->findOrFail($uuid->toString());
|
||||
|
||||
return (bool) $contact->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<Contact> $intents
|
||||
*/
|
||||
public function batchCreate(array $intents): int
|
||||
{
|
||||
return \App\Models\Contact::query()->insertOrIgnore(
|
||||
array_map(function (Contact $intent): array {
|
||||
return $this->contactMapper->toModelAttributes($intent);
|
||||
}, $intents)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return LengthAwarePaginator<int, \App\Models\Contact>
|
||||
*/
|
||||
public function fetchPaginatedAll(int $paginate): LengthAwarePaginator
|
||||
{
|
||||
return \App\Models\Contact::query()
|
||||
->orderBy('id')
|
||||
->paginate($paginate);
|
||||
}
|
||||
|
||||
public function fetchByUuid(UuidInterface $uuid): ?\App\Models\Contact
|
||||
{
|
||||
return \App\Models\Contact::query()->find($uuid->toString());
|
||||
}
|
||||
}
|
||||
15
app/Services/Storage/DatabaseImportStorageProvider.php
Normal file
15
app/Services/Storage/DatabaseImportStorageProvider.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Storage;
|
||||
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
class DatabaseImportStorageProvider implements ProcessImportStorageProvider
|
||||
{
|
||||
public function fetchByUuid(UuidInterface $uuid): ?\App\Models\ContactImport
|
||||
{
|
||||
return \App\Models\ContactImport::query()->find($uuid->toString());
|
||||
}
|
||||
}
|
||||
13
app/Services/Storage/ProcessImportStorageProvider.php
Normal file
13
app/Services/Storage/ProcessImportStorageProvider.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Storage;
|
||||
|
||||
use App\Models\ContactImport;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
interface ProcessImportStorageProvider
|
||||
{
|
||||
public function fetchByUuid(UuidInterface $uuid): ?ContactImport;
|
||||
}
|
||||
83
app/Services/Validators/ContactValidator.php
Normal file
83
app/Services/Validators/ContactValidator.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Validators;
|
||||
|
||||
use App\Data\Contact;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class ContactValidator
|
||||
{
|
||||
/**
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email:rfc', 'max:254'],
|
||||
'first_name' => ['nullable', 'string', 'max:100'],
|
||||
'last_name' => ['nullable', 'string', 'max:100'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rulesForStore(): array
|
||||
{
|
||||
return $this->rulesForRequest(unique: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rulesForUpdate(Contact $contact): array
|
||||
{
|
||||
return $this->rulesForRequest(unique: true, ignore: $contact);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'first_name.max' => 'First name may not be greater than 100 characters.',
|
||||
'last_name.max' => 'Last name may not be greater than 100 characters.',
|
||||
'email.required' => 'Email is required.',
|
||||
'email.email' => 'Email must be a valid email address.',
|
||||
'email.max' => 'Email may not be greater than 254 characters.',
|
||||
'email.unique' => 'Email has already been taken.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
private function rulesForRequest(bool $unique, ?Contact $ignore = null): array
|
||||
{
|
||||
$rules = $this->rules();
|
||||
/**
|
||||
* @var array<string> $emailRules
|
||||
*/
|
||||
$emailRules = Arr::pull($rules, 'email');
|
||||
|
||||
if ($unique) {
|
||||
$rule = Rule::unique('contacts', 'email');
|
||||
if ($ignore !== null) {
|
||||
$rule = $rule->ignore($ignore->uuid->toString());
|
||||
}
|
||||
$emailRules[] = $rule;
|
||||
}
|
||||
|
||||
/** @var array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string> $result */
|
||||
$result = [
|
||||
...$rules,
|
||||
'email' => $emailRules,
|
||||
];
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user