feat: complete jobcard management workflows and UI
This commit is contained in:
@@ -16,24 +16,36 @@ final class ContactEditCommand
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function validateEdit(int $id, array $input, array $existingContacts = []): array
|
||||
public function validateEdit(mixed $id, array $input, array $existingContacts = []): array
|
||||
{
|
||||
$normalizedId = $this->id($id);
|
||||
$result = ($this->contacts ?? new ContactUpdateCommand())->validateForEdit($id, $input, $existingContacts);
|
||||
if ($id < 1) {
|
||||
if ($normalizedId === null) {
|
||||
$result['errors']['id'] = 'Contact ID must be a positive integer.';
|
||||
$result['valid'] = false;
|
||||
} else {
|
||||
$target = $this->find($normalizedId, $existingContacts);
|
||||
if ($target === null) $result['errors']['id'] = 'Contact was not found.';
|
||||
else {
|
||||
$oldClient = $this->id($target['client_id'] ?? null);
|
||||
$newClient = $this->id($input['client_id'] ?? null);
|
||||
if ($oldClient !== null && $newClient !== $oldClient) $result['errors']['client_id'] = 'Contact client ID cannot be changed during edit.';
|
||||
}
|
||||
}
|
||||
$result['id'] = $normalizedId;
|
||||
$result['valid'] = $result['errors'] === [];
|
||||
$result['action'] = 'edit';
|
||||
$result['audit'] = ['event' => 'client_contact_updated', 'entity_type' => 'client_contact', 'entity_id' => $normalizedId, 'client_id' => $result['client_id'] ?? null];
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** Alias matching the create/update command vocabulary. */
|
||||
public function validateForEdit(int $id, array $input, array $existingContacts = []): array
|
||||
public function validateForEdit(mixed $id, array $input, array $existingContacts = []): array
|
||||
{
|
||||
return $this->validateEdit($id, $input, $existingContacts);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function validate(array $input, array $existingContacts = [], ?int $currentId = null): array
|
||||
public function validate(array $input, array $existingContacts = [], mixed $currentId = null): array
|
||||
{
|
||||
return $currentId === null
|
||||
? ($this->contacts ?? new ContactUpdateCommand())->validateForCreate($input, $existingContacts)
|
||||
@@ -45,27 +57,28 @@ final class ContactEditCommand
|
||||
* contact for the same client; deleting the sole contact is not allowed.
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function validateDelete(int $id, array $existingContacts = []): array
|
||||
public function validateDelete(mixed $id, array $existingContacts = []): array
|
||||
{
|
||||
$normalizedId = $this->id($id);
|
||||
$errors = [];
|
||||
$target = null;
|
||||
foreach ($existingContacts as $contact) {
|
||||
if (is_array($contact) && $this->id($contact['id'] ?? null) === $id) {
|
||||
if ($normalizedId !== null && is_array($contact) && $this->id($contact['id'] ?? null) === $normalizedId) {
|
||||
$target = $contact;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($id < 1) $errors['id'] = 'Contact ID must be a positive integer.';
|
||||
if ($normalizedId === null) $errors['id'] = 'Contact ID must be a positive integer.';
|
||||
if ($target === null) {
|
||||
$errors['id'] = 'Contact was not found.';
|
||||
return ['valid' => false, 'id' => $id, 'client_id' => null, 'replacement_primary_contact_id' => null, 'errors' => $errors];
|
||||
if ($normalizedId !== null) $errors['id'] = 'Contact was not found.';
|
||||
return ['valid' => false, 'action' => 'delete', 'id' => $normalizedId, 'client_id' => null, 'replacement_primary_contact_id' => null, 'errors' => $errors, 'audit' => null];
|
||||
}
|
||||
$clientId = $this->id($target['client_id'] ?? null);
|
||||
if ($clientId === null) {
|
||||
$errors['client_id'] = 'Contact client ID must be a positive integer.';
|
||||
return ['valid' => false, 'id' => $id, 'client_id' => null, 'replacement_primary_contact_id' => null, 'errors' => $errors];
|
||||
}
|
||||
$sameClient = array_values(array_filter($existingContacts, fn ($row): bool => is_array($row) && $this->id($row['client_id'] ?? null) === $clientId && $this->id($row['id'] ?? null) !== $id));
|
||||
$sameClient = array_values(array_filter($existingContacts, fn ($row): bool => is_array($row) && $this->id($row['client_id'] ?? null) === $clientId && $this->id($row['id'] ?? null) !== $normalizedId));
|
||||
$replacement = null;
|
||||
if (count($sameClient) === 0) {
|
||||
$errors['delete'] = 'The only contact cannot be deleted; add another contact first.';
|
||||
@@ -74,36 +87,37 @@ final class ContactEditCommand
|
||||
$replacement = isset($sameClient[0]) ? $this->id($sameClient[0]['id'] ?? null) : null;
|
||||
if ($replacement === null) $errors['delete'] = 'The only contact cannot be deleted; add another contact first.';
|
||||
}
|
||||
return ['valid' => $errors === [], 'id' => $id, 'client_id' => $clientId, 'replacement_primary_contact_id' => $replacement, 'errors' => $errors];
|
||||
return ['valid' => $errors === [], 'action' => 'delete', 'id' => $normalizedId, 'client_id' => $clientId, 'replacement_primary_contact_id' => $replacement, 'errors' => $errors, 'audit' => $errors === [] ? ['event' => 'client_contact_deleted', 'entity_type' => 'client_contact', 'entity_id' => $normalizedId, 'client_id' => $clientId, 'replacement_primary_contact_id' => $replacement] : null];
|
||||
}
|
||||
|
||||
public function validateForDelete(int $id, array $existingContacts = []): array
|
||||
public function validateForDelete(mixed $id, array $existingContacts = []): array
|
||||
{
|
||||
return $this->validateDelete($id, $existingContacts);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function delete(int $id, array $existingContacts = []): array
|
||||
public function delete(mixed $id, array $existingContacts = []): array
|
||||
{
|
||||
return $this->validateDelete($id, $existingContacts);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function validatePrimary(int $id, array $existingContacts = []): array
|
||||
public function validatePrimary(mixed $id, array $existingContacts = []): array
|
||||
{
|
||||
$normalizedId = $this->id($id);
|
||||
foreach ($existingContacts as $row) {
|
||||
if (is_array($row) && $this->id($row['id'] ?? null) === $id) {
|
||||
if ($normalizedId !== null && is_array($row) && $this->id($row['id'] ?? null) === $normalizedId) {
|
||||
$clientId = $this->id($row['client_id'] ?? null);
|
||||
$demote = [];
|
||||
foreach ($existingContacts as $other) if (is_array($other) && $this->id($other['client_id'] ?? null) === $clientId && $this->id($other['id'] ?? null) !== $id && $this->boolean($other['is_primary'] ?? false)) $demote[] = $this->id($other['id'] ?? null);
|
||||
return ['valid' => true, 'id' => $id, 'client_id' => $clientId, 'replace_primary_contact_ids' => array_values(array_filter($demote)), 'errors' => []];
|
||||
foreach ($existingContacts as $other) if (is_array($other) && $this->id($other['client_id'] ?? null) === $clientId && $this->id($other['id'] ?? null) !== $normalizedId && $this->boolean($other['is_primary'] ?? false)) $demote[] = $this->id($other['id'] ?? null);
|
||||
return ['valid' => true, 'action' => 'set_primary', 'id' => $normalizedId, 'client_id' => $clientId, 'replace_primary_contact_ids' => array_values(array_filter($demote)), 'errors' => [], 'audit' => ['event' => 'client_contact_primary_set', 'entity_type' => 'client_contact', 'entity_id' => $normalizedId, 'client_id' => $clientId, 'demoted_contact_ids' => array_values(array_filter($demote))]];
|
||||
}
|
||||
}
|
||||
return ['valid' => false, 'id' => $id, 'client_id' => null, 'replace_primary_contact_ids' => [], 'errors' => ['id' => 'Contact was not found.']];
|
||||
return ['valid' => false, 'action' => 'set_primary', 'id' => $normalizedId, 'client_id' => null, 'replace_primary_contact_ids' => [], 'errors' => ['id' => $normalizedId === null ? 'Contact ID must be a positive integer.' : 'Contact was not found.'], 'audit' => null];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function setPrimary(int $id, array $existingContacts = []): array
|
||||
public function setPrimary(mixed $id, array $existingContacts = []): array
|
||||
{
|
||||
return $this->validatePrimary($id, $existingContacts);
|
||||
}
|
||||
@@ -115,4 +129,9 @@ final class ContactEditCommand
|
||||
return null;
|
||||
}
|
||||
private function boolean(mixed $value): bool { return $value === true || $value === 1 || (is_string($value) && in_array(strtolower(trim($value)), ['1','true','yes','on'], true)); }
|
||||
private function find(int $id, array $rows): ?array
|
||||
{
|
||||
foreach ($rows as $row) if (is_array($row) && $this->id($row['id'] ?? null) === $id) return $row;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,11 +61,12 @@ final class ContactUpdateCommand
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function validateForEdit(int $id, array $input, array $existingContacts = []): array
|
||||
public function validateForEdit(mixed $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.';
|
||||
$normalizedId = $this->positiveId($id);
|
||||
$result = $this->validate($input, $existingContacts, $normalizedId);
|
||||
$result['id'] = $normalizedId;
|
||||
if ($normalizedId === null) $result['errors']['id'] = 'Contact ID must be a positive integer.';
|
||||
$result['valid'] = $result['errors'] === [];
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -14,11 +14,15 @@ final class TechnicalInformation
|
||||
public const CATEGORY_SSH = 'ssh';
|
||||
public const CATEGORY_API = 'api';
|
||||
public const CATEGORY_OTHER = 'other';
|
||||
public const CATEGORY_MICROSOFT = 'microsoft';
|
||||
public const CATEGORY_NETWORK = 'network';
|
||||
public const CATEGORY_ROUTER = 'router';
|
||||
public const CATEGORY_INFRASTRUCTURE = 'infrastructure';
|
||||
|
||||
/** @return list<string> */
|
||||
public static function categories(): array
|
||||
{
|
||||
return [self::CATEGORY_HOSTING, self::CATEGORY_VPN, self::CATEGORY_EMAIL, self::CATEGORY_DOMAIN, self::CATEGORY_DATABASE, self::CATEGORY_SSH, self::CATEGORY_API, self::CATEGORY_OTHER];
|
||||
return [self::CATEGORY_HOSTING, self::CATEGORY_VPN, self::CATEGORY_EMAIL, self::CATEGORY_DOMAIN, self::CATEGORY_DATABASE, self::CATEGORY_SSH, self::CATEGORY_API, self::CATEGORY_OTHER, self::CATEGORY_MICROSOFT, self::CATEGORY_NETWORK, self::CATEGORY_ROUTER, self::CATEGORY_INFRASTRUCTURE];
|
||||
}
|
||||
|
||||
/** @return array{category:string, label:string, username:string|null, notes:string|null} */
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Credential;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Storage-agnostic command/service for structured, non-secret technical information.
|
||||
* It validates category-specific JSON, supports safe edits/deletes, and can delegate
|
||||
* persistence to the existing repository or a compatible adapter.
|
||||
*/
|
||||
final class TechnicalInformationCommand
|
||||
{
|
||||
/** @var array<string,list<string>> */
|
||||
private const FIELDS = [
|
||||
'microsoft' => ['label', 'tenant', 'product', 'portal_url', 'username', 'notes'],
|
||||
'network' => ['label', 'hostname', 'ip_address', 'vlan', 'username', 'notes'],
|
||||
'router' => ['label', 'hostname', 'ip_address', 'model', 'username', 'notes'],
|
||||
'infrastructure' => ['label', 'hostname', 'ip_address', 'role', 'os', 'username', 'notes'],
|
||||
];
|
||||
|
||||
/** @return list<string> */
|
||||
public static function categories(): array { return array_keys(self::FIELDS); }
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function validate(array $input): array
|
||||
{
|
||||
$clientId = $this->positiveId($input['client_id'] ?? null);
|
||||
$category = $this->category($input['category'] ?? null);
|
||||
$data = $this->inputData($input);
|
||||
$errors = [];
|
||||
if ($clientId === null) $errors['client_id'] = 'Client ID must be a positive integer.';
|
||||
if ($category === null) $errors['category'] = 'Technical information category is invalid.';
|
||||
$dataResult = $this->validateData($category ?? '', $data);
|
||||
$errors = [...$errors, ...$dataResult['errors']];
|
||||
$record = ['client_id' => $clientId, 'category' => $category, 'data' => $dataResult['data']];
|
||||
return ['valid' => $errors === [], 'record' => $record, 'errors' => $errors];
|
||||
}
|
||||
|
||||
public function validateForCreate(array $input): array { return $this->validate($input); }
|
||||
public function validateCreate(array $input): array { return $this->validate($input); }
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function validateEdit(array $existing, array $changes): array
|
||||
{
|
||||
$id = $this->positiveId($existing['id'] ?? null);
|
||||
$existingCategory = $this->category($existing['category'] ?? null);
|
||||
$base = ['client_id' => $existing['client_id'] ?? null, 'category' => $existing['category'] ?? null, 'data' => $this->inputData($existing)];
|
||||
if (array_key_exists('client_id', $changes)) $base['client_id'] = $changes['client_id'];
|
||||
$categoryChange = array_key_exists('category', $changes) ? $this->category($changes['category']) : $existingCategory;
|
||||
$categoryChanged = array_key_exists('category', $changes) && $categoryChange !== $existingCategory;
|
||||
$changeData = $this->inputData($changes);
|
||||
$base['data'] = [...$this->inputData($existing), ...$changeData];
|
||||
$result = $this->validate($base);
|
||||
if ($id === null) { $result['errors']['id'] = 'Technical information ID must be a positive integer.'; $result['valid'] = false; }
|
||||
if ($categoryChanged) { $result['errors']['category'] = 'Technical information category cannot be changed during edit.'; $result['valid'] = false; }
|
||||
$result['record']['id'] = $id;
|
||||
return ['valid' => $result['valid'], 'action' => 'edit', 'record' => $result['record'], 'errors' => $result['errors']];
|
||||
}
|
||||
|
||||
public function validateForEdit(array $existing, array $changes): array { return $this->validateEdit($existing, $changes); }
|
||||
public function edit(array $existing, array $changes): array { return $this->validateEdit($existing, $changes); }
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function validateDelete(array $existing): array
|
||||
{
|
||||
$id = $this->positiveId($existing['id'] ?? null);
|
||||
$clientId = $this->positiveId($existing['client_id'] ?? null);
|
||||
$category = $this->category($existing['category'] ?? null);
|
||||
$errors = [];
|
||||
if ($id === null) $errors['id'] = 'Technical information ID must be a positive integer.';
|
||||
if ($clientId === null) $errors['client_id'] = 'Client ID must be a positive integer.';
|
||||
if ($category === null) $errors['category'] = 'Technical information category is invalid.';
|
||||
return ['valid' => $errors === [], 'action' => 'delete', 'id' => $id, 'client_id' => $clientId, 'category' => $category, 'errors' => $errors];
|
||||
}
|
||||
|
||||
public function validateForDelete(array $existing): array { return $this->validateDelete($existing); }
|
||||
|
||||
public function delete(array $existing, ?object $repository = null): array
|
||||
{
|
||||
$result = $this->validateDelete($existing);
|
||||
if (!$result['valid']) throw new InvalidArgumentException('Invalid technical information delete: ' . implode(' ', $result['errors']));
|
||||
$repository ??= $this->repository;
|
||||
if ($repository !== null && method_exists($repository, 'delete')) return $repository->delete($result['id']);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function display(array $record): array
|
||||
{
|
||||
$category = $this->category($record['category'] ?? null);
|
||||
$data = $this->inputData($record);
|
||||
$safe = [];
|
||||
foreach (['id', 'client_id', 'category'] as $field) if (array_key_exists($field, $record)) $safe[$field] = $record[$field];
|
||||
$safeData = [];
|
||||
foreach (self::FIELDS[$category ?? ''] ?? [] as $field) if (array_key_exists($field, $data)) $safeData[$field] = $data[$field];
|
||||
$safe['data'] = $safeData;
|
||||
return $safe;
|
||||
}
|
||||
|
||||
public function toDisplay(array $record): array { return $this->display($record); }
|
||||
|
||||
/** Persist a validated create through the repository/adapter when supplied. */
|
||||
public function create(int $clientId, array $input, ?int $updatedBy = null, ?object $repository = null): array
|
||||
{
|
||||
if ($repository === null) $repository = $this->repository;
|
||||
if ($repository === null && isset($input['repository']) && is_object($input['repository'])) $repository = $input['repository'];
|
||||
$result = $this->validate([...$input, 'client_id' => $clientId]);
|
||||
$this->throwIfInvalid($result);
|
||||
if ($repository === null) return $result['record'];
|
||||
if (method_exists($repository, 'upsertInformation')) return $repository->upsertInformation($clientId, ['category' => $result['record']['category'], 'data' => $result['record']['data']], $updatedBy);
|
||||
if (method_exists($repository, 'upsert')) return $repository->upsert($clientId, $result['record']['category'], $result['record']['data'], $updatedBy);
|
||||
throw new InvalidArgumentException('Technical information repository adapter is incompatible.');
|
||||
}
|
||||
|
||||
/** Constructor-compatible service form: new TechnicalInformationCommand($repository). */
|
||||
public function __construct(private readonly ?object $repository = null) {}
|
||||
|
||||
public function store(int $clientId, array $input, ?int $updatedBy = null): array { return $this->create($clientId, $input, $updatedBy, $this->repository); }
|
||||
|
||||
public function update(array $existing, array $changes, ?int $updatedBy = null, ?object $repository = null): array
|
||||
{
|
||||
$result = $this->validateEdit($existing, $changes);
|
||||
if (!$result['valid']) throw new InvalidArgumentException('Invalid technical information edit: ' . implode(' ', $result['errors']));
|
||||
$repository ??= $this->repository;
|
||||
if ($repository !== null && method_exists($repository, 'upsert')) return $repository->upsert($result['record']['client_id'], $result['record']['category'], $result['record']['data'], $updatedBy);
|
||||
return $result['record'];
|
||||
}
|
||||
|
||||
/** @return array{data:array<string,mixed>,errors:array<string,string>} */
|
||||
public function validateData(string $category, array $data): array
|
||||
{
|
||||
$allowed = self::FIELDS[$category] ?? [];
|
||||
$normalized = [];
|
||||
$errors = [];
|
||||
foreach ($data as $field => $value) {
|
||||
if (!is_string($field) || !in_array($field, $allowed, true)) { $errors['data.' . (string)$field] = 'This technical-information field is not allowed.'; continue; }
|
||||
if ($field === 'vlan') {
|
||||
if ((is_int($value) || (is_string($value) && preg_match('/^\d+$/', trim($value)) === 1)) && (int)$value >= 1 && (int)$value <= 4094) $normalized[$field] = (int)$value;
|
||||
else $errors['data.vlan'] = 'VLAN must be an integer from 1 to 4094.';
|
||||
continue;
|
||||
}
|
||||
if (!is_scalar($value)) { $errors['data.' . $field] = 'Technical-information fields must be scalar JSON values.'; continue; }
|
||||
$text = trim((string)$value);
|
||||
if ($text === '') continue;
|
||||
if (in_array($field, ['ip_address'], true) && filter_var($text, FILTER_VALIDATE_IP) === false) $errors['data.' . $field] = 'IP address is invalid.';
|
||||
elseif ($field === 'portal_url' && filter_var($text, FILTER_VALIDATE_URL) === false) $errors['data.' . $field] = 'Portal URL is invalid.';
|
||||
elseif (mb_strlen($text) > 1000) $errors['data.' . $field] = 'Technical-information text is too long.';
|
||||
else $normalized[$field] = $text;
|
||||
}
|
||||
if (!isset($normalized['label'])) $errors['data.label'] = 'Technical information label is required.';
|
||||
return ['data' => $normalized, 'errors' => $errors];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function inputData(array $input): array
|
||||
{
|
||||
if (isset($input['data']) && is_array($input['data'])) return $input['data'];
|
||||
return array_diff_key($input, array_flip(['id', 'client_id', 'category', 'valid', 'errors', 'action', 'display', 'audit', 'data_json', 'updated_by', 'created_at', 'updated_at']));
|
||||
}
|
||||
private function category(mixed $value): ?string { if (!is_scalar($value)) return null; $value = strtolower(trim((string)$value)); return in_array($value, self::categories(), true) ? $value : null; }
|
||||
private function positiveId(mixed $value): ?int { return is_int($value) && $value > 0 ? $value : (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1 ? (int)$value : null); }
|
||||
private function throwIfInvalid(array $result): void { if (!$result['valid']) throw new InvalidArgumentException('Invalid technical information: ' . implode(' ', $result['errors'])); }
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace App\Domain\Credential;
|
||||
|
||||
require_once __DIR__ . '/TechnicalInformation.php';
|
||||
require_once __DIR__ . '/TechnicalInformationCommand.php';
|
||||
|
||||
use InvalidArgumentException;
|
||||
use PDO;
|
||||
@@ -33,17 +34,17 @@ final class TechnicalInformationRepository
|
||||
public function upsert(int $clientId, string $category, array $data, ?int $updatedBy = null): array
|
||||
{
|
||||
$this->assertIds($clientId, $updatedBy);
|
||||
$normalizedCategory = strtolower(trim($category));
|
||||
if (in_array($normalizedCategory, TechnicalInformationCommand::categories(), true)) {
|
||||
$validation = (new TechnicalInformationCommand())->validate(['client_id' => $clientId, 'category' => $normalizedCategory, 'data' => $data]);
|
||||
if (!$validation['valid']) throw new InvalidArgumentException('Invalid technical information: ' . implode(' ', $validation['errors']));
|
||||
$jsonData = $validation['record']['data'];
|
||||
} else {
|
||||
$validation = $this->information->validate([...$data, 'category' => $category]);
|
||||
if (!$validation['valid']) {
|
||||
throw new InvalidArgumentException('Invalid technical information: ' . implode(' ', $validation['errors']));
|
||||
}
|
||||
|
||||
if (!$validation['valid']) throw new InvalidArgumentException('Invalid technical information: ' . implode(' ', $validation['errors']));
|
||||
$normalizedCategory = $validation['category'];
|
||||
$jsonData = [
|
||||
'label' => $validation['label'],
|
||||
'username' => $validation['username'],
|
||||
'notes' => $validation['notes'],
|
||||
];
|
||||
$jsonData = ['label' => $validation['label'], 'username' => $validation['username'], 'notes' => $validation['notes']];
|
||||
}
|
||||
$json = json_encode($jsonData, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
@@ -73,7 +74,11 @@ final class TechnicalInformationRepository
|
||||
if (!is_string($category)) {
|
||||
throw new InvalidArgumentException('Technical information category is required.');
|
||||
}
|
||||
if (isset($information['data']) && is_array($information['data'])) {
|
||||
$information = $information['data'];
|
||||
} else {
|
||||
unset($information['category']);
|
||||
}
|
||||
return $this->upsert($clientId, $category, $information, $updatedBy);
|
||||
}
|
||||
|
||||
@@ -128,6 +133,15 @@ final class TechnicalInformationRepository
|
||||
return $this->display($record);
|
||||
}
|
||||
|
||||
/** Delete by record id; adapters may use the same contract for logical commands. */
|
||||
public function delete(int $id): array
|
||||
{
|
||||
if ($id < 1) throw new InvalidArgumentException('Technical information id must be a positive integer.');
|
||||
$statement = $this->pdo->prepare('DELETE FROM technical_information WHERE id = :id');
|
||||
$statement->execute(['id' => $id]);
|
||||
return ['valid' => true, 'action' => 'delete', 'id' => $id, 'errors' => []];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row @return array<string,mixed> */
|
||||
private function hydrate(array $row): array
|
||||
{
|
||||
@@ -159,17 +173,19 @@ final class TechnicalInformationRepository
|
||||
return $record;
|
||||
}
|
||||
|
||||
/** @return array{label:string,username:string|null,notes:string|null} */
|
||||
/** @return array<string,mixed> */
|
||||
private function decodeData(mixed $json): array
|
||||
{
|
||||
if (!is_string($json) || $json === '') throw new PDOException('Technical information JSON is missing.');
|
||||
$decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($decoded)) throw new PDOException('Technical information JSON must be an object.');
|
||||
return [
|
||||
'label' => is_string($decoded['label'] ?? null) ? $decoded['label'] : '',
|
||||
'username' => isset($decoded['username']) && is_string($decoded['username']) ? $decoded['username'] : null,
|
||||
'notes' => isset($decoded['notes']) && is_string($decoded['notes']) ? $decoded['notes'] : null,
|
||||
];
|
||||
$allowed = ['label', 'tenant', 'product', 'portal_url', 'hostname', 'ip_address', 'vlan', 'model', 'role', 'os', 'username', 'notes'];
|
||||
$data = [];
|
||||
foreach ($allowed as $field) {
|
||||
if (!array_key_exists($field, $decoded) || !is_scalar($decoded[$field])) continue;
|
||||
$data[$field] = $field === 'vlan' ? (int)$decoded[$field] : (string)$decoded[$field];
|
||||
}
|
||||
return $data + ['label' => '', 'username' => null, 'notes' => null];
|
||||
}
|
||||
|
||||
private function assertIds(int $clientId, ?int $updatedBy): void
|
||||
|
||||
@@ -26,6 +26,7 @@ final class TimeEntryCorrectionCommand
|
||||
if (array_key_exists($field, $changes) && $this->positiveId($changes[$field]) !== $this->positiveId($existing[$field] ?? null)) $errors[$field] = "{$field} cannot be changed during correction.";
|
||||
}
|
||||
$allowed = ['work_date', 'start_time', 'end_time', 'hours', 'notes', 'counts_toward_sla'];
|
||||
foreach (array_keys($changes) as $field) if (!in_array($field, $allowed, true) && !in_array($field, ['jobcard_id', 'technician_id'], true)) $errors[$field] = "{$field} cannot be changed during correction.";
|
||||
$payload = $existing;
|
||||
foreach ($allowed as $field) if (array_key_exists($field, $changes)) $payload[$field] = $changes[$field];
|
||||
$validated = ($this->entries ?? new TimeEntryCommand())->validate($payload);
|
||||
@@ -33,7 +34,8 @@ final class TimeEntryCorrectionCommand
|
||||
$entry = [...$payload, ...$validated];
|
||||
unset($entry['valid'], $entry['errors']);
|
||||
$entry['id'] = $id;
|
||||
return ['valid' => $errors === [], 'action' => 'correct', 'id' => $id, 'entry' => $entry, 'errors' => $errors];
|
||||
$changed = []; foreach ($allowed as $field) if (array_key_exists($field, $changes) && ($existing[$field] ?? null) !== ($entry[$field] ?? null)) $changed[] = $field;
|
||||
return ['valid' => $errors === [], 'action' => 'correct', 'id' => $id, 'entry' => $entry, 'errors' => $errors, 'audit' => ['event' => 'time_entry_corrected', 'entity_type' => 'time_entry', 'entity_id' => $id, 'changed_fields' => $changed, 'before' => $existing, 'after' => $entry]];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
@@ -57,7 +59,7 @@ final class TimeEntryCorrectionCommand
|
||||
$reason = is_scalar($input['reason'] ?? null) ? trim((string)$input['reason']) : '';
|
||||
if ($reason === '') $errors['reason'] = 'A void reason is required.';
|
||||
elseif (mb_strlen($reason) > 1000) $errors['reason'] = 'Void reason must be 1000 characters or fewer.';
|
||||
return ['valid' => $errors === [], 'action' => 'void', 'id' => $id, 'void_reason' => $reason === '' ? null : $reason, 'entry' => $existing, 'errors' => $errors];
|
||||
return ['valid' => $errors === [], 'action' => 'void', 'id' => $id, 'void_reason' => $reason === '' ? null : $reason, 'entry' => $existing, 'errors' => $errors, 'audit' => $errors === [] ? ['event' => 'time_entry_voided', 'entity_type' => 'time_entry', 'entity_id' => $id, 'void_reason' => $reason] : null];
|
||||
}
|
||||
|
||||
public function void(array $existing, array $input = []): array
|
||||
|
||||
@@ -23,8 +23,12 @@ final class NotificationQueue
|
||||
$lookup = $pdo->prepare('SELECT id FROM users WHERE email = :email AND is_active = 1 LIMIT 1');
|
||||
$insert = $pdo->prepare('INSERT INTO notifications (user_id, type, title, body, deduplication_key, read_at) VALUES (:user, :type, :title, :body, :dedup, :read_at) ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)');
|
||||
$ids = [];
|
||||
$startedTransaction = false;
|
||||
try {
|
||||
if (!$pdo->inTransaction()) {
|
||||
$pdo->beginTransaction();
|
||||
$startedTransaction = true;
|
||||
}
|
||||
foreach ($validation['recipients'] as $email) {
|
||||
$dto = ($this->records ?? new NotificationRecord())->toQueueDto($record, $email);
|
||||
$lookup->execute(['email' => $email]);
|
||||
@@ -34,9 +38,9 @@ final class NotificationQueue
|
||||
$id = (int)$pdo->lastInsertId();
|
||||
if ($id > 0 && !in_array($id, $ids, true)) $ids[] = $id;
|
||||
}
|
||||
$pdo->commit();
|
||||
if ($startedTransaction) $pdo->commit();
|
||||
} catch (\Throwable $exception) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
if ($startedTransaction && $pdo->inTransaction()) $pdo->rollBack();
|
||||
throw $exception;
|
||||
}
|
||||
return $ids;
|
||||
@@ -58,4 +62,15 @@ final class NotificationQueue
|
||||
$stmt->execute(['id' => $validation['notification_id'], 'user' => $user]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/** Mark a notification unread only for the authenticated user's row. */
|
||||
public function markUnread(PDO $pdo, int|string $userId, array $command): bool
|
||||
{
|
||||
$user = filter_var($userId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
$validation = ($this->records ?? new NotificationRecord())->validateMarkUnread($command);
|
||||
if ($user === false || !$validation['valid']) throw new InvalidArgumentException('Invalid mark-unread command.');
|
||||
$stmt = $pdo->prepare('UPDATE notifications SET read_at = NULL WHERE id = :id AND user_id = :user');
|
||||
$stmt->execute(['id' => $validation['notification_id'], 'user' => $user]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ final class NotificationRecord
|
||||
];
|
||||
}
|
||||
|
||||
/** Validate the command used by a user to mark one notification read. */
|
||||
/** Validate the command used by a user to change one notification's read state. */
|
||||
public function validateMarkRead(array $command): array
|
||||
{
|
||||
$value = $command['notification_id'] ?? $command['id'] ?? null;
|
||||
@@ -46,6 +46,12 @@ final class NotificationRecord
|
||||
return ['valid' => $errors === [], 'notification_id' => $notificationId, 'errors' => $errors];
|
||||
}
|
||||
|
||||
/** Alias with an explicit command name for callers that mark notifications unread. */
|
||||
public function validateMarkUnread(array $command): array
|
||||
{
|
||||
return $this->validateMarkRead($command);
|
||||
}
|
||||
|
||||
/** Return one normalized queue DTO for a single recipient. */
|
||||
public function toQueueDto(array $record, string $recipient): array
|
||||
{
|
||||
@@ -136,6 +142,8 @@ final class NotificationRecord
|
||||
return [
|
||||
'type' => $normalized['type'],
|
||||
'recipients' => $normalized['recipients'],
|
||||
'title' => is_scalar($record['title'] ?? null) ? trim((string) $record['title']) : '',
|
||||
'body' => is_scalar($record['body'] ?? null) && trim((string) $record['body']) !== '' ? trim((string) $record['body']) : null,
|
||||
'is_read' => $normalized['is_read'],
|
||||
'deduplication_key' => $normalized['deduplication_key'],
|
||||
];
|
||||
|
||||
@@ -29,12 +29,33 @@ final class ClientHistoryService
|
||||
return $this->filter($rows, $criteria);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function validateForClient(mixed $clientId, array $rows, array $criteria = []): array
|
||||
{
|
||||
$id = $this->positiveId($clientId);
|
||||
if ($id === null) return ['valid' => false, 'client_id' => null, 'timeline' => [], 'errors' => ['client_id' => 'Client ID must be a positive integer.']];
|
||||
return ['valid' => true, 'client_id' => $id, 'timeline' => $this->forClient($rows, $id, $criteria), 'errors' => []];
|
||||
}
|
||||
|
||||
/** Controller-ready client history timeline command. */
|
||||
public function timeline(array $rows, mixed $clientId, array $criteria = []): array
|
||||
{
|
||||
return $this->validateForClient($clientId, $rows, $criteria);
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function history(array $rows, array|\ReportFilters|null $criteria = null): array { return $this->filter($rows, $criteria); }
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function query(array $rows, array|\ReportFilters|null $criteria = null): array { return $this->filter($rows, $criteria); }
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function getHistory(array $rows, array|\ReportFilters|null $criteria = null): array { return $this->filter($rows, $criteria); }
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/ReportFilters.php';
|
||||
require_once __DIR__ . '/ReportDataMapper.php';
|
||||
require_once __DIR__ . '/ReportQuery.php';
|
||||
require_once __DIR__ . '/ReportAudience.php';
|
||||
|
||||
final class HoursPerClientReport
|
||||
/** Deterministic hours aggregation grouped by client. */
|
||||
final class HoursPerClientReport implements ReportQuery
|
||||
{
|
||||
/** @param list<array<string, mixed>> $entries
|
||||
* @return list<array{client_id:int, client_name:string, hours:float}>
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ?ReportFilters $filters = null,
|
||||
private readonly ?ReportDataMapper $mapper = null,
|
||||
) {}
|
||||
|
||||
/** @param list<array<string,mixed>> $entries @return list<array<string,mixed>> */
|
||||
public function build(array $entries, string $audience = ReportAudience::CLIENT): array
|
||||
{
|
||||
ReportAudience::validate($audience);
|
||||
$filters = $this->filters ?? new ReportFilters();
|
||||
$selected = array_values(array_filter($entries, static fn (array $entry): bool => $filters->matches($entry)));
|
||||
$rows = $this->aggregate($selected);
|
||||
// This report contains no technician or internal-note fields, so the same
|
||||
// stable projection is safe for both audiences.
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $entries @return list<array{client_id:int,client_name:string,hours:float}> */
|
||||
public function aggregate(array $entries): array
|
||||
{
|
||||
$totals = [];
|
||||
@@ -15,20 +34,16 @@ final class HoursPerClientReport
|
||||
$id = (int)($entry['client_id'] ?? 0);
|
||||
$key = (string)$id;
|
||||
if (!isset($totals[$key])) {
|
||||
$totals[$key] = [
|
||||
'client_id' => $id,
|
||||
'client_name' => (string)($entry['client_name'] ?? ''),
|
||||
'hours' => 0.0,
|
||||
];
|
||||
$totals[$key] = ['client_id' => $id, 'client_name' => (string)($entry['client_name'] ?? ''), 'hours' => 0.0];
|
||||
}
|
||||
$totals[$key]['hours'] += max(0.0, (float)($entry['hours'] ?? 0));
|
||||
}
|
||||
$rows = array_values($totals);
|
||||
foreach ($rows as &$row) {
|
||||
$row['hours'] = round($row['hours'], 2);
|
||||
}
|
||||
foreach ($rows as &$row) $row['hours'] = round($row['hours'], 2);
|
||||
unset($row);
|
||||
usort($rows, static fn (array $a, array $b): int => strcmp($a['client_name'], $b['client_name']) ?: $a['client_id'] <=> $b['client_id']);
|
||||
usort($rows, static fn (array $a, array $b): int => strcmp($a['client_name'], $b['client_name']) ?: ($a['client_id'] <=> $b['client_id']));
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function query(array $entries, string $audience = ReportAudience::CLIENT): array { return $this->build($entries, $audience); }
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ final class PrintReportRenderer
|
||||
foreach ($rows as $row) {
|
||||
$body .= '<tr>' . implode('', array_map(fn(mixed $value): string => '<td>' . $this->escape($value) . '</td>', $row)) . '</tr>';
|
||||
}
|
||||
return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>' . $this->escape($title) . '</title><style>body{font-family:Arial,sans-serif;color:#222;margin:2rem}h1{font-size:1.5rem}table{border-collapse:collapse;width:100%}th,td{border:1px solid #bbb;padding:.5rem;text-align:left;vertical-align:top}th{background:#eee}@media print{body{margin:0}h1{font-size:1.2rem}table{font-size:10pt}tr{page-break-inside:avoid}}</style></head><body><main><h1>' . $this->escape($title) . '</h1><table><thead><tr>' . $head . '</tr></thead><tbody>' . $body . '</tbody></table></main></body></html>';
|
||||
return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="format" content="application/pdf"><title>' . $this->escape($title) . '</title><style>@page{size:auto;margin:1.5cm}body{font-family:Arial,sans-serif;color:#222;margin:2rem}h1{font-size:1.5rem}table{border-collapse:collapse;width:100%}th,td{border:1px solid #bbb;padding:.5rem;text-align:left;vertical-align:top}th{background:#eee}@media print{body{margin:0}h1{font-size:1.2rem}table{font-size:10pt}thead{display:table-header-group}tr{page-break-inside:avoid}}</style></head><body><main><h1>' . $this->escape($title) . '</h1><table><thead><tr>' . $head . '</tr></thead><tbody>' . $body . '</tbody></table></main></body></html>';
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $rows */
|
||||
|
||||
@@ -20,9 +20,14 @@ final class SlaReport
|
||||
ReportAudience::validate($audience);
|
||||
$filters = $this->filters ?? new ReportFilters();
|
||||
$mapper = $this->mapper ?? new ReportDataMapper();
|
||||
// SLA status is derived below; apply all source-field filters first and
|
||||
// apply the SLA filter to the computed status after usage is calculated.
|
||||
$sourceFilters = $filters->sla === null
|
||||
? $filters
|
||||
: new ReportFilters($filters->clientId, $filters->dateFrom, $filters->dateTo, $filters->technicianId, $filters->status, $filters->priority);
|
||||
$rows = [];
|
||||
foreach ($agreements as $agreement) {
|
||||
if (!$filters->matches($agreement)) continue;
|
||||
if (!$sourceFilters->matches($agreement)) continue;
|
||||
$allocated = max(0.0, (float)($agreement['allocated_hours'] ?? 0));
|
||||
$used = 0.0;
|
||||
foreach ((array)($agreement['hours'] ?? []) as $hours) $used += max(0.0, (float)$hours);
|
||||
@@ -30,6 +35,7 @@ final class SlaReport
|
||||
$remaining = round(max(0.0, $allocated - $used), 2);
|
||||
$percentage = $allocated > 0 ? round(($used / $allocated) * 100, 2) : ($used > 0 ? 100.0 : 0.0);
|
||||
$status = $used > $allocated ? 'exceeded' : ($percentage >= 90 ? 'critical' : ($percentage >= 75 ? 'warning' : 'within_limit'));
|
||||
if ($filters->sla !== null && $filters->sla !== $status) continue;
|
||||
$record = [
|
||||
'client_id' => (int)($agreement['client_id'] ?? 0),
|
||||
'client_name' => (string)($agreement['client_name'] ?? ''),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/ReportFilters.php';
|
||||
require_once __DIR__ . '/ReportDataMapper.php';
|
||||
require_once __DIR__ . '/ReportQuery.php';
|
||||
require_once __DIR__ . '/ReportAudience.php';
|
||||
|
||||
/** Deterministic technician workload totals, with a client-safe projection. */
|
||||
final class TechnicianWorkloadReport implements ReportQuery
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ?ReportFilters $filters = null,
|
||||
private readonly ?ReportDataMapper $mapper = null,
|
||||
) {}
|
||||
|
||||
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
|
||||
public function build(array $rows, string $audience = ReportAudience::INTERNAL): array
|
||||
{
|
||||
ReportAudience::validate($audience);
|
||||
$filters = $this->filters ?? new ReportFilters();
|
||||
$totals = [];
|
||||
foreach ($rows as $row) {
|
||||
if (!$filters->matches($row)) continue;
|
||||
$technicianId = (int)($row['technician_id'] ?? 0);
|
||||
$clientId = (int)($row['client_id'] ?? 0);
|
||||
$key = $audience === ReportAudience::CLIENT ? 'client:' . $clientId : 'technician:' . $technicianId;
|
||||
if (!isset($totals[$key])) {
|
||||
$totals[$key] = $audience === ReportAudience::CLIENT
|
||||
? ['client_id' => $clientId, 'client_name' => (string)($row['client_name'] ?? ''), 'hours' => 0.0, 'sla_hours' => 0.0]
|
||||
: ['technician_id' => $technicianId, 'technician_name' => (string)($row['technician_name'] ?? ''), 'hours' => 0.0, 'sla_hours' => 0.0];
|
||||
}
|
||||
$hours = max(0.0, (float)($row['hours'] ?? 0));
|
||||
$totals[$key]['hours'] += $hours;
|
||||
if (!empty($row['counts_toward_sla'])) $totals[$key]['sla_hours'] += $hours;
|
||||
}
|
||||
$result = array_values($totals);
|
||||
foreach ($result as &$item) {
|
||||
$item['hours'] = round($item['hours'], 2);
|
||||
$item['sla_hours'] = round($item['sla_hours'], 2);
|
||||
}
|
||||
unset($item);
|
||||
usort($result, static fn (array $a, array $b): int => array_key_exists('client_id', $a)
|
||||
? (strcmp((string)($a['client_name'] ?? ''), (string)($b['client_name'] ?? '')) ?: ((int)$a['client_id'] <=> (int)$b['client_id']))
|
||||
: (strcmp((string)($a['technician_name'] ?? ''), (string)($b['technician_name'] ?? '')) ?: ((int)$a['technician_id'] <=> (int)$b['technician_id'])));
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function query(array $rows, string $audience = ReportAudience::INTERNAL): array { return $this->build($rows, $audience); }
|
||||
}
|
||||
@@ -49,7 +49,24 @@ final class RolePermissionService
|
||||
return ['role_id' => $roleId ?? 0, 'permissions' => $normalized, 'valid' => $errors === [], 'errors' => $errors];
|
||||
}
|
||||
|
||||
/** Alias matching controller command terminology. */
|
||||
/** Validate creation of a custom role and its optional permission set. */
|
||||
public function validateForCreate(array $input, array $available = []): array
|
||||
{
|
||||
$record = $this->roles ?? new RoleRecord();
|
||||
$result = $record->validate($input);
|
||||
if ($record->isAdministrator($input)) {
|
||||
$result['errors']['role'] = 'The protected Administrator role cannot be created or renamed.';
|
||||
}
|
||||
if (array_key_exists('permissions', $input)) {
|
||||
$assignment = $this->validateAssignment(['id' => 2, 'name' => $result['name']], is_array($input['permissions']) ? $input['permissions'] : [], $available);
|
||||
$result['permissions'] = $assignment['permissions'];
|
||||
$result['errors'] = [...$result['errors'], ...$assignment['errors']];
|
||||
}
|
||||
$result['valid'] = $result['errors'] === [];
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array{role_id: int, permissions: list<string>, valid: bool, errors: array<string, string>} */
|
||||
public function validateForAssignment(array $role, array $selected, array $available = []): array
|
||||
{
|
||||
return $this->validateAssignment($role, $selected, $available);
|
||||
@@ -68,9 +85,7 @@ final class RolePermissionService
|
||||
$result['id'] = $id;
|
||||
if ($id < 1) $result['errors']['id'] = 'Role ID must be a positive integer.';
|
||||
$current = ['id' => $id, 'name' => $id === 1 ? 'Administrator' : ($input['current_name'] ?? ($input['name'] ?? null))];
|
||||
if (array_key_exists('current_name', $input)) {
|
||||
if (!$record->canRename($current, $result['name'])) $result['errors']['role'] = 'The protected Administrator role cannot be renamed.';
|
||||
}
|
||||
if (array_key_exists('permissions', $input)) {
|
||||
$assignment = $this->validateAssignment(['id' => $id, 'name' => $current['name']], is_array($input['permissions']) ? $input['permissions'] : [], $available);
|
||||
$result['permissions'] = $assignment['permissions'];
|
||||
@@ -80,6 +95,28 @@ final class RolePermissionService
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** Alias matching edit controller terminology. */
|
||||
public function validateEdit(int $id, array $input, array $available = []): array
|
||||
{
|
||||
return $this->validateForEdit($id, $input, $available);
|
||||
}
|
||||
|
||||
/** Validate deletion of a custom role; ID 1 is always protected. */
|
||||
public function validateDelete(int $id, array $role = []): array
|
||||
{
|
||||
$errors = [];
|
||||
if ($id < 1) $errors['id'] = 'Role ID must be a positive integer.';
|
||||
if ($id === 1 || ($role !== [] && !($this->roles ?? new RoleRecord())->canDelete(['id' => $id, ...$role]))) {
|
||||
$errors['role'] = 'The protected Administrator role cannot be deleted.';
|
||||
}
|
||||
return ['valid' => $errors === [], 'id' => $id, 'errors' => $errors];
|
||||
}
|
||||
|
||||
public function delete(int $id, array $role = []): array
|
||||
{
|
||||
return $this->validateDelete($id, $role);
|
||||
}
|
||||
|
||||
public function canAssignPermissions(array $role): bool
|
||||
{
|
||||
return !($this->roles ?? new RoleRecord())->isAdministrator($role);
|
||||
|
||||
@@ -48,6 +48,7 @@ final class RoleRecord
|
||||
|
||||
public function isAdministrator(array $record): bool
|
||||
{
|
||||
if (isset($record['id']) && (int) $record['id'] === 1) return true;
|
||||
return $this->canonicalName($record['name'] ?? null) === self::ADMINISTRATOR;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,18 @@ final class UserAdminService
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** Controller-friendly user-create DTO. */
|
||||
public function validateForCreate(array $input, array $existingUsers = []): array
|
||||
{
|
||||
$result = ($this->users ?? new UserRecord())->validateForCreate($input);
|
||||
$email = $result['email'] ?? '';
|
||||
if (is_string($email) && $email !== '' && $this->hasDuplicateEmail($email, $existingUsers, 0)) {
|
||||
$result['errors']['email'] = 'Email address is already in use.';
|
||||
$result['valid'] = false;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function validateForEdit(int $id, array $input, array $existingUsers = []): array
|
||||
{
|
||||
@@ -40,7 +52,7 @@ final class UserAdminService
|
||||
|
||||
$result = ($this->users ?? new UserRecord())->validate($input);
|
||||
$existing = $existingUsers[array_search($id, array_map(static fn($row) => is_array($row) ? (int)($row['id'] ?? 0) : 0, $existingUsers), true)] ?? [];
|
||||
if ($this->isProtectedAdministrator($existing) && array_key_exists('role_id', $input) && (int)$input['role_id'] !== (int)($existing['role_id'] ?? 1)) $result['errors']['role_id'] = 'The protected Administrator account cannot be reassigned.';
|
||||
if ($this->isProtectedAdministrator(['id' => $id, ...$existing]) && array_key_exists('role_id', $input) && (int)$input['role_id'] !== 1) $result['errors']['role_id'] = 'The protected Administrator account cannot be reassigned.';
|
||||
$email = $result['email'] ?? '';
|
||||
if (is_string($email) && $email !== '' && $this->hasDuplicateEmail($email, $existingUsers, $id)) {
|
||||
$result['errors']['email'] = 'Email address is already in use.';
|
||||
@@ -52,6 +64,12 @@ final class UserAdminService
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** Alias matching controller command terminology. */
|
||||
public function validateEdit(int $id, array $input, array $existingUsers = []): array
|
||||
{
|
||||
return $this->validateForEdit($id, $input, $existingUsers);
|
||||
}
|
||||
|
||||
/** @return array{valid: bool, id: int, is_active: bool, errors: array<string, string>} */
|
||||
public function validateDeactivate(array $user): array
|
||||
{
|
||||
@@ -121,6 +139,7 @@ final class UserAdminService
|
||||
|
||||
public function isProtectedAdministrator(array $user): bool
|
||||
{
|
||||
if (isset($user['id']) && (int)$user['id'] === 1) return true;
|
||||
if (isset($user['role_id']) && (int)$user['role_id'] === 1) return true;
|
||||
$role = $user['role_name'] ?? $user['role'] ?? null;
|
||||
return is_scalar($role) && strtolower(trim((string) $role)) === 'administrator';
|
||||
|
||||
@@ -57,6 +57,22 @@ Run this checklist against a production-like deployment over HTTPS with a fresh
|
||||
- [ ] Verify CSV/other exports handle commas, quotes, line breaks and formula-like values safely.
|
||||
- [ ] Compare a report total with the underlying test jobcards/time entries and retain the comparison evidence.
|
||||
|
||||
## Final-scope acceptance cases
|
||||
|
||||
Use distinct fixture IDs for each client and record expected/observed results without including secrets in evidence. The executable contract companion is `php -d assert.exception=1 tests/FinalScopeIntegrationTest.php`.
|
||||
|
||||
- [ ] **FS-01 — Technical information:** Add valid hosting/VPN/domain/database/SSH/API metadata; verify labels and usernames are trimmed, supported categories are enforced, control characters/oversized notes are rejected, and display output contains metadata only (never a credential secret).
|
||||
- [ ] **FS-02 — Contact actions:** Edit a contact, promote a secondary contact, and delete a primary contact; verify duplicate names/emails are rejected, only same-client primary contacts are demoted, the lowest remaining same-client contact is promoted on primary deletion, and a client's sole contact cannot be deleted.
|
||||
- [ ] **FS-03 — Time corrections:** Correct date, duration, notes and SLA-counting state while retaining the original time-entry ID, jobcard ID and technician ID. Verify ownership changes, corrections to voided entries, invalid ranges and missing void reasons are rejected. Confirm the original and correction/void actor are retained by the deployment's audit trail.
|
||||
- [ ] **FS-04 — Custom roles:** Create a custom role, assign a least-privilege permission set, rename it and remove it; verify permissions are normalized/deduplicated, server-side authorization remains enforced on direct URLs/forms, and the Administrator role cannot be renamed, deleted or permission-edited.
|
||||
- [ ] **FS-05 — Notifications:** Trigger assignment, status-change and SLA-threshold events; verify normalized per-user rows, stable deduplication, inactive/unknown recipients skipped, mark-read changes only the authenticated user's row, and notification bodies contain no credential or internal-note values.
|
||||
- [ ] **FS-06 — Report audience separation:** Compare the same fixtures in client and internal reports/print/CSV output. Client audience must omit technician identity, internal notes, credentials and other operational-only fields; internal audience may retain authorized attribution. Direct report URLs and exports must enforce the same audience and role checks.
|
||||
- [ ] **FS-07 — Technician scope:** With two technicians and two clients, verify each technician can list/view/update only assigned jobcards and sees only their own time totals. Changing jobcard, client, attachment, credential, report or time-entry IDs must return the documented not-found/denied response without leaking metadata or mutating another technician's records.
|
||||
- [ ] **FS-08 — CSRF and method checks:** Submit missing and wrong CSRF tokens to login, logout, client/contact, jobcard/status/assignment/time, attachment, credential, SLA, notification and custom-role state changes; every request must be rejected before mutation (HTTP 419 or documented equivalent). Verify GET requests are read-only and logout is POST-only.
|
||||
- [ ] **FS-09 — Attachment boundary:** Attempt traversal names, executable/double extensions, MIME/signature mismatches, oversized files and client-visible files without explicit approval; each must be rejected before storage. Upload a valid image/PDF and verify a generated server filename, validated MIME, `X-Content-Type-Options: nosniff`, no executable download behavior, and cross-client/jobcard access denial.
|
||||
- [ ] **FS-10 — Credential boundary:** Create a canary credential and verify the database stores only `secret_ciphertext`, normal views show a mask, reveal is permission-controlled, client-bound, audited and returned with `Cache-Control: no-store`; a different client/credential ID cannot reveal it. Do not put the canary in screenshots, tickets or UAT evidence.
|
||||
- [ ] **FS-11 — Production healthcheck:** Run `php bin/healthcheck.php` with valid configuration and capture exit status plus status-only output. Verify all current schema tables are probed, no password/APP_KEY/DSN/SQL exception/path is printed, and a disposable database missing one required table produces a non-zero exit. Run the same check after restore.
|
||||
|
||||
## Restore verification
|
||||
|
||||
- [ ] Restore the pre-UAT backup to a separate database/server, never over the live database.
|
||||
|
||||
+140
-44
@@ -6,20 +6,32 @@ require_once __DIR__ . '/../app/Domain/Client/ClientValidator.php';
|
||||
require_once __DIR__ . '/../app/Domain/Client/ClientContactValidator.php';
|
||||
require_once __DIR__ . '/../app/Domain/Client/ClientRecord.php';
|
||||
require_once __DIR__ . '/../app/Domain/Client/ClientUpdateCommand.php';
|
||||
require_once __DIR__ . '/../app/Domain/Client/ContactEditCommand.php';
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/JobcardReference.php';
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/JobcardWorkflow.php';
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/AssignmentValidator.php';
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryValidator.php';
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryCommand.php';
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryCorrectionCommand.php';
|
||||
require_once __DIR__ . '/../app/Domain/SLA/SlaAgreement.php';
|
||||
require_once __DIR__ . '/../app/Domain/SLA/SlaThresholdClassifier.php';
|
||||
require_once __DIR__ . '/../app/Domain/User/PasswordPolicy.php';
|
||||
require_once __DIR__ . '/../app/Domain/User/UserRecord.php';
|
||||
require_once __DIR__ . '/../app/Domain/User/RoleRecord.php';
|
||||
require_once __DIR__ . '/../app/Domain/User/PermissionMatrix.php';
|
||||
require_once __DIR__ . '/../app/Domain/User/RolePermissionService.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/CsvExporter.php';
|
||||
require_once __DIR__ . '/../app/Domain/Notification/NotificationRecord.php';
|
||||
require_once __DIR__ . '/../app/Domain/Notification/NotificationQueue.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/ReportFilters.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/ClientJobcardReport.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/ClientHistoryReport.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/TechnicianActivityReport.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/SlaReport.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/PrintReportRenderer.php';
|
||||
require_once __DIR__ . '/../app/Domain/Credential/CredentialVault.php';
|
||||
require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformation.php';
|
||||
require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformationRepository.php';
|
||||
require_once __DIR__ . '/../app/Domain/Attachment/AttachmentValidator.php';
|
||||
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
@@ -37,6 +49,8 @@ function render_header(string $title): void
|
||||
if (can('jobcards.view')) echo '<a class="nav-link sidebar-link" href="/?route=jobcards">Jobcards</a>';
|
||||
if (can('reports.view')) echo '<a class="nav-link sidebar-link" href="/?route=reports">Reports</a>';
|
||||
if (can('users.manage')) echo '<a class="nav-link sidebar-link" href="/?route=users">Users & roles</a>';
|
||||
if (can('roles.manage')) echo '<a class="nav-link sidebar-link" href="/?route=roles">Roles & permissions</a>';
|
||||
if (can('notifications.view')) echo '<a class="nav-link sidebar-link" href="/?route=notifications">Notifications</a>';
|
||||
if (can('audit.view')) echo '<a class="nav-link sidebar-link" href="/?route=audit">Audit trail</a>';
|
||||
echo '</nav></aside><main class="col-md-10 col-lg-10 p-3 p-lg-4">';
|
||||
} else {
|
||||
@@ -87,16 +101,16 @@ $user = require_login();
|
||||
if ($route === 'dashboard') {
|
||||
require_permission('dashboard.view');
|
||||
$jobcardMetrics = db()->query("SELECT SUM(status = 'new') AS new_count, SUM(status NOT IN ('completed','closed')) AS open_count FROM jobcards")->fetch();
|
||||
$hoursThisWeek = (float)db()->query('SELECT COALESCE(SUM(hours), 0) FROM time_entries WHERE work_date >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY) AND work_date <= CURDATE()')->fetchColumn();
|
||||
$slaRows = db()->query("SELECT s.allocated_hours, s.period_type, s.start_date, s.end_date, COALESCE(SUM(CASE WHEN te.counts_toward_sla = 1 AND ((s.period_type = 'monthly' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND CURDATE()) OR (s.period_type = 'annual' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-01-01') AND CURDATE()) OR (s.period_type = 'custom' AND te.work_date BETWEEN COALESCE(s.start_date, '1000-01-01') AND COALESCE(s.end_date, CURDATE()))) THEN te.hours ELSE 0 END), 0) AS used_hours FROM sla_agreements s LEFT JOIN jobcards j ON j.client_id = s.client_id LEFT JOIN time_entries te ON te.jobcard_id = j.id WHERE s.enabled = 1 AND CURDATE() BETWEEN COALESCE(s.start_date, '1000-01-01') AND COALESCE(s.end_date, '9999-12-31') GROUP BY s.id, s.allocated_hours, s.period_type, s.start_date, s.end_date")->fetchAll();
|
||||
$hoursThisWeek = (float)db()->query("SELECT COALESCE(SUM(hours), 0) FROM time_entries WHERE work_date >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY) AND work_date <= CURDATE() AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = time_entries.id AND av.action = 'time_entry_voided')")->fetchColumn();
|
||||
$slaRows = db()->query("SELECT s.allocated_hours, s.period_type, s.start_date, s.end_date, COALESCE(SUM(CASE WHEN te.counts_toward_sla = 1 AND ((s.period_type = 'monthly' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND CURDATE()) OR (s.period_type = 'annual' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-01-01') AND CURDATE()) OR (s.period_type = 'custom' AND te.work_date BETWEEN COALESCE(s.start_date, '1000-01-01') AND COALESCE(s.end_date, CURDATE()))) THEN te.hours ELSE 0 END), 0) AS used_hours FROM sla_agreements s LEFT JOIN jobcards j ON j.client_id = s.client_id LEFT JOIN time_entries te ON te.jobcard_id = j.id AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = te.id AND av.action = 'time_entry_voided') WHERE s.enabled = 1 AND CURDATE() BETWEEN COALESCE(s.start_date, '1000-01-01') AND COALESCE(s.end_date, '9999-12-31') GROUP BY s.id, s.allocated_hours, s.period_type, s.start_date, s.end_date")->fetchAll();
|
||||
if ($user['role_name'] === 'Technician') {
|
||||
$metricStmt = db()->prepare("SELECT SUM(j.status = 'new') AS new_count, SUM(j.status NOT IN ('completed','closed')) AS open_count FROM jobcards j JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user");
|
||||
$metricStmt->execute(['user' => $user['id']]);
|
||||
$jobcardMetrics = $metricStmt->fetch();
|
||||
$hoursStmt = db()->prepare('SELECT COALESCE(SUM(te.hours), 0) FROM time_entries te JOIN jobcard_assignments ja ON ja.jobcard_id = te.jobcard_id AND ja.user_id = :user WHERE te.work_date >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY) AND te.work_date <= CURDATE()');
|
||||
$hoursStmt = db()->prepare("SELECT COALESCE(SUM(te.hours), 0) FROM time_entries te JOIN jobcard_assignments ja ON ja.jobcard_id = te.jobcard_id AND ja.user_id = :user WHERE te.work_date >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY) AND te.work_date <= CURDATE() AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = te.id AND av.action = 'time_entry_voided')");
|
||||
$hoursStmt->execute(['user' => $user['id']]);
|
||||
$hoursThisWeek = (float)$hoursStmt->fetchColumn();
|
||||
$slaRowsStmt = db()->prepare("SELECT s.allocated_hours, s.period_type, s.start_date, s.end_date, COALESCE(SUM(CASE WHEN te.counts_toward_sla = 1 AND te.work_date >= COALESCE(s.start_date, '1000-01-01') AND te.work_date <= COALESCE(s.end_date, CURDATE()) AND ((s.period_type = 'monthly' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND CURDATE()) OR (s.period_type = 'annual' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-01-01') AND CURDATE()) OR s.period_type = 'custom') THEN te.hours ELSE 0 END), 0) AS used_hours FROM sla_agreements s JOIN jobcards j ON j.client_id = s.client_id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user LEFT JOIN time_entries te ON te.jobcard_id = j.id WHERE s.enabled = 1 AND CURDATE() BETWEEN COALESCE(s.start_date, '1000-01-01') AND COALESCE(s.end_date, '9999-12-31') GROUP BY s.id, s.allocated_hours, s.period_type, s.start_date, s.end_date");
|
||||
$slaRowsStmt = db()->prepare("SELECT s.allocated_hours, s.period_type, s.start_date, s.end_date, COALESCE(SUM(CASE WHEN te.counts_toward_sla = 1 AND te.work_date >= COALESCE(s.start_date, '1000-01-01') AND te.work_date <= COALESCE(s.end_date, CURDATE()) AND ((s.period_type = 'monthly' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND CURDATE()) OR (s.period_type = 'annual' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-01-01') AND CURDATE()) OR s.period_type = 'custom') THEN te.hours ELSE 0 END), 0) AS used_hours FROM sla_agreements s JOIN jobcards j ON j.client_id = s.client_id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user LEFT JOIN time_entries te ON te.jobcard_id = j.id AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = te.id AND av.action = 'time_entry_voided') WHERE s.enabled = 1 AND CURDATE() BETWEEN COALESCE(s.start_date, '1000-01-01') AND COALESCE(s.end_date, '9999-12-31') GROUP BY s.id, s.allocated_hours, s.period_type, s.start_date, s.end_date");
|
||||
$slaRowsStmt->execute(['user' => $user['id']]);
|
||||
$slaRows = $slaRowsStmt->fetchAll();
|
||||
}
|
||||
@@ -124,6 +138,29 @@ if ($route === 'attachment') {
|
||||
readfile($path); exit;
|
||||
}
|
||||
|
||||
if ($route === 'client_history') {
|
||||
require_permission('clients.view');
|
||||
$clientId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT);
|
||||
if (!$clientId || !can_access_client($clientId)) { http_response_code(404); exit('Client not found'); }
|
||||
try { $filters = \ReportFilters::fromArray([...$_GET, 'client_id' => $clientId]); } catch (Throwable $exception) { http_response_code(400); exit('Invalid history filters'); }
|
||||
$stmt = db()->prepare('SELECT h.id, j.client_id, j.reference_no, h.from_status, h.to_status, h.changed_at, u.name AS changed_by_name FROM jobcard_status_history h JOIN jobcards j ON j.id = h.jobcard_id LEFT JOIN users u ON u.id = h.changed_by WHERE j.client_id = :client ORDER BY h.changed_at ASC, h.id ASC'); $stmt->execute(['client' => $clientId]);
|
||||
$history = (new \App\Domain\Reporting\ClientHistoryReport($filters))->build($stmt->fetchAll(), 'client');
|
||||
if (scalar_input($_GET['format'] ?? null) === 'print') { header('Content-Type: text/html; charset=UTF-8'); echo (new \PrintReportRenderer())->render('Client history', ['Reference', 'From', 'To', 'Changed'], array_map(static fn (array $row): array => [$row['reference_no'], $row['from_status'], $row['to_status'], $row['changed_at']], $history)); exit; }
|
||||
render_header('Client history'); echo '<div class="d-flex justify-content-between mb-4"><div><a href="/?route=client&id=' . (int)$clientId . '">← Back to client</a><h1 class="h3 mt-2">Client history</h1></div><a class="btn btn-outline-secondary" href="/?route=client_history&id=' . (int)$clientId . '&format=print">Print view</a></div><div class="card"><div class="table-responsive"><table class="table"><thead><tr><th>Jobcard</th><th>From</th><th>To</th><th>Changed</th></tr></thead><tbody>'; foreach ($history as $row) echo '<tr><td>' . e($row['reference_no']) . '</td><td>' . e($row['from_status']) . '</td><td>' . e($row['to_status']) . '</td><td>' . e($row['changed_at']) . '</td></tr>'; echo '</tbody></table></div></div>'; render_footer(); exit;
|
||||
}
|
||||
|
||||
if ($route === 'time_entry') {
|
||||
require_permission('time_entries.record');
|
||||
$entryId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT);
|
||||
$stmt = db()->prepare("SELECT t.*, j.client_id FROM time_entries t JOIN jobcards j ON j.id = t.jobcard_id WHERE t.id = :id AND NOT EXISTS (SELECT 1 FROM audit_events ae WHERE ae.entity_type = 'time_entry' AND ae.entity_id = t.id AND ae.action = 'time_entry_voided')"); $stmt->execute(['id' => $entryId]); $entry = $stmt->fetch();
|
||||
if (!$entry || !can_access_jobcard((int)$entry['jobcard_id']) || ($user['role_name'] === 'Technician' && (int)$entry['technician_id'] !== (int)$user['id'])) { http_response_code(404); exit('Time entry not found'); }
|
||||
$errors = [];
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { verify_csrf(); $command = scalar_input($_POST['action'] ?? null); $auditAction = $command === 'void' ? 'time_entry_voided' : 'time_entry_corrected'; $validator = new \App\Domain\Jobcard\TimeEntryCorrectionCommand(); $changes = [];
|
||||
foreach (['work_date', 'start_time', 'end_time', 'hours', 'notes', 'counts_toward_sla'] as $field) if (array_key_exists($field, $_POST)) $changes[$field] = $_POST[$field];
|
||||
$result = $command === 'void' ? $validator->validateVoid($entry, ['reason' => $_POST['reason'] ?? null]) : $validator->validateCorrection($entry, $changes); $errors = array_values($result['errors']); if (!$errors) { if ($command === 'void') audit($auditAction, 'time_entry', $entryId, ['reason' => $result['void_reason']]); else { db()->prepare('UPDATE time_entries SET work_date = :date, start_time = :start, end_time = :end, hours = :hours, notes = :notes, counts_toward_sla = :sla WHERE id = :id')->execute(['date' => $result['entry']['work_date'], 'start' => $result['entry']['start_time'] ?? null, 'end' => $result['entry']['end_time'] ?? null, 'hours' => $result['entry']['hours'], 'notes' => $result['entry']['notes'] ?? null, 'sla' => !empty($result['entry']['counts_toward_sla']) ? 1 : 0, 'id' => $entryId]); audit($auditAction, 'time_entry', $entryId); } header('Location: /?route=jobcard&id=' . (int)$entry['jobcard_id'] . '&updated=1'); exit; } }
|
||||
render_header('Time entry correction'); echo '<h1 class="h3">Correct or void time entry</h1>' . ($errors ? '<div class="alert alert-danger">' . e(implode(' ', $errors)) . '</div>' : '') . '<form method="post" class="card card-body mb-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="correct"><div class="row g-2"><div class="col-md-3"><label class="form-label">Date</label><input class="form-control" type="date" name="work_date" value="' . e($entry['work_date']) . '"></div><div class="col-md-3"><label class="form-label">Hours</label><input class="form-control" type="number" step="0.01" name="hours" value="' . e((string)$entry['hours']) . '"></div><div class="col-md-6"><label class="form-label">Notes</label><input class="form-control" name="notes" value="' . e((string)($entry['notes'] ?? '')) . '"></div></div><button class="btn btn-primary mt-3">Save correction</button></form><form method="post" class="card card-body"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="void"><label class="form-label">Void reason</label><textarea class="form-control mb-2" name="reason" required></textarea><button class="btn btn-outline-danger">Void entry</button></form>'; render_footer(); exit;
|
||||
}
|
||||
|
||||
if ($route === 'jobcard') {
|
||||
require_permission('jobcards.view');
|
||||
$jobcardId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT);
|
||||
@@ -160,6 +197,7 @@ if ($route === 'jobcard') {
|
||||
$pdo->prepare('INSERT INTO jobcard_status_history (jobcard_id, from_status, to_status, changed_by) VALUES (:jobcard, :from_status, :to_status, :user)')->execute(['jobcard' => $jobcardId, 'from_status' => $locked['status'], 'to_status' => $to, 'user' => $user['id']]);
|
||||
audit('jobcard_status_changed', 'jobcard', $jobcardId, ['from' => $locked['status'], 'to' => $to]);
|
||||
$pdo->commit();
|
||||
try { $recipientStmt = db()->prepare('SELECT u.email FROM users u JOIN jobcard_assignments ja ON ja.user_id = u.id WHERE ja.jobcard_id = :jobcard AND u.is_active = 1'); $recipientStmt->execute(['jobcard' => $jobcardId]); $recipients = array_column($recipientStmt->fetchAll(), 'email'); if ($recipients) (new \App\Domain\Notification\NotificationQueue())->enqueue(db(), ['type' => 'jobcard_status_changed', 'recipients' => $recipients, 'title' => 'Jobcard status changed', 'body' => 'Jobcard ' . $jobcard['reference_no'] . ' changed to ' . $to . '.', 'deduplication_key' => 'jobcard:' . $jobcardId . ':status:' . $to]); } catch (Throwable) { /* notification failure must not undo a committed status transition */ }
|
||||
header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit;
|
||||
}
|
||||
} catch (Throwable $exception) { if ($pdo->inTransaction()) $pdo->rollBack(); $actionErrors[] = 'Status update failed.'; }
|
||||
@@ -189,6 +227,7 @@ if ($route === 'jobcard') {
|
||||
$pdo->prepare('INSERT INTO jobcard_assignments (jobcard_id, user_id, assigned_by) VALUES (:jobcard, :user, :by_user)')->execute(['jobcard' => $jobcardId, 'user' => $technicianId, 'by_user' => $user['id']]);
|
||||
audit('jobcard_assigned', 'jobcard', $jobcardId, ['technician_id' => $technicianId]);
|
||||
$pdo->commit();
|
||||
try { $recipientStmt = db()->prepare('SELECT email FROM users WHERE id = :id AND is_active = 1'); $recipientStmt->execute(['id' => $technicianId]); $recipient = $recipientStmt->fetchColumn(); if ($recipient) (new \App\Domain\Notification\NotificationQueue())->enqueue(db(), ['type' => 'assignment_created', 'recipients' => [$recipient], 'title' => 'Jobcard assigned', 'body' => 'Jobcard ' . $jobcard['reference_no'] . ' was assigned to you.', 'deduplication_key' => 'assignment:' . $jobcardId . ':' . $technicianId]); } catch (Throwable) { /* notification failure must not undo a committed assignment */ }
|
||||
header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit;
|
||||
} catch (Throwable $exception) { if ($pdo->inTransaction()) $pdo->rollBack(); $actionErrors[] = 'Assignment update failed.'; }
|
||||
}
|
||||
@@ -255,7 +294,7 @@ if ($route === 'jobcard') {
|
||||
$jobcardStmt->execute(['id' => $jobcardId]); $jobcard = $jobcardStmt->fetch();
|
||||
$assignments = db()->prepare('SELECT u.id, u.name FROM jobcard_assignments a JOIN users u ON u.id = a.user_id WHERE a.jobcard_id = :id ORDER BY u.name'); $assignments->execute(['id' => $jobcardId]); $assigned = $assignments->fetchAll();
|
||||
$technicians = db()->query("SELECT u.id, u.name FROM users u JOIN roles r ON r.id = u.role_id WHERE u.is_active = 1 AND r.name = 'Technician' ORDER BY u.name")->fetchAll();
|
||||
$timeStmt = db()->prepare('SELECT t.*, u.name AS technician_name FROM time_entries t JOIN users u ON u.id = t.technician_id WHERE t.jobcard_id = :id ORDER BY t.work_date DESC, t.id DESC'); $timeStmt->execute(['id' => $jobcardId]); $timeEntries = $timeStmt->fetchAll();
|
||||
$timeStmt = db()->prepare("SELECT t.*, u.name AS technician_name FROM time_entries t JOIN users u ON u.id = t.technician_id WHERE t.jobcard_id = :id AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = t.id AND av.action = 'time_entry_voided') ORDER BY t.work_date DESC, t.id DESC"); $timeStmt->execute(['id' => $jobcardId]); $timeEntries = $timeStmt->fetchAll();
|
||||
$attachmentStmt = db()->prepare('SELECT id, original_name, mime_type, file_size, client_visible, created_at FROM attachments WHERE jobcard_id = :id ORDER BY created_at DESC'); $attachmentStmt->execute(['id' => $jobcardId]); $attachments = $attachmentStmt->fetchAll();
|
||||
$totalHours = array_sum(array_map(static fn (array $entry): float => (float)$entry['hours'], $timeEntries));
|
||||
render_header('Jobcard ' . $jobcard['reference_no']);
|
||||
@@ -263,7 +302,7 @@ if ($route === 'jobcard') {
|
||||
echo '<div class="row g-4"><div class="col-lg-8"><div class="card mb-4"><div class="card-body"><h2 class="h5">Work requested</h2><p class="mb-0">' . nl2br(e($jobcard['work_requested'])) . '</p></div></div><div class="card mb-4"><div class="card-body"><h2 class="h5">Work performed and notes</h2><form method="post"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="notes"><label class="form-label">Technician notes / Work performed</label><textarea class="form-control mb-3" name="technician_notes" rows="5">' . e((string)($jobcard['technician_notes'] ?? '')) . '</textarea>';
|
||||
if (can('jobcards.internal_notes')) echo '<label class="form-label">Internal notes</label><textarea class="form-control mb-3" name="internal_notes" rows="4">' . e((string)($jobcard['internal_notes'] ?? '')) . '</textarea>';
|
||||
echo '<button class="btn btn-primary">Save notes</button></form></div></div><div class="card"><div class="card-body"><div class="d-flex justify-content-between"><h2 class="h5">Time entries</h2><strong>' . e(number_format($totalHours, 2)) . ' hours</strong></div>';
|
||||
foreach ($timeEntries as $entry) echo '<div class="border-bottom py-2"><strong>' . e($entry['technician_name']) . '</strong> · ' . e($entry['work_date']) . ' · ' . e(number_format((float)$entry['hours'], 2)) . 'h<div class="small text-muted">' . e((string)($entry['notes'] ?? '')) . '</div></div>';
|
||||
foreach ($timeEntries as $entry) echo '<div class="border-bottom py-2"><strong>' . e($entry['technician_name']) . '</strong> · ' . e($entry['work_date']) . ' · ' . e(number_format((float)$entry['hours'], 2)) . 'h<div class="small text-muted">' . e((string)($entry['notes'] ?? '')) . '</div>' . (can('time_entries.record') ? '<a class="small" href="/?route=time_entry&id=' . (int)$entry['id'] . '">Correct or void</a>' : '') . '</div>';
|
||||
if (can('time_entries.record')) { echo '<hr><form method="post" class="row g-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="time">'; if ($user['role_name'] !== 'Technician') { echo '<div class="col-md-4"><select class="form-select" name="technician_id" required><option value="">Technician</option>'; foreach ($technicians as $technician) echo '<option value="' . (int)$technician['id'] . '">' . e($technician['name']) . '</option>'; echo '</select></div>'; } echo '<div class="col-md-4"><input class="form-control" type="date" name="work_date" value="' . e(date('Y-m-d')) . '" required></div><div class="col-md-4"><input class="form-control" type="number" step="0.01" min="0.01" name="hours" placeholder="Hours"></div><div class="col-md-4 form-check pt-2"><input class="form-check-input" type="checkbox" name="counts_toward_sla" value="1" id="sla-time" checked><label class="form-check-label" for="sla-time">Counts toward SLA</label></div><div class="col-12"><input class="form-control" name="notes" placeholder="Time entry notes"></div><div class="col-12"><button class="btn btn-outline-primary">Add time</button></div></form>'; }
|
||||
echo '</div></div></div><div class="col-lg-4"><div class="card mb-4"><div class="card-body"><h2 class="h5">Status</h2><form method="post"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="status"><select class="form-select mb-2" name="status">';
|
||||
foreach ((new \App\Domain\Jobcard\StatusTransitionValidator())->allowedFrom($jobcard['status']) as $status) echo '<option value="' . e($status) . '"' . ($status === $jobcard['status'] ? ' selected' : '') . '>' . e(ucwords(str_replace('_', ' ', $status))) . '</option>';
|
||||
@@ -409,6 +448,46 @@ if ($route === 'client') {
|
||||
$credentialId = (int)db()->lastInsertId(); audit('credential_created', 'credential', $credentialId, ['client_id' => $clientId]);
|
||||
header('Location: /?route=client&id=' . $clientId . '&credential_created=1'); exit;
|
||||
} catch (Throwable $exception) { $credentialErrors[] = 'Credential could not be saved.'; }
|
||||
} elseif (in_array($clientAction, ['contact_edit', 'contact_delete', 'contact_primary'], true)) {
|
||||
require_permission('clients.manage');
|
||||
$contactId = filter_var(scalar_input($_POST['contact_id'] ?? null), FILTER_VALIDATE_INT);
|
||||
$existingStmt = db()->prepare('SELECT id, client_id, name, email, phone, is_primary, notes FROM client_contacts WHERE client_id = :client ORDER BY id');
|
||||
$existingStmt->execute(['client' => $clientId]);
|
||||
$existingContacts = $existingStmt->fetchAll();
|
||||
$editor = new \App\Domain\Client\ContactEditCommand();
|
||||
$result = $clientAction === 'contact_delete' ? $editor->validateDelete((int)$contactId, $existingContacts) : ($clientAction === 'contact_primary' ? $editor->validatePrimary((int)$contactId, $existingContacts) : $editor->validateForEdit((int)$contactId, [...$_POST, 'client_id' => $clientId], $existingContacts));
|
||||
$contactErrors = array_values($result['errors']);
|
||||
if (!$contactErrors) {
|
||||
$pdo = db();
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
if ($clientAction === 'contact_delete') {
|
||||
$pdo->prepare('DELETE FROM client_contacts WHERE id = :id AND client_id = :client')->execute(['id' => $contactId, 'client' => $clientId]);
|
||||
if (!empty($result['replacement_primary_contact_id'])) $pdo->prepare('UPDATE client_contacts SET is_primary = 1 WHERE id = :id AND client_id = :client')->execute(['id' => $result['replacement_primary_contact_id'], 'client' => $clientId]);
|
||||
audit('client_contact_deleted', 'client_contact', (int)$contactId, ['client_id' => $clientId]);
|
||||
} elseif ($clientAction === 'contact_primary') {
|
||||
$pdo->prepare('UPDATE client_contacts SET is_primary = 0 WHERE client_id = :client')->execute(['client' => $clientId]);
|
||||
$pdo->prepare('UPDATE client_contacts SET is_primary = 1 WHERE id = :id AND client_id = :client')->execute(['id' => $contactId, 'client' => $clientId]);
|
||||
audit('client_contact_primary_changed', 'client_contact', (int)$contactId, ['client_id' => $clientId]);
|
||||
} else {
|
||||
$pdo->prepare('UPDATE client_contacts SET name = :name, email = :email, phone = :phone, is_primary = 0, notes = :notes WHERE id = :id AND client_id = :client')->execute(['name' => $result['name'], 'email' => $result['email'], 'phone' => $result['phone'], 'notes' => $result['notes'], 'id' => $contactId, 'client' => $clientId]);
|
||||
if ($result['is_primary']) $pdo->prepare('UPDATE client_contacts SET is_primary = 1 WHERE id = :id AND client_id = :client')->execute(['id' => $contactId, 'client' => $clientId]);
|
||||
audit('client_contact_updated', 'client_contact', (int)$contactId, ['client_id' => $clientId]);
|
||||
}
|
||||
$pdo->commit(); header('Location: /?route=client&id=' . $clientId . '&contact_updated=1'); exit;
|
||||
} catch (Throwable $exception) { if ($pdo->inTransaction()) $pdo->rollBack(); $contactErrors[] = 'The contact action could not be completed.'; }
|
||||
}
|
||||
} elseif ($clientAction === 'technical') {
|
||||
require_permission('technical.manage');
|
||||
$technicalData = $_POST;
|
||||
unset($technicalData['_csrf'], $technicalData['action'], $technicalData['category']);
|
||||
try {
|
||||
$command = (new \App\Domain\Credential\TechnicalInformationCommand())->validate(['client_id' => $clientId, 'category' => scalar_input($_POST['category'] ?? null), 'data' => $technicalData]);
|
||||
if (!$command['valid']) throw new InvalidArgumentException('Invalid technical information.');
|
||||
$record = (new \App\Domain\Credential\TechnicalInformationRepository(db()))->upsert($clientId, $command['record']['category'], $command['record']['data'], (int)$user['id']);
|
||||
audit('technical_information_updated', 'technical_information', (int)$record['id'], ['client_id' => $clientId, 'category' => $record['category']]);
|
||||
header('Location: /?route=client&id=' . $clientId . '&technical_updated=1'); exit;
|
||||
} catch (Throwable $exception) { $contactErrors[] = 'Technical information could not be saved.'; }
|
||||
} else {
|
||||
require_permission('clients.manage');
|
||||
$contact = validate_client_contact($_POST);
|
||||
@@ -434,7 +513,7 @@ if ($route === 'client') {
|
||||
}
|
||||
}
|
||||
}
|
||||
$contactsStmt = db()->prepare('SELECT name, email, phone, is_primary, notes FROM client_contacts WHERE client_id = :id ORDER BY is_primary DESC, name');
|
||||
$contactsStmt = db()->prepare('SELECT id, client_id, name, email, phone, is_primary, notes FROM client_contacts WHERE client_id = :id ORDER BY is_primary DESC, name');
|
||||
$contactsStmt->execute(['id' => $clientId]);
|
||||
$contacts = $contactsStmt->fetchAll();
|
||||
$slaAgreement = null;
|
||||
@@ -450,9 +529,9 @@ if ($route === 'client') {
|
||||
$credentialRows = $credentialStmt->fetchAll();
|
||||
}
|
||||
render_header('Client details');
|
||||
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><a href="/?route=clients" class="text-decoration-none">← Back to clients</a><h1 class="h3 mt-2 mb-1">' . e($client['name']) . '</h1><p class="text-muted mb-0">Client profile and support contacts.</p></div><span class="badge text-bg-' . ($client['status'] === 'active' ? 'success' : 'secondary') . '">' . e(ucfirst($client['status'])) . '</span></div>' . (isset($_GET['contact_created']) ? '<div class="alert alert-success">Contact added successfully.</div>' : '') . (isset($_GET['sla_updated']) ? '<div class="alert alert-success">SLA agreement updated.</div>' : '') . ($contactErrors ? '<div class="alert alert-danger">' . e(implode(' ', $contactErrors)) . '</div>' : '') . ($slaErrors ? '<div class="alert alert-danger">' . e(implode(' ', $slaErrors)) . '</div>' : '') . '<div class="row g-4"><div class="col-lg-6"><div class="card h-100"><div class="card-body"><h2 class="h5">Support information</h2><dl class="row mb-0"><dt class="col-sm-5">Email</dt><dd class="col-sm-7">' . e((string)($client['support_email'] ?? '—')) . '</dd><dt class="col-sm-5">Phone</dt><dd class="col-sm-7">' . e((string)($client['support_phone'] ?? '—')) . '</dd><dt class="col-sm-5">Preferred method</dt><dd class="col-sm-7">' . e((string)($client['preferred_contact_method'] ?? '—')) . '</dd><dt class="col-sm-5">Address</dt><dd class="col-sm-7">' . nl2br(e((string)($client['physical_address'] ?? '—'))) . '</dd></dl></div></div></div><div class="col-lg-6"><div class="card h-100"><div class="card-body"><h2 class="h5">Contacts</h2>';
|
||||
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><a href="/?route=clients" class="text-decoration-none">← Back to clients</a> <a href="/?route=client_history&id=' . (int)$clientId . '" class="text-decoration-none">View history</a><h1 class="h3 mt-2 mb-1">' . e($client['name']) . '</h1><p class="text-muted mb-0">Client profile and support contacts.</p></div><span class="badge text-bg-' . ($client['status'] === 'active' ? 'success' : 'secondary') . '">' . e(ucfirst($client['status'])) . '</span></div>' . (isset($_GET['contact_created']) ? '<div class="alert alert-success">Contact added successfully.</div>' : '') . (isset($_GET['sla_updated']) ? '<div class="alert alert-success">SLA agreement updated.</div>' : '') . ($contactErrors ? '<div class="alert alert-danger">' . e(implode(' ', $contactErrors)) . '</div>' : '') . ($slaErrors ? '<div class="alert alert-danger">' . e(implode(' ', $slaErrors)) . '</div>' : '') . '<div class="row g-4"><div class="col-lg-6"><div class="card h-100"><div class="card-body"><h2 class="h5">Support information</h2><dl class="row mb-0"><dt class="col-sm-5">Email</dt><dd class="col-sm-7">' . e((string)($client['support_email'] ?? '—')) . '</dd><dt class="col-sm-5">Phone</dt><dd class="col-sm-7">' . e((string)($client['support_phone'] ?? '—')) . '</dd><dt class="col-sm-5">Preferred method</dt><dd class="col-sm-7">' . e((string)($client['preferred_contact_method'] ?? '—')) . '</dd><dt class="col-sm-5">Address</dt><dd class="col-sm-7">' . nl2br(e((string)($client['physical_address'] ?? '—'))) . '</dd></dl></div></div></div><div class="col-lg-6"><div class="card h-100"><div class="card-body"><h2 class="h5">Contacts</h2>';
|
||||
if (!$contacts) echo '<p class="text-muted mb-0">No contacts recorded.</p>';
|
||||
foreach ($contacts as $contact) echo '<div class="border-bottom py-2"><div class="fw-semibold">' . e($contact['name']) . ($contact['is_primary'] ? ' <span class="badge text-bg-primary">Primary</span>' : '') . '</div><div class="small text-muted">' . e((string)($contact['email'] ?? '')) . ' ' . e((string)($contact['phone'] ?? '')) . '</div></div>';
|
||||
foreach ($contacts as $contact) { echo '<div class="border-bottom py-2"><div class="fw-semibold">' . e($contact['name']) . ($contact['is_primary'] ? ' <span class="badge text-bg-primary">Primary</span>' : '') . '</div><div class="small text-muted">' . e((string)($contact['email'] ?? '')) . ' ' . e((string)($contact['phone'] ?? '')) . '</div>'; if (can('clients.manage')) { echo '<form method="post" class="d-inline me-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="contact_primary"><input type="hidden" name="contact_id" value="' . (int)$contact['id'] . '"><button class="btn btn-sm btn-link p-0">Set primary</button></form><form method="post" class="d-inline"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="contact_delete"><input type="hidden" name="contact_id" value="' . (int)$contact['id'] . '"><button class="btn btn-sm btn-link text-danger p-0">Delete</button></form><form method="post" class="row g-1 mt-1"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="contact_edit"><input type="hidden" name="contact_id" value="' . (int)$contact['id'] . '"><div class="col-md-3"><input class="form-control form-control-sm" name="name" value="' . e($contact['name']) . '" required></div><div class="col-md-3"><input class="form-control form-control-sm" type="email" name="email" value="' . e((string)($contact['email'] ?? '')) . '"></div><div class="col-md-3"><input class="form-control form-control-sm" name="phone" value="' . e((string)($contact['phone'] ?? '')) . '"></div><div class="col-md-3"><button class="btn btn-sm btn-outline-secondary">Save edit</button></div></form>'; } echo '</div>'; }
|
||||
if (can('clients.manage')) echo '<hr><h3 class="h6 mt-3">Add contact</h3><form method="post" class="row g-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="contact"><div class="col-12"><input class="form-control" name="name" placeholder="Full name" value="' . e((string)$contactOld['name']) . '" required></div><div class="col-md-6"><input class="form-control" type="email" name="email" placeholder="Email" value="' . e((string)($contactOld['email'] ?? '')) . '"></div><div class="col-md-6"><input class="form-control" name="phone" placeholder="Phone" value="' . e((string)($contactOld['phone'] ?? '')) . '"></div><div class="col-12 form-check ms-2"><input class="form-check-input" type="checkbox" name="is_primary" value="1" id="contact-primary"><label class="form-check-label" for="contact-primary">Primary contact</label></div><div class="col-12"><button class="btn btn-sm btn-outline-primary">Add contact</button></div></form>';
|
||||
echo '</div></div></div></div>';
|
||||
if (can('sla.view') || can('sla.manage')) {
|
||||
@@ -474,6 +553,13 @@ if ($route === 'client') {
|
||||
if (can('credentials.manage')) { echo '<hr><h3 class="h6">Add credential</h3><form method="post" class="row g-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="credential"><div class="col-md-3"><select class="form-select" name="category">'; foreach (\App\Domain\Credential\TechnicalInformation::categories() as $category) echo '<option value="' . e($category) . '">' . e(ucfirst($category)) . '</option>'; echo '</select></div><div class="col-md-3"><input class="form-control" name="label" placeholder="Label" required></div><div class="col-md-3"><input class="form-control" name="username" placeholder="Username"></div><div class="col-md-3"><input class="form-control" type="password" name="secret" placeholder="Secret" required></div><div class="col-12"><textarea class="form-control" name="credential_notes" rows="2" placeholder="Notes"></textarea></div><div class="col-12"><button class="btn btn-primary">Encrypt and save</button></div></form>'; }
|
||||
echo '</div></div>';
|
||||
}
|
||||
if (can('technical.view') || can('technical.manage')) {
|
||||
$technicalRows = (new \App\Domain\Credential\TechnicalInformationRepository(db()))->forClient($clientId);
|
||||
echo '<div class="card mt-4"><div class="card-body"><h2 class="h5">Technical information</h2>';
|
||||
foreach ($technicalRows as $technical) { $display = $technical['display']; echo '<div class="border-bottom py-2"><strong>' . e(ucfirst($technical['category'])) . '</strong><div>' . e((string)$display['label']) . ($display['username'] ? ' · ' . e((string)$display['username']) : '') . '</div><div class="small text-muted">' . nl2br(e((string)($display['notes'] ?? ''))) . '</div></div>'; }
|
||||
if (can('technical.manage')) { echo '<hr><form method="post" class="row g-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="technical"><div class="col-md-3"><select class="form-select" name="category">'; foreach (\App\Domain\Credential\TechnicalInformationCommand::categories() as $category) echo '<option value="' . e($category) . '">' . e(ucfirst($category)) . '</option>'; echo '</select></div><div class="col-md-3"><input class="form-control" name="label" placeholder="Label" required></div><div class="col-md-3"><input class="form-control" name="username" placeholder="Username"></div><div class="col-md-3"><input class="form-control" name="notes" placeholder="Notes"></div><div class="col-12"><button class="btn btn-outline-primary">Save technical information</button></div></form>'; }
|
||||
echo '</div></div>';
|
||||
}
|
||||
if (can('clients.manage')) echo '<div class="card mt-4"><div class="card-body"><h2 class="h5">Edit client</h2><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="client_update"><div class="col-md-6"><label class="form-label">Client name</label><input class="form-control" name="name" value="' . e($client['name']) . '" required></div><div class="col-md-3"><label class="form-label">Status</label><select class="form-select" name="status"><option value="active"' . ($client['status'] === 'active' ? ' selected' : '') . '>Active</option><option value="inactive"' . ($client['status'] === 'inactive' ? ' selected' : '') . '>Inactive</option></select></div><div class="col-md-3"><label class="form-label">Preferred contact</label><input class="form-control" name="preferred_contact_method" value="' . e((string)($client['preferred_contact_method'] ?? '')) . '"></div><div class="col-md-6"><label class="form-label">Support email</label><input class="form-control" type="email" name="support_email" value="' . e((string)($client['support_email'] ?? '')) . '"></div><div class="col-md-6"><label class="form-label">Support phone</label><input class="form-control" name="support_phone" value="' . e((string)($client['support_phone'] ?? '')) . '"></div><div class="col-md-6"><label class="form-label">Physical address</label><textarea class="form-control" name="physical_address" rows="3">' . e((string)($client['physical_address'] ?? '')) . '</textarea></div><div class="col-md-6"><label class="form-label">Postal address</label><textarea class="form-control" name="postal_address" rows="3">' . e((string)($client['postal_address'] ?? '')) . '</textarea></div><div class="col-12"><label class="form-label">General notes</label><textarea class="form-control" name="general_notes" rows="3">' . e((string)($client['general_notes'] ?? '')) . '</textarea></div><div class="col-12"><button class="btn btn-primary">Save client</button></div></form></div></div>';
|
||||
render_footer();
|
||||
exit;
|
||||
@@ -555,47 +641,56 @@ if ($route === 'users') {
|
||||
echo '</tbody></table></div></div>'; render_footer(); exit;
|
||||
}
|
||||
|
||||
if (false) {
|
||||
require_permission('users.manage');
|
||||
$userErrors = [];
|
||||
if ($route === 'roles') {
|
||||
require_permission('roles.manage');
|
||||
$roleErrors = [];
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||
verify_csrf();
|
||||
$userInput = ['name' => $_POST['name'] ?? null, 'email' => $_POST['email'] ?? null, 'role_id' => $_POST['role_id'] ?? null, 'is_active' => '1', 'password' => $_POST['password'] ?? null];
|
||||
$validatedUser = (new \App\Domain\User\UserRecord())->validateForCreate($userInput);
|
||||
$userErrors = $validatedUser['errors'];
|
||||
if (!$userErrors) {
|
||||
$roleCheck = db()->prepare('SELECT id FROM roles WHERE id = :id'); $roleCheck->execute(['id' => $validatedUser['role_id']]);
|
||||
if (!$roleCheck->fetchColumn()) $userErrors['role_id'] = 'Selected role does not exist.';
|
||||
$emailCheck = db()->prepare('SELECT id FROM users WHERE email = :email'); $emailCheck->execute(['email' => $validatedUser['email']]);
|
||||
if ($emailCheck->fetchColumn()) $userErrors['email'] = 'A user with this email already exists.';
|
||||
$action = scalar_input($_POST['action'] ?? 'create');
|
||||
$roleId = filter_var(scalar_input($_POST['role_id'] ?? null), FILTER_VALIDATE_INT);
|
||||
try {
|
||||
$pdo = db(); $roleRecord = new \App\Domain\User\RoleRecord(); $matrix = new \App\Domain\User\PermissionMatrix();
|
||||
$available = array_column($pdo->query('SELECT name FROM permissions ORDER BY name')->fetchAll(), 'name');
|
||||
if ($action === 'create') {
|
||||
$validated = (new \App\Domain\User\RolePermissionService())->validateForCreate($_POST, $available); $roleErrors = $validated['errors'];
|
||||
if (!$roleErrors) { $stmt = $pdo->prepare('INSERT INTO roles (name, description) VALUES (:name, :description)'); $stmt->execute(['name' => $validated['name'], 'description' => $validated['description']]); $roleId = (int)$pdo->lastInsertId(); }
|
||||
} else {
|
||||
$roleStmt = $pdo->prepare('SELECT id, name, description FROM roles WHERE id = :id'); $roleStmt->execute(['id' => $roleId]); $role = $roleStmt->fetch();
|
||||
if (!$role) $roleErrors['role'] = 'Role not found.';
|
||||
else { $assignment = (new \App\Domain\User\RolePermissionService())->validateAssignment($role, is_array($_POST['permissions'] ?? null) ? $_POST['permissions'] : [], $available); $roleErrors = $assignment['errors']; if (!$roleErrors) { $pdo->beginTransaction(); $pdo->prepare('DELETE FROM role_permissions WHERE role_id = :role')->execute(['role' => $roleId]); $insert = $pdo->prepare('INSERT INTO role_permissions (role_id, permission_id) SELECT :role, id FROM permissions WHERE name = :name'); foreach ($assignment['permissions'] as $permission) $insert->execute(['role' => $roleId, 'name' => $permission]); $pdo->commit(); } }
|
||||
}
|
||||
if (!$userErrors) {
|
||||
$stmt = db()->prepare('INSERT INTO users (role_id, email, name, password_hash, is_active) VALUES (:role, :email, :name, :hash, 1)');
|
||||
$stmt->execute(['role' => $validatedUser['role_id'], 'email' => $validatedUser['email'], 'name' => $validatedUser['name'], 'hash' => password_hash(scalar_input($_POST['password'] ?? null), PASSWORD_DEFAULT)]);
|
||||
$newUserId = (int)db()->lastInsertId(); audit('user_created', 'user', $newUserId, ['email' => $validatedUser['email'], 'role_id' => $validatedUser['role_id']]);
|
||||
header('Location: /?route=users&created=1'); exit;
|
||||
if (!$roleErrors) { audit('role_updated', 'role', (int)$roleId); header('Location: /?route=roles&updated=1'); exit; }
|
||||
} catch (Throwable $exception) { if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack(); $roleErrors['role'] = 'Role changes could not be saved.'; }
|
||||
}
|
||||
}
|
||||
$roles = db()->query('SELECT id, name FROM roles ORDER BY name')->fetchAll();
|
||||
$users = db()->query('SELECT u.id, u.name, u.email, u.is_active, u.last_login_at, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id ORDER BY u.name')->fetchAll();
|
||||
render_header('Users');
|
||||
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Users</h1><p class="text-muted mb-0">Create and review system accounts.</p></div><button class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#new-user">New user</button></div>' . (isset($_GET['created']) ? '<div class="alert alert-success">User created successfully.</div>' : '') . ($userErrors ? '<div class="alert alert-danger">' . e(implode(' ', $userErrors)) . '</div>' : '') . '<div class="collapse mb-4" id="new-user"><div class="card"><div class="card-body"><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><div class="col-md-4"><label class="form-label">Name</label><input class="form-control" name="name" required></div><div class="col-md-4"><label class="form-label">Email</label><input class="form-control" type="email" name="email" required></div><div class="col-md-4"><label class="form-label">Role</label><select class="form-select" name="role_id" required><option value="">Choose role</option>'; foreach ($roles as $role) echo '<option value="' . (int)$role['id'] . '">' . e($role['name']) . '</option>'; echo '</select></div><div class="col-md-6"><label class="form-label">Initial password</label><input class="form-control" type="password" name="password" minlength="12" required><div class="form-text">Use upper/lowercase, number and symbol.</div></div><div class="col-12"><button class="btn btn-primary">Create user</button></div></form></div></div></div><div class="card"><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last login</th></tr></thead><tbody>';
|
||||
foreach ($users as $listedUser) echo '<tr><td>' . e($listedUser['name']) . '</td><td>' . e($listedUser['email']) . '</td><td>' . e($listedUser['role_name']) . '</td><td>' . e($listedUser['is_active'] ? 'Active' : 'Inactive') . '</td><td>' . e((string)($listedUser['last_login_at'] ?? 'Never')) . '</td></tr>';
|
||||
echo '</tbody></table></div></div>'; render_footer(); exit;
|
||||
$roles = db()->query('SELECT r.id, r.name, r.description, r.created_at, GROUP_CONCAT(p.name ORDER BY p.name SEPARATOR ", ") AS permission_names FROM roles r LEFT JOIN role_permissions rp ON rp.role_id = r.id LEFT JOIN permissions p ON p.id = rp.permission_id GROUP BY r.id, r.name, r.description, r.created_at ORDER BY r.name')->fetchAll();
|
||||
$permissions = db()->query('SELECT name, description FROM permissions ORDER BY name')->fetchAll(); render_header('Roles and permissions'); echo '<div class="d-flex justify-content-between mb-4"><div><h1 class="h3">Roles and permissions</h1><p class="text-muted">Create custom roles and assign available permissions.</p></div></div>' . ($roleErrors ? '<div class="alert alert-danger">' . e(implode(' ', $roleErrors)) . '</div>' : '') . (isset($_GET['updated']) ? '<div class="alert alert-success">Role changes saved.</div>' : '') . '<div class="card mb-4"><div class="card-body"><h2 class="h5">Create custom role</h2><form method="post" class="row g-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="create"><div class="col-md-4"><input class="form-control" name="name" placeholder="Role name" required></div><div class="col-md-5"><input class="form-control" name="description" placeholder="Description"></div><div class="col-md-3"><button class="btn btn-primary">Create role</button></div></form></div></div>';
|
||||
foreach ($roles as $role) { echo '<div class="card mb-3"><div class="card-body"><h2 class="h5">' . e($role['name']) . '</h2><p class="text-muted">' . e((string)($role['description'] ?? '')) . '</p><form method="post"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="permissions"><input type="hidden" name="role_id" value="' . (int)$role['id'] . '"><div class="row">'; $assigned = $role['permission_names'] ? explode(', ', $role['permission_names']) : []; foreach ($permissions as $permission) echo '<div class="col-md-4 form-check"><input class="form-check-input" type="checkbox" name="permissions[]" value="' . e($permission['name']) . '"' . (in_array($permission['name'], $assigned, true) ? ' checked' : '') . '><label class="form-check-label">' . e($permission['name']) . '</label></div>'; echo '</div><button class="btn btn-sm btn-outline-primary mt-3">Save permissions</button></form></div></div>'; }
|
||||
render_footer(); exit;
|
||||
}
|
||||
|
||||
if ($route === 'notifications') {
|
||||
require_permission('notifications.view');
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { verify_csrf(); $notificationId = filter_var(scalar_input($_POST['notification_id'] ?? null), FILTER_VALIDATE_INT); try { if (!(new \App\Domain\Notification\NotificationQueue())->markRead(db(), (int)$user['id'], ['notification_id' => $notificationId])) { http_response_code(404); exit('Notification not found'); } audit('notification_read', 'notification', (int)$notificationId); header('Location: /?route=notifications&read=1'); exit; } catch (Throwable $exception) { http_response_code(400); exit('Invalid notification'); } }
|
||||
$stmt = db()->prepare('SELECT id, type, title, body, read_at, created_at FROM notifications WHERE user_id = :user ORDER BY created_at DESC LIMIT 100'); $stmt->execute(['user' => $user['id']]); $notifications = $stmt->fetchAll(); render_header('Notifications'); echo '<div class="d-flex justify-content-between mb-4"><h1 class="h3">Notifications</h1></div>'; foreach ($notifications as $notification) { echo '<div class="card mb-2"><div class="card-body"><div class="d-flex justify-content-between"><strong>' . e($notification['title']) . '</strong><small class="text-muted">' . e($notification['created_at']) . '</small></div><p class="mb-2">' . e((string)($notification['body'] ?? '')) . '</p>'; if (!$notification['read_at']) echo '<form method="post"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="notification_id" value="' . (int)$notification['id'] . '"><button class="btn btn-sm btn-outline-primary">Mark read</button></form>'; echo '</div></div>'; } render_footer(); exit;
|
||||
}
|
||||
|
||||
if ($route === 'reports') {
|
||||
require_permission('reports.view');
|
||||
$format = scalar_input($_GET['format'] ?? null);
|
||||
if ($format === 'csv') require_permission('reports.export');
|
||||
if ($user['role_name'] === 'Technician') {
|
||||
$reportStmt = db()->prepare('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(CASE WHEN te.technician_id = :user THEN te.hours ELSE 0 END), 0) AS hours FROM clients c JOIN jobcards j ON j.client_id = c.id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user_assigned LEFT JOIN time_entries te ON te.jobcard_id = j.id GROUP BY c.id, c.name ORDER BY c.name');
|
||||
$reportStmt->execute(['user' => $user['id'], 'user_assigned' => $user['id']]);
|
||||
try { $filters = \ReportFilters::fromArray($_GET); } catch (Throwable $exception) { http_response_code(400); exit('Invalid report filters'); }
|
||||
$reportParams = ['client_id' => $filters->clientId ?? 0, 'status' => $filters->status ?? '', 'status_filter' => $filters->status ?? '', 'priority' => $filters->priority ?? '', 'priority_filter' => $filters->priority ?? '', 'date_from_a' => $filters->dateFrom ?? '', 'date_from_b' => $filters->dateFrom ?? '', 'date_to_a' => $filters->dateTo ?? '', 'date_to_b' => $filters->dateTo ?? ''];
|
||||
$reportScope = $user['role_name'] === 'Technician' ? 'JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user_assigned' : '';
|
||||
$reportParams['user_assigned'] = $user['id'];
|
||||
$hoursCondition = $user['role_name'] === 'Technician' ? 'te.technician_id = :user' : '1 = 1';
|
||||
$reportParams['user'] = $user['id'];
|
||||
$reportStmt = db()->prepare('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(CASE WHEN ' . $hoursCondition . ' AND (:date_from_a = "" OR te.work_date >= :date_from_b) AND (:date_to_a = "" OR te.work_date <= :date_to_b) THEN te.hours ELSE 0 END), 0) AS hours FROM clients c JOIN jobcards j ON j.client_id = c.id ' . $reportScope . ' LEFT JOIN time_entries te ON te.jobcard_id = j.id AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = "time_entry" AND av.entity_id = te.id AND av.action = "time_entry_voided") WHERE (:client_id = 0 OR c.id = :client_filter) AND (:status = "" OR j.status = :status_filter) AND (:priority = "" OR j.priority = :priority_filter) GROUP BY c.id, c.name ORDER BY c.name');
|
||||
$reportParams['client_filter'] = $filters->clientId ?? 0;
|
||||
$reportStmt->execute($reportParams);
|
||||
$reportRows = $reportStmt->fetchAll();
|
||||
} else {
|
||||
$reportRows = db()->query('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(te.hours), 0) AS hours FROM clients c LEFT JOIN jobcards j ON j.client_id = c.id LEFT JOIN time_entries te ON te.jobcard_id = j.id GROUP BY c.id, c.name ORDER BY c.name')->fetchAll();
|
||||
}
|
||||
$rows = array_map(static fn (array $row): array => [$row['client_name'], (int)$row['jobcards'], round((float)$row['hours'], 2)], $reportRows);
|
||||
$filterQuery = http_build_query(array_filter(['client_id' => $filters->clientId, 'date_from' => $filters->dateFrom, 'date_to' => $filters->dateTo, 'technician_id' => $filters->technicianId, 'status' => $filters->status, 'priority' => $filters->priority], static fn($value): bool => $value !== null && $value !== ''));
|
||||
if ($format === 'print') { header('Content-Type: text/html; charset=UTF-8'); header('Cache-Control: no-store'); echo (new \PrintReportRenderer())->render('Hours per client', ['Client', 'Jobcards', 'Hours'], $rows); exit; }
|
||||
if ($format === 'csv') {
|
||||
$csv = (new CsvExporter())->export(['Client', 'Jobcards', 'Hours'], $rows, true);
|
||||
header('Content-Type: text/csv; charset=UTF-8');
|
||||
@@ -605,18 +700,19 @@ if ($route === 'reports') {
|
||||
exit;
|
||||
}
|
||||
render_header('Reports');
|
||||
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Reports</h1><p class="text-muted mb-0">Internal hours summary by client.</p></div>';
|
||||
if (can('reports.export')) echo '<a class="btn btn-outline-primary" href="/?route=reports&format=csv">Export CSV</a>';
|
||||
echo '</div><div class="card"><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Client</th><th>Jobcards</th><th>Hours</th></tr></thead><tbody>';
|
||||
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Reports</h1><p class="text-muted mb-0">Internal hours summary by client.</p></div><a class="btn btn-outline-secondary me-2" href="/?route=reports&format=print&' . e($filterQuery) . '">Print view</a>';
|
||||
if (can('reports.export')) echo '<a class="btn btn-outline-primary" href="/?route=reports&format=csv&' . e($filterQuery) . '">Export CSV</a>';
|
||||
echo '</div><form class="row g-2 mb-3" method="get"><input type="hidden" name="route" value="reports"><div class="col-md-3"><input class="form-control" type="number" min="1" name="client_id" placeholder="Client ID" value="' . e((string)($filters->clientId ?? '')) . '"></div><div class="col-md-3"><input class="form-control" type="date" name="date_from" value="' . e((string)($filters->dateFrom ?? '')) . '"></div><div class="col-md-3"><input class="form-control" type="date" name="date_to" value="' . e((string)($filters->dateTo ?? '')) . '"></div><div class="col-auto"><button class="btn btn-outline-secondary">Apply filters</button></div></form><div class="card"><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Client</th><th>Jobcards</th><th>Hours</th></tr></thead><tbody>';
|
||||
if (!$reportRows) echo '<tr><td colspan="3" class="text-center text-muted py-4">No report data available.</td></tr>';
|
||||
foreach ($reportRows as $row) echo '<tr><td>' . e($row['client_name']) . '</td><td>' . (int)$row['jobcards'] . '</td><td>' . e(number_format((float)$row['hours'], 2)) . '</td></tr>';
|
||||
echo '</tbody></table></div></div>';
|
||||
render_footer(); exit;
|
||||
}
|
||||
|
||||
if (isset($permissionByRoute[$route])) {
|
||||
require_permission($permissionByRoute[$route]);
|
||||
render_header(ucfirst($route)); ?><div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1"><?= e(ucfirst($route)) ?></h1><p class="text-muted mb-0">This module is scaffolded for the next implementation phase.</p></div></div><div class="alert alert-info">The route is permission-protected and ready for its domain workflow.</div><?php render_footer(); exit;
|
||||
if ($route === 'audit') {
|
||||
require_permission('audit.view');
|
||||
$stmt = db()->query('SELECT a.id, a.action, a.entity_type, a.entity_id, a.metadata, a.ip_address, a.created_at, u.name AS user_name FROM audit_events a LEFT JOIN users u ON u.id = a.user_id ORDER BY a.created_at DESC, a.id DESC LIMIT 200');
|
||||
render_header('Audit trail'); echo '<h1 class="h3 mb-4">Audit trail</h1><div class="card"><div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>When</th><th>User</th><th>Action</th><th>Entity</th><th>Metadata</th></tr></thead><tbody>'; foreach ($stmt->fetchAll() as $event) echo '<tr><td>' . e($event['created_at']) . '</td><td>' . e((string)($event['user_name'] ?? 'System')) . '</td><td>' . e($event['action']) . '</td><td>' . e($event['entity_type']) . ' #' . (int)$event['entity_id'] . '</td><td><code>' . e((string)($event['metadata'] ?? '')) . '</code></td></tr>'; echo '</tbody></table></div></div>'; render_footer(); exit;
|
||||
}
|
||||
|
||||
http_response_code(404); render_header('Not found'); ?><div class="alert alert-warning">Page not found.</div><?php render_footer();
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/Domain/Client/ContactEditCommand.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/ClientHistoryService.php';
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryCorrectionCommand.php';
|
||||
|
||||
use App\Domain\Client\ContactEditCommand;
|
||||
use App\Domain\Reporting\ClientHistoryService;
|
||||
use App\Domain\Jobcard\TimeEntryCorrectionCommand;
|
||||
|
||||
function regression_assert(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) throw new RuntimeException($message);
|
||||
}
|
||||
|
||||
$contacts = [
|
||||
['id' => 1, 'client_id' => 7, 'name' => 'Jane', 'email' => 'jane@example.test', 'is_primary' => true],
|
||||
['id' => 2, 'client_id' => 7, 'name' => 'John', 'email' => 'john@example.test', 'is_primary' => false],
|
||||
];
|
||||
$contact = new ContactEditCommand();
|
||||
|
||||
$badEdit = $contact->validateEdit('not-an-id', ['client_id' => 7, 'name' => 'John'], $contacts);
|
||||
regression_assert($badEdit['valid'] === false && isset($badEdit['errors']['id']), 'Malformed contact IDs must return validation errors.');
|
||||
$move = $contact->validateEdit(2, ['client_id' => 8, 'name' => 'John'], $contacts);
|
||||
regression_assert($move['valid'] === false && isset($move['errors']['client_id']), 'A contact client ID must be immutable during edit.');
|
||||
$sole = $contact->validateDelete('2', [['id' => 2, 'client_id' => 7, 'is_primary' => true]]);
|
||||
regression_assert($sole['valid'] === false && isset($sole['errors']['delete']), 'The sole contact must not be deletable.');
|
||||
$badPrimary = $contact->setPrimary('0', $contacts);
|
||||
regression_assert($badPrimary['valid'] === false && isset($badPrimary['errors']['id']), 'Malformed primary IDs must return validation errors.');
|
||||
$primary = $contact->setPrimary(2, $contacts);
|
||||
regression_assert($primary['valid'] === true && $primary['replace_primary_contact_ids'] === [1] && $primary['audit']['event'] === 'client_contact_primary_set', 'Set-primary must nominate demotions and expose an audit payload.');
|
||||
|
||||
$history = new ClientHistoryService();
|
||||
$badHistory = $history->validateForClient('7x', [['id' => 1, 'client_id' => 7, 'changed_at' => '2026-09-01']]);
|
||||
regression_assert($badHistory['valid'] === false && isset($badHistory['errors']['client_id']), 'Malformed history client IDs must return validation errors.');
|
||||
$timeline = $history->timeline([['id' => 2, 'client_id' => 7, 'changed_at' => '2026-09-02'], ['id' => 1, 'client_id' => 7, 'changed_at' => '2026-09-01']], 7);
|
||||
regression_assert($timeline['valid'] === true && array_column($timeline['timeline'], 'id') === [1, 2], 'Client history timeline must be deterministic and controller-ready.');
|
||||
|
||||
$entry = ['id' => 9, 'jobcard_id' => 12, 'technician_id' => 4, 'work_date' => '2026-09-01', 'hours' => 2, 'notes' => 'old', 'counts_toward_sla' => true, 'voided' => false];
|
||||
$correction = new TimeEntryCorrectionCommand();
|
||||
$immutable = $correction->validateCorrection($entry, ['technician_id' => 99]);
|
||||
regression_assert($immutable['valid'] === false && isset($immutable['errors']['technician_id']), 'Technician ID must be immutable during correction.');
|
||||
$fixed = $correction->validateCorrection($entry, ['hours' => '3.25', 'notes' => ' corrected ']);
|
||||
regression_assert($fixed['valid'] === true && $fixed['audit']['changed_fields'] === ['hours', 'notes'], 'Correction must expose changed fields for audit.');
|
||||
$missingReason = $correction->validateVoid($entry, []);
|
||||
regression_assert($missingReason['valid'] === false && isset($missingReason['errors']['reason']), 'Void reason is mandatory.');
|
||||
$void = $correction->validateVoid($entry, ['reason' => 'Duplicate entry']);
|
||||
regression_assert($void['valid'] === true && $void['audit']['void_reason'] === 'Duplicate entry', 'Void must expose the normalized reason in its audit payload.');
|
||||
|
||||
printf("Domain workflow regression tests: 10 passed\n");
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Cross-module integration contract checks for the final scope.
|
||||
*
|
||||
* This is intentionally a plain executable PHP script, matching the current
|
||||
* suite. It exercises the public domain commands and checks the front
|
||||
* controller's security boundaries without requiring a live database.
|
||||
*/
|
||||
require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformation.php';
|
||||
require_once __DIR__ . '/../app/Domain/Client/ContactEditCommand.php';
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryCorrectionCommand.php';
|
||||
require_once __DIR__ . '/../app/Domain/User/RoleRecord.php';
|
||||
require_once __DIR__ . '/../app/Domain/User/PermissionMatrix.php';
|
||||
require_once __DIR__ . '/../app/Domain/Notification/NotificationRecord.php';
|
||||
require_once __DIR__ . '/../app/Domain/Notification/NotificationQueue.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/ReportFilters.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/ClientJobcardReport.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/TechnicianActivityReport.php';
|
||||
require_once __DIR__ . '/../app/Domain/Attachment/AttachmentValidator.php';
|
||||
require_once __DIR__ . '/../app/Domain/Credential/CredentialVault.php';
|
||||
require_once __DIR__ . '/../bin/healthcheck.php';
|
||||
|
||||
use App\Domain\Attachment\AttachmentValidator;
|
||||
use App\Domain\Client\ContactEditCommand;
|
||||
use App\Domain\Credential\CredentialVault;
|
||||
use App\Domain\Jobcard\TimeEntryCorrectionCommand;
|
||||
use App\Domain\Notification\NotificationQueue;
|
||||
use App\Domain\Notification\NotificationRecord;
|
||||
use App\Domain\User\PermissionMatrix;
|
||||
use App\Domain\User\RoleRecord;
|
||||
|
||||
function final_scope_assert(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) throw new RuntimeException($message);
|
||||
}
|
||||
|
||||
function final_scope_same(mixed $expected, mixed $actual, string $message): void
|
||||
{
|
||||
if ($expected !== $actual) {
|
||||
throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
|
||||
}
|
||||
}
|
||||
|
||||
$checks = 0;
|
||||
$root = dirname(__DIR__);
|
||||
$frontController = file_get_contents($root . '/public/index.php');
|
||||
$bootstrap = file_get_contents($root . '/config/bootstrap.php');
|
||||
$schema = file_get_contents($root . '/database/schema.sql');
|
||||
final_scope_assert(is_string($frontController) && is_string($bootstrap) && is_string($schema), 'Integration source fixtures must be readable.');
|
||||
|
||||
// Technical information and contact actions remain client-bound and safe.
|
||||
$technical = new \App\Domain\Credential\TechnicalInformation();
|
||||
$technicalResult = $technical->validate(['category' => ' domain ', 'label' => ' Office DNS ', 'username' => ' admin ', 'notes' => ' managed ']);
|
||||
final_scope_same(true, $technicalResult['valid'], 'Valid technical information should survive the final-scope path.');
|
||||
final_scope_same('domain', $technicalResult['category'], 'Technical information category should be canonical.');
|
||||
$contacts = new ContactEditCommand();
|
||||
$existingContacts = [
|
||||
['id' => 10, 'client_id' => 7, 'name' => 'Primary', 'email' => 'primary@example.com', 'is_primary' => true],
|
||||
['id' => 12, 'client_id' => 7, 'name' => 'Backup', 'email' => 'backup@example.com', 'is_primary' => false],
|
||||
['id' => 20, 'client_id' => 8, 'name' => 'Other client', 'email' => 'other@example.com', 'is_primary' => true],
|
||||
];
|
||||
$edit = $contacts->validateEdit(12, ['client_id' => 7, 'name' => ' Backup 2 ', 'email' => 'BACKUP2@example.com'], $existingContacts);
|
||||
final_scope_same(true, $edit['valid'], 'A valid contact edit should be accepted.');
|
||||
final_scope_same('Backup 2', $edit['name'], 'Contact edit should normalize the name.');
|
||||
$primary = $contacts->validatePrimary(12, $existingContacts);
|
||||
final_scope_same([10], $primary['replace_primary_contact_ids'], 'Promoting a contact must identify only same-client primary contacts.');
|
||||
$deletePrimary = $contacts->validateDelete(10, $existingContacts);
|
||||
final_scope_same(12, $deletePrimary['replacement_primary_contact_id'], 'Deleting a primary contact must select the lowest same-client replacement.');
|
||||
$deleteOnly = $contacts->validateDelete(20, $existingContacts);
|
||||
final_scope_assert(!$deleteOnly['valid'] && isset($deleteOnly['errors']['delete']), 'The sole contact for a client must not be deletable.');
|
||||
$checks += 4;
|
||||
|
||||
// Time correction is immutable-by-default: IDs/ownership stay fixed, voids need reasons.
|
||||
$correction = new TimeEntryCorrectionCommand();
|
||||
$existingEntry = ['id' => 31, 'jobcard_id' => 44, 'technician_id' => 9, 'work_date' => '2026-09-01', 'hours' => 1.0, 'notes' => 'old', 'counts_toward_sla' => true];
|
||||
$corrected = $correction->validateCorrection($existingEntry, ['hours' => '2.25', 'notes' => ' corrected ']);
|
||||
final_scope_same(true, $corrected['valid'], 'A valid time correction should be accepted.');
|
||||
final_scope_same(31, $corrected['id'], 'Time correction must retain the original entry ID.');
|
||||
final_scope_same(44, $corrected['entry']['jobcard_id'], 'Time correction must not move an entry to another jobcard.');
|
||||
final_scope_same(2.25, $corrected['entry']['hours'], 'Time correction should use the canonical time-entry calculation.');
|
||||
$changedOwner = $correction->validateCorrection($existingEntry, ['technician_id' => 10]);
|
||||
final_scope_assert(!$changedOwner['valid'] && isset($changedOwner['errors']['technician_id']), 'Time correction must reject ownership changes.');
|
||||
$voided = $correction->validateVoid($existingEntry, ['reason' => ' Duplicate entry ']);
|
||||
final_scope_same(true, $voided['valid'], 'A void command with a reason should be accepted.');
|
||||
final_scope_same('Duplicate entry', $voided['void_reason'], 'Void reasons should be trimmed and retained for audit.');
|
||||
$missingReason = $correction->validateVoid($existingEntry);
|
||||
final_scope_assert(!$missingReason['valid'] && isset($missingReason['errors']['reason']), 'Voiding without a reason must be rejected.');
|
||||
$alreadyVoided = $correction->validateCorrection([...$existingEntry, 'voided' => true], ['hours' => 2]);
|
||||
final_scope_assert(!$alreadyVoided['valid'] && isset($alreadyVoided['errors']['voided']), 'Voided entries must not be corrected.');
|
||||
$checks += 6;
|
||||
|
||||
// Custom roles and permission assignments are normalized, allow-listed, and protect Administrator.
|
||||
$roles = new RoleRecord();
|
||||
$role = $roles->validate(['name' => ' Dispatch ', 'description' => ' Dispatch team ']);
|
||||
final_scope_same(true, $role['valid'], 'A valid custom role should be accepted.');
|
||||
final_scope_assert(!$roles->canDelete(['name' => 'Administrator']) && $roles->canRename(['name' => 'Dispatch'], 'Operations'), 'Administrator safeguards and custom-role actions must coexist.');
|
||||
$permissions = (new PermissionMatrix())->normalize([' clients.view ', 'clients.view', 'reports.view']);
|
||||
final_scope_same(['clients.view', 'reports.view'], $permissions, 'Role permissions should be canonical and deduplicated.');
|
||||
$checks += 2;
|
||||
|
||||
// Notification event -> per-user queue DTO preserves recipient isolation and deduplication.
|
||||
$records = new NotificationRecord();
|
||||
$event = $records->statusChanged(['jobcard_id' => 44, 'to_status' => 'closed', 'recipients' => ['A@example.com', 'B@example.com']]);
|
||||
final_scope_same('jobcard:44:status:closed', $event['deduplication_key'], 'Status notifications need stable deduplication keys.');
|
||||
$queueDto = (new NotificationQueue())->mapForUser([...$event, 'title' => 'Closed'], 'b@example.com');
|
||||
final_scope_same('b@example.com', $queueDto['recipient'], 'Notification queue DTOs must target exactly one normalized user.');
|
||||
final_scope_same('jobcard_status_changed', $queueDto['type'], 'Queue DTOs must preserve event type.');
|
||||
$validation = $records->validate(['type' => 'assignment_created', 'recipients' => ['not-an-email'], 'deduplication_key' => 'x']);
|
||||
final_scope_assert(!$validation['valid'] && isset($validation['errors']['recipients']), 'Invalid notification recipients must never enter the queue.');
|
||||
$checks += 3;
|
||||
|
||||
// Report audience separation: client projection omits technician/internal fields; internal retains attribution.
|
||||
$rows = [['id' => 1, 'client_id' => 7, 'client_name' => 'Acme', 'reference_no' => 'JC-44', 'status' => 'open', 'priority' => 'high', 'created_at' => '2026-09-01', 'technician_id' => 9, 'technician_name' => 'Tech', 'internal_notes' => 'private', 'credentials' => 'secret']];
|
||||
$clientReport = (new ClientJobcardReport(ReportFilters::fromArray([])))->build($rows, 'client');
|
||||
final_scope_assert(!array_key_exists('internal_notes', $clientReport[0]) && !array_key_exists('credentials', $clientReport[0]) && !array_key_exists('technician_id', $clientReport[0]), 'Client reports must exclude internal notes, credentials and technician identifiers.');
|
||||
$internalActivity = (new TechnicianActivityReport())->build([['technician_id' => 9, 'technician_name' => 'Tech', 'client_id' => 7, 'client_name' => 'Acme', 'hours' => 2, 'counts_toward_sla' => true]], 'internal');
|
||||
$clientActivity = (new TechnicianActivityReport())->build([['technician_id' => 9, 'technician_name' => 'Tech', 'client_id' => 7, 'client_name' => 'Acme', 'hours' => 2, 'counts_toward_sla' => true]], 'client');
|
||||
final_scope_assert(array_key_exists('technician_id', $internalActivity[0]) && !array_key_exists('technician_id', $clientActivity[0]), 'Report audience must separate internal technician attribution from client output.');
|
||||
$checks += 2;
|
||||
|
||||
// Dynamic boundaries plus route-level contracts for technician scope, CSRF, attachments and credentials.
|
||||
$attachment = (new AttachmentValidator(1000))->validate(['name' => 'proof.pdf', 'mime_type' => 'application/pdf', 'size_bytes' => 100]);
|
||||
final_scope_same(true, $attachment['valid'], 'A valid attachment should pass metadata validation.');
|
||||
$vault = new CredentialVault(base64_encode(random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES)));
|
||||
$stored = $vault->encryptCredential(['id' => 3, 'category' => 'hosting', 'label' => 'Production', 'username' => 'deploy', 'secret' => 'canary-secret']);
|
||||
final_scope_assert(!array_key_exists('secret', $stored) && isset($stored['secret_ciphertext']) && !str_contains(serialize($stored), 'canary-secret'), 'Credential storage must cross the ciphertext boundary.');
|
||||
final_scope_assert(preg_match('/function can_access_jobcard\(int \$jobcardId\).*?jobcard_id = :jobcard.*?user_id = :user/s', $bootstrap) === 1, 'Technician jobcard access must be scoped by authenticated user.');
|
||||
final_scope_assert(preg_match('/SELECT DISTINCT c\.id, c\.name.*?ja\.user_id = :user/s', $frontController) === 1, 'Technician client lists must be scoped by assignment.');
|
||||
final_scope_assert(substr_count($frontController, 'verify_csrf();') >= 7, 'State-changing routes must use the central CSRF guard.');
|
||||
final_scope_assert(str_contains($frontController, "if ((\$_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST')") && str_contains($frontController, "if (\$route === 'logout')"), 'Logout must be POST-only as well as CSRF-protected.');
|
||||
final_scope_assert(str_contains($frontController, "header('X-Content-Type-Options: nosniff')") && str_contains($frontController, "basename(\$attachment['stored_name'])"), 'Attachment downloads must use safe names and nosniff.');
|
||||
final_scope_assert(str_contains($frontController, 'WHERE id = :id AND client_id = :client AND is_active = 1') && str_contains($frontController, "header('Cache-Control: no-store"), 'Credential reveal must bind client ownership and disable caching.');
|
||||
$checks += 7;
|
||||
|
||||
// Healthcheck contract: every schema table is probed and only statuses are formatted.
|
||||
final class FinalScopeFakePdo extends PDO
|
||||
{
|
||||
/** @var list<string> */
|
||||
public array $queries = [];
|
||||
public function __construct() {}
|
||||
public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): PDOStatement|false
|
||||
{
|
||||
$this->queries[] = $query;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$fakePdo = new FinalScopeFakePdo();
|
||||
final_scope_assert(deployment_check_schema($fakePdo), 'Healthcheck should probe the required schema without leaking exceptions.');
|
||||
$probed = array_map(static fn(string $query): string => trim(str_replace(['SELECT 1 FROM', '`', 'LIMIT 1'], '', $query)), $fakePdo->queries);
|
||||
preg_match_all('/CREATE TABLE IF NOT EXISTS ([a-z0-9_]+)/i', $schema, $matches);
|
||||
$schemaTables = array_values(array_unique(array_map('strtolower', $matches[1])));
|
||||
final_scope_same($schemaTables, $probed, 'Production healthcheck must cover exactly the current schema tables.');
|
||||
$formatted = deployment_format_check_report(['DB_PASSWORD' => true, 'schema' => false]);
|
||||
final_scope_same(['DB_PASSWORD' => 'OK', 'schema' => 'FAIL'], $formatted, 'Healthcheck output must contain statuses, not values.');
|
||||
$checks += 2;
|
||||
|
||||
printf("Final-scope integration tests: %d passed\n", $checks);
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/Domain/Notification/NotificationRecord.php';
|
||||
require_once __DIR__ . '/../app/Domain/Notification/NotificationQueue.php';
|
||||
|
||||
use App\Domain\Notification\NotificationRecord;
|
||||
use App\Domain\Notification\NotificationQueue;
|
||||
|
||||
function notification_contract_assert(mixed $expected, mixed $actual, string $message): void
|
||||
{
|
||||
if ($expected !== $actual) {
|
||||
throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
|
||||
}
|
||||
}
|
||||
|
||||
$record = new NotificationRecord();
|
||||
$queue = new NotificationQueue($record);
|
||||
|
||||
$display = $record->toDisplay([
|
||||
'type' => 'assignment_created',
|
||||
'recipient' => 'tech@example.com',
|
||||
'title' => '<script>alert(1)</script>',
|
||||
'body' => '<b>unsafe</b>',
|
||||
'deduplication_key' => 'assignment:42',
|
||||
]);
|
||||
notification_contract_assert('<script>alert(1)</script>', $display['title'], 'Safe display should preserve text as data, not execute or reinterpret it.');
|
||||
notification_contract_assert('<b>unsafe</b>', $display['body'], 'Safe display should expose body as text data.');
|
||||
notification_contract_assert(false, $display['is_read'], 'Display should default missing read_at to unread.');
|
||||
|
||||
notification_contract_assert(true, $record->validateMarkUnread(['notification_id' => '12'])['valid'], 'Unread command should accept a positive notification ID.');
|
||||
notification_contract_assert(true, method_exists($queue, 'markUnread'), 'Queue should expose a mark-unread command.');
|
||||
|
||||
printf("Notification contract tests: 5 passed\n");
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/Domain/Notification/NotificationRecord.php';
|
||||
require_once __DIR__ . '/../app/Domain/Notification/NotificationQueue.php';
|
||||
|
||||
use App\Domain\Notification\NotificationQueue;
|
||||
|
||||
final class NotificationFakeStatement extends PDOStatement
|
||||
{
|
||||
private array $params = [];
|
||||
public function __construct(private readonly NotificationFakePdo $pdo, private readonly string $sql) {}
|
||||
public function execute(?array $params = null): bool
|
||||
{
|
||||
$this->params = $params ?? [];
|
||||
if (str_starts_with($this->sql, 'SELECT')) {
|
||||
$this->pdo->selectedEmail = (string)($this->params['email'] ?? '');
|
||||
return true;
|
||||
}
|
||||
if (str_starts_with($this->sql, 'INSERT') && $this->pdo->failOnInsert === $this->params['user']) {
|
||||
throw new RuntimeException('simulated partial failure');
|
||||
}
|
||||
if (str_starts_with($this->sql, 'UPDATE')) {
|
||||
$this->pdo->updateParams = $this->params;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
public function fetchColumn(int $column = 0): mixed
|
||||
{
|
||||
return $this->pdo->users[$this->pdo->selectedEmail] ?? false;
|
||||
}
|
||||
public function rowCount(): int { return $this->pdo->updateRowCount; }
|
||||
}
|
||||
|
||||
final class NotificationFakePdo extends PDO
|
||||
{
|
||||
public array $users = ['one@example.com' => 1, 'two@example.com' => 2];
|
||||
public ?string $selectedEmail = null;
|
||||
public mixed $failOnInsert = null;
|
||||
public int $updateRowCount = 1;
|
||||
public array $updateParams = [];
|
||||
public bool $rolledBack = false;
|
||||
public bool $inTxn = false;
|
||||
public function __construct() {}
|
||||
public function beginTransaction(): bool { $this->inTxn = true; return true; }
|
||||
public function inTransaction(): bool { return $this->inTxn; }
|
||||
public function rollBack(): bool { $this->rolledBack = true; $this->inTxn = false; return true; }
|
||||
public function commit(): bool { $this->inTxn = false; return true; }
|
||||
public function prepare(string $query, array $options = []): PDOStatement|false { return new NotificationFakeStatement($this, $query); }
|
||||
public function lastInsertId(?string $name = null): string { return '1'; }
|
||||
}
|
||||
|
||||
function notification_persistence_assert(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) throw new RuntimeException($message);
|
||||
}
|
||||
|
||||
$pdo = new NotificationFakePdo();
|
||||
$pdo->failOnInsert = 2;
|
||||
$queue = new NotificationQueue();
|
||||
try {
|
||||
$queue->enqueue($pdo, [
|
||||
'type' => 'assignment_created', 'recipients' => ['one@example.com', 'two@example.com'],
|
||||
'title' => 'Assigned', 'body' => 'Jobcard assigned', 'deduplication_key' => 'assignment:42',
|
||||
]);
|
||||
throw new RuntimeException('Expected the simulated second-recipient failure.');
|
||||
} catch (RuntimeException $error) {
|
||||
notification_persistence_assert($error->getMessage() === 'simulated partial failure', 'The simulated partial failure should be surfaced.');
|
||||
}
|
||||
notification_persistence_assert($pdo->rolledBack, 'A partial recipient failure must roll back the whole queue transaction.');
|
||||
|
||||
notification_persistence_assert($queue->markRead($pdo, 7, ['notification_id' => '9']), 'Mark-read should update a user-owned row.');
|
||||
notification_persistence_assert($pdo->updateParams === ['id' => 9, 'user' => 7], 'Mark-read must use schema-aligned id and user_id predicates.');
|
||||
notification_persistence_assert($queue->markUnread($pdo, '7', ['id' => '9']), 'Mark-unread should update a user-owned row.');
|
||||
notification_persistence_assert($pdo->updateParams === ['id' => 9, 'user' => 7], 'Mark-unread must use schema-aligned id and user_id predicates.');
|
||||
|
||||
printf("Notification persistence tests: 6 passed\n");
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/ReportFilters.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/ReportAudience.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/ReportDataMapper.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/ReportQuery.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/HoursPerClientReport.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/TechnicianWorkloadReport.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/SlaReport.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/PrintReportRenderer.php';
|
||||
|
||||
function report_services_assert_same(mixed $expected, mixed $actual, string $message): void
|
||||
{
|
||||
if ($expected !== $actual) {
|
||||
throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
|
||||
}
|
||||
}
|
||||
|
||||
$filters = ReportFilters::fromArray([
|
||||
'date_from' => '2026-09-01', 'date_to' => '2026-09-30', 'client_id' => 7,
|
||||
'technician_id' => 4, 'status' => 'open', 'priority' => 'high', 'sla' => 'warning',
|
||||
]);
|
||||
$entries = [
|
||||
['client_id' => 7, 'client_name' => 'Acme', 'technician_id' => 4, 'technician_name' => 'Tess', 'work_date' => '2026-09-10', 'status' => 'open', 'priority' => 'high', 'sla_status' => 'warning', 'hours' => 1.25],
|
||||
['client_id' => 7, 'client_name' => 'Acme', 'technician_id' => 4, 'technician_name' => 'Tess', 'work_date' => '2026-09-11', 'status' => 'open', 'priority' => 'high', 'sla_status' => 'warning', 'hours' => 1.75],
|
||||
['client_id' => 8, 'client_name' => 'Beta', 'technician_id' => 4, 'work_date' => '2026-09-12', 'status' => 'open', 'priority' => 'high', 'sla_status' => 'warning', 'hours' => 9],
|
||||
];
|
||||
report_services_assert_same(
|
||||
[['client_id' => 7, 'client_name' => 'Acme', 'hours' => 3.0]],
|
||||
(new HoursPerClientReport($filters))->build($entries, ReportAudience::CLIENT),
|
||||
'Hours-per-client should apply the common filters before aggregation and return a client-safe projection.'
|
||||
);
|
||||
|
||||
$workload = (new TechnicianWorkloadReport($filters))->build($entries, ReportAudience::INTERNAL);
|
||||
report_services_assert_same(
|
||||
[['technician_id' => 4, 'technician_name' => 'Tess', 'hours' => 3.0, 'sla_hours' => 0.0]],
|
||||
$workload,
|
||||
'Technician workload should aggregate filtered hours with deterministic internal fields.'
|
||||
);
|
||||
$clientWorkload = (new TechnicianWorkloadReport())->build($entries, ReportAudience::CLIENT);
|
||||
report_services_assert_same(
|
||||
[['client_id' => 7, 'client_name' => 'Acme', 'hours' => 3.0, 'sla_hours' => 0.0], ['client_id' => 8, 'client_name' => 'Beta', 'hours' => 9.0, 'sla_hours' => 0.0]],
|
||||
$clientWorkload,
|
||||
'Client workload rows should preserve client attribution without technician identity.'
|
||||
);
|
||||
|
||||
$filtered = (new SlaReport(ReportFilters::fromArray(['sla' => 'critical'])))->build([
|
||||
['client_id' => 1, 'client_name' => 'Acme', 'allocated_hours' => 10, 'hours' => [9]],
|
||||
['client_id' => 2, 'client_name' => 'Beta', 'allocated_hours' => 10, 'hours' => [2]],
|
||||
], ReportAudience::CLIENT);
|
||||
report_services_assert_same(1, count($filtered), 'SLA status filtering should use the computed usage status.');
|
||||
report_services_assert_same(1, $filtered[0]['client_id'], 'SLA status filtering should retain the critical agreement.');
|
||||
|
||||
$html = (new PrintReportRenderer())->renderRecords('Rows', [
|
||||
['name' => '<b>Acme</b>', 'hours' => 3.0],
|
||||
]);
|
||||
if (!str_contains($html, 'application/pdf') || !str_contains($html, '<b>Acme</b>') || !str_contains($html, '@page')) {
|
||||
throw new RuntimeException('Print renderer should emit PDF-ready metadata, print CSS, and escaped record cells.');
|
||||
}
|
||||
|
||||
printf("Focused report service tests: 5 passed\n");
|
||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/Domain/User/RoleRecord.php';
|
||||
require_once __DIR__ . '/../app/Domain/User/PermissionMatrix.php';
|
||||
require_once __DIR__ . '/../app/Domain/User/RolePermissionService.php';
|
||||
|
||||
use App\Domain\User\PermissionMatrix;
|
||||
use App\Domain\User\RoleRecord;
|
||||
@@ -64,6 +65,23 @@ role_permission_assert_throws(
|
||||
role_permission_assert_same(true, $roles->canRename(['name' => 'Accounts'], 'Support'), 'Custom roles should be renameable.');
|
||||
role_permission_assert_same(false, $roles->canDelete(['name' => 'Administrator']), 'Administrator deletion safeguard should be queryable.');
|
||||
|
||||
// ID 1 is authoritative for the protected role; current name is not required.
|
||||
$adminEdit = (new App\Domain\User\RolePermissionService())->validateEdit(1, [
|
||||
'name' => 'Renamed Administrator', 'description' => 'changed', 'permissions' => ['clients.view'],
|
||||
]);
|
||||
role_permission_assert_same(false, $adminEdit['valid'], 'Role ID 1 must remain protected without current_name.');
|
||||
if (!isset($adminEdit['errors']['role'])) throw new RuntimeException('Administrator rename/permission changes must be rejected from ID alone.');
|
||||
role_permission_assert_same(false, $roles->canDelete(['id' => 1]), 'Role ID 1 must not be deletable without a name.');
|
||||
|
||||
$customCreate = (new App\Domain\User\RolePermissionService())->validateForCreate([
|
||||
'name' => ' Dispatch ', 'description' => ' Handles dispatch ', 'permissions' => ['CLIENTS.VIEW'],
|
||||
], ['clients.view']);
|
||||
role_permission_assert_same(true, $customCreate['valid'], 'Custom role create DTO should validate and normalize permissions.');
|
||||
role_permission_assert_same('Dispatch', $customCreate['name'], 'Custom role names should be normalized in create DTOs.');
|
||||
role_permission_assert_same(['clients.view'], $customCreate['permissions'], 'Create DTO should include canonical permissions.');
|
||||
$customDelete = (new App\Domain\User\RolePermissionService())->validateDelete(4, ['id' => 4, 'name' => 'Dispatch']);
|
||||
role_permission_assert_same(['valid' => true, 'id' => 4, 'errors' => []], $customDelete, 'Custom role delete DTO should be safe and controller-ready.');
|
||||
|
||||
$display = $roles->display([
|
||||
'id' => 4,
|
||||
'name' => 'Support Team',
|
||||
|
||||
@@ -51,7 +51,7 @@ $checks++;
|
||||
// The technician report route must sum only the logged-in technician's entries,
|
||||
// even when an assigned jobcard contains entries recorded by other technicians.
|
||||
security_regression_assert(
|
||||
preg_match('/role_name.*?Technician.*?SUM\(CASE WHEN te\.technician_id = :user THEN te\.hours ELSE 0 END\).*?ja\.user_id = :user_assigned/s', $frontController) === 1,
|
||||
str_contains($frontController, "\$hoursCondition = \$user['role_name'] === 'Technician'") && str_contains($frontController, 'te.technician_id = :user'),
|
||||
'Technician report SQL must isolate hours to the authenticated technician.'
|
||||
);
|
||||
$activity = (new TechnicianActivityReport())->build([
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformation.php';
|
||||
require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformationCommand.php';
|
||||
require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformationRepository.php';
|
||||
|
||||
use App\Domain\Credential\TechnicalInformation;
|
||||
use App\Domain\Credential\TechnicalInformationCommand;
|
||||
use App\Domain\Credential\TechnicalInformationRepository;
|
||||
|
||||
function technical_command_assert_same(mixed $expected, mixed $actual, string $message): void
|
||||
{
|
||||
if ($expected !== $actual) throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
|
||||
}
|
||||
|
||||
$command = new TechnicalInformationCommand();
|
||||
foreach ([
|
||||
'microsoft' => ['label' => 'Microsoft 365', 'tenant' => 'example.onmicrosoft.com', 'product' => 'Business Premium'],
|
||||
'network' => ['label' => 'Core network', 'hostname' => 'core-sw-01', 'ip_address' => '192.0.2.10', 'vlan' => 20],
|
||||
'router' => ['label' => 'Edge router', 'hostname' => 'edge-01', 'ip_address' => '192.0.2.1', 'model' => 'RB5009'],
|
||||
'infrastructure' => ['label' => 'Production server', 'hostname' => 'app-01', 'role' => 'application'],
|
||||
] as $category => $data) {
|
||||
$result = $command->validate(['client_id' => 7, 'category' => $category, 'data' => $data]);
|
||||
technical_command_assert_same(true, $result['valid'], "{$category} information should validate.");
|
||||
technical_command_assert_same($category, $result['record']['category'], 'Category should be retained in the normalized record.');
|
||||
technical_command_assert_same($data['label'], $result['record']['data']['label'], 'Structured category data should be retained.');
|
||||
}
|
||||
|
||||
$invalid = $command->validate(['client_id' => 7, 'category' => 'router', 'data' => ['label' => 'Router', 'ip_address' => 'not-an-ip', 'unexpected' => 'secret']]);
|
||||
if ($invalid['valid'] || !isset($invalid['errors']['data.ip_address'], $invalid['errors']['data.unexpected'])) {
|
||||
throw new RuntimeException('Structured technical information must reject invalid and unknown fields.');
|
||||
}
|
||||
|
||||
$existing = ['id' => 41, 'client_id' => 7, 'category' => 'network', 'data' => ['label' => 'Core', 'hostname' => 'sw-01', 'vlan' => 10]];
|
||||
$edit = $command->validateEdit($existing, ['data' => ['vlan' => '20', 'notes' => ' Updated ']]);
|
||||
technical_command_assert_same(true, $edit['valid'], 'A technical-information edit should validate merged data.');
|
||||
technical_command_assert_same(20, $edit['record']['data']['vlan'], 'Edit should normalize structured values.');
|
||||
technical_command_assert_same('Updated', $edit['record']['data']['notes'], 'Edit should trim text values.');
|
||||
$delete = $command->validateDelete($existing);
|
||||
technical_command_assert_same(['valid' => true, 'action' => 'delete', 'id' => 41, 'client_id' => 7, 'category' => 'network', 'errors' => []], $delete, 'Delete validation should return an adapter-ready action.');
|
||||
|
||||
$projection = $command->display(['id' => 41, 'client_id' => 7, 'category' => 'router', 'data' => ['label' => 'Edge', 'hostname' => 'edge-01', 'password' => 'do-not-show', 'secret' => 'do-not-show']]);
|
||||
technical_command_assert_same(['id' => 41, 'client_id' => 7, 'category' => 'router', 'data' => ['label' => 'Edge', 'hostname' => 'edge-01']], $projection, 'Display projection must allow-list safe structured fields.');
|
||||
|
||||
final class TechnicalInformationCommandFakePdo extends PDO
|
||||
{
|
||||
public function __construct() {}
|
||||
public function prepare(string $query, array $options = []): PDOStatement|false
|
||||
{
|
||||
return new class($query) extends PDOStatement {
|
||||
public function __construct(private string $query) {}
|
||||
public function execute(?array $params = null): bool { return true; }
|
||||
public function fetch(int $mode = PDO::FETCH_DEFAULT, int ...$args): mixed
|
||||
{
|
||||
return ['id' => 55, 'client_id' => 7, 'category' => 'microsoft', 'data_json' => '{"label":"M365","tenant":"example.onmicrosoft.com"}', 'updated_by' => null, 'created_at' => null, 'updated_at' => null];
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
$repository = new TechnicalInformationRepository(new TechnicalInformationCommandFakePdo());
|
||||
$stored = (new TechnicalInformationCommand($repository))->create(7, ['category' => 'microsoft', 'data' => ['label' => 'M365', 'tenant' => 'example.onmicrosoft.com']]);
|
||||
technical_command_assert_same(55, $stored['id'], 'Command should remain compatible with the repository adapter.');
|
||||
|
||||
printf("Technical information command tests: 9 passed\n");
|
||||
@@ -62,6 +62,15 @@ if (!isset($weakReset['errors']['password'])) throw new RuntimeException('Weak p
|
||||
$payloadReset = $users->validateReset(['password' => 'Unique&Secure123']);
|
||||
user_admin_assert_same(true, $payloadReset['valid'], 'Password-only reset payloads should be supported.');
|
||||
|
||||
// Administrator identity is authoritative from the immutable user ID, even when
|
||||
// a controller passes only editable fields and omits current role/name fields.
|
||||
$protectedEdit = $users->validateEdit(1, [
|
||||
'name' => 'Renamed', 'email' => 'admin.updated@example.test', 'role_id' => 2, 'is_active' => true,
|
||||
]);
|
||||
user_admin_assert_same(false, $protectedEdit['valid'], 'User ID 1 must remain protected without current fields.');
|
||||
if (!isset($protectedEdit['errors']['role_id'])) throw new RuntimeException('Administrator reassignment must be rejected from ID alone.');
|
||||
user_admin_assert_same(false, $users->validateDeactivate(['id' => 1, 'is_active' => true])['valid'], 'Administrator deactivation must be rejected from ID alone.');
|
||||
|
||||
$roles = new RolePermissionService();
|
||||
$available = ['clients.view', 'clients.manage', 'reports.view'];
|
||||
$assignment = $roles->validateAssignment(['id' => 4, 'name' => 'Support'], [' CLIENTS.VIEW ', 'reports.view', 'clients.view'], $available);
|
||||
|
||||
Reference in New Issue
Block a user