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