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

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