commit 480494c5ed7842bcc7ebd0120abfb5cb400dcbbe Author: Marco0300 Date: Tue Sep 1 18:54:47 2026 +0200 feat: bootstrap JOBcard CRM foundation diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..379ed00 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +APP_ENV=development +APP_KEY=replace-with-a-long-random-secret +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=replace-with-a-long-unique-password +DB_PASSWORD=replace-with-a-long-database-password +DB_ROOT_PASSWORD=replace-with-a-long-root-password diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28885c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +.env.* +!.env.example +vendor/ +node_modules/ +.DS_Store +storage/logs/ +storage/uploads/ +.phpunit.result.cache diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e39f3ce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM php:8.4-fpm-alpine + +RUN docker-php-ext-install pdo_mysql +WORKDIR /var/www/html + +COPY app ./app +COPY config ./config +COPY database ./database +COPY public ./public + +RUN addgroup -g 1000 appgroup && adduser -D -u 1000 -G appgroup appuser \ + && chown -R appuser:appgroup /var/www/html +USER appuser +CMD ["php-fpm", "-F"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d1bfd8 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# JOBcard & Client Management System + +Greenfield PHP/MariaDB implementation of the approved JOBcard scope. + +## Current increment + +Verified foundation and early domain slices: Docker runtime, MariaDB schema, secure session authentication, environment-based Administrator bootstrap, role-aware navigation/dashboard, CSRF protection, password hashing, audit events, client/contact validation, client search/detail views, jobcard creation/listing, jobcard references and status rules, time-entry validation/aggregation, SLA calculations/classification, and safe internal versus client-facing reporting contracts. + +## Requirements + +- Docker Engine with Compose v2 +- A `.env` file copied from `.env.example` with unique values filled in + +## Run locally + +```bash +cp .env.example .env +# Replace every placeholder in .env with unique local values. +docker compose up --build +``` + +Open http://localhost:8082. The first application boot creates the Administrator user from `ADMIN_EMAIL` and `ADMIN_PASSWORD`; the password is hashed with PHP's password API and is never stored in configuration or SQL. + +## Verification + +```bash +docker compose config +find app config database public -type f -name '*.php' -print0 | xargs -0 -n1 php -l +``` + +## Security notes + +- Do not commit `.env` or production credentials. +- Set `APP_KEY` to a long random value and keep it in a secrets manager in production. +- Credential vault encryption and the remaining domain modules are scheduled in later phases. +- The initial schema is intentionally migration-ready but is delivered as an idempotent bootstrap SQL file for the first Docker increment. diff --git a/app/.gitkeep b/app/.gitkeep new file mode 100644 index 0000000..993a96a --- /dev/null +++ b/app/.gitkeep @@ -0,0 +1 @@ +# Domain services and controllers will be added in subsequent phases. diff --git a/app/Domain/Client/ClientContactValidator.php b/app/Domain/Client/ClientContactValidator.php new file mode 100644 index 0000000..5b8eb6c --- /dev/null +++ b/app/Domain/Client/ClientContactValidator.php @@ -0,0 +1,72 @@ + trim((string)($input['name'] ?? '')), + 'email' => $email === '' ? null : strtolower($email), + 'phone' => $phone === '' ? null : $phone, + 'is_primary' => normalize_client_contact_primary($input['is_primary'] ?? $input['primary'] ?? false), + ]; +} + +/** + * Validate and normalize a client contact in one reusable operation. + */ +function validate_client_contact(array $input): array +{ + $contact = normalize_client_contact($input); + $errors = []; + + if ($contact['name'] === '') { + $errors['name'] = 'Contact name is required.'; + } elseif (mb_strlen($contact['name']) > 120) { + $errors['name'] = 'Contact name must be 120 characters or fewer.'; + } + + if ($contact['email'] !== null && filter_var($contact['email'], FILTER_VALIDATE_EMAIL) === false) { + $errors['email'] = 'Contact email must be a valid email address.'; + } elseif ($contact['email'] !== null && mb_strlen($contact['email']) > 190) { + $errors['email'] = 'Contact email must be 190 characters or fewer.'; + } + + if ($contact['phone'] !== null && mb_strlen($contact['phone']) > 60) { + $errors['phone'] = 'Contact phone must be 60 characters or fewer.'; + } + + if (!is_bool($contact['is_primary'])) { + $errors['is_primary'] = 'Primary contact flag must be boolean.'; + $contact['is_primary'] = false; + } + + return [...$contact, 'errors' => $errors]; +} + +function normalize_client_contact_primary(mixed $value): bool|int|string +{ + if (is_bool($value)) { + return $value; + } + if (is_int($value) && ($value === 0 || $value === 1)) { + return $value === 1; + } + if (is_string($value)) { + $normalized = strtolower(trim($value)); + if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) { + return true; + } + if (in_array($normalized, ['', '0', 'false', 'no', 'off'], true)) { + return false; + } + } + return $value; +} diff --git a/app/Domain/Client/ClientHelpers.php b/app/Domain/Client/ClientHelpers.php new file mode 100644 index 0000000..c1d7e2e --- /dev/null +++ b/app/Domain/Client/ClientHelpers.php @@ -0,0 +1,18 @@ + 190) $errors['name'] = 'Client name must be 190 characters or fewer.'; + if (!in_array($status, ['active', 'inactive'], true)) $errors['status'] = 'Invalid client status.'; + return ['name' => $name, 'status' => $status, 'errors' => $errors]; +} diff --git a/app/Domain/Jobcard/JobcardReference.php b/app/Domain/Jobcard/JobcardReference.php new file mode 100644 index 0000000..ecd1948 --- /dev/null +++ b/app/Domain/Jobcard/JobcardReference.php @@ -0,0 +1,24 @@ + 9999) { + throw new \InvalidArgumentException('Year must be four digits.'); + } + return sprintf('JC-%04d-%06d', $year, $sequence); + } + + public static function isValid(string $reference): bool + { + return preg_match('/^JC-\d{4}-\d{6}$/', $reference) === 1; + } +} diff --git a/app/Domain/Jobcard/StatusTransitionValidator.php b/app/Domain/Jobcard/StatusTransitionValidator.php new file mode 100644 index 0000000..063954a --- /dev/null +++ b/app/Domain/Jobcard/StatusTransitionValidator.php @@ -0,0 +1,31 @@ + ['assigned'], + 'assigned' => ['in_progress'], + 'in_progress' => ['awaiting_client', 'awaiting_parts', 'completed'], + 'awaiting_client' => ['in_progress', 'completed'], + 'awaiting_parts' => ['in_progress', 'completed'], + 'completed' => ['closed'], + 'closed' => [], + ]; + + public function canTransition(string $from, string $to): bool + { + return in_array($from, self::STATUSES, true) + && in_array($to, self::STATUSES, true) + && ($from === $to || in_array($to, self::TRANSITIONS[$from], true)); + } + + public function allowedFrom(string $from): array + { + return in_array($from, self::STATUSES, true) ? array_merge([$from], self::TRANSITIONS[$from]) : []; + } +} diff --git a/app/Domain/Jobcard/TimeAggregator.php b/app/Domain/Jobcard/TimeAggregator.php new file mode 100644 index 0000000..102dbf0 --- /dev/null +++ b/app/Domain/Jobcard/TimeAggregator.php @@ -0,0 +1,28 @@ +total(array_values(array_filter( + $entries, + static fn ($entry): bool => is_array($entry) && (($entry['counts_toward_sla'] ?? true) === true) + ))); + } +} diff --git a/app/Domain/Jobcard/TimeCalculator.php b/app/Domain/Jobcard/TimeCalculator.php new file mode 100644 index 0000000..1844f07 --- /dev/null +++ b/app/Domain/Jobcard/TimeCalculator.php @@ -0,0 +1,17 @@ += 0 ? round($manualHours, 2) : null; + } + if ($start === null || $end === null || !preg_match('/^\d{2}:\d{2}$/', $start) || !preg_match('/^\d{2}:\d{2}$/', $end)) { + return null; + } + [$startHour, $startMinute] = array_map('intval', explode(':', $start)); + [$endHour, $endMinute] = array_map('intval', explode(':', $end)); + if ($startHour > 23 || $endHour > 23 || $startMinute > 59 || $endMinute > 59) return null; + $minutes = ($endHour * 60 + $endMinute) - ($startHour * 60 + $startMinute); + return $minutes > 0 ? round($minutes / 60, 2) : null; +} diff --git a/app/Domain/Jobcard/TimeEntryValidator.php b/app/Domain/Jobcard/TimeEntryValidator.php new file mode 100644 index 0000000..4e3a6c0 --- /dev/null +++ b/app/Domain/Jobcard/TimeEntryValidator.php @@ -0,0 +1,47 @@ +validDate($date)) $errors['work_date'] = 'Work date must be a valid date.'; + + $manual = null; + if (array_key_exists('hours', $entry) && $entry['hours'] !== null) { + if (!is_numeric($entry['hours'])) { + $errors['hours'] = 'Hours must be numeric.'; + } else { + $manual = (float) $entry['hours']; + if ($manual < 0) $errors['hours'] = 'Hours must not be negative.'; + } + } + $start = $entry['start_time'] ?? null; + $end = $entry['end_time'] ?? null; + if ($manual === null && (($start === null) xor ($end === null))) { + $errors['time'] = 'Start and end time must be supplied together.'; + } + $hours = \calculate_duration_hours($start !== null ? (string)$start : null, $end !== null ? (string)$end : null, $manual); + if ($hours === null && !isset($errors['hours']) && !isset($errors['time'])) { + $errors['time'] = 'A positive duration or manual hours is required.'; + } + return ['valid' => $errors === [], 'hours' => $hours, 'errors' => $errors]; + } + + public function isValid(array $entry): bool + { + return $this->validate($entry)['valid']; + } + + private function validDate(string $date): bool + { + $parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $date); + return $parsed !== false && $parsed->format('Y-m-d') === $date; + } +} diff --git a/app/Domain/Reporting/HoursPerClientReport.php b/app/Domain/Reporting/HoursPerClientReport.php new file mode 100644 index 0000000..2483340 --- /dev/null +++ b/app/Domain/Reporting/HoursPerClientReport.php @@ -0,0 +1,34 @@ +> $entries + * @return list + */ + public function aggregate(array $entries): array + { + $totals = []; + foreach ($entries as $entry) { + $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]['hours'] += max(0.0, (float)($entry['hours'] ?? 0)); + } + $rows = array_values($totals); + 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']); + return $rows; + } +} diff --git a/app/Domain/Reporting/ReportDataMapper.php b/app/Domain/Reporting/ReportDataMapper.php new file mode 100644 index 0000000..479fe4c --- /dev/null +++ b/app/Domain/Reporting/ReportDataMapper.php @@ -0,0 +1,64 @@ + */ + private const CLIENT_FIELDS = [ + 'id', + 'name', + 'registration_number', + 'status', + 'support_email', + 'support_phone', + 'preferred_contact_method', + 'physical_address', + 'postal_address', + 'general_notes', + 'client_id', + 'client_name', + 'reference_no', + 'priority', + 'work_requested', + 'completed_at', + 'closed_at', + 'allocated_hours', + 'used_hours', + 'remaining_hours', + 'usage_percentage', + 'status_label', + 'period_type', + 'start_date', + 'end_date', + 'hours', + ]; + + /** @return array */ + public function clientFacing(array $record): array + { + $safe = []; + foreach (self::CLIENT_FIELDS as $field) { + if (array_key_exists($field, $record)) { + $safe[$field] = $record[$field]; + } + } + return $safe; + } + + /** @return array */ + public function internal(array $record): array + { + return array_diff_key($record, $this->clientFacing($record)); + } + + /** @return array{client: array, internal: array} */ + public function map(array $record): array + { + return ['client' => $this->clientFacing($record), 'internal' => $this->internal($record)]; + } +} diff --git a/app/Domain/Reporting/SlaReport.php b/app/Domain/Reporting/SlaReport.php new file mode 100644 index 0000000..73cfa86 --- /dev/null +++ b/app/Domain/Reporting/SlaReport.php @@ -0,0 +1,39 @@ +> $agreements + * @return list> + */ + public function rows(array $agreements): array + { + $rows = []; + foreach ($agreements as $agreement) { + $allocated = max(0.0, (float)($agreement['allocated_hours'] ?? 0)); + $used = 0.0; + foreach ((array)($agreement['hours'] ?? []) as $hours) { + $used += max(0.0, (float)$hours); + } + $used = round($used, 2); + $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')); + $rows[] = [ + 'client_id' => (int)($agreement['client_id'] ?? 0), + 'client_name' => (string)($agreement['client_name'] ?? ''), + 'allocated_hours' => $allocated, + 'used_hours' => $used, + 'remaining_hours' => $remaining, + 'usage_percentage' => $percentage, + 'status' => $status, + ]; + } + usort($rows, static fn (array $a, array $b): int => strcmp($a['client_name'], $b['client_name']) ?: $a['client_id'] <=> $b['client_id']); + return $rows; + } +} diff --git a/app/Domain/SLA/SlaCalculator.php b/app/Domain/SLA/SlaCalculator.php new file mode 100644 index 0000000..fd6f929 --- /dev/null +++ b/app/Domain/SLA/SlaCalculator.php @@ -0,0 +1,11 @@ + max(0.0, (float)$value), $hours)), 2); + $remaining = round(max(0.0, $allocatedHours - $used), 2); + $percentage = $allocatedHours > 0 ? round(($used / $allocatedHours) * 100, 2) : ($used > 0 ? 100.0 : 0.0); + $status = $used > $allocatedHours ? 'exceeded' : ($percentage >= 90 ? 'critical' : ($percentage >= 75 ? 'warning' : 'within_limit')); + return ['used' => $used, 'remaining' => $remaining, 'percentage' => $percentage, 'status' => $status]; +} diff --git a/app/Domain/SLA/SlaThresholdClassifier.php b/app/Domain/SLA/SlaThresholdClassifier.php new file mode 100644 index 0000000..1a90c2a --- /dev/null +++ b/app/Domain/SLA/SlaThresholdClassifier.php @@ -0,0 +1,29 @@ + 0.0 ? self::EXCEEDED : self::WITHIN_LIMIT; + if ($usedHours > $allocatedHours) return self::EXCEEDED; + $percentage = ($usedHours / $allocatedHours) * 100; + return $percentage >= 90.0 ? self::CRITICAL : ($percentage >= 75.0 ? self::WARNING : self::WITHIN_LIMIT); + } + + public function percentage(float $usedHours, float $allocatedHours): float + { + if ($allocatedHours < 0) throw new \InvalidArgumentException('Allocated hours must not be negative.'); + $usedHours = max(0.0, $usedHours); + return $allocatedHours > 0.0 ? round(($usedHours / $allocatedHours) * 100, 2) : ($usedHours > 0.0 ? 100.0 : 0.0); + } +} diff --git a/config/bootstrap.php b/config/bootstrap.php new file mode 100644 index 0000000..5d3182f --- /dev/null +++ b/config/bootstrap.php @@ -0,0 +1,141 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + return $pdo; +} + +function csrf_token(): string +{ + if (empty($_SESSION['csrf'])) { + $_SESSION['csrf'] = bin2hex(random_bytes(32)); + } + return $_SESSION['csrf']; +} + +function verify_csrf(): void +{ + $provided = (string)($_POST['_csrf'] ?? ''); + if (!hash_equals((string)($_SESSION['csrf'] ?? ''), $provided)) { + http_response_code(419); + exit('Invalid CSRF token'); + } +} + +function ensure_initial_administrator(): void +{ + static $checked = false; + if ($checked) { + return; + } + $checked = true; + $count = (int)db()->query('SELECT COUNT(*) FROM users')->fetchColumn(); + if ($count !== 0) { + return; + } + + $email = strtolower(trim(env_required('ADMIN_EMAIL'))); + $password = env_required('ADMIN_PASSWORD'); + if (strlen($password) < 12) { + throw new RuntimeException('ADMIN_PASSWORD must be at least 12 characters'); + } + $roleId = (int)db()->query("SELECT id FROM roles WHERE name = 'Administrator'")->fetchColumn(); + if ($roleId < 1) { + throw new RuntimeException('Administrator role is missing from the database'); + } + $stmt = db()->prepare('INSERT INTO users (role_id, email, name, password_hash) VALUES (:role, :email, :name, :hash)'); + $stmt->execute([ + 'role' => $roleId, + 'email' => $email, + 'name' => 'System Administrator', + 'hash' => password_hash($password, PASSWORD_DEFAULT), + ]); +} + +function current_user(): ?array +{ + ensure_initial_administrator(); + static $user = false; + if ($user !== false) { + return $user; + } + $id = $_SESSION['user_id'] ?? null; + if (!$id) { + return $user = null; + } + $stmt = db()->prepare('SELECT u.*, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = :id AND u.is_active = 1'); + $stmt->execute(['id' => $id]); + return $user = ($stmt->fetch() ?: null); +} + +function require_login(): array +{ + $user = current_user(); + if (!$user) { + header('Location: /?route=login'); + exit; + } + return $user; +} + +function can(string $permission): bool +{ + $user = current_user(); + if (!$user) return false; + static $permissions = null; + if ($permissions === null) { + $stmt = db()->prepare('SELECT p.name FROM permissions p JOIN role_permissions rp ON rp.permission_id = p.id WHERE rp.role_id = :role'); + $stmt->execute(['role' => $user['role_id']]); + $permissions = array_column($stmt->fetchAll(), 'name'); + } + return in_array($permission, $permissions, true); +} + +function require_permission(string $permission): void +{ + if (!can($permission)) { + http_response_code(403); + exit('Forbidden'); + } +} + +function audit(string $action, string $entityType, ?int $entityId = null, array $metadata = []): void +{ + $user = current_user(); + $stmt = db()->prepare('INSERT INTO audit_events (user_id, action, entity_type, entity_id, metadata, ip_address) VALUES (:user_id, :action, :entity_type, :entity_id, :metadata, :ip)'); + $stmt->execute([ + 'user_id' => $user['id'] ?? null, + 'action' => $action, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'metadata' => $metadata ? json_encode($metadata, JSON_THROW_ON_ERROR) : null, + 'ip' => $_SERVER['REMOTE_ADDR'] ?? null, + ]); +} + +function e(string $value): string +{ + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..b7e8580 --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,180 @@ +CREATE TABLE roles ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(80) NOT NULL UNIQUE, + description VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB; + +CREATE TABLE permissions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(120) NOT NULL UNIQUE, + description VARCHAR(255) NULL +) ENGINE=InnoDB; + +CREATE TABLE role_permissions ( + role_id BIGINT UNSIGNED NOT NULL, + permission_id BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (role_id, permission_id), + FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, + FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE users ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + role_id BIGINT UNSIGNED NOT NULL, + email VARCHAR(190) NOT NULL UNIQUE, + name VARCHAR(120) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + last_login_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (role_id) REFERENCES roles(id) +) ENGINE=InnoDB; + +CREATE TABLE clients ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(190) NOT NULL, + registration_number VARCHAR(120) NULL, + status ENUM('active', 'inactive') NOT NULL DEFAULT 'active', + support_email VARCHAR(190) NULL, + support_phone VARCHAR(60) NULL, + preferred_contact_method VARCHAR(40) NULL, + physical_address TEXT NULL, + postal_address TEXT NULL, + general_notes TEXT NULL, + created_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX clients_name_idx (name), + INDEX clients_status_idx (status), + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE TABLE client_contacts ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + client_id BIGINT UNSIGNED NOT NULL, + name VARCHAR(120) NOT NULL, + email VARCHAR(190) NULL, + phone VARCHAR(60) NULL, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + notes TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE, + INDEX contacts_client_idx (client_id) +) ENGINE=InnoDB; + +CREATE TABLE sla_agreements ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + client_id BIGINT UNSIGNED NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + agreement_type VARCHAR(120) NULL, + allocated_hours DECIMAL(10,2) NOT NULL DEFAULT 0, + period_type ENUM('monthly', 'annual', 'custom') NOT NULL DEFAULT 'monthly', + start_date DATE NULL, + end_date DATE NULL, + rollover_enabled BOOLEAN NOT NULL DEFAULT FALSE, + notes TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE, + INDEX sla_client_idx (client_id) +) ENGINE=InnoDB; + +CREATE TABLE jobcards ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + reference_no VARCHAR(40) NOT NULL UNIQUE, + client_id BIGINT UNSIGNED NOT NULL, + created_by BIGINT UNSIGNED NULL, + priority VARCHAR(40) NOT NULL DEFAULT 'normal', + status VARCHAR(60) NOT NULL DEFAULT 'new', + work_requested TEXT NOT NULL, + technician_notes TEXT NULL, + internal_notes TEXT NULL, + completed_at DATETIME NULL, + closed_at DATETIME NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (client_id) REFERENCES clients(id), + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX jobcards_client_idx (client_id), + INDEX jobcards_status_idx (status), + INDEX jobcards_created_idx (created_at) +) ENGINE=InnoDB; + +CREATE TABLE jobcard_assignments ( + jobcard_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + assigned_by BIGINT UNSIGNED NULL, + PRIMARY KEY (jobcard_id, user_id), + FOREIGN KEY (jobcard_id) REFERENCES jobcards(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (assigned_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE TABLE time_entries ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + jobcard_id BIGINT UNSIGNED NOT NULL, + technician_id BIGINT UNSIGNED NOT NULL, + work_date DATE NOT NULL, + start_time TIME NULL, + end_time TIME NULL, + hours DECIMAL(10,2) NOT NULL, + notes TEXT NULL, + counts_toward_sla BOOLEAN NOT NULL DEFAULT TRUE, + created_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (jobcard_id) REFERENCES jobcards(id) ON DELETE CASCADE, + FOREIGN KEY (technician_id) REFERENCES users(id), + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX time_jobcard_idx (jobcard_id), + INDEX time_date_idx (work_date) +) ENGINE=InnoDB; + +CREATE TABLE audit_events ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NULL, + action VARCHAR(120) NOT NULL, + entity_type VARCHAR(80) NOT NULL, + entity_id BIGINT UNSIGNED NULL, + metadata JSON NULL, + ip_address VARCHAR(45) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX audit_entity_idx (entity_type, entity_id), + INDEX audit_created_idx (created_at), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +INSERT INTO roles (name, description) VALUES + ('Administrator', 'Full system access'), + ('Accounts', 'Client, jobcard and reporting access'), + ('Technician', 'Assigned support work access'); + +INSERT INTO permissions (name, description) VALUES + ('dashboard.view', 'View the operational dashboard'), + ('clients.view', 'View client records'), + ('clients.manage', 'Create and edit client records'), + ('jobcards.view', 'View jobcards'), + ('jobcards.manage', 'Create and update jobcards'), + ('reports.view', 'View reports'), + ('reports.export', 'Export reports'), + ('users.manage', 'Manage users'), + ('roles.manage', 'Manage roles and permissions'), + ('audit.view', 'View audit events'), + ('credentials.view', 'View protected credentials'); + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r CROSS JOIN permissions p WHERE r.name = 'Administrator'; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r JOIN permissions p ON p.name IN +('dashboard.view','clients.view','jobcards.view','reports.view','reports.export') +WHERE r.name = 'Accounts'; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r JOIN permissions p ON p.name IN +('dashboard.view','clients.view','jobcards.view','jobcards.manage') +WHERE r.name = 'Technician'; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a1dbc8c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,55 @@ +services: + app: + build: . + environment: + APP_ENV: ${APP_ENV:-development} + APP_KEY: ${APP_KEY:?Set APP_KEY in .env} + ADMIN_EMAIL: ${ADMIN_EMAIL:?Set ADMIN_EMAIL in .env} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env} + DB_HOST: db + DB_PORT: 3306 + DB_DATABASE: jobcard + DB_USERNAME: jobcard + DB_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env} + volumes: + - ./public:/var/www/html/public + - ./app:/var/www/html/app + - ./database:/var/www/html/database + - ./config:/var/www/html/config + depends_on: + db: + condition: service_healthy + networks: [jobcard] + + web: + image: nginx:1.27-alpine + ports: + - "8082:80" + volumes: + - ./public:/var/www/html/public:ro + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: [app] + networks: [jobcard] + + db: + image: mariadb:11.4 + environment: + MARIADB_DATABASE: jobcard + MARIADB_USER: jobcard + MARIADB_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env} + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?Set DB_ROOT_PASSWORD in .env} + volumes: + - db-data:/var/lib/mysql + - ./database/schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 5s + timeout: 5s + retries: 20 + networks: [jobcard] + +volumes: + db-data: + +networks: + jobcard: diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..ee33a17 --- /dev/null +++ b/nginx/default.conf @@ -0,0 +1,18 @@ +server { + listen 80; + root /var/www/html/public; + index index.php; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /var/www/html/public$fastcgi_script_name; + fastcgi_param HTTP_PROXY ""; + fastcgi_pass app:9000; + } + + location ~ /\. { deny all; } +} diff --git a/public/assets/app.css b/public/assets/app.css new file mode 100644 index 0000000..f112b03 --- /dev/null +++ b/public/assets/app.css @@ -0,0 +1,10 @@ +body { background: #f5f7fb; } +.navbar-brand { letter-spacing: .02em; } +.metric-card { border: 0; box-shadow: 0 .25rem 1rem rgba(24, 39, 75, .06); } +.sidebar-link.active { background: rgba(13, 110, 253, .1); color: #0d6efd; font-weight: 600; } +.login-card { max-width: 430px; margin: 10vh auto; } +@media (max-width: 767.98px) { + .desktop-table { display: none; } + .mobile-card { display: block; } +} +@media (min-width: 768px) { .mobile-card { display: none; } } diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..a543e5d --- /dev/null +++ b/public/index.php @@ -0,0 +1,176 @@ + true, 'secure' => !empty($_SERVER['HTTPS']), 'samesite' => 'Lax']); +session_start(); + +function render_header(string $title): void +{ + $user = current_user(); + echo '' . e($title) . ' · JOBcard'; + if ($user) { + echo '
'; + } else { + echo '
'; + } +} +function render_footer(): void +{ + $user = current_user(); + echo '
' . ($user ? '
' : '') . ''; +} + +$route = $_GET['route'] ?? (current_user() ? 'dashboard' : 'login'); + +if ($route === 'logout') { + if (current_user()) audit('logout', 'user', (int)current_user()['id']); + $_SESSION = []; + session_destroy(); + header('Location: /?route=login'); + exit; +} + +if ($route === 'login') { + if (current_user()) { header('Location: /?route=dashboard'); exit; } + $error = null; + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verify_csrf(); + $stmt = db()->prepare('SELECT u.*, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id WHERE u.email = :email LIMIT 1'); + $stmt->execute(['email' => strtolower(trim((string)($_POST['email'] ?? ''))) ]); + $user = $stmt->fetch(); + if (!$user || !$user['is_active'] || !password_verify((string)($_POST['password'] ?? ''), $user['password_hash'])) { + $error = 'The email or password is incorrect.'; + } else { + session_regenerate_id(true); + $_SESSION['user_id'] = (int)$user['id']; + $_SESSION['csrf'] = bin2hex(random_bytes(32)); + db()->prepare('UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = :id')->execute(['id' => $user['id']]); + audit('login_success', 'user', (int)$user['id']); + header('Location: /?route=dashboard'); exit; + } + } + render_header('Sign in'); ?>

Dashboard

Your operational overview.

New jobcards
0
Open jobcards
0
Hours this week
0.0
SLA warnings
0

Foundation ready

Authentication, role-aware navigation, CSRF protection, password hashing and audit logging are active. Client and jobcard modules will populate this dashboard in the next increments.

'clients.view','jobcards'=>'jobcards.view','reports'=>'reports.view','users'=>'users.manage','audit'=>'audit.view']; +if ($route === 'jobcards') { + require_permission('jobcards.view'); + $errors = []; + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + require_permission('jobcards.manage'); + verify_csrf(); + $clientId = filter_var($_POST['client_id'] ?? null, FILTER_VALIDATE_INT); + $workRequested = trim((string)($_POST['work_requested'] ?? '')); + $priority = (string)($_POST['priority'] ?? 'normal'); + if (!$clientId || $workRequested === '' || mb_strlen($workRequested) > 10000 || !in_array($priority, ['low', 'normal', 'high', 'critical'], true)) { + $errors[] = 'Select a client, enter the requested work, and choose a valid priority.'; + } else { + $clientCheck = db()->prepare("SELECT id FROM clients WHERE id = :id AND status = 'active'"); + $clientCheck->execute(['id' => $clientId]); + if (!$clientCheck->fetchColumn()) $errors[] = 'The selected client is not active or does not exist.'; + } + if (!$errors) { + $reference = 'JC-' . date('Ymd') . '-' . strtoupper(bin2hex(random_bytes(3))); + $stmt = db()->prepare('INSERT INTO jobcards (reference_no, client_id, created_by, priority, status, work_requested) VALUES (:reference, :client, :created_by, :priority, \'new\', :requested)'); + $stmt->execute(['reference' => $reference, 'client' => $clientId, 'created_by' => $user['id'], 'priority' => $priority, 'requested' => $workRequested]); + $jobcardId = (int)db()->lastInsertId(); + audit('jobcard_created', 'jobcard', $jobcardId, ['reference_no' => $reference]); + header('Location: /?route=jobcards&created=1'); exit; + } + } + $clients = db()->query("SELECT id, name FROM clients WHERE status = 'active' ORDER BY name")->fetchAll(); + $jobcards = db()->query('SELECT j.reference_no, j.priority, j.status, j.work_requested, j.created_at, c.name AS client_name FROM jobcards j JOIN clients c ON c.id = j.client_id ORDER BY j.created_at DESC LIMIT 100')->fetchAll(); + render_header('Jobcards'); + echo '

Jobcards

Track requested work and operational status.

'; + if (can('jobcards.manage')) echo ''; + echo '
'; + if (isset($_GET['created'])) echo '
Jobcard created successfully.
'; + if ($errors) echo '
' . e(implode(' ', $errors)) . '
'; + if (can('jobcards.manage')) { echo '

Create jobcard

'; } + echo '
'; + if (!$jobcards) echo ''; + foreach ($jobcards as $jobcard) echo ''; + echo '
ReferenceClientPriorityStatusWork requestedCreated
No jobcards found.
' . e($jobcard['reference_no']) . '' . e($jobcard['client_name']) . '' . e(ucfirst($jobcard['priority'])) . '' . e(ucwords(str_replace('_', ' ', $jobcard['status']))) . '' . e($jobcard['work_requested']) . '' . e($jobcard['created_at']) . '
'; + render_footer(); exit; +} + +if ($route === 'client') { + require_permission('clients.view'); + $clientId = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT); + if (!$clientId) { http_response_code(400); exit('Invalid client'); } + $stmt = db()->prepare('SELECT * FROM clients WHERE id = :id'); + $stmt->execute(['id' => $clientId]); + $client = $stmt->fetch(); + if (!$client) { http_response_code(404); exit('Client not found'); } + $contactsStmt = db()->prepare('SELECT 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(); + render_header('Client details'); + echo '
← Back to clients

' . e($client['name']) . '

Client profile and support contacts.

' . e(ucfirst($client['status'])) . '

Support information

Email
' . e((string)($client['support_email'] ?? '—')) . '
Phone
' . e((string)($client['support_phone'] ?? '—')) . '
Preferred method
' . e((string)($client['preferred_contact_method'] ?? '—')) . '
Address
' . nl2br(e((string)($client['physical_address'] ?? '—'))) . '

Contacts

'; + if (!$contacts) echo '

No contacts recorded.

'; + foreach ($contacts as $contact) echo '
' . e($contact['name']) . ($contact['is_primary'] ? ' Primary' : '') . '
' . e((string)($contact['email'] ?? '')) . ' ' . e((string)($contact['phone'] ?? '')) . '
'; + echo '
'; + render_footer(); + exit; +} + +if ($route === 'clients') { + require_permission('clients.view'); + $errors = []; + $old = ['name' => '', 'status' => 'active']; + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + require_permission('clients.manage'); + verify_csrf(); + $validated = validate_client($_POST); + $old = $validated; + $errors = $validated['errors']; + if ($errors === []) { + $stmt = db()->prepare('INSERT INTO clients (name, status, created_by) VALUES (:name, :status, :created_by)'); + $stmt->execute(['name' => $validated['name'], 'status' => $validated['status'], 'created_by' => $user['id']]); + $clientId = (int)db()->lastInsertId(); + audit('client_created', 'client', $clientId, ['name' => $validated['name']]); + header('Location: /?route=clients&created=1'); + exit; + } + } + $search = trim((string)($_GET['q'] ?? '')); + $stmt = db()->prepare('SELECT id, name, status, support_email, support_phone, created_at FROM clients WHERE (:search = \'\' OR name LIKE :like_name OR support_email LIKE :like_email) ORDER BY name LIMIT 100'); + $stmt->execute(['search' => $search, 'like_name' => "%{$search}%", 'like_email' => "%{$search}%"]); + $clients = $stmt->fetchAll(); + render_header('Clients'); + echo '

Clients

Manage client records and support contacts.

'; + if (can('clients.manage')) echo ''; + echo '
'; + if (isset($_GET['created'])) echo '
Client created successfully.
'; + if (can('clients.manage')) { + echo '

Create client

' . (isset($errors['name']) ? '
' . e($errors['name']) . '
' : '') . '
'; + } + echo '
'; + if (!$clients) echo ''; + foreach ($clients as $client) echo ''; + echo '
ClientStatusSupport emailPhone
No clients found.
' . e($client['name']) . '' . e(ucfirst($client['status'])) . '' . e((string)($client['support_email'] ?? '—')) . '' . e((string)($client['support_phone'] ?? '—')) . '
'; + render_footer(); + exit; +} + +if (isset($permissionByRoute[$route])) { + require_permission($permissionByRoute[$route]); + render_header(ucfirst($route)); ?>

This module is scaffolded for the next implementation phase.

The route is permission-protected and ready for its domain workflow.
Page not found.
' ', + 'email' => 'not-an-email', + 'phone' => str_repeat('1', 61), + 'is_primary' => 'maybe', +]); +foreach (['name', 'email', 'phone', 'is_primary'] as $key) { + if (!isset($invalid['errors'][$key])) { + throw new RuntimeException("Expected validation error for {$key}"); + } +} + +$valid = validate_client_contact([ + 'name' => ' Jane Doe ', + 'email' => ' JANE@example.com ', + 'phone' => ' +27 11 555 0100 ', + 'is_primary' => '1', +]); +if ($valid['errors'] !== [] + || $valid['name'] !== 'Jane Doe' + || $valid['email'] !== 'jane@example.com' + || $valid['phone'] !== '+27 11 555 0100' + || $valid['is_primary'] !== true +) { + throw new RuntimeException('Expected contact input to be normalized'); +} + +$optional = normalize_client_contact(['name' => 'Sam']); +if ($optional !== ['name' => 'Sam', 'email' => null, 'phone' => null, 'is_primary' => false]) { + throw new RuntimeException('Expected optional contact fields to normalize to null/defaults'); +} + +printf("Client contact validator tests: 3 passed\n"); diff --git a/tests/ClientValidatorTest.php b/tests/ClientValidatorTest.php new file mode 100644 index 0000000..143a32e --- /dev/null +++ b/tests/ClientValidatorTest.php @@ -0,0 +1,29 @@ + str_repeat('A', 191)], 'name'], + [['name' => 'Acme', 'status' => 'paused'], 'status'], +]; +foreach ($cases as [$input, $errorKey]) { + $result = validate_client($input); + if (!isset($result['errors'][$errorKey])) { + throw new RuntimeException("Expected validation error for {$errorKey}"); + } +} +$valid = validate_client(['name' => ' Acme IT ', 'status' => 'active']); +if ($valid['errors'] !== [] || $valid['name'] !== 'Acme IT') { + throw new RuntimeException('Expected valid client input to be normalized'); +} +if (!client_name_is_duplicate(' acme IT ', ['Acme IT', 'Other'])) { + throw new RuntimeException('Expected duplicate client names to be detected case-insensitively'); +} +if (client_name_is_duplicate('New Client', ['Acme IT'])) { + throw new RuntimeException('Expected unique client name to be accepted'); +} + +printf("Client validator tests: %d passed\n", count($cases) + 2); diff --git a/tests/JobcardCalculationsTest.php b/tests/JobcardCalculationsTest.php new file mode 100644 index 0000000..5b513dc --- /dev/null +++ b/tests/JobcardCalculationsTest.php @@ -0,0 +1,24 @@ + 4.5, 'remaining' => 15.5, 'percentage' => 22.5, 'status' => 'within_limit']) { + throw new RuntimeException('Unexpected SLA calculation: ' . json_encode($sla)); +} +$over = calculate_sla_usage(10.0, [8.0, 4.0]); +if ($over['status'] !== 'exceeded' || $over['remaining'] !== 0.0) { + throw new RuntimeException('Expected exceeded SLA to clamp remaining hours to zero'); +} +printf("Jobcard calculation tests: 5 passed\n"); diff --git a/tests/JobcardDomainTest.php b/tests/JobcardDomainTest.php new file mode 100644 index 0000000..293abf7 --- /dev/null +++ b/tests/JobcardDomainTest.php @@ -0,0 +1,33 @@ +canTransition('new', 'assigned')) throw new RuntimeException('new should transition to assigned.'); +if ($transitions->canTransition('new', 'completed')) throw new RuntimeException('new should not skip to completed.'); +if (!$transitions->canTransition('in_progress', 'in_progress')) throw new RuntimeException('Same status should be allowed.'); + +$validEntry = (new TimeEntryValidator())->validate(['work_date' => '2026-09-01', 'start_time' => '09:00', 'end_time' => '11:30']); +if (!$validEntry['valid'] || $validEntry['hours'] !== 2.5 || $validEntry['errors'] !== []) throw new RuntimeException('Expected valid time entry.'); +$invalidEntry = (new TimeEntryValidator())->validate(['work_date' => 'not-a-date', 'start_time' => '11:00', 'end_time' => '10:00']); +if ($invalidEntry['valid'] || count($invalidEntry['errors']) !== 2) throw new RuntimeException('Expected invalid time entry errors.'); +$nonNumericEntry = (new TimeEntryValidator())->validate(['work_date' => '2026-09-01', 'hours' => 'not-a-number']); +if ($nonNumericEntry['valid'] || !isset($nonNumericEntry['errors']['hours'])) throw new RuntimeException('Expected non-numeric hours to be rejected.'); + +$aggregator = new TimeAggregator(); +if ($aggregator->total([['hours' => 2.25], ['hours' => 1.5], ['hours' => -4]]) !== 3.75) throw new RuntimeException('Expected positive time aggregation.'); +if ($aggregator->slaTotal([['hours' => 2, 'counts_toward_sla' => true], ['hours' => 3, 'counts_toward_sla' => false]]) !== 2.0) throw new RuntimeException('Expected SLA-filtered aggregation.'); +printf("Jobcard domain tests: 12 passed\n"); diff --git a/tests/ReportingContractsTest.php b/tests/ReportingContractsTest.php new file mode 100644 index 0000000..57d63ea --- /dev/null +++ b/tests/ReportingContractsTest.php @@ -0,0 +1,68 @@ + 7, + 'name' => 'Acme IT', + 'status' => 'active', + 'support_email' => 'support@example.test', + 'internal_notes' => 'never disclose', + 'password' => 'secret', + 'credentials' => 'token', + 'technical_ip' => '10.0.0.1', + 'unknown_field' => 'not approved', +]; +$mapper = new ReportDataMapper(); +reporting_assert_same( + ['id' => 7, 'name' => 'Acme IT', 'status' => 'active', 'support_email' => 'support@example.test'], + $mapper->clientFacing($record), + 'Client-facing report data must be allow-listed.' +); +reporting_assert_same( + ['internal_notes' => 'never disclose', 'password' => 'secret', 'credentials' => 'token', 'technical_ip' => '10.0.0.1', 'unknown_field' => 'not approved'], + $mapper->internal($record), + 'Internal report data must remain separate from client-facing data.' +); + +$hours = new HoursPerClientReport(); +reporting_assert_same( + [ + ['client_id' => 7, 'client_name' => 'Acme IT', 'hours' => 3.0], + ['client_id' => 2, 'client_name' => 'Beta', 'hours' => 3.5], + ], + $hours->aggregate([ + ['client_id' => 7, 'client_name' => 'Acme IT', 'hours' => 1.25], + ['client_id' => 2, 'client_name' => 'Beta', 'hours' => 3.5, 'internal_notes' => 'omit'], + ['client_id' => 7, 'client_name' => 'Acme IT', 'hours' => 1.75, 'password' => 'omit'], + ]), + 'Hours must aggregate deterministically per client.' +); + +$sla = new SlaReport(); +reporting_assert_same( + [ + ['client_id' => 7, 'client_name' => 'Acme IT', 'allocated_hours' => 10.0, 'used_hours' => 9.0, 'remaining_hours' => 1.0, 'usage_percentage' => 90.0, 'status' => 'critical'], + ], + $sla->rows([[ + 'client_id' => 7, + 'client_name' => 'Acme IT', + 'allocated_hours' => 10, + 'hours' => [4, 5, -2], + 'internal_notes' => 'omit', + 'credentials' => 'omit', + ]]), + 'SLA rows must expose only safe, deterministic report fields.' +); + +printf("Reporting contract tests: 3 passed\n"); diff --git a/tests/SlaDomainTest.php b/tests/SlaDomainTest.php new file mode 100644 index 0000000..71c01db --- /dev/null +++ b/tests/SlaDomainTest.php @@ -0,0 +1,13 @@ +classify(7.5, 10.0) !== 'warning') throw new RuntimeException('75% should be warning.'); +if ($classifier->classify(9.0, 10.0) !== 'critical') throw new RuntimeException('90% should be critical.'); +if ($classifier->classify(10.01, 10.0) !== 'exceeded') throw new RuntimeException('Over allocation should be exceeded.'); +if ($classifier->classify(0.0, 0.0) !== 'within_limit') throw new RuntimeException('No allocation and no usage should be within limit.'); +printf("SLA domain tests: 4 passed\n"); diff --git a/tests/smoke.php b/tests/smoke.php new file mode 100644 index 0000000..f9224cc --- /dev/null +++ b/tests/smoke.php @@ -0,0 +1,22 @@ +alert("x")') === '<script>alert("x")</script>'); +$checks++; + +$missingRaised = false; +try { + env_required('JOBcard_MISSING_VALUE'); +} catch (RuntimeException $exception) { + $missingRaised = str_contains($exception->getMessage(), 'JOBcard_MISSING_VALUE'); +} +assert($missingRaised === true); +$checks++; + +printf("Foundation smoke tests: %d passed\n", $checks);