feat: complete jobcard client management foundation
This commit is contained in:
@@ -40,7 +40,7 @@ final class ClientRecord
|
||||
public function normalize(array $record): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->text($record['name'] ?? null) ?? '',
|
||||
'name' => $this->normalizeName($record['name'] ?? null),
|
||||
'registration_number' => $this->text($record['registration_number'] ?? null),
|
||||
'status' => strtolower($this->text($record['status'] ?? null) ?? 'active'),
|
||||
'support_email' => $this->lowerText($record['support_email'] ?? null),
|
||||
@@ -114,6 +114,14 @@ final class ClientRecord
|
||||
return $text === null ? null : strtolower($text);
|
||||
}
|
||||
|
||||
private function normalizeName(mixed $value): ?string
|
||||
{
|
||||
$text = $this->text($value);
|
||||
if ($text === null) return null;
|
||||
$collapsed = preg_replace('/\s+/u', ' ', $text);
|
||||
return $collapsed === false ? $text : $collapsed;
|
||||
}
|
||||
|
||||
private function validPhone(string $phone): bool
|
||||
{
|
||||
if (mb_strlen($phone) > 60 || preg_match('/^[0-9+().\-\s]+$/', $phone) !== 1) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Client;
|
||||
|
||||
require_once __DIR__ . '/ClientRecord.php';
|
||||
|
||||
/** Validates client create/edit and explicit active-state transitions. */
|
||||
final class ClientUpdateCommand
|
||||
{
|
||||
public function __construct(private readonly ?ClientRecord $clients = null)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function validate(array $input, array $existingClients = [], ?int $currentId = null): array
|
||||
{
|
||||
$result = ($this->clients ?? new ClientRecord())->validate($input);
|
||||
$nameKey = $this->duplicateKey(is_string($result['name'] ?? null) ? $result['name'] : '');
|
||||
if ($nameKey !== '' && $this->hasDuplicate($nameKey, $existingClients, $currentId)) {
|
||||
$result['errors']['name'] = 'Client name is already in use.';
|
||||
}
|
||||
$result['valid'] = $result['errors'] === [];
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function validateForCreate(array $input, array $existingClients = []): array
|
||||
{
|
||||
return $this->validate($input, $existingClients);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function validateForEdit(int $id, array $input, array $existingClients = []): array
|
||||
{
|
||||
$errors = [];
|
||||
if ($id < 1) $errors['id'] = 'Client ID must be a positive integer.';
|
||||
$result = $this->validate($input, $existingClients, $id);
|
||||
$result['id'] = $id;
|
||||
$result['errors'] = [...$errors, ...$result['errors']];
|
||||
$result['valid'] = $result['errors'] === [];
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array{valid: bool, id: int, status: string, errors: array<string, string>} */
|
||||
public function validateDeactivate(array $client): array
|
||||
{
|
||||
return $this->validateTransition($client, 'active', 'inactive');
|
||||
}
|
||||
|
||||
/** @return array{valid: bool, id: int, status: string, errors: array<string, string>} */
|
||||
public function validateReactivate(array $client): array
|
||||
{
|
||||
return $this->validateTransition($client, 'inactive', 'active');
|
||||
}
|
||||
|
||||
/** @return array{valid: bool, id: int, status: string, errors: array<string, string>} */
|
||||
public function deactivate(array $client): array
|
||||
{
|
||||
return $this->validateDeactivate($client);
|
||||
}
|
||||
|
||||
/** @return array{valid: bool, id: int, status: string, errors: array<string, string>} */
|
||||
public function reactivate(array $client): array
|
||||
{
|
||||
return $this->validateReactivate($client);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function display(array $client): array
|
||||
{
|
||||
return ($this->clients ?? new ClientRecord())->display($client);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toDisplay(array $client): array
|
||||
{
|
||||
return $this->display($client);
|
||||
}
|
||||
|
||||
/** @return array{valid: bool, id: int, status: string, errors: array<string, string>} */
|
||||
private function validateTransition(array $client, string $from, string $to): array
|
||||
{
|
||||
$id = $this->positiveId($client['id'] ?? null);
|
||||
$status = strtolower(trim(is_scalar($client['status'] ?? null) ? (string) $client['status'] : ''));
|
||||
$errors = [];
|
||||
if ($id === null) $errors['id'] = 'Client ID must be a positive integer.';
|
||||
if ($status !== $from) $errors['status'] = "Only {$from} clients can be changed to {$to}.";
|
||||
return ['valid' => $errors === [], 'id' => $id ?? 0, 'status' => $to, 'errors' => $errors];
|
||||
}
|
||||
|
||||
private function hasDuplicate(string $candidate, array $rows, ?int $currentId): bool
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
$name = is_array($row) ? ($row['name'] ?? null) : $row;
|
||||
if (!is_scalar($name) || $this->duplicateKey((string) $name) !== $candidate) continue;
|
||||
$rowId = is_array($row) ? $this->positiveId($row['id'] ?? null) : null;
|
||||
if ($currentId === null || $rowId !== $currentId) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function duplicateKey(string $value): string
|
||||
{
|
||||
$collapsed = preg_replace('/\s+/u', ' ', trim($value));
|
||||
return strtolower($collapsed === false ? trim($value) : $collapsed);
|
||||
}
|
||||
|
||||
private function positiveId(mixed $value): ?int
|
||||
{
|
||||
if (is_int($value) && $value > 0) return $value;
|
||||
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) {
|
||||
$id = filter_var(trim($value), FILTER_VALIDATE_INT);
|
||||
return $id === false ? null : $id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Client;
|
||||
|
||||
require_once __DIR__ . '/ClientContactValidator.php';
|
||||
|
||||
/** Validates client-contact create/edit payloads, including primary promotion. */
|
||||
final class ContactUpdateCommand
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const DISPLAY_FIELDS = ['id', 'client_id', 'name', 'email', 'phone', 'is_primary', 'notes'];
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function validate(array $input, array $existingContacts = [], ?int $currentId = null): array
|
||||
{
|
||||
$contact = validate_client_contact($input);
|
||||
$contact['name'] = $this->name($contact['name']);
|
||||
$clientId = $this->positiveId($input['client_id'] ?? null);
|
||||
$notes = $this->text($input['notes'] ?? null);
|
||||
$errors = $contact['errors'];
|
||||
if ($clientId === null) $errors['client_id'] = 'Client ID must be a positive integer.';
|
||||
if ($notes !== null && mb_strlen($notes) > 10000) $errors['notes'] = 'Contact notes must be 10000 characters or fewer.';
|
||||
|
||||
if ($clientId !== null) {
|
||||
foreach (['name' => $contact['name'], 'email' => $contact['email']] as $field => $value) {
|
||||
if ($value === null || $value === '') continue;
|
||||
if ($this->hasDuplicate($field, (string) $value, $clientId, $existingContacts, $currentId)) {
|
||||
$errors[$field] = "Contact {$field} is already in use for this client.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$replace = [];
|
||||
if ($clientId !== null && $contact['is_primary'] === true) {
|
||||
foreach ($existingContacts as $row) {
|
||||
if (!is_array($row) || $this->positiveId($row['client_id'] ?? null) !== $clientId) continue;
|
||||
if (!$this->asBool($row['is_primary'] ?? false)) continue;
|
||||
$id = $this->positiveId($row['id'] ?? null);
|
||||
if ($id !== null && $id !== $currentId) $replace[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'client_id' => $clientId,
|
||||
'name' => $contact['name'],
|
||||
'email' => $contact['email'],
|
||||
'phone' => $contact['phone'],
|
||||
'is_primary' => $contact['is_primary'],
|
||||
'notes' => $notes,
|
||||
'replace_primary_contact_ids' => $replace,
|
||||
'valid' => $errors === [],
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function validateForCreate(array $input, array $existingContacts = []): array
|
||||
{
|
||||
return $this->validate($input, $existingContacts);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function validateForEdit(int $id, array $input, array $existingContacts = []): array
|
||||
{
|
||||
$result = $this->validate($input, $existingContacts, $id);
|
||||
$result['id'] = $id;
|
||||
if ($id < 1) $result['errors']['id'] = 'Contact ID must be a positive integer.';
|
||||
$result['valid'] = $result['errors'] === [];
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function display(array $contact): array
|
||||
{
|
||||
$safe = [];
|
||||
foreach (self::DISPLAY_FIELDS as $field) {
|
||||
if (array_key_exists($field, $contact)) $safe[$field] = $contact[$field];
|
||||
}
|
||||
return $safe;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toDisplay(array $contact): array
|
||||
{
|
||||
return $this->display($contact);
|
||||
}
|
||||
|
||||
private function hasDuplicate(string $field, string $value, int $clientId, array $rows, ?int $currentId): bool
|
||||
{
|
||||
$candidate = $field === 'email' ? strtolower(trim($value)) : $this->duplicateKey($value);
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row) || $this->positiveId($row['client_id'] ?? null) !== $clientId) continue;
|
||||
$rowId = $this->positiveId($row['id'] ?? null);
|
||||
if ($currentId !== null && $rowId === $currentId) continue;
|
||||
$other = $row[$field] ?? null;
|
||||
if ($other !== null && ($field === 'email' ? strtolower(trim((string) $other)) === $candidate : $this->duplicateKey((string) $other) === $candidate)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function duplicateKey(string $value): string
|
||||
{
|
||||
$collapsed = preg_replace('/\s+/u', ' ', trim($value));
|
||||
return strtolower($collapsed === false ? trim($value) : $collapsed);
|
||||
}
|
||||
|
||||
private function name(string $value): string
|
||||
{
|
||||
$collapsed = preg_replace('/\s+/u', ' ', trim($value));
|
||||
return $collapsed === false ? trim($value) : $collapsed;
|
||||
}
|
||||
|
||||
private function text(mixed $value): ?string
|
||||
{
|
||||
if (!is_scalar($value)) return null;
|
||||
$value = trim((string) $value);
|
||||
return $value === '' ? null : $value;
|
||||
}
|
||||
|
||||
private function positiveId(mixed $value): ?int
|
||||
{
|
||||
if (is_int($value) && $value > 0) return $value;
|
||||
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) {
|
||||
$id = filter_var(trim($value), FILTER_VALIDATE_INT);
|
||||
return $id === false ? null : $id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function asBool(mixed $value): bool
|
||||
{
|
||||
return $value === true || $value === 1 || (is_string($value) && in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user