| @@ -0,0 +1,13 @@ | |||||
| { | |||||
| "permissions": { | |||||
| "allow": [ | |||||
| "Bash(Get-ChildItem \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\" -Force)", | |||||
| "Bash(Select-Object Name, Attributes)", | |||||
| "Bash(Format-Table -AutoSize)", | |||||
| "PowerShell(php -r \"json_decode\\(file_get_contents\\('d:/Development/PHP/Campaign-Tracker/composer.json'\\), true\\) === null ? print\\('INVALID JSON'\\) : print\\('JSON OK'\\);\")", | |||||
| "PowerShell(php -l \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\\\\core\\\\Database.php\")", | |||||
| "PowerShell(php -l \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\\\\app\\\\Controllers\\\\AuthController.php\")", | |||||
| "PowerShell(docker compose exec campaign-tracker-app php scripts/debug_sheets.php 2>&1)" | |||||
| ] | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,19 @@ | |||||
| APP_ENV=local | |||||
| APP_DEBUG=true | |||||
| DB_HOST=sqlserver | |||||
| DB_PORT=1433 | |||||
| DB_DATABASE=Campaign_Tracker | |||||
| DB_USERNAME=sa | |||||
| DB_PASSWORD=Dev_Password123! | |||||
| # ── Keycloak ─────────────────────────────────────────────────────────────────── | |||||
| # KEYCLOAK_BASE_URL: Base URL of your Keycloak server. | |||||
| # Keycloak 17+ (no /auth prefix): http://localhost:8080 | |||||
| # Keycloak < 17 (has /auth prefix): http://localhost:8080/auth | |||||
| KEYCLOAK_BASE_URL=http://kci-app01.ntp.kentcommunications.com:8180/ | |||||
| KEYCLOAK_REALM=KCI | |||||
| KEYCLOAK_CLIENT_ID=canopy-web | |||||
| KEYCLOAK_CLIENT_SECRET=LHWXp5UUuES00Dz2iCjTJJgX9su6co0y | |||||
| KEYCLOAK_REDIRECT_URI=http://localhost:8801/auth/callback | |||||
| KEYCLOAK_LOGOUT_REDIRECT_URI=http://localhost:8801/login | |||||
| @@ -0,0 +1,47 @@ | |||||
| # Dependencies | |||||
| /vendor/ | |||||
| # Environment | |||||
| .env | |||||
| .env.local | |||||
| .env.*.local | |||||
| # Logs | |||||
| *.log | |||||
| /storage/logs/ | |||||
| # Cache | |||||
| /storage/cache/ | |||||
| /bootstrap/cache/ | |||||
| .phpunit.result.cache | |||||
| /.php-cs-fixer.cache | |||||
| /.php_cs.cache | |||||
| # Uploads / generated | |||||
| /public/uploads/ | |||||
| /public/build/ | |||||
| /public/hot | |||||
| # Composer | |||||
| composer.phar | |||||
| # PHPUnit | |||||
| /coverage/ | |||||
| .phpunit.cache/ | |||||
| # IDE & OS | |||||
| .idea/ | |||||
| .vscode/ | |||||
| *.sublime-project | |||||
| *.sublime-workspace | |||||
| .DS_Store | |||||
| Thumbs.db | |||||
| desktop.ini | |||||
| # Docker | |||||
| .docker/data/ | |||||
| # Node (if any frontend tooling) | |||||
| node_modules/ | |||||
| npm-debug.log* | |||||
| yarn-error.log* | |||||
| @@ -0,0 +1,910 @@ | |||||
| # AGENT.md — PHP Coding Standard | |||||
| This file defines the coding standards and working rules for AI agents and developers contributing to this PHP codebase. It is based on the principles from **PHP: The Right Way** and adapted into practical project instructions. | |||||
| Source reference: https://phptherightway.com/ | |||||
| --- | |||||
| ## 1. Core Philosophy | |||||
| Write PHP that is: | |||||
| - **Readable** before clever. | |||||
| - **Secure by default**. | |||||
| - **Consistent with community standards**. | |||||
| - **Easy to test, debug, and refactor**. | |||||
| - **Separated by responsibility**: routing, controllers, services, models, persistence, templates, and configuration should not be mixed together. | |||||
| PHP does not have only one canonical “right way,” so prefer widely accepted standards, documented project conventions, and clear tradeoffs over personal style. | |||||
| --- | |||||
| ## 2. PHP Version Standard | |||||
| Use the current stable PHP version supported by the project. | |||||
| Default expectation: | |||||
| ```text | |||||
| PHP 8.x+ | |||||
| ``` | |||||
| Do not introduce code that depends on unsupported PHP versions unless the project explicitly targets a legacy runtime. | |||||
| When adding a language feature, verify that it is supported by the project’s configured PHP version in `composer.json`. | |||||
| Example: | |||||
| ```json | |||||
| { | |||||
| "require": { | |||||
| "php": ">=8.2" | |||||
| } | |||||
| } | |||||
| ``` | |||||
| --- | |||||
| ## 3. Coding Style | |||||
| Follow recognized PHP standards unless the repository already defines stricter rules. | |||||
| Preferred standards: | |||||
| - **PSR-1**: Basic Coding Standard | |||||
| - **PSR-12**: Extended Coding Style | |||||
| - **PSR-4**: Autoloading | |||||
| Use automated tooling rather than manual formatting arguments. | |||||
| Recommended tools: | |||||
| ```bash | |||||
| composer require --dev squizlabs/php_codesniffer | |||||
| composer require --dev friendsofphp/php-cs-fixer | |||||
| ``` | |||||
| Example checks: | |||||
| ```bash | |||||
| vendor/bin/phpcs --standard=PSR12 src tests | |||||
| vendor/bin/php-cs-fixer fix --dry-run --diff | |||||
| ``` | |||||
| Example fix: | |||||
| ```bash | |||||
| vendor/bin/php-cs-fixer fix | |||||
| ``` | |||||
| ### Naming Rules | |||||
| Use English names for code symbols and infrastructure. | |||||
| Use: | |||||
| ```php | |||||
| class InvoiceRepository | |||||
| { | |||||
| public function findByCustomerId(int $customerId): array | |||||
| { | |||||
| // ... | |||||
| } | |||||
| } | |||||
| ``` | |||||
| Avoid unclear abbreviations: | |||||
| ```php | |||||
| class InvRepo | |||||
| { | |||||
| public function fbcid($cid) | |||||
| { | |||||
| // ... | |||||
| } | |||||
| } | |||||
| ``` | |||||
| ### Formatting Rules | |||||
| - Use `<?php` tags. Do not use short open tags. | |||||
| - Use strict types at the top of new PHP files when practical: | |||||
| ```php | |||||
| declare(strict_types=1); | |||||
| ``` | |||||
| - One class per file. | |||||
| - Match namespaces to directory structure. | |||||
| - Keep functions and methods small and focused. | |||||
| - Prefer explicit visibility: `public`, `protected`, or `private`. | |||||
| - Avoid global state unless required by the framework or legacy integration. | |||||
| --- | |||||
| ## 4. Project Structure | |||||
| Prefer a predictable structure. | |||||
| Example: | |||||
| ```text | |||||
| project-root/ | |||||
| public/ | |||||
| index.php | |||||
| src/ | |||||
| Controller/ | |||||
| Service/ | |||||
| Repository/ | |||||
| Entity/ | |||||
| ValueObject/ | |||||
| templates/ | |||||
| config/ | |||||
| tests/ | |||||
| var/ | |||||
| cache/ | |||||
| logs/ | |||||
| vendor/ | |||||
| composer.json | |||||
| ``` | |||||
| Rules: | |||||
| - `public/` is the web root. | |||||
| - Do not expose `src/`, `config/`, `tests/`, `vendor/`, or `.env` files through the web server. | |||||
| - Put application code under `src/`. | |||||
| - Put generated cache/log files under `var/` or another ignored runtime directory. | |||||
| - Keep secrets outside the web root. | |||||
| --- | |||||
| ## 5. Namespaces and Autoloading | |||||
| All new application classes must use namespaces. | |||||
| Use PSR-4 autoloading through Composer. | |||||
| Example `composer.json`: | |||||
| ```json | |||||
| { | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "App\\": "src/" | |||||
| } | |||||
| }, | |||||
| "autoload-dev": { | |||||
| "psr-4": { | |||||
| "Tests\\": "tests/" | |||||
| } | |||||
| } | |||||
| } | |||||
| ``` | |||||
| After changing autoload rules, run: | |||||
| ```bash | |||||
| composer dump-autoload | |||||
| ``` | |||||
| Example class: | |||||
| ```php | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Service; | |||||
| final class InvoiceCalculator | |||||
| { | |||||
| public function calculateTotal(array $items): int | |||||
| { | |||||
| // Return cents, not floating-point dollars. | |||||
| return array_sum(array_column($items, 'amountCents')); | |||||
| } | |||||
| } | |||||
| ``` | |||||
| --- | |||||
| ## 6. Dependency Management | |||||
| Use Composer for PHP dependencies. | |||||
| Rules: | |||||
| - Add packages with `composer require` or `composer require --dev`. | |||||
| - Commit `composer.json` and `composer.lock` for applications. | |||||
| - Do not manually copy vendor libraries into the project. | |||||
| - Do not edit files under `vendor/`. | |||||
| - Prefer maintained packages with clear documentation, tests, and recent releases. | |||||
| - Remove unused packages. | |||||
| Commands: | |||||
| ```bash | |||||
| composer install | |||||
| composer update vendor/package | |||||
| composer audit | |||||
| composer validate | |||||
| ``` | |||||
| Use `composer update` intentionally. Do not casually update every dependency in unrelated work. | |||||
| --- | |||||
| ## 7. Object-Oriented Design | |||||
| Prefer clear object-oriented code for domain and application logic. | |||||
| Use classes for cohesive behavior: | |||||
| ```php | |||||
| final class CustomerName | |||||
| { | |||||
| public function __construct(private string $value) | |||||
| { | |||||
| if (trim($value) === '') { | |||||
| throw new InvalidArgumentException('Customer name is required.'); | |||||
| } | |||||
| } | |||||
| public function value(): string | |||||
| { | |||||
| return $this->value; | |||||
| } | |||||
| } | |||||
| ``` | |||||
| Guidelines: | |||||
| - Keep controllers thin. | |||||
| - Put business rules in services or domain objects. | |||||
| - Put persistence logic in repositories or data access classes. | |||||
| - Use interfaces when multiple implementations are expected or when it improves testing. | |||||
| - Avoid huge “utility” classes. | |||||
| - Avoid magic methods unless they provide clear framework integration or a documented benefit. | |||||
| --- | |||||
| ## 8. Dependency Injection | |||||
| Prefer dependency injection over creating dependencies inside classes. | |||||
| Good: | |||||
| ```php | |||||
| final class RegisterUser | |||||
| { | |||||
| public function __construct( | |||||
| private UserRepository $users, | |||||
| private PasswordHasher $passwords | |||||
| ) { | |||||
| } | |||||
| public function handle(string $email, string $plainPassword): void | |||||
| { | |||||
| $hash = $this->passwords->hash($plainPassword); | |||||
| $this->users->create($email, $hash); | |||||
| } | |||||
| } | |||||
| ``` | |||||
| Avoid: | |||||
| ```php | |||||
| final class RegisterUser | |||||
| { | |||||
| public function handle(string $email, string $plainPassword): void | |||||
| { | |||||
| $users = new UserRepository(); | |||||
| $passwords = new PasswordHasher(); | |||||
| // ... | |||||
| } | |||||
| } | |||||
| ``` | |||||
| Rules: | |||||
| - Constructor injection is preferred for required dependencies. | |||||
| - Do not use service locators casually. | |||||
| - Do not hide dependencies in global variables. | |||||
| - Keep dependency containers at application boundaries, not inside domain logic. | |||||
| --- | |||||
| ## 9. Database Access | |||||
| Use PDO or a well-maintained database abstraction layer/ORM. | |||||
| Never concatenate untrusted input into SQL. | |||||
| Bad: | |||||
| ```php | |||||
| $sql = "SELECT * FROM users WHERE id = " . $_GET['id']; | |||||
| ``` | |||||
| Good: | |||||
| ```php | |||||
| $stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id'); | |||||
| $stmt->bindValue(':id', $id, PDO::PARAM_INT); | |||||
| $stmt->execute(); | |||||
| $user = $stmt->fetch(PDO::FETCH_ASSOC); | |||||
| ``` | |||||
| Rules: | |||||
| - Use prepared statements and bound parameters. | |||||
| - Validate input before using it in writes. | |||||
| - Keep SQL out of templates. | |||||
| - Keep database access out of controllers where practical. | |||||
| - Use transactions when multiple writes must succeed or fail together. | |||||
| - Do not rely only on client-side validation. | |||||
| - Do not expose raw database errors to users. | |||||
| Transaction example: | |||||
| ```php | |||||
| $pdo->beginTransaction(); | |||||
| try { | |||||
| $orders->create($order); | |||||
| $auditLog->record('order.created', $order->id()); | |||||
| $pdo->commit(); | |||||
| } catch (Throwable $e) { | |||||
| $pdo->rollBack(); | |||||
| throw $e; | |||||
| } | |||||
| ``` | |||||
| --- | |||||
| ## 10. Input Validation and Output Escaping | |||||
| Treat all external data as untrusted. | |||||
| Untrusted data includes: | |||||
| - `$_GET` | |||||
| - `$_POST` | |||||
| - `$_REQUEST` | |||||
| - `$_COOKIE` | |||||
| - `$_SERVER` | |||||
| - uploaded files | |||||
| - request bodies | |||||
| - session values | |||||
| - database values originally supplied by users | |||||
| - third-party API responses | |||||
| ### Validate on Input | |||||
| Example: | |||||
| ```php | |||||
| $email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL); | |||||
| if ($email === false || $email === null) { | |||||
| throw new InvalidArgumentException('A valid email address is required.'); | |||||
| } | |||||
| ``` | |||||
| ### Escape on Output | |||||
| For HTML output: | |||||
| ```php | |||||
| function e(string $value): string | |||||
| { | |||||
| return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); | |||||
| } | |||||
| ``` | |||||
| Usage: | |||||
| ```php | |||||
| <p><?= e($user->name()) ?></p> | |||||
| ``` | |||||
| Rules: | |||||
| - Escape based on context: HTML, attribute, JavaScript, CSS, URL, SQL, shell. | |||||
| - Do not use the same escaping function for every context. | |||||
| - Prefer template engines with automatic escaping when appropriate. | |||||
| - Avoid allowing raw HTML from users. If required, sanitize with a proven whitelist sanitizer. | |||||
| - Use `escapeshellarg()` when passing controlled values to shell commands, and avoid shell execution when possible. | |||||
| - Never trust file paths supplied by users. Reject path traversal values such as `../`, `/`, `\`, and null bytes. | |||||
| --- | |||||
| ## 11. Passwords and Authentication | |||||
| Never store plain-text passwords. | |||||
| Use PHP’s password API: | |||||
| ```php | |||||
| $hash = password_hash($plainPassword, PASSWORD_DEFAULT); | |||||
| if (! password_verify($plainPassword, $hash)) { | |||||
| throw new RuntimeException('Invalid credentials.'); | |||||
| } | |||||
| ``` | |||||
| Rules: | |||||
| - Use `password_hash()` for new password hashes. | |||||
| - Use `password_verify()` for login checks. | |||||
| - Use `password_needs_rehash()` when algorithm/cost settings change. | |||||
| - Do not create your own password hashing algorithm. | |||||
| - Do not use general-purpose hashes like `md5`, `sha1`, or raw `sha256` for passwords. | |||||
| - Rate-limit login attempts. | |||||
| - Regenerate session IDs after login. | |||||
| - Use secure, HTTP-only, SameSite cookies for sessions. | |||||
| --- | |||||
| ## 12. Serialization and Data Exchange | |||||
| Do not call `unserialize()` on untrusted data. | |||||
| Prefer JSON for data exchange: | |||||
| ```php | |||||
| $data = json_decode($json, true, flags: JSON_THROW_ON_ERROR); | |||||
| $json = json_encode($data, JSON_THROW_ON_ERROR); | |||||
| ``` | |||||
| Rules: | |||||
| - Use `JSON_THROW_ON_ERROR` for new code. | |||||
| - Validate decoded data before using it. | |||||
| - Avoid PHP serialization for data that crosses trust boundaries. | |||||
| --- | |||||
| ## 13. Configuration and Secrets | |||||
| Rules: | |||||
| - Keep secrets out of source control. | |||||
| - Do not commit passwords, API keys, private keys, tokens, or production DSNs. | |||||
| - Store configuration outside the public web root. | |||||
| - Use environment variables or ignored local config files for secrets. | |||||
| - Provide a safe example file such as `.env.example`. | |||||
| Example `.gitignore` entries: | |||||
| ```text | |||||
| .env | |||||
| .env.local | |||||
| /config/local.php | |||||
| /var/cache/ | |||||
| /var/log/ | |||||
| /vendor/ | |||||
| ``` | |||||
| Example `.env.example`: | |||||
| ```text | |||||
| APP_ENV=local | |||||
| APP_DEBUG=true | |||||
| DATABASE_URL=mysql://user:password@localhost:3306/app | |||||
| ``` | |||||
| --- | |||||
| ## 14. Error Handling and Logging | |||||
| Use exceptions for exceptional failure paths. | |||||
| Development: | |||||
| - Show errors locally. | |||||
| - Log errors. | |||||
| - Use Xdebug when debugging complex issues. | |||||
| Production: | |||||
| - Do not display errors to users. | |||||
| - Log errors to a secure log destination. | |||||
| - Return safe, generic error messages. | |||||
| - Preserve enough context in logs for troubleshooting. | |||||
| Do not leak: | |||||
| - stack traces to users | |||||
| - SQL statements with secrets | |||||
| - environment variables | |||||
| - full filesystem paths | |||||
| - tokens or passwords | |||||
| Example: | |||||
| ```php | |||||
| try { | |||||
| $service->handle($request); | |||||
| } catch (Throwable $e) { | |||||
| $logger->error('Order processing failed.', [ | |||||
| 'exception' => $e, | |||||
| 'requestId' => $requestId, | |||||
| ]); | |||||
| http_response_code(500); | |||||
| echo 'An unexpected error occurred.'; | |||||
| } | |||||
| ``` | |||||
| --- | |||||
| ## 15. Templates and Views | |||||
| Keep presentation separate from business logic. | |||||
| Rules: | |||||
| - Do not query the database from templates. | |||||
| - Do not place business rules in templates. | |||||
| - Escape output by default. | |||||
| - Prefer simple view models or arrays passed into templates. | |||||
| - Use a template engine with automatic escaping when it fits the project. | |||||
| Plain PHP template example: | |||||
| ```php | |||||
| <h1><?= e($pageTitle) ?></h1> | |||||
| <ul> | |||||
| <?php foreach ($users as $user): ?> | |||||
| <li><?= e($user->name()) ?></li> | |||||
| <?php endforeach; ?> | |||||
| </ul> | |||||
| ``` | |||||
| --- | |||||
| ## 16. HTTP and Web Application Rules | |||||
| Rules: | |||||
| - Use the front controller pattern where appropriate. | |||||
| - Keep routing separate from business logic. | |||||
| - Validate request methods. | |||||
| - Use CSRF protection for state-changing forms. | |||||
| - Use proper HTTP status codes. | |||||
| - Redirect after successful POST to avoid duplicate form submission. | |||||
| - Do not trust headers such as `X-Forwarded-For` unless configured behind a trusted proxy. | |||||
| Example POST guard: | |||||
| ```php | |||||
| if ($_SERVER['REQUEST_METHOD'] !== 'POST') { | |||||
| http_response_code(405); | |||||
| exit('Method Not Allowed'); | |||||
| } | |||||
| ``` | |||||
| --- | |||||
| ## 17. Security Checklist | |||||
| Before completing any feature, verify: | |||||
| - [ ] All external input is validated. | |||||
| - [ ] All output is escaped for the correct context. | |||||
| - [ ] SQL uses prepared statements or safe query builders. | |||||
| - [ ] Authentication and authorization are checked server-side. | |||||
| - [ ] Secrets are not committed. | |||||
| - [ ] Errors are not exposed in production responses. | |||||
| - [ ] File uploads validate size, extension, MIME type, and storage path. | |||||
| - [ ] Passwords use `password_hash()` and `password_verify()`. | |||||
| - [ ] CSRF protection exists for state-changing requests. | |||||
| - [ ] Dangerous functions are avoided or justified: `eval`, `exec`, `shell_exec`, `system`, `passthru`, `unserialize`. | |||||
| - [ ] Dependencies have no known vulnerabilities according to `composer audit`. | |||||
| --- | |||||
| ## 18. Testing Standard | |||||
| Automated tests are expected for new behavior. | |||||
| Preferred tools: | |||||
| - PHPUnit | |||||
| - Pest, if the project already uses it | |||||
| Rules: | |||||
| - Add or update tests with every behavior change. | |||||
| - Cover success paths and failure paths. | |||||
| - Unit-test business logic. | |||||
| - Integration-test database and framework wiring where useful. | |||||
| - Functional-test important user flows. | |||||
| - Avoid relying on `var_dump()` or manual browser testing as the only verification. | |||||
| Example PHPUnit test: | |||||
| ```php | |||||
| final class InvoiceCalculatorTest extends TestCase | |||||
| { | |||||
| public function testItCalculatesTotalInCents(): void | |||||
| { | |||||
| $calculator = new InvoiceCalculator(); | |||||
| $total = $calculator->calculateTotal([ | |||||
| ['amountCents' => 1000], | |||||
| ['amountCents' => 2500], | |||||
| ]); | |||||
| self::assertSame(3500, $total); | |||||
| } | |||||
| } | |||||
| ``` | |||||
| Run tests: | |||||
| ```bash | |||||
| vendor/bin/phpunit | |||||
| ``` | |||||
| --- | |||||
| ## 19. Static Analysis and Quality Gates | |||||
| Use static analysis when available. | |||||
| Recommended tools: | |||||
| ```bash | |||||
| composer require --dev phpstan/phpstan | |||||
| composer require --dev vimeo/psalm | |||||
| ``` | |||||
| Common quality commands: | |||||
| ```bash | |||||
| composer validate | |||||
| composer audit | |||||
| vendor/bin/phpcs --standard=PSR12 src tests | |||||
| vendor/bin/phpunit | |||||
| vendor/bin/phpstan analyse src tests | |||||
| ``` | |||||
| Do not ignore tool failures without documenting why. | |||||
| --- | |||||
| ## 20. Documentation | |||||
| Use PHPDoc where it adds clarity, especially for arrays, generics-like structures, complex return values, and public APIs. | |||||
| Good: | |||||
| ```php | |||||
| /** | |||||
| * @return list<Customer> | |||||
| */ | |||||
| public function findActiveCustomers(): array | |||||
| { | |||||
| // ... | |||||
| } | |||||
| ``` | |||||
| Avoid noisy comments that repeat the code: | |||||
| ```php | |||||
| // Increment i by one. | |||||
| $i++; | |||||
| ``` | |||||
| Rules: | |||||
| - Explain why, not just what. | |||||
| - Document non-obvious tradeoffs. | |||||
| - Keep README setup instructions current. | |||||
| - Update examples when behavior changes. | |||||
| --- | |||||
| ## 21. Performance and Caching | |||||
| Rules: | |||||
| - Measure before optimizing. | |||||
| - Avoid unnecessary database queries in loops. | |||||
| - Use pagination for large result sets. | |||||
| - Cache expensive reads where appropriate. | |||||
| - Use OPcache in production. | |||||
| - Do not cache user-specific sensitive data in shared caches without a clear key strategy. | |||||
| --- | |||||
| ## 22. Agent Workflow | |||||
| When modifying this codebase, the AI agent must: | |||||
| 1. Inspect existing project conventions before adding new patterns. | |||||
| 2. Prefer small, focused changes. | |||||
| 3. Preserve public behavior unless explicitly asked to change it. | |||||
| 4. Add or update tests when behavior changes. | |||||
| 5. Run relevant checks when possible. | |||||
| 6. Explain any checks that could not be run. | |||||
| 7. Avoid introducing new dependencies unless they solve a clear problem. | |||||
| 8. Never place secrets in code, tests, fixtures, logs, or documentation. | |||||
| 9. Keep generated code consistent with this file. | |||||
| 10. Leave the repository better organized than it was found. | |||||
| --- | |||||
| ## 23. Pull Request / Review Checklist | |||||
| Before considering work complete: | |||||
| - [ ] Code follows PSR-12 or project-specific style. | |||||
| - [ ] Namespaces and autoloading are correct. | |||||
| - [ ] Composer files are valid. | |||||
| - [ ] No unrelated dependency updates were introduced. | |||||
| - [ ] New behavior is tested. | |||||
| - [ ] Existing tests pass. | |||||
| - [ ] SQL is parameterized. | |||||
| - [ ] User input is validated. | |||||
| - [ ] Output is escaped. | |||||
| - [ ] No secrets are committed. | |||||
| - [ ] Errors are handled safely. | |||||
| - [ ] Documentation was updated where needed. | |||||
| --- | |||||
| ## 24. Legacy PHP Exception Policy | |||||
| If this project contains legacy PHP: | |||||
| - Do not rewrite large areas without approval. | |||||
| - Add tests around legacy behavior before refactoring. | |||||
| - Improve safety incrementally. | |||||
| - Replace deprecated patterns as touched. | |||||
| - Avoid mixing modernization with unrelated feature work. | |||||
| - Document any compatibility constraints. | |||||
| Legacy code should still move toward: | |||||
| - Composer autoloading | |||||
| - Namespaces | |||||
| - PDO/prepared statements | |||||
| - Centralized configuration | |||||
| - Automated tests | |||||
| - Safer error handling | |||||
| --- | |||||
| ## 25. Non-Negotiable Rules | |||||
| The agent must not: | |||||
| - Commit secrets. | |||||
| - Build SQL using untrusted string concatenation. | |||||
| - Store plain-text passwords. | |||||
| - Use `md5`, `sha1`, or raw fast hashes for passwords. | |||||
| - Display production errors to users. | |||||
| - `unserialize()` untrusted data. | |||||
| - Put database queries in templates. | |||||
| - Edit files under `vendor/`. | |||||
| - Add dependencies without a clear reason. | |||||
| - Ignore failing tests or quality checks without explanation. | |||||
| --- | |||||
| ## 26. Recommended Composer Scripts | |||||
| A project may include scripts like this: | |||||
| ```json | |||||
| { | |||||
| "scripts": { | |||||
| "test": "phpunit", | |||||
| "style": "phpcs --standard=PSR12 src tests", | |||||
| "style:fix": "php-cs-fixer fix", | |||||
| "analyse": "phpstan analyse src tests", | |||||
| "quality": [ | |||||
| "@style", | |||||
| "@test", | |||||
| "@analyse" | |||||
| ] | |||||
| } | |||||
| } | |||||
| ``` | |||||
| Then run: | |||||
| ```bash | |||||
| composer quality | |||||
| ``` | |||||
| --- | |||||
| ## 27. Final Instruction to Coding Agents | |||||
| When in doubt, choose the boring, obvious, secure PHP solution: | |||||
| - Composer-managed dependencies | |||||
| - PSR-style code | |||||
| - Namespaced classes | |||||
| - Dependency injection | |||||
| - PDO prepared statements | |||||
| - Escaped output | |||||
| - Tested behavior | |||||
| - Clear errors and logs | |||||
| - No secrets in source control | |||||
| ## Project Overview | |||||
| This project is a small PHP MVC framework called MindVisionCode PHP. | |||||
| It is intentionally inspired by a Classic ASP MVC framework style: | |||||
| - Central dispatcher | |||||
| - Controllers and actions | |||||
| - ViewModels | |||||
| - Repository classes | |||||
| - Simple validation | |||||
| - Database migrations | |||||
| - Small, readable files | |||||
| - Minimal dependencies | |||||
| Do not turn this into Laravel, Symfony, Slim, or another large framework. | |||||
| ## Tech Stack | |||||
| - PHP 8.2+ | |||||
| - Composer | |||||
| - PSR-4 autoloading | |||||
| - PDO | |||||
| - PHP views | |||||
| - Optional SQLite/MySQL/SQL Server through PDO | |||||
| ## Development Commands | |||||
| Install dependencies: | |||||
| ```bash | |||||
| composer install | |||||
| ``` | |||||
| Regenerate autoload files: | |||||
| ```bash | |||||
| composer dump-autoload | |||||
| ``` | |||||
| Run local server: | |||||
| ```bash | |||||
| php -S localhost:8000 -t public | |||||
| ``` | |||||
| Run basic tests: | |||||
| ```bash | |||||
| php tests/run.php | |||||
| ``` | |||||
| ## Coding Rules | |||||
| - Keep code simple and readable. | |||||
| - Prefer small classes. | |||||
| - Use typed properties and return types where practical. | |||||
| - Avoid hidden magic. | |||||
| - Do not add dependencies without a clear reason. | |||||
| - Preserve the framework style. | |||||
| - Explain any architectural changes. | |||||
| ## Request Flow | |||||
| Browser → public/index.php → Request → Dispatcher → Router → Route → Controller → ViewModel/Repository → View → Response | |||||
| ## Creating Table | |||||
| When you create tables and code for a table you will need a corrisponding _audit table with audit_id , id that refrences the object id , an action R I U D , the fields in json , username and created at | |||||
| @@ -0,0 +1,21 @@ | |||||
| FROM php:8.3-apache | |||||
| RUN apt-get update \ | |||||
| && apt-get install -y --no-install-recommends \ | |||||
| curl gnupg2 unzip \ | |||||
| && curl -fsSL https://packages.microsoft.com/keys/microsoft.asc \ | |||||
| | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \ | |||||
| && echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/debian/12/prod bookworm main" \ | |||||
| > /etc/apt/sources.list.d/mssql-release.list \ | |||||
| && apt-get update \ | |||||
| && ACCEPT_EULA=Y apt-get install -y msodbcsql18 unixodbc-dev \ | |||||
| && pecl install sqlsrv pdo_sqlsrv \ | |||||
| && docker-php-ext-enable sqlsrv pdo_sqlsrv \ | |||||
| && a2enmod rewrite \ | |||||
| && rm -rf /var/lib/apt/lists/* | |||||
| COPY --from=composer:latest /usr/bin/composer /usr/bin/composer | |||||
| COPY docker/apache/vhost.conf /etc/apache2/sites-available/000-default.conf | |||||
| WORKDIR /var/www/html | |||||
| @@ -0,0 +1,52 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Controllers; | |||||
| use Core\Controller; | |||||
| use Core\Request; | |||||
| use Core\Response; | |||||
| class AuthController extends Controller | |||||
| { | |||||
| public function login(): Response | |||||
| { | |||||
| if (auth()->check()) { | |||||
| return $this->redirect('/'); | |||||
| } | |||||
| return $this->redirect(auth()->beginLogin()); | |||||
| } | |||||
| public function callback(): Response | |||||
| { | |||||
| $request = Request::capture(); | |||||
| $error = (string) $request->input('error', ''); | |||||
| if ($error !== '') { | |||||
| $desc = (string) $request->input('error_description', $error); | |||||
| return new Response('Authentication error: ' . e($desc), 400); | |||||
| } | |||||
| $code = (string) $request->input('code', ''); | |||||
| $state = (string) $request->input('state', ''); | |||||
| if ($code === '' || $state === '') { | |||||
| return $this->redirect('/login'); | |||||
| } | |||||
| try { | |||||
| auth()->handleCallback($code, $state); | |||||
| } catch (\Throwable $e) { | |||||
| return new Response('Login failed: ' . e($e->getMessage()), 400); | |||||
| } | |||||
| return $this->redirect('/'); | |||||
| } | |||||
| public function logout(): Response | |||||
| { | |||||
| return $this->redirect(auth()->logout()); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,323 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Controllers; | |||||
| use App\Models\Campaign; | |||||
| use App\Repositories\CampaignAuditRepository; | |||||
| use App\Repositories\CampaignRepository; | |||||
| use App\Repositories\CampaignTypeRepository; | |||||
| use App\ViewModels\CampaignViewModel; | |||||
| use Core\Controller; | |||||
| use Core\Request; | |||||
| use Core\Response; | |||||
| use Core\Validator; | |||||
| class CampaignController extends Controller | |||||
| { | |||||
| public function index(): Response | |||||
| { | |||||
| $request = Request::capture(); | |||||
| $model = new CampaignViewModel(); | |||||
| $model->saved = $request->input('saved') === '1'; | |||||
| $model->deleted = $request->input('deleted') === '1'; | |||||
| return $this->view('campaigns.index', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| public function data(): Response | |||||
| { | |||||
| $rows = $this->repo()->allWithType(); | |||||
| $data = array_map(static function (array $row): array { | |||||
| $attrValues = []; | |||||
| $campaignTypeAttributes = []; | |||||
| if (!empty($row['attribute_values'])) { | |||||
| $attrValues = json_decode((string) $row['attribute_values'], true) ?? []; | |||||
| } | |||||
| if (!empty($row['campaign_type_attributes'])) { | |||||
| $campaignTypeAttributes = json_decode((string) $row['campaign_type_attributes'], true) ?? []; | |||||
| } | |||||
| $summary = implode(', ', array_map( | |||||
| static fn($k, $v) => "{$k}: {$v}", | |||||
| array_keys($attrValues), | |||||
| array_values($attrValues) | |||||
| )); | |||||
| return [ | |||||
| 'id' => (int) $row['id'], | |||||
| 'campaign_type_id' => (int) $row['campaign_type_id'], | |||||
| 'campaign_type_name' => (string) $row['campaign_type_name'], | |||||
| 'campaign_type_attributes' => $campaignTypeAttributes, | |||||
| 'attribute_values' => $attrValues, | |||||
| 'attributes_summary' => $summary, | |||||
| 'created_at' => (string) $row['created_at'], | |||||
| ]; | |||||
| }, $rows); | |||||
| return $this->json($data); | |||||
| } | |||||
| public function create(): Response | |||||
| { | |||||
| $model = new CampaignViewModel(); | |||||
| $model->title = 'New Campaign'; | |||||
| $model->campaignTypes = $this->loadCampaignTypes(); | |||||
| return $this->view('campaigns.create', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| public function store(): Response | |||||
| { | |||||
| $request = Request::capture(); | |||||
| $model = new CampaignViewModel(); | |||||
| $model->title = 'New Campaign'; | |||||
| $model->campaignTypes = $this->loadCampaignTypes(); | |||||
| [$form, $errors] = $this->validateForm($request, $model->campaignTypes); | |||||
| if (!empty($errors)) { | |||||
| $model->form = $form; | |||||
| $model->errors = $errors; | |||||
| return $this->view('campaigns.create', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| $campaign = new Campaign(); | |||||
| $campaign->campaignTypeId = (int) $form['campaign_type_id']; | |||||
| $campaign->attributeValues = $form['attribute_values']; | |||||
| $this->repo()->create($campaign); | |||||
| // Audit: I — query back the inserted row to capture the generated id. | |||||
| $inserted = $this->repo()->findLatestByType($campaign->campaignTypeId); | |||||
| if ($inserted !== null) { | |||||
| $this->auditRepo()->log( | |||||
| (int) $inserted['id'], | |||||
| 'I', | |||||
| $this->toAuditFields($inserted), | |||||
| $this->currentUsername() | |||||
| ); | |||||
| } | |||||
| return $this->redirect('/campaigns?saved=1'); | |||||
| } | |||||
| public function edit(string $id): Response | |||||
| { | |||||
| $row = $this->repo()->findWithType((int) $id); | |||||
| if ($row === null) { | |||||
| return $this->redirect('/campaigns'); | |||||
| } | |||||
| $storedValues = []; | |||||
| if (!empty($row['attribute_values'])) { | |||||
| $storedValues = json_decode((string) $row['attribute_values'], true) ?? []; | |||||
| } | |||||
| $model = new CampaignViewModel(); | |||||
| $model->title = 'Edit Campaign'; | |||||
| $model->campaign = $row; | |||||
| $model->saved = Request::capture()->input('saved') === '1'; | |||||
| $model->campaignTypes = $this->loadCampaignTypes(); | |||||
| $model->form = [ | |||||
| 'campaign_type_id' => (int) $row['campaign_type_id'], | |||||
| 'attribute_values' => $storedValues, | |||||
| ]; | |||||
| return $this->view('campaigns.edit', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| public function update(string $id): Response | |||||
| { | |||||
| $before = $this->repo()->findWithType((int) $id); | |||||
| if ($before === null) { | |||||
| return $this->redirect('/campaigns'); | |||||
| } | |||||
| $request = Request::capture(); | |||||
| $model = new CampaignViewModel(); | |||||
| $model->title = 'Edit Campaign'; | |||||
| $model->campaign = $before; | |||||
| $model->campaignTypes = $this->loadCampaignTypes(); | |||||
| [$form, $errors] = $this->validateForm($request, $model->campaignTypes); | |||||
| if (!empty($errors)) { | |||||
| $model->form = $form; | |||||
| $model->errors = $errors; | |||||
| return $this->view('campaigns.edit', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| $campaign = new Campaign(); | |||||
| $campaign->id = (int) $id; | |||||
| $campaign->campaignTypeId = (int) $form['campaign_type_id']; | |||||
| $campaign->attributeValues = $form['attribute_values']; | |||||
| $this->repo()->update($campaign); | |||||
| // Audit: U — capture before and after snapshots. | |||||
| $after = $this->repo()->findWithType((int) $id); | |||||
| $this->auditRepo()->log( | |||||
| (int) $id, | |||||
| 'U', | |||||
| [ | |||||
| 'before' => $this->toAuditFields($before), | |||||
| 'after' => $this->toAuditFields($after ?? []), | |||||
| ], | |||||
| $this->currentUsername() | |||||
| ); | |||||
| return $this->redirect('/campaigns/' . $id . '/edit?saved=1'); | |||||
| } | |||||
| public function destroy(string $id): Response | |||||
| { | |||||
| $row = $this->repo()->find((int) $id); | |||||
| if ($row !== null) { | |||||
| $this->repo()->delete((int) $id); | |||||
| // Audit: D — snapshot of the row at the moment of deletion. | |||||
| $this->auditRepo()->log( | |||||
| (int) $row['id'], | |||||
| 'D', | |||||
| $this->toAuditFields($row), | |||||
| $this->currentUsername() | |||||
| ); | |||||
| } | |||||
| return $this->redirect('/campaigns?deleted=1'); | |||||
| } | |||||
| // ── Helpers ─────────────────────────────────────────────────────────────── | |||||
| /** | |||||
| * @return list<array{id: int, name: string, attributes: list<array{name: string, type: string}>}> | |||||
| */ | |||||
| private function loadCampaignTypes(): array | |||||
| { | |||||
| return array_map(static function (array $type): array { | |||||
| return [ | |||||
| 'id' => (int) $type['id'], | |||||
| 'name' => (string) $type['name'], | |||||
| 'attributes' => json_decode((string) ($type['attributes'] ?? '[]'), true) ?? [], | |||||
| ]; | |||||
| }, $this->ctRepo()->allOrderedByName()); | |||||
| } | |||||
| /** | |||||
| * @param list<array{id: int, name: string, attributes: list<array{name: string, type: string}> }> $types | |||||
| * @return list<array{name: string, type: string}> | |||||
| */ | |||||
| private function attributesForType(int $typeId, array $types): array | |||||
| { | |||||
| foreach ($types as $type) { | |||||
| if ($type['id'] === $typeId) { | |||||
| return $type['attributes']; | |||||
| } | |||||
| } | |||||
| return []; | |||||
| } | |||||
| /** | |||||
| * @param list<array{id: int, name: string, attributes: list<array{name: string, type: string}> }> $campaignTypes | |||||
| * @return array{0: array{campaign_type_id: int|string, attribute_values: array<string, string>}, 1: array<string, list<string>>} | |||||
| */ | |||||
| private function validateForm(Request $request, array $campaignTypes): array | |||||
| { | |||||
| $campaignTypeId = (int) $request->input('campaign_type_id', 0); | |||||
| $submittedValues = (array) ($request->input('attribute_values') ?? []); | |||||
| $errors = []; | |||||
| if (!verify_csrf_token((string) $request->input('_token', ''))) { | |||||
| $errors['_token'][] = 'Your form session expired. Please refresh the page and try again.'; | |||||
| } | |||||
| if ($campaignTypeId === 0) { | |||||
| $errors['campaign_type_id'][] = 'Please select a campaign type.'; | |||||
| } | |||||
| // Build attribute values — only keep keys that belong to the selected type. | |||||
| $typeAttributes = $this->attributesForType($campaignTypeId, $campaignTypes); | |||||
| $attributeValues = []; | |||||
| foreach ($typeAttributes as $attr) { | |||||
| $attributeValues[$attr['name']] = trim((string) ($submittedValues[$attr['name']] ?? '')); | |||||
| } | |||||
| $form = [ | |||||
| 'campaign_type_id' => $campaignTypeId, | |||||
| 'attribute_values' => $attributeValues, | |||||
| ]; | |||||
| return [$form, $errors]; | |||||
| } | |||||
| /** | |||||
| * @param array<string, mixed> $row | |||||
| * @return array<string, mixed> | |||||
| */ | |||||
| private function toAuditFields(array $row): array | |||||
| { | |||||
| $attrValues = []; | |||||
| if (!empty($row['attribute_values'])) { | |||||
| $raw = $row['attribute_values']; | |||||
| $attrValues = is_string($raw) ? (json_decode($raw, true) ?? []) : (array) $raw; | |||||
| } | |||||
| return [ | |||||
| 'campaign_type_id' => (int) ($row['campaign_type_id'] ?? 0), | |||||
| 'campaign_type_name' => (string) ($row['campaign_type_name'] ?? ''), | |||||
| 'attribute_values' => $attrValues, | |||||
| 'created_at' => (string) ($row['created_at'] ?? ''), | |||||
| 'updated_at' => (string) ($row['updated_at'] ?? ''), | |||||
| ]; | |||||
| } | |||||
| private function currentUsername(): string | |||||
| { | |||||
| return auth()->user()?->username ?? 'system'; | |||||
| } | |||||
| private function repo(): CampaignRepository | |||||
| { | |||||
| return new CampaignRepository(database()); | |||||
| } | |||||
| private function auditRepo(): CampaignAuditRepository | |||||
| { | |||||
| return new CampaignAuditRepository(database()); | |||||
| } | |||||
| private function ctRepo(): CampaignTypeRepository | |||||
| { | |||||
| return new CampaignTypeRepository(database()); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,293 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Controllers; | |||||
| use App\Models\CampaignType; | |||||
| use App\Repositories\CampaignTypeAuditRepository; | |||||
| use App\Repositories\CampaignTypeRepository; | |||||
| use App\ViewModels\CampaignTypeViewModel; | |||||
| use Core\Controller; | |||||
| use Core\Request; | |||||
| use Core\Response; | |||||
| use Core\Validator; | |||||
| class CampaignTypeController extends Controller | |||||
| { | |||||
| public function index(): Response | |||||
| { | |||||
| $request = Request::capture(); | |||||
| $model = new CampaignTypeViewModel(); | |||||
| $model->saved = $request->input('saved') === '1'; | |||||
| $model->deleted = $request->input('deleted') === '1'; | |||||
| return $this->view('campaign-types.index', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| public function data(): Response | |||||
| { | |||||
| $rows = $this->repo()->allOrderedByName(); | |||||
| $data = array_map(static function (array $row): array { | |||||
| $attrs = []; | |||||
| if (!empty($row['attributes'])) { | |||||
| $attrs = json_decode((string) $row['attributes'], true) ?? []; | |||||
| } | |||||
| return [ | |||||
| 'id' => (int) $row['id'], | |||||
| 'name' => (string) $row['name'], | |||||
| 'attribute_count' => count($attrs), | |||||
| 'attributes_summary' => implode(', ', array_column($attrs, 'name')), | |||||
| 'created_at' => (string) $row['created_at'], | |||||
| ]; | |||||
| }, $rows); | |||||
| return $this->json($data); | |||||
| } | |||||
| public function create(): Response | |||||
| { | |||||
| $model = new CampaignTypeViewModel(); | |||||
| $model->title = 'New Campaign Type'; | |||||
| return $this->view('campaign-types.create', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| public function store(): Response | |||||
| { | |||||
| $request = Request::capture(); | |||||
| [$form, $errors] = $this->validateForm($request); | |||||
| if (empty($errors) && $this->repo()->findByName($form['name']) !== null) { | |||||
| $errors['name'][] = 'A campaign type with that name already exists.'; | |||||
| } | |||||
| if (!empty($errors)) { | |||||
| $model = new CampaignTypeViewModel(); | |||||
| $model->title = 'New Campaign Type'; | |||||
| $model->form = $form; | |||||
| $model->errors = $errors; | |||||
| return $this->view('campaign-types.create', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| $campaignType = new CampaignType(); | |||||
| $campaignType->name = $form['name']; | |||||
| $campaignType->attributes = $form['attributes']; | |||||
| $this->repo()->create($campaignType); | |||||
| // Audit: I — capture the newly inserted row (query by name to get the generated id). | |||||
| $inserted = $this->repo()->findByName($form['name']); | |||||
| if ($inserted !== null) { | |||||
| $this->auditRepo()->log( | |||||
| (int) $inserted['id'], | |||||
| 'I', | |||||
| $this->toAuditFields($inserted), | |||||
| $this->currentUsername() | |||||
| ); | |||||
| } | |||||
| return $this->redirect('/campaign-types?saved=1'); | |||||
| } | |||||
| public function edit(string $id): Response | |||||
| { | |||||
| $row = $this->repo()->find((int) $id); | |||||
| if ($row === null) { | |||||
| return $this->redirect('/campaign-types'); | |||||
| } | |||||
| $model = new CampaignTypeViewModel(); | |||||
| $model->title = 'Edit Campaign Type'; | |||||
| $model->campaignType = $row; | |||||
| $model->saved = Request::capture()->input('saved') === '1'; | |||||
| $model->form = [ | |||||
| 'name' => (string) $row['name'], | |||||
| 'attributes' => json_decode((string) ($row['attributes'] ?? '[]'), true) ?? [], | |||||
| ]; | |||||
| return $this->view('campaign-types.edit', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| public function update(string $id): Response | |||||
| { | |||||
| $before = $this->repo()->find((int) $id); | |||||
| if ($before === null) { | |||||
| return $this->redirect('/campaign-types'); | |||||
| } | |||||
| $request = Request::capture(); | |||||
| [$form, $errors] = $this->validateForm($request); | |||||
| if (empty($errors)) { | |||||
| $existing = $this->repo()->findByName($form['name']); | |||||
| if ($existing !== null && (int) $existing['id'] !== (int) $id) { | |||||
| $errors['name'][] = 'A campaign type with that name already exists.'; | |||||
| } | |||||
| } | |||||
| if (!empty($errors)) { | |||||
| $model = new CampaignTypeViewModel(); | |||||
| $model->title = 'Edit Campaign Type'; | |||||
| $model->campaignType = $before; | |||||
| $model->form = $form; | |||||
| $model->errors = $errors; | |||||
| return $this->view('campaign-types.edit', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| $campaignType = new CampaignType(); | |||||
| $campaignType->id = (int) $id; | |||||
| $campaignType->name = $form['name']; | |||||
| $campaignType->attributes = $form['attributes']; | |||||
| $this->repo()->update($campaignType); | |||||
| // Audit: U — capture before and after snapshots. | |||||
| $after = $this->repo()->find((int) $id); | |||||
| $this->auditRepo()->log( | |||||
| (int) $id, | |||||
| 'U', | |||||
| [ | |||||
| 'before' => $this->toAuditFields($before), | |||||
| 'after' => $this->toAuditFields($after ?? []), | |||||
| ], | |||||
| $this->currentUsername() | |||||
| ); | |||||
| return $this->redirect('/campaign-types/' . $id . '/edit?saved=1'); | |||||
| } | |||||
| public function destroy(string $id): Response | |||||
| { | |||||
| $row = $this->repo()->find((int) $id); | |||||
| if ($row !== null) { | |||||
| $this->repo()->delete((int) $id); | |||||
| // Audit: D — snapshot of the row at the moment of deletion. | |||||
| $this->auditRepo()->log( | |||||
| (int) $row['id'], | |||||
| 'D', | |||||
| $this->toAuditFields($row), | |||||
| $this->currentUsername() | |||||
| ); | |||||
| } | |||||
| return $this->redirect('/campaign-types?deleted=1'); | |||||
| } | |||||
| // ── Helpers ─────────────────────────────────────────────────────────────── | |||||
| /** | |||||
| * Build the fields payload for an audit entry. | |||||
| * The attributes column is decoded so the audit JSON nests cleanly. | |||||
| * | |||||
| * @param array<string, mixed> $row | |||||
| * @return array<string, mixed> | |||||
| */ | |||||
| private function toAuditFields(array $row): array | |||||
| { | |||||
| $attrs = []; | |||||
| if (!empty($row['attributes'])) { | |||||
| $raw = $row['attributes']; | |||||
| $attrs = is_string($raw) ? (json_decode($raw, true) ?? []) : (array) $raw; | |||||
| } | |||||
| return [ | |||||
| 'name' => (string) ($row['name'] ?? ''), | |||||
| 'attributes' => $attrs, | |||||
| 'created_at' => (string) ($row['created_at'] ?? ''), | |||||
| 'updated_at' => (string) ($row['updated_at'] ?? ''), | |||||
| ]; | |||||
| } | |||||
| private function currentUsername(): string | |||||
| { | |||||
| return auth()->user()?->username ?? 'system'; | |||||
| } | |||||
| /** | |||||
| * @return array{0: array{name: string, attributes: list<array{name: string, type: string}>}, 1: array<string, list<string>>} | |||||
| */ | |||||
| private function validateForm(Request $request): array | |||||
| { | |||||
| $name = trim((string) $request->input('name', '')); | |||||
| $attributeNames = (array) ($request->input('attribute_name') ?? []); | |||||
| $attributeTypes = (array) ($request->input('attribute_type') ?? []); | |||||
| $attributeOrders = (array) ($request->input('attribute_order') ?? []); | |||||
| $errors = []; | |||||
| if (!verify_csrf_token((string) $request->input('_token', ''))) { | |||||
| $errors['_token'][] = 'Your form session expired. Please refresh the page and try again.'; | |||||
| } | |||||
| $validator = (new Validator()) | |||||
| ->required('name', $name, 'Campaign type name is required.') | |||||
| ->maxLength('name', $name, 255, 'Name must be 255 characters or fewer.'); | |||||
| $errors = array_merge($errors, $validator->errors()); | |||||
| $attributes = []; | |||||
| foreach ($attributeNames as $i => $attrName) { | |||||
| $attrName = trim((string) $attrName); | |||||
| $attrType = trim((string) ($attributeTypes[$i] ?? 'text')); | |||||
| if ($attrName === '') { | |||||
| continue; | |||||
| } | |||||
| $attributes[] = [ | |||||
| 'name' => $attrName, | |||||
| 'type' => in_array($attrType, ['text', 'number', 'date', 'boolean'], true) ? $attrType : 'text', | |||||
| 'order' => isset($attributeOrders[$i]) && (string) $attributeOrders[$i] !== '' | |||||
| ? max(1, (int) $attributeOrders[$i]) | |||||
| : count($attributes) + 1, | |||||
| ]; | |||||
| } | |||||
| // Sort by the user-supplied order, then renumber sequentially so storage is always clean. | |||||
| usort($attributes, static fn(array $a, array $b): int => $a['order'] <=> $b['order']); | |||||
| foreach ($attributes as $seq => &$attr) { | |||||
| $attr['order'] = $seq + 1; | |||||
| } | |||||
| unset($attr); | |||||
| return [['name' => $name, 'attributes' => $attributes], $errors]; | |||||
| } | |||||
| private function repo(): CampaignTypeRepository | |||||
| { | |||||
| return new CampaignTypeRepository(database()); | |||||
| } | |||||
| private function auditRepo(): CampaignTypeAuditRepository | |||||
| { | |||||
| return new CampaignTypeAuditRepository(database()); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,31 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Controllers; | |||||
| use Core\Controller; | |||||
| use Core\Response; | |||||
| class HealthController extends Controller | |||||
| { | |||||
| public function index(): Response | |||||
| { | |||||
| $dbOk = false; | |||||
| $dbError = null; | |||||
| try { | |||||
| database()->first('SELECT 1 AS ping'); | |||||
| $dbOk = true; | |||||
| } catch (\Throwable $e) { | |||||
| $dbError = $e->getMessage(); | |||||
| } | |||||
| return $this->view('health.index', [ | |||||
| 'pageTitle' => 'Health Check — Campaign Tracker', | |||||
| 'dbOk' => $dbOk, | |||||
| 'dbError' => $dbError, | |||||
| 'appEnv' => env('APP_ENV', 'unknown'), | |||||
| ]); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,32 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Controllers; | |||||
| use App\ViewModels\HomeIndexViewModel; | |||||
| use Core\Controller; | |||||
| class HomeController extends Controller | |||||
| { | |||||
| public function index() | |||||
| { | |||||
| $model = new HomeIndexViewModel(); | |||||
| $model->title = 'Campaign Tracker'; | |||||
| $model->eyebrow = 'PHP MVC application'; | |||||
| $model->message = 'Manage campaign types and their configurable attributes using a lightweight PHP MVC stack backed by SQL Server.'; | |||||
| $model->routeExample = '/campaign-types'; | |||||
| return $this->view('home.index', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| public function user(string $id) | |||||
| { | |||||
| return $this->json([ | |||||
| 'userId' => $id, | |||||
| ]); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,557 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Controllers; | |||||
| use App\Models\Job; | |||||
| use App\Repositories\CampaignRepository; | |||||
| use App\Repositories\JobAuditRepository; | |||||
| use App\Repositories\JobRepository; | |||||
| use App\Repositories\JobTypeRepository; | |||||
| use App\Services\FileImportService; | |||||
| use App\Services\GoogleSheetImportService; | |||||
| use App\ViewModels\JobViewModel; | |||||
| use Core\Controller; | |||||
| use Core\Request; | |||||
| use Core\Response; | |||||
| use Core\Validator; | |||||
| class JobController extends Controller | |||||
| { | |||||
| public function index(): Response | |||||
| { | |||||
| $request = Request::capture(); | |||||
| $model = new JobViewModel(); | |||||
| $model->saved = $request->input('saved') === '1'; | |||||
| $model->deleted = $request->input('deleted') === '1'; | |||||
| return $this->view('jobs.index', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| public function data(): Response | |||||
| { | |||||
| return $this->json($this->formatJobRows($this->repo()->allWithDetails())); | |||||
| } | |||||
| public function dataForCampaign(string $campaignId): Response | |||||
| { | |||||
| return $this->json($this->formatJobRows( | |||||
| $this->repo()->allWithDetailsForCampaign((int) $campaignId) | |||||
| )); | |||||
| } | |||||
| public function campaign(string $campaignId): Response | |||||
| { | |||||
| $campaign = $this->campaignRepo()->findWithType((int) $campaignId); | |||||
| if ($campaign === null) { | |||||
| return $this->redirect('/campaigns'); | |||||
| } | |||||
| return $this->view('jobs.campaign', [ | |||||
| 'campaign' => $campaign, | |||||
| 'jobTypes' => $this->loadJobTypes(), | |||||
| 'pageTitle' => 'Campaign #' . $campaignId . ' Jobs', | |||||
| ]); | |||||
| } | |||||
| public function googleSheetsList(string $campaignId): Response | |||||
| { | |||||
| if ($this->campaignRepo()->find((int) $campaignId) === null) { | |||||
| return Response::json(['error' => 'Campaign not found.'], 404); | |||||
| } | |||||
| $request = Request::capture(); | |||||
| if (!verify_csrf_token((string) $request->input('_token', ''))) { | |||||
| return Response::json(['error' => 'Your form session expired. Please refresh and try again.'], 419); | |||||
| } | |||||
| $url = trim((string) $request->input('sheet_url', '')); | |||||
| if ($url === '') { | |||||
| return Response::json(['error' => 'Enter a Google Sheets URL.'], 422); | |||||
| } | |||||
| try { | |||||
| return Response::json($this->googleSheets()->sheets($url)); | |||||
| } catch (\Throwable $e) { | |||||
| return Response::json(['error' => $e->getMessage()], 422); | |||||
| } | |||||
| } | |||||
| public function importGoogleSheet(string $campaignId): Response | |||||
| { | |||||
| if ($this->campaignRepo()->find((int) $campaignId) === null) { | |||||
| return Response::json(['error' => 'Campaign not found.'], 404); | |||||
| } | |||||
| $request = Request::capture(); | |||||
| if (!verify_csrf_token((string) $request->input('_token', ''))) { | |||||
| return Response::json(['error' => 'Your form session expired. Please refresh and try again.'], 419); | |||||
| } | |||||
| $url = trim((string) $request->input('sheet_url', '')); | |||||
| $gid = trim((string) $request->input('sheet_gid', '')); | |||||
| $jobTypeId = (int) $request->input('job_type_id', 0); | |||||
| $jobType = $this->jtRepo()->find($jobTypeId); | |||||
| if ($url === '' || $gid === '' || $jobType === null) { | |||||
| return Response::json(['error' => 'Select a Google Sheets file, sheet, and job type.'], 422); | |||||
| } | |||||
| try { | |||||
| $sheet = $this->googleSheets()->rows($url, $gid); | |||||
| $attributes = json_decode((string) ($jobType['attributes'] ?? '[]'), true) ?? []; | |||||
| $result = $this->importRows( | |||||
| (int) $campaignId, | |||||
| $jobTypeId, | |||||
| $attributes, | |||||
| $sheet['headers'], | |||||
| $sheet['rows'] | |||||
| ); | |||||
| return Response::json($result); | |||||
| } catch (\Throwable $e) { | |||||
| return Response::json(['error' => $e->getMessage()], 422); | |||||
| } | |||||
| } | |||||
| public function create(): Response | |||||
| { | |||||
| $model = new JobViewModel(); | |||||
| $model->title = 'New Job'; | |||||
| $model->campaigns = $this->loadCampaigns(); | |||||
| $model->jobTypes = $this->loadJobTypes(); | |||||
| return $this->view('jobs.create', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| public function store(): Response | |||||
| { | |||||
| $request = Request::capture(); | |||||
| $model = new JobViewModel(); | |||||
| $model->title = 'New Job'; | |||||
| $model->campaigns = $this->loadCampaigns(); | |||||
| $model->jobTypes = $this->loadJobTypes(); | |||||
| [$form, $errors] = $this->validateForm($request, $model->jobTypes); | |||||
| if (!empty($errors)) { | |||||
| $model->form = $form; | |||||
| $model->errors = $errors; | |||||
| return $this->view('jobs.create', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| $job = new Job(); | |||||
| $job->campaignId = (int) $form['campaign_id']; | |||||
| $job->jobTypeId = (int) $form['job_type_id']; | |||||
| $job->attributeValues = $form['attribute_values']; | |||||
| $this->repo()->create($job); | |||||
| $inserted = $this->repo()->findLatestByCampaignAndType($job->campaignId, $job->jobTypeId); | |||||
| if ($inserted !== null) { | |||||
| $this->auditRepo()->log((int) $inserted['id'], 'I', $this->toAuditFields($inserted), $this->currentUsername()); | |||||
| } | |||||
| return $this->redirect('/jobs?saved=1'); | |||||
| } | |||||
| public function edit(string $id): Response | |||||
| { | |||||
| $row = $this->repo()->findWithDetails((int) $id); | |||||
| if ($row === null) { | |||||
| return $this->redirect('/jobs'); | |||||
| } | |||||
| $storedValues = !empty($row['attribute_values']) | |||||
| ? (json_decode((string) $row['attribute_values'], true) ?? []) | |||||
| : []; | |||||
| $model = new JobViewModel(); | |||||
| $model->title = 'Edit Job'; | |||||
| $model->job = $row; | |||||
| $model->saved = Request::capture()->input('saved') === '1'; | |||||
| $model->campaigns = $this->loadCampaigns(); | |||||
| $model->jobTypes = $this->loadJobTypes(); | |||||
| $model->form = [ | |||||
| 'campaign_id' => (int) $row['campaign_id'], | |||||
| 'job_type_id' => (int) $row['job_type_id'], | |||||
| 'attribute_values' => $storedValues, | |||||
| ]; | |||||
| return $this->view('jobs.edit', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| public function update(string $id): Response | |||||
| { | |||||
| $before = $this->repo()->findWithDetails((int) $id); | |||||
| if ($before === null) { | |||||
| return $this->redirect('/jobs'); | |||||
| } | |||||
| $request = Request::capture(); | |||||
| $model = new JobViewModel(); | |||||
| $model->title = 'Edit Job'; | |||||
| $model->job = $before; | |||||
| $model->campaigns = $this->loadCampaigns(); | |||||
| $model->jobTypes = $this->loadJobTypes(); | |||||
| [$form, $errors] = $this->validateForm($request, $model->jobTypes); | |||||
| if (!empty($errors)) { | |||||
| $model->form = $form; | |||||
| $model->errors = $errors; | |||||
| return $this->view('jobs.edit', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| $job = new Job(); | |||||
| $job->id = (int) $id; | |||||
| $job->campaignId = (int) $form['campaign_id']; | |||||
| $job->jobTypeId = (int) $form['job_type_id']; | |||||
| $job->attributeValues = $form['attribute_values']; | |||||
| $this->repo()->update($job); | |||||
| $after = $this->repo()->findWithDetails((int) $id); | |||||
| $this->auditRepo()->log((int) $id, 'U', [ | |||||
| 'before' => $this->toAuditFields($before), | |||||
| 'after' => $this->toAuditFields($after ?? []), | |||||
| ], $this->currentUsername()); | |||||
| return $this->redirect('/jobs/' . $id . '/edit?saved=1'); | |||||
| } | |||||
| public function destroy(string $id): Response | |||||
| { | |||||
| $row = $this->repo()->findWithDetails((int) $id); | |||||
| if ($row !== null) { | |||||
| $this->repo()->delete((int) $id); | |||||
| $this->auditRepo()->log((int) $row['id'], 'D', $this->toAuditFields($row), $this->currentUsername()); | |||||
| } | |||||
| return $this->redirect('/jobs?deleted=1'); | |||||
| } | |||||
| // ── Helpers ─────────────────────────────────────────────────────────────── | |||||
| private function loadCampaigns(): array | |||||
| { | |||||
| return $this->campaignRepo()->allWithType(); | |||||
| } | |||||
| private function loadJobTypes(): array | |||||
| { | |||||
| return array_map(static function (array $t): array { | |||||
| return [ | |||||
| 'id' => (int) $t['id'], | |||||
| 'name' => (string) $t['name'], | |||||
| 'attributes' => json_decode((string) ($t['attributes'] ?? '[]'), true) ?? [], | |||||
| ]; | |||||
| }, $this->jtRepo()->allOrderedByName()); | |||||
| } | |||||
| private function attributesForType(int $typeId, array $types): array | |||||
| { | |||||
| foreach ($types as $type) { | |||||
| if ($type['id'] === $typeId) return $type['attributes']; | |||||
| } | |||||
| return []; | |||||
| } | |||||
| /** | |||||
| * @param list<array{name?: string, type?: string, order?: int}> $attributes | |||||
| * @param list<string> $headers | |||||
| * @param list<array<string, string>> $rows | |||||
| * @return array{imported: int, skipped: int, matched_attributes: list<string>} | |||||
| */ | |||||
| private function importRows(int $campaignId, int $jobTypeId, array $attributes, array $headers, array $rows): array | |||||
| { | |||||
| $headersByName = []; | |||||
| foreach ($headers as $header) { | |||||
| $normalized = $this->normalizeImportHeader($header); | |||||
| if ($normalized !== '') { | |||||
| $headersByName[$normalized] = $header; | |||||
| } | |||||
| } | |||||
| $matchedAttributes = []; | |||||
| foreach ($attributes as $attribute) { | |||||
| $name = trim((string) ($attribute['name'] ?? '')); | |||||
| if ($name === '') { | |||||
| continue; | |||||
| } | |||||
| $header = $headersByName[$this->normalizeImportHeader($name)] ?? null; | |||||
| if ($header !== null) { | |||||
| $matchedAttributes[$name] = $header; | |||||
| } | |||||
| } | |||||
| if ($matchedAttributes === []) { | |||||
| throw new \RuntimeException('No sheet headers matched the selected job type attributes.'); | |||||
| } | |||||
| $imported = 0; | |||||
| $skipped = 0; | |||||
| foreach ($rows as $row) { | |||||
| $attributeValues = []; | |||||
| $hasValue = false; | |||||
| foreach ($matchedAttributes as $attributeName => $header) { | |||||
| $value = trim((string) ($row[$header] ?? '')); | |||||
| $attributeValues[$attributeName] = $value; | |||||
| $hasValue = $hasValue || $value !== ''; | |||||
| } | |||||
| if (!$hasValue) { | |||||
| $skipped++; | |||||
| continue; | |||||
| } | |||||
| $job = new Job(); | |||||
| $job->campaignId = $campaignId; | |||||
| $job->jobTypeId = $jobTypeId; | |||||
| $job->attributeValues = $attributeValues; | |||||
| $this->repo()->create($job); | |||||
| $inserted = $this->repo()->findLatestByCampaignAndType($campaignId, $jobTypeId); | |||||
| if ($inserted !== null) { | |||||
| $this->auditRepo()->log( | |||||
| (int) $inserted['id'], | |||||
| 'I', | |||||
| $this->toAuditFields($inserted), | |||||
| $this->currentUsername() | |||||
| ); | |||||
| } | |||||
| $imported++; | |||||
| } | |||||
| return [ | |||||
| 'imported' => $imported, | |||||
| 'skipped' => $skipped, | |||||
| 'matched_attributes' => array_keys($matchedAttributes), | |||||
| ]; | |||||
| } | |||||
| private function normalizeImportHeader(string $value): string | |||||
| { | |||||
| $value = strtolower(trim($value)); | |||||
| $value = preg_replace('/[^a-z0-9]+/', ' ', $value) ?? ''; | |||||
| return trim(preg_replace('/\s+/', ' ', $value) ?? ''); | |||||
| } | |||||
| private function googleSheets(): GoogleSheetImportService | |||||
| { | |||||
| return new GoogleSheetImportService(); | |||||
| } | |||||
| private function fileImport(): FileImportService | |||||
| { | |||||
| return new FileImportService(); | |||||
| } | |||||
| // ── File upload import ──────────────────────────────────────────────────── | |||||
| public function fileSheetsList(string $campaignId): Response | |||||
| { | |||||
| if ($this->campaignRepo()->find((int) $campaignId) === null) { | |||||
| return Response::json(['error' => 'Campaign not found.'], 404); | |||||
| } | |||||
| $request = Request::capture(); | |||||
| if (!verify_csrf_token((string) $request->input('_token', ''))) { | |||||
| return Response::json(['error' => 'Your form session expired. Please refresh and try again.'], 419); | |||||
| } | |||||
| $upload = $_FILES['import_file'] ?? null; | |||||
| if ($upload === null || ($upload['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) { | |||||
| return Response::json(['error' => 'No file was uploaded.'], 422); | |||||
| } | |||||
| try { | |||||
| $service = $this->fileImport(); | |||||
| $filename = $service->store($upload); | |||||
| $sheets = $service->sheets($filename); | |||||
| return Response::json(['temp_file' => $filename, 'sheets' => $sheets]); | |||||
| } catch (\Throwable $e) { | |||||
| return Response::json(['error' => $e->getMessage()], 422); | |||||
| } | |||||
| } | |||||
| public function importFile(string $campaignId): Response | |||||
| { | |||||
| if ($this->campaignRepo()->find((int) $campaignId) === null) { | |||||
| return Response::json(['error' => 'Campaign not found.'], 404); | |||||
| } | |||||
| $request = Request::capture(); | |||||
| if (!verify_csrf_token((string) $request->input('_token', ''))) { | |||||
| return Response::json(['error' => 'Your form session expired. Please refresh and try again.'], 419); | |||||
| } | |||||
| $tempFile = basename(trim((string) $request->input('temp_file', ''))); | |||||
| $gid = trim((string) $request->input('sheet_gid', '0')); | |||||
| $jobTypeId = (int) $request->input('job_type_id', 0); | |||||
| $jobType = $this->jtRepo()->find($jobTypeId); | |||||
| if ($tempFile === '' || $jobType === null) { | |||||
| return Response::json(['error' => 'Select a file, sheet, and job type.'], 422); | |||||
| } | |||||
| try { | |||||
| $service = $this->fileImport(); | |||||
| $sheet = $service->rows($tempFile, $gid); | |||||
| $attributes = json_decode((string) ($jobType['attributes'] ?? '[]'), true) ?? []; | |||||
| $result = $this->importRows( | |||||
| (int) $campaignId, | |||||
| $jobTypeId, | |||||
| $attributes, | |||||
| $sheet['headers'], | |||||
| $sheet['rows'] | |||||
| ); | |||||
| $service->delete($tempFile); | |||||
| return Response::json($result); | |||||
| } catch (\Throwable $e) { | |||||
| return Response::json(['error' => $e->getMessage()], 422); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * @param list<array<string, mixed>> $rows | |||||
| * @return list<array<string, mixed>> | |||||
| */ | |||||
| private function formatJobRows(array $rows): array | |||||
| { | |||||
| return array_map(static function (array $row): array { | |||||
| $attrValues = !empty($row['attribute_values']) | |||||
| ? (json_decode((string) $row['attribute_values'], true) ?? []) | |||||
| : []; | |||||
| $jobTypeAttributes = !empty($row['job_type_attributes']) | |||||
| ? (json_decode((string) $row['job_type_attributes'], true) ?? []) | |||||
| : []; | |||||
| $summary = implode(', ', array_map( | |||||
| static fn($k, $v) => "{$k}: {$v}", | |||||
| array_keys($attrValues), | |||||
| array_values($attrValues) | |||||
| )); | |||||
| return [ | |||||
| 'id' => (int) $row['id'], | |||||
| 'campaign_id' => (int) $row['campaign_id'], | |||||
| 'campaign_type_name' => (string) $row['campaign_type_name'], | |||||
| 'job_type_id' => (int) $row['job_type_id'], | |||||
| 'job_type_name' => (string) $row['job_type_name'], | |||||
| 'job_type_attributes' => $jobTypeAttributes, | |||||
| 'attribute_values' => $attrValues, | |||||
| 'attributes_summary' => $summary, | |||||
| 'created_at' => (string) $row['created_at'], | |||||
| 'updated_at' => (string) ($row['updated_at'] ?? ''), | |||||
| ]; | |||||
| }, $rows); | |||||
| } | |||||
| private function validateForm(Request $request, array $jobTypes): array | |||||
| { | |||||
| $campaignId = (int) $request->input('campaign_id', 0); | |||||
| $jobTypeId = (int) $request->input('job_type_id', 0); | |||||
| $submittedValues = (array) ($request->input('attribute_values') ?? []); | |||||
| $errors = []; | |||||
| if (!verify_csrf_token((string) $request->input('_token', ''))) { | |||||
| $errors['_token'][] = 'Your form session expired. Please refresh and try again.'; | |||||
| } | |||||
| if ($campaignId === 0) { | |||||
| $errors['campaign_id'][] = 'Please select a campaign.'; | |||||
| } | |||||
| if ($jobTypeId === 0) { | |||||
| $errors['job_type_id'][] = 'Please select a job type.'; | |||||
| } | |||||
| $typeAttributes = $this->attributesForType($jobTypeId, $jobTypes); | |||||
| $attributeValues = []; | |||||
| foreach ($typeAttributes as $attr) { | |||||
| $attributeValues[$attr['name']] = trim((string) ($submittedValues[$attr['name']] ?? '')); | |||||
| } | |||||
| return [ | |||||
| ['campaign_id' => $campaignId, 'job_type_id' => $jobTypeId, 'attribute_values' => $attributeValues], | |||||
| $errors, | |||||
| ]; | |||||
| } | |||||
| private function toAuditFields(array $row): array | |||||
| { | |||||
| $attrValues = []; | |||||
| if (!empty($row['attribute_values'])) { | |||||
| $raw = $row['attribute_values']; | |||||
| $attrValues = is_string($raw) ? (json_decode($raw, true) ?? []) : (array) $raw; | |||||
| } | |||||
| return [ | |||||
| 'campaign_id' => (int) ($row['campaign_id'] ?? 0), | |||||
| 'campaign_type_name' => (string) ($row['campaign_type_name'] ?? ''), | |||||
| 'job_type_id' => (int) ($row['job_type_id'] ?? 0), | |||||
| 'job_type_name' => (string) ($row['job_type_name'] ?? ''), | |||||
| 'attribute_values' => $attrValues, | |||||
| 'created_at' => (string) ($row['created_at'] ?? ''), | |||||
| 'updated_at' => (string) ($row['updated_at'] ?? ''), | |||||
| ]; | |||||
| } | |||||
| private function currentUsername(): string | |||||
| { | |||||
| return auth()->user()?->username ?? 'system'; | |||||
| } | |||||
| private function repo(): JobRepository | |||||
| { | |||||
| return new JobRepository(database()); | |||||
| } | |||||
| private function auditRepo(): JobAuditRepository | |||||
| { | |||||
| return new JobAuditRepository(database()); | |||||
| } | |||||
| private function campaignRepo(): CampaignRepository | |||||
| { | |||||
| return new CampaignRepository(database()); | |||||
| } | |||||
| private function jtRepo(): JobTypeRepository | |||||
| { | |||||
| return new JobTypeRepository(database()); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,254 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Controllers; | |||||
| use App\Models\JobType; | |||||
| use App\Repositories\JobTypeAuditRepository; | |||||
| use App\Repositories\JobTypeRepository; | |||||
| use App\ViewModels\JobTypeViewModel; | |||||
| use Core\Controller; | |||||
| use Core\Request; | |||||
| use Core\Response; | |||||
| use Core\Validator; | |||||
| class JobTypeController extends Controller | |||||
| { | |||||
| public function index(): Response | |||||
| { | |||||
| $request = Request::capture(); | |||||
| $model = new JobTypeViewModel(); | |||||
| $model->saved = $request->input('saved') === '1'; | |||||
| $model->deleted = $request->input('deleted') === '1'; | |||||
| return $this->view('job-types.index', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| public function data(): Response | |||||
| { | |||||
| $rows = $this->repo()->allOrderedByName(); | |||||
| $data = array_map(static function (array $row): array { | |||||
| $attrs = !empty($row['attributes']) | |||||
| ? (json_decode((string) $row['attributes'], true) ?? []) | |||||
| : []; | |||||
| return [ | |||||
| 'id' => (int) $row['id'], | |||||
| 'name' => (string) $row['name'], | |||||
| 'attribute_count' => count($attrs), | |||||
| 'attributes_summary' => implode(', ', array_column($attrs, 'name')), | |||||
| 'created_at' => (string) $row['created_at'], | |||||
| ]; | |||||
| }, $rows); | |||||
| return $this->json($data); | |||||
| } | |||||
| public function create(): Response | |||||
| { | |||||
| $model = new JobTypeViewModel(); | |||||
| $model->title = 'New Job Type'; | |||||
| return $this->view('job-types.create', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| public function store(): Response | |||||
| { | |||||
| $request = Request::capture(); | |||||
| [$form, $errors] = $this->validateForm($request); | |||||
| if (empty($errors) && $this->repo()->findByName($form['name']) !== null) { | |||||
| $errors['name'][] = 'A job type with that name already exists.'; | |||||
| } | |||||
| if (!empty($errors)) { | |||||
| $model = new JobTypeViewModel(); | |||||
| $model->title = 'New Job Type'; | |||||
| $model->form = $form; | |||||
| $model->errors = $errors; | |||||
| return $this->view('job-types.create', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| $jobType = new JobType(); | |||||
| $jobType->name = $form['name']; | |||||
| $jobType->attributes = $form['attributes']; | |||||
| $this->repo()->create($jobType); | |||||
| $inserted = $this->repo()->findByName($form['name']); | |||||
| if ($inserted !== null) { | |||||
| $this->auditRepo()->log((int) $inserted['id'], 'I', $this->toAuditFields($inserted), $this->currentUsername()); | |||||
| } | |||||
| return $this->redirect('/job-types?saved=1'); | |||||
| } | |||||
| public function edit(string $id): Response | |||||
| { | |||||
| $row = $this->repo()->find((int) $id); | |||||
| if ($row === null) { | |||||
| return $this->redirect('/job-types'); | |||||
| } | |||||
| $model = new JobTypeViewModel(); | |||||
| $model->title = 'Edit Job Type'; | |||||
| $model->jobType = $row; | |||||
| $model->saved = Request::capture()->input('saved') === '1'; | |||||
| $model->form = [ | |||||
| 'name' => (string) $row['name'], | |||||
| 'attributes' => json_decode((string) ($row['attributes'] ?? '[]'), true) ?? [], | |||||
| ]; | |||||
| return $this->view('job-types.edit', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| public function update(string $id): Response | |||||
| { | |||||
| $row = $this->repo()->find((int) $id); | |||||
| if ($row === null) { | |||||
| return $this->redirect('/job-types'); | |||||
| } | |||||
| $request = Request::capture(); | |||||
| [$form, $errors] = $this->validateForm($request); | |||||
| if (empty($errors)) { | |||||
| $existing = $this->repo()->findByName($form['name']); | |||||
| if ($existing !== null && (int) $existing['id'] !== (int) $id) { | |||||
| $errors['name'][] = 'A job type with that name already exists.'; | |||||
| } | |||||
| } | |||||
| if (!empty($errors)) { | |||||
| $model = new JobTypeViewModel(); | |||||
| $model->title = 'Edit Job Type'; | |||||
| $model->jobType = $row; | |||||
| $model->form = $form; | |||||
| $model->errors = $errors; | |||||
| return $this->view('job-types.edit', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| $before = $row; | |||||
| $jobType = new JobType(); | |||||
| $jobType->id = (int) $id; | |||||
| $jobType->name = $form['name']; | |||||
| $jobType->attributes = $form['attributes']; | |||||
| $this->repo()->update($jobType); | |||||
| $after = $this->repo()->find((int) $id); | |||||
| $this->auditRepo()->log((int) $id, 'U', [ | |||||
| 'before' => $this->toAuditFields($before), | |||||
| 'after' => $this->toAuditFields($after ?? []), | |||||
| ], $this->currentUsername()); | |||||
| return $this->redirect('/job-types/' . $id . '/edit?saved=1'); | |||||
| } | |||||
| public function destroy(string $id): Response | |||||
| { | |||||
| $row = $this->repo()->find((int) $id); | |||||
| if ($row !== null) { | |||||
| $this->repo()->delete((int) $id); | |||||
| $this->auditRepo()->log((int) $row['id'], 'D', $this->toAuditFields($row), $this->currentUsername()); | |||||
| } | |||||
| return $this->redirect('/job-types?deleted=1'); | |||||
| } | |||||
| // ── Helpers ─────────────────────────────────────────────────────────────── | |||||
| private function toAuditFields(array $row): array | |||||
| { | |||||
| $attrs = []; | |||||
| if (!empty($row['attributes'])) { | |||||
| $raw = $row['attributes']; | |||||
| $attrs = is_string($raw) ? (json_decode($raw, true) ?? []) : (array) $raw; | |||||
| } | |||||
| return [ | |||||
| 'name' => (string) ($row['name'] ?? ''), | |||||
| 'attributes' => $attrs, | |||||
| 'created_at' => (string) ($row['created_at'] ?? ''), | |||||
| 'updated_at' => (string) ($row['updated_at'] ?? ''), | |||||
| ]; | |||||
| } | |||||
| private function currentUsername(): string | |||||
| { | |||||
| return auth()->user()?->username ?? 'system'; | |||||
| } | |||||
| private function validateForm(Request $request): array | |||||
| { | |||||
| $name = trim((string) $request->input('name', '')); | |||||
| $attributeNames = (array) ($request->input('attribute_name') ?? []); | |||||
| $attributeTypes = (array) ($request->input('attribute_type') ?? []); | |||||
| $attributeOrders = (array) ($request->input('attribute_order') ?? []); | |||||
| $errors = []; | |||||
| if (!verify_csrf_token((string) $request->input('_token', ''))) { | |||||
| $errors['_token'][] = 'Your form session expired. Please refresh and try again.'; | |||||
| } | |||||
| $errors = array_merge($errors, (new Validator()) | |||||
| ->required('name', $name, 'Job type name is required.') | |||||
| ->maxLength('name', $name, 255, 'Name must be 255 characters or fewer.') | |||||
| ->errors()); | |||||
| $attributes = []; | |||||
| foreach ($attributeNames as $i => $attrName) { | |||||
| $attrName = trim((string) $attrName); | |||||
| $attrType = trim((string) ($attributeTypes[$i] ?? 'text')); | |||||
| if ($attrName === '') continue; | |||||
| $attributes[] = [ | |||||
| 'name' => $attrName, | |||||
| 'type' => in_array($attrType, ['text', 'number', 'date', 'boolean'], true) ? $attrType : 'text', | |||||
| 'order' => isset($attributeOrders[$i]) && (string) $attributeOrders[$i] !== '' | |||||
| ? max(1, (int) $attributeOrders[$i]) | |||||
| : count($attributes) + 1, | |||||
| ]; | |||||
| } | |||||
| usort($attributes, static fn(array $a, array $b): int => $a['order'] <=> $b['order']); | |||||
| foreach ($attributes as $seq => &$attr) { | |||||
| $attr['order'] = $seq + 1; | |||||
| } | |||||
| unset($attr); | |||||
| return [['name' => $name, 'attributes' => $attributes], $errors]; | |||||
| } | |||||
| private function repo(): JobTypeRepository | |||||
| { | |||||
| return new JobTypeRepository(database()); | |||||
| } | |||||
| private function auditRepo(): JobTypeAuditRepository | |||||
| { | |||||
| return new JobTypeAuditRepository(database()); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,20 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Models; | |||||
| class Campaign | |||||
| { | |||||
| public ?int $id = null; | |||||
| public int $campaignTypeId = 0; | |||||
| /** | |||||
| * Key → value pairs matching the parent campaign type's attribute names. | |||||
| * @var array<string, string> | |||||
| */ | |||||
| public array $attributeValues = []; | |||||
| public ?string $createdAt = null; | |||||
| public ?string $updatedAt = null; | |||||
| } | |||||
| @@ -0,0 +1,19 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Models; | |||||
| class CampaignType | |||||
| { | |||||
| public ?int $id = null; | |||||
| public string $name = ''; | |||||
| /** | |||||
| * @var list<array{name: string, type: string}> | |||||
| */ | |||||
| public array $attributes = []; | |||||
| public ?string $createdAt = null; | |||||
| public ?string $updatedAt = null; | |||||
| } | |||||
| @@ -0,0 +1,18 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Models; | |||||
| class Job | |||||
| { | |||||
| public ?int $id = null; | |||||
| public int $campaignId = 0; | |||||
| public int $jobTypeId = 0; | |||||
| /** @var array<string, string> */ | |||||
| public array $attributeValues = []; | |||||
| public ?string $createdAt = null; | |||||
| public ?string $updatedAt = null; | |||||
| } | |||||
| @@ -0,0 +1,17 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Models; | |||||
| class JobType | |||||
| { | |||||
| public ?int $id = null; | |||||
| public string $name = ''; | |||||
| /** @var list<array{name: string, type: string}> */ | |||||
| public array $attributes = []; | |||||
| public ?string $createdAt = null; | |||||
| public ?string $updatedAt = null; | |||||
| } | |||||
| @@ -0,0 +1,73 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Repositories; | |||||
| use Core\Repository; | |||||
| /** | |||||
| * Writes and reads entries in campaign_audit. | |||||
| * | |||||
| * Action codes: | |||||
| * I – record was inserted (created) | |||||
| * U – record was updated (fields contains {"before":{...},"after":{...}}) | |||||
| * D – record was deleted (snapshot of the row at time of deletion) | |||||
| * R – record was restored after a previous deletion | |||||
| */ | |||||
| class CampaignAuditRepository extends Repository | |||||
| { | |||||
| protected string $table = 'campaign_audit'; | |||||
| protected string $primaryKey = 'audit_id'; | |||||
| /** | |||||
| * Write one audit entry. | |||||
| * | |||||
| * @param array<string, mixed> $fields Snapshot or before/after payload. | |||||
| */ | |||||
| public function log(int $campaignId, string $action, array $fields, string $username): void | |||||
| { | |||||
| $this->database->execute( | |||||
| "INSERT INTO campaign_audit (id, action, fields, username) | |||||
| VALUES (:id, :action, :fields, :username)", | |||||
| [ | |||||
| 'id' => $campaignId, | |||||
| 'action' => $action, | |||||
| 'fields' => json_encode($fields, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), | |||||
| 'username' => $username, | |||||
| ] | |||||
| ); | |||||
| } | |||||
| /** | |||||
| * All audit entries for one campaign, oldest first. | |||||
| * | |||||
| * @return list<array<string, mixed>> | |||||
| */ | |||||
| public function forRecord(int $campaignId): array | |||||
| { | |||||
| return $this->database->query( | |||||
| 'SELECT audit_id, id, action, fields, username, created_at | |||||
| FROM campaign_audit | |||||
| WHERE id = :id | |||||
| ORDER BY audit_id ASC', | |||||
| ['id' => $campaignId] | |||||
| ); | |||||
| } | |||||
| /** | |||||
| * Most recent audit entries across all campaigns. | |||||
| * | |||||
| * @return list<array<string, mixed>> | |||||
| */ | |||||
| public function recent(int $limit = 50): array | |||||
| { | |||||
| $limit = max(1, $limit); | |||||
| return $this->database->query( | |||||
| "SELECT TOP ({$limit}) audit_id, id, action, fields, username, created_at | |||||
| FROM campaign_audit | |||||
| ORDER BY audit_id DESC" | |||||
| ); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,91 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Repositories; | |||||
| use App\Models\Campaign; | |||||
| use Core\Repository; | |||||
| class CampaignRepository extends Repository | |||||
| { | |||||
| protected string $table = 'campaign'; | |||||
| protected string $primaryKey = 'id'; | |||||
| /** | |||||
| * All campaigns joined with their campaign type name, ordered by id desc. | |||||
| * | |||||
| * @return list<array<string, mixed>> | |||||
| */ | |||||
| public function allWithType(): array | |||||
| { | |||||
| return $this->database->query( | |||||
| 'SELECT c.id, c.campaign_type_id, c.attribute_values, | |||||
| c.created_at, c.updated_at, | |||||
| ct.name AS campaign_type_name, | |||||
| ct.attributes AS campaign_type_attributes | |||||
| FROM campaign c | |||||
| INNER JOIN campaign_type ct ON c.campaign_type_id = ct.id | |||||
| ORDER BY c.id DESC' | |||||
| ); | |||||
| } | |||||
| /** | |||||
| * Single campaign joined with its campaign type name and attributes. | |||||
| */ | |||||
| public function findWithType(int $id): ?array | |||||
| { | |||||
| return $this->database->first( | |||||
| 'SELECT c.id, c.campaign_type_id, c.attribute_values, | |||||
| c.created_at, c.updated_at, | |||||
| ct.name AS campaign_type_name, | |||||
| ct.attributes AS campaign_type_attributes | |||||
| FROM campaign c | |||||
| INNER JOIN campaign_type ct ON c.campaign_type_id = ct.id | |||||
| WHERE c.id = :id', | |||||
| ['id' => $id] | |||||
| ); | |||||
| } | |||||
| /** | |||||
| * Return the most recently inserted campaign for a given type. | |||||
| * Used after an INSERT to retrieve the generated id for audit logging. | |||||
| */ | |||||
| public function findLatestByType(int $typeId): ?array | |||||
| { | |||||
| return $this->database->first( | |||||
| 'SELECT TOP (1) * FROM campaign | |||||
| WHERE campaign_type_id = :type_id | |||||
| ORDER BY id DESC', | |||||
| ['type_id' => $typeId] | |||||
| ); | |||||
| } | |||||
| public function create(Campaign $campaign): bool | |||||
| { | |||||
| return $this->database->execute( | |||||
| 'INSERT INTO campaign (campaign_type_id, attribute_values) | |||||
| VALUES (:campaign_type_id, :attribute_values)', | |||||
| [ | |||||
| 'campaign_type_id' => $campaign->campaignTypeId, | |||||
| 'attribute_values' => json_encode($campaign->attributeValues, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), | |||||
| ] | |||||
| ); | |||||
| } | |||||
| public function update(Campaign $campaign): bool | |||||
| { | |||||
| return $this->database->execute( | |||||
| 'UPDATE campaign | |||||
| SET campaign_type_id = :campaign_type_id, | |||||
| attribute_values = :attribute_values, | |||||
| updated_at = CURRENT_TIMESTAMP | |||||
| WHERE id = :id', | |||||
| [ | |||||
| 'campaign_type_id' => $campaign->campaignTypeId, | |||||
| 'attribute_values' => json_encode($campaign->attributeValues, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), | |||||
| 'id' => $campaign->id, | |||||
| ] | |||||
| ); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,73 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Repositories; | |||||
| use Core\Repository; | |||||
| /** | |||||
| * Writes and reads entries in campaign_type_audit. | |||||
| * | |||||
| * Action codes: | |||||
| * I – record was inserted (created) | |||||
| * U – record was updated (fields contains {"before":{...},"after":{...}}) | |||||
| * D – record was deleted (snapshot of the row at time of deletion) | |||||
| * R – record was restored after a previous deletion | |||||
| */ | |||||
| class CampaignTypeAuditRepository extends Repository | |||||
| { | |||||
| protected string $table = 'campaign_type_audit'; | |||||
| protected string $primaryKey = 'audit_id'; | |||||
| /** | |||||
| * Write one audit entry. | |||||
| * | |||||
| * @param array<string, mixed> $fields Snapshot or before/after payload. | |||||
| */ | |||||
| public function log(int $campaignTypeId, string $action, array $fields, string $username): void | |||||
| { | |||||
| $this->database->execute( | |||||
| "INSERT INTO campaign_type_audit (id, action, fields, username) | |||||
| VALUES (:id, :action, :fields, :username)", | |||||
| [ | |||||
| 'id' => $campaignTypeId, | |||||
| 'action' => $action, | |||||
| 'fields' => json_encode($fields, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), | |||||
| 'username' => $username, | |||||
| ] | |||||
| ); | |||||
| } | |||||
| /** | |||||
| * All audit entries for one campaign type, oldest first. | |||||
| * | |||||
| * @return list<array<string, mixed>> | |||||
| */ | |||||
| public function forRecord(int $campaignTypeId): array | |||||
| { | |||||
| return $this->database->query( | |||||
| 'SELECT audit_id, id, action, fields, username, created_at | |||||
| FROM campaign_type_audit | |||||
| WHERE id = :id | |||||
| ORDER BY audit_id ASC', | |||||
| ['id' => $campaignTypeId] | |||||
| ); | |||||
| } | |||||
| /** | |||||
| * Most recent audit entries across all campaign types. | |||||
| * | |||||
| * @return list<array<string, mixed>> | |||||
| */ | |||||
| public function recent(int $limit = 50): array | |||||
| { | |||||
| $limit = max(1, $limit); | |||||
| return $this->database->query( | |||||
| "SELECT TOP ({$limit}) audit_id, id, action, fields, username, created_at | |||||
| FROM campaign_type_audit | |||||
| ORDER BY audit_id DESC" | |||||
| ); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,56 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Repositories; | |||||
| use App\Models\CampaignType; | |||||
| use Core\Repository; | |||||
| class CampaignTypeRepository extends Repository | |||||
| { | |||||
| protected string $table = 'campaign_type'; | |||||
| /** | |||||
| * @return list<array<string, mixed>> | |||||
| */ | |||||
| public function allOrderedByName(): array | |||||
| { | |||||
| return $this->database->query( | |||||
| 'SELECT * FROM campaign_type ORDER BY name ASC' | |||||
| ); | |||||
| } | |||||
| public function findByName(string $name): ?array | |||||
| { | |||||
| return $this->database->first( | |||||
| 'SELECT * FROM campaign_type WHERE name = :name', | |||||
| ['name' => $name] | |||||
| ); | |||||
| } | |||||
| public function create(CampaignType $campaignType): bool | |||||
| { | |||||
| return $this->database->execute( | |||||
| 'INSERT INTO campaign_type (name, attributes) VALUES (:name, :attributes)', | |||||
| [ | |||||
| 'name' => $campaignType->name, | |||||
| 'attributes' => json_encode($campaignType->attributes, JSON_THROW_ON_ERROR), | |||||
| ] | |||||
| ); | |||||
| } | |||||
| public function update(CampaignType $campaignType): bool | |||||
| { | |||||
| return $this->database->execute( | |||||
| 'UPDATE campaign_type | |||||
| SET name = :name, attributes = :attributes, updated_at = CURRENT_TIMESTAMP | |||||
| WHERE id = :id', | |||||
| [ | |||||
| 'name' => $campaignType->name, | |||||
| 'attributes' => json_encode($campaignType->attributes, JSON_THROW_ON_ERROR), | |||||
| 'id' => $campaignType->id, | |||||
| ] | |||||
| ); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,41 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Repositories; | |||||
| use Core\Repository; | |||||
| /** | |||||
| * Action codes: I Insert · U Update · D Delete · R Restore | |||||
| */ | |||||
| class JobAuditRepository extends Repository | |||||
| { | |||||
| protected string $table = 'job_audit'; | |||||
| protected string $primaryKey = 'audit_id'; | |||||
| /** @param array<string, mixed> $fields */ | |||||
| public function log(int $jobId, string $action, array $fields, string $username): void | |||||
| { | |||||
| $this->database->execute( | |||||
| "INSERT INTO job_audit (id, action, fields, username) | |||||
| VALUES (:id, :action, :fields, :username)", | |||||
| [ | |||||
| 'id' => $jobId, | |||||
| 'action' => $action, | |||||
| 'fields' => json_encode($fields, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), | |||||
| 'username' => $username, | |||||
| ] | |||||
| ); | |||||
| } | |||||
| /** @return list<array<string, mixed>> */ | |||||
| public function forRecord(int $jobId): array | |||||
| { | |||||
| return $this->database->query( | |||||
| 'SELECT audit_id, id, action, fields, username, created_at | |||||
| FROM job_audit WHERE id = :id ORDER BY audit_id ASC', | |||||
| ['id' => $jobId] | |||||
| ); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,109 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Repositories; | |||||
| use App\Models\Job; | |||||
| use Core\Repository; | |||||
| class JobRepository extends Repository | |||||
| { | |||||
| protected string $table = 'job'; | |||||
| protected string $primaryKey = 'id'; | |||||
| /** @return list<array<string, mixed>> */ | |||||
| public function allWithDetails(): array | |||||
| { | |||||
| return $this->database->query( | |||||
| 'SELECT j.id, j.campaign_id, j.job_type_id, j.attribute_values, | |||||
| j.created_at, j.updated_at, | |||||
| ct.name AS campaign_type_name, | |||||
| jt.name AS job_type_name, | |||||
| jt.attributes AS job_type_attributes | |||||
| FROM job j | |||||
| INNER JOIN campaign c ON j.campaign_id = c.id | |||||
| INNER JOIN campaign_type ct ON c.campaign_type_id = ct.id | |||||
| INNER JOIN job_type jt ON j.job_type_id = jt.id | |||||
| ORDER BY j.id DESC' | |||||
| ); | |||||
| } | |||||
| /** @return list<array<string, mixed>> */ | |||||
| public function allWithDetailsForCampaign(int $campaignId): array | |||||
| { | |||||
| return $this->database->query( | |||||
| 'SELECT j.id, j.campaign_id, j.job_type_id, j.attribute_values, | |||||
| j.created_at, j.updated_at, | |||||
| ct.name AS campaign_type_name, | |||||
| jt.name AS job_type_name, | |||||
| jt.attributes AS job_type_attributes | |||||
| FROM job j | |||||
| INNER JOIN campaign c ON j.campaign_id = c.id | |||||
| INNER JOIN campaign_type ct ON c.campaign_type_id = ct.id | |||||
| INNER JOIN job_type jt ON j.job_type_id = jt.id | |||||
| WHERE j.campaign_id = :campaign_id | |||||
| ORDER BY j.id DESC', | |||||
| ['campaign_id' => $campaignId] | |||||
| ); | |||||
| } | |||||
| public function findWithDetails(int $id): ?array | |||||
| { | |||||
| return $this->database->first( | |||||
| 'SELECT j.id, j.campaign_id, j.job_type_id, j.attribute_values, | |||||
| j.created_at, j.updated_at, | |||||
| ct.name AS campaign_type_name, | |||||
| jt.name AS job_type_name, | |||||
| jt.attributes AS job_type_attributes | |||||
| FROM job j | |||||
| INNER JOIN campaign c ON j.campaign_id = c.id | |||||
| INNER JOIN campaign_type ct ON c.campaign_type_id = ct.id | |||||
| INNER JOIN job_type jt ON j.job_type_id = jt.id | |||||
| WHERE j.id = :id', | |||||
| ['id' => $id] | |||||
| ); | |||||
| } | |||||
| /** Used after INSERT to recover the generated id for audit logging. */ | |||||
| public function findLatestByCampaignAndType(int $campaignId, int $jobTypeId): ?array | |||||
| { | |||||
| return $this->database->first( | |||||
| 'SELECT TOP (1) * FROM job | |||||
| WHERE campaign_id = :campaign_id AND job_type_id = :job_type_id | |||||
| ORDER BY id DESC', | |||||
| ['campaign_id' => $campaignId, 'job_type_id' => $jobTypeId] | |||||
| ); | |||||
| } | |||||
| public function create(Job $job): bool | |||||
| { | |||||
| return $this->database->execute( | |||||
| 'INSERT INTO job (campaign_id, job_type_id, attribute_values) | |||||
| VALUES (:campaign_id, :job_type_id, :attribute_values)', | |||||
| [ | |||||
| 'campaign_id' => $job->campaignId, | |||||
| 'job_type_id' => $job->jobTypeId, | |||||
| 'attribute_values' => json_encode($job->attributeValues, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), | |||||
| ] | |||||
| ); | |||||
| } | |||||
| public function update(Job $job): bool | |||||
| { | |||||
| return $this->database->execute( | |||||
| 'UPDATE job | |||||
| SET campaign_id = :campaign_id, | |||||
| job_type_id = :job_type_id, | |||||
| attribute_values = :attribute_values, | |||||
| updated_at = CURRENT_TIMESTAMP | |||||
| WHERE id = :id', | |||||
| [ | |||||
| 'campaign_id' => $job->campaignId, | |||||
| 'job_type_id' => $job->jobTypeId, | |||||
| 'attribute_values' => json_encode($job->attributeValues, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), | |||||
| 'id' => $job->id, | |||||
| ] | |||||
| ); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,41 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Repositories; | |||||
| use Core\Repository; | |||||
| /** | |||||
| * Action codes: I Insert · U Update · D Delete · R Restore | |||||
| */ | |||||
| class JobTypeAuditRepository extends Repository | |||||
| { | |||||
| protected string $table = 'job_type_audit'; | |||||
| protected string $primaryKey = 'audit_id'; | |||||
| /** @param array<string, mixed> $fields */ | |||||
| public function log(int $jobTypeId, string $action, array $fields, string $username): void | |||||
| { | |||||
| $this->database->execute( | |||||
| "INSERT INTO job_type_audit (id, action, fields, username) | |||||
| VALUES (:id, :action, :fields, :username)", | |||||
| [ | |||||
| 'id' => $jobTypeId, | |||||
| 'action' => $action, | |||||
| 'fields' => json_encode($fields, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), | |||||
| 'username' => $username, | |||||
| ] | |||||
| ); | |||||
| } | |||||
| /** @return list<array<string, mixed>> */ | |||||
| public function forRecord(int $jobTypeId): array | |||||
| { | |||||
| return $this->database->query( | |||||
| 'SELECT audit_id, id, action, fields, username, created_at | |||||
| FROM job_type_audit WHERE id = :id ORDER BY audit_id ASC', | |||||
| ['id' => $jobTypeId] | |||||
| ); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,52 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Repositories; | |||||
| use App\Models\JobType; | |||||
| use Core\Repository; | |||||
| class JobTypeRepository extends Repository | |||||
| { | |||||
| protected string $table = 'job_type'; | |||||
| /** @return list<array<string, mixed>> */ | |||||
| public function allOrderedByName(): array | |||||
| { | |||||
| return $this->database->query('SELECT * FROM job_type ORDER BY name ASC'); | |||||
| } | |||||
| public function findByName(string $name): ?array | |||||
| { | |||||
| return $this->database->first( | |||||
| 'SELECT * FROM job_type WHERE name = :name', | |||||
| ['name' => $name] | |||||
| ); | |||||
| } | |||||
| public function create(JobType $jobType): bool | |||||
| { | |||||
| return $this->database->execute( | |||||
| 'INSERT INTO job_type (name, attributes) VALUES (:name, :attributes)', | |||||
| [ | |||||
| 'name' => $jobType->name, | |||||
| 'attributes' => json_encode($jobType->attributes, JSON_THROW_ON_ERROR), | |||||
| ] | |||||
| ); | |||||
| } | |||||
| public function update(JobType $jobType): bool | |||||
| { | |||||
| return $this->database->execute( | |||||
| 'UPDATE job_type | |||||
| SET name = :name, attributes = :attributes, updated_at = CURRENT_TIMESTAMP | |||||
| WHERE id = :id', | |||||
| [ | |||||
| 'name' => $jobType->name, | |||||
| 'attributes' => json_encode($jobType->attributes, JSON_THROW_ON_ERROR), | |||||
| 'id' => $jobType->id, | |||||
| ] | |||||
| ); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,385 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Services; | |||||
| use RuntimeException; | |||||
| /** | |||||
| * Reads job row data from an uploaded CSV or Excel (.xlsx) file. | |||||
| * No external libraries — CSV uses fgetcsv, xlsx uses ZipArchive + SimpleXML. | |||||
| */ | |||||
| class FileImportService | |||||
| { | |||||
| private string $tempDir; | |||||
| public function __construct() | |||||
| { | |||||
| $this->tempDir = rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . 'ct_imports' . DIRECTORY_SEPARATOR; | |||||
| if (!is_dir($this->tempDir)) { | |||||
| mkdir($this->tempDir, 0700, true); | |||||
| } | |||||
| } | |||||
| // ── Upload ──────────────────────────────────────────────────────────────── | |||||
| /** | |||||
| * Move an uploaded file to the temp store and return its assigned filename. | |||||
| * | |||||
| * @param array{name: string, tmp_name: string, error: int} $upload $_FILES entry | |||||
| */ | |||||
| public function store(array $upload): string | |||||
| { | |||||
| if (($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { | |||||
| throw new RuntimeException('File upload failed (error code ' . $upload['error'] . ').'); | |||||
| } | |||||
| $ext = strtolower(pathinfo((string) $upload['name'], PATHINFO_EXTENSION)); | |||||
| if (!in_array($ext, ['csv', 'xlsx'], true)) { | |||||
| throw new RuntimeException('Only CSV (.csv) and Excel (.xlsx) files are supported.'); | |||||
| } | |||||
| $filename = bin2hex(random_bytes(16)) . '.' . $ext; | |||||
| $dest = $this->tempDir . $filename; | |||||
| if (!move_uploaded_file((string) $upload['tmp_name'], $dest)) { | |||||
| throw new RuntimeException('Could not save the uploaded file.'); | |||||
| } | |||||
| $this->cleanup(); | |||||
| return $filename; | |||||
| } | |||||
| // ── Sheet list ──────────────────────────────────────────────────────────── | |||||
| /** | |||||
| * Returns the sheets in the file in the same shape as GoogleSheetImportService. | |||||
| * | |||||
| * @return list<array{gid: string, title: string}> | |||||
| */ | |||||
| public function sheets(string $filename): array | |||||
| { | |||||
| $path = $this->guardedPath($filename); | |||||
| $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); | |||||
| if ($ext === 'csv') { | |||||
| return [['gid' => '0', 'title' => 'CSV data']]; | |||||
| } | |||||
| return $this->xlsxSheets($path); | |||||
| } | |||||
| // ── Row data ────────────────────────────────────────────────────────────── | |||||
| /** | |||||
| * Returns rows for the given sheet. | |||||
| * For CSV the gid is ignored (only one sheet). | |||||
| * For xlsx the gid is the 0-based sheet index returned by sheets(). | |||||
| * | |||||
| * @return array{headers: list<string>, rows: list<array<string, string>>} | |||||
| */ | |||||
| public function rows(string $filename, string $gid): array | |||||
| { | |||||
| $path = $this->guardedPath($filename); | |||||
| $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); | |||||
| if ($ext === 'csv') { | |||||
| return $this->parseCsv((string) file_get_contents($path)); | |||||
| } | |||||
| return $this->xlsxRows($path, (int) $gid); | |||||
| } | |||||
| public function delete(string $filename): void | |||||
| { | |||||
| $path = $this->tempDir . basename($filename); | |||||
| if (file_exists($path)) { | |||||
| @unlink($path); | |||||
| } | |||||
| } | |||||
| // ── xlsx parsing ────────────────────────────────────────────────────────── | |||||
| /** | |||||
| * @return list<array{gid: string, title: string}> | |||||
| */ | |||||
| private function xlsxSheets(string $path): array | |||||
| { | |||||
| $zip = $this->openZip($path); | |||||
| try { | |||||
| $xml = $zip->getFromName('xl/workbook.xml'); | |||||
| if ($xml === false) { | |||||
| throw new RuntimeException('Invalid xlsx file — workbook.xml not found.'); | |||||
| } | |||||
| $wb = simplexml_load_string($xml); | |||||
| $sheets = []; | |||||
| $index = 0; | |||||
| foreach ($wb->sheets->sheet as $sheet) { | |||||
| $title = trim((string) $sheet['name']); | |||||
| if ($title !== '') { | |||||
| $sheets[] = ['gid' => (string) $index, 'title' => $title]; | |||||
| $index++; | |||||
| } | |||||
| } | |||||
| return $sheets; | |||||
| } finally { | |||||
| $zip->close(); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * @return array{headers: list<string>, rows: list<array<string, string>>} | |||||
| */ | |||||
| private function xlsxRows(string $path, int $sheetIndex): array | |||||
| { | |||||
| $zip = $this->openZip($path); | |||||
| try { | |||||
| // Shared strings table | |||||
| $sharedStrings = []; | |||||
| $ssXml = $zip->getFromName('xl/sharedStrings.xml'); | |||||
| if ($ssXml !== false) { | |||||
| $ss = simplexml_load_string($ssXml); | |||||
| foreach ($ss->si as $si) { | |||||
| if (isset($si->t)) { | |||||
| $sharedStrings[] = (string) $si->t; | |||||
| } else { | |||||
| $text = ''; | |||||
| foreach ($si->r as $r) { | |||||
| $text .= (string) $r->t; | |||||
| } | |||||
| $sharedStrings[] = $text; | |||||
| } | |||||
| } | |||||
| } | |||||
| // Resolve sheet file from workbook rels | |||||
| $sheetFile = $this->xlsxSheetFile($zip, $sheetIndex); | |||||
| if ($sheetFile === null) { | |||||
| throw new RuntimeException("Sheet index {$sheetIndex} not found in this file."); | |||||
| } | |||||
| $sheetXml = $zip->getFromName($sheetFile); | |||||
| if ($sheetXml === false) { | |||||
| throw new RuntimeException("Cannot read sheet data."); | |||||
| } | |||||
| return $this->parseSheetXml($sheetXml, $sharedStrings); | |||||
| } finally { | |||||
| $zip->close(); | |||||
| } | |||||
| } | |||||
| private function xlsxSheetFile(\ZipArchive $zip, int $sheetIndex): ?string | |||||
| { | |||||
| $wbXml = $zip->getFromName('xl/workbook.xml'); | |||||
| $relXml = $zip->getFromName('xl/_rels/workbook.xml.rels'); | |||||
| if ($wbXml === false || $relXml === false) { | |||||
| return null; | |||||
| } | |||||
| $wb = simplexml_load_string($wbXml); | |||||
| $rel = simplexml_load_string($relXml); | |||||
| // Build rId → target map | |||||
| $relMap = []; | |||||
| foreach ($rel->Relationship as $r) { | |||||
| $relMap[(string) $r['Id']] = (string) $r['Target']; | |||||
| } | |||||
| $index = 0; | |||||
| foreach ($wb->sheets->sheet as $sheet) { | |||||
| if ($index === $sheetIndex) { | |||||
| // r:id attribute lives in the "r" namespace | |||||
| $rAttrs = $sheet->attributes('r', true); | |||||
| $rId = $rAttrs ? (string) $rAttrs['id'] : (string) $sheet['r:id']; | |||||
| if (isset($relMap[$rId])) { | |||||
| $target = $relMap[$rId]; | |||||
| return str_starts_with($target, '/') ? ltrim($target, '/') : 'xl/' . $target; | |||||
| } | |||||
| } | |||||
| $index++; | |||||
| } | |||||
| return null; | |||||
| } | |||||
| /** | |||||
| * @param list<string> $sharedStrings | |||||
| * @return array{headers: list<string>, rows: list<array<string, string>>} | |||||
| */ | |||||
| private function parseSheetXml(string $xml, array $sharedStrings): array | |||||
| { | |||||
| $sheet = simplexml_load_string($xml); | |||||
| $rawRows = []; | |||||
| $maxCol = 0; | |||||
| foreach ($sheet->sheetData->row as $row) { | |||||
| $rowIdx = (int) $row['r'] - 1; | |||||
| $cells = []; | |||||
| foreach ($row->c as $cell) { | |||||
| $ref = (string) $cell['r']; | |||||
| $type = (string) $cell['t']; | |||||
| $value = ''; | |||||
| if (isset($cell->v)) { | |||||
| $v = (string) $cell->v; | |||||
| if ($type === 's') { | |||||
| $value = $sharedStrings[(int) $v] ?? ''; | |||||
| } elseif ($type === 'inlineStr' && isset($cell->is->t)) { | |||||
| $value = (string) $cell->is->t; | |||||
| } else { | |||||
| $value = $v; | |||||
| } | |||||
| } | |||||
| $colIdx = $this->colIndex(preg_replace('/\d/', '', $ref) ?? ''); | |||||
| $cells[$colIdx] = $value; | |||||
| $maxCol = max($maxCol, $colIdx); | |||||
| } | |||||
| $rawRows[$rowIdx] = $cells; | |||||
| } | |||||
| if (empty($rawRows)) { | |||||
| return ['headers' => [], 'rows' => []]; | |||||
| } | |||||
| $minRow = min(array_keys($rawRows)); | |||||
| $headers = []; | |||||
| for ($c = 0; $c <= $maxCol; $c++) { | |||||
| $headers[] = trim((string) ($rawRows[$minRow][$c] ?? '')); | |||||
| } | |||||
| $allRowIndexes = array_keys($rawRows); | |||||
| sort($allRowIndexes); | |||||
| $rows = []; | |||||
| foreach ($allRowIndexes as $ri) { | |||||
| if ($ri === $minRow) continue; | |||||
| $row = []; | |||||
| $hasValue = false; | |||||
| foreach ($headers as $c => $header) { | |||||
| if ($header === '') continue; | |||||
| $value = trim((string) ($rawRows[$ri][$c] ?? '')); | |||||
| $row[$header] = $value; | |||||
| $hasValue = $hasValue || $value !== ''; | |||||
| } | |||||
| if ($hasValue) { | |||||
| $rows[] = $row; | |||||
| } | |||||
| } | |||||
| return ['headers' => $headers, 'rows' => $rows]; | |||||
| } | |||||
| private function colIndex(string $col): int | |||||
| { | |||||
| $col = strtoupper($col); | |||||
| $index = 0; | |||||
| for ($i = 0, $len = strlen($col); $i < $len; $i++) { | |||||
| $index = $index * 26 + (ord($col[$i]) - 64); | |||||
| } | |||||
| return $index - 1; | |||||
| } | |||||
| // ── CSV parsing ─────────────────────────────────────────────────────────── | |||||
| /** | |||||
| * @return array{headers: list<string>, rows: list<array<string, string>>} | |||||
| */ | |||||
| private function parseCsv(string $csv): array | |||||
| { | |||||
| $handle = fopen('php://temp', 'r+'); | |||||
| if ($handle === false) { | |||||
| throw new RuntimeException('Unable to parse the CSV file.'); | |||||
| } | |||||
| fwrite($handle, $csv); | |||||
| rewind($handle); | |||||
| $headers = fgetcsv($handle); | |||||
| if ($headers === false) { | |||||
| fclose($handle); | |||||
| throw new RuntimeException('The CSV file is empty.'); | |||||
| } | |||||
| $headers = array_map( | |||||
| static fn($h): string => trim((string) $h, " \t\n\r\0\x0B\xEF\xBB\xBF"), | |||||
| $headers | |||||
| ); | |||||
| $rows = []; | |||||
| while (($values = fgetcsv($handle)) !== false) { | |||||
| $row = []; | |||||
| $hasValue = false; | |||||
| foreach ($headers as $i => $header) { | |||||
| if ($header === '') continue; | |||||
| $value = trim((string) ($values[$i] ?? '')); | |||||
| $row[$header] = $value; | |||||
| $hasValue = $hasValue || $value !== ''; | |||||
| } | |||||
| if ($hasValue) { | |||||
| $rows[] = $row; | |||||
| } | |||||
| } | |||||
| fclose($handle); | |||||
| return ['headers' => $headers, 'rows' => $rows]; | |||||
| } | |||||
| // ── Helpers ─────────────────────────────────────────────────────────────── | |||||
| private function openZip(string $path): \ZipArchive | |||||
| { | |||||
| $zip = new \ZipArchive(); | |||||
| $result = $zip->open($path); | |||||
| if ($result !== true) { | |||||
| throw new RuntimeException('Cannot open the file. Make sure it is a valid .xlsx file (error ' . $result . ').'); | |||||
| } | |||||
| return $zip; | |||||
| } | |||||
| private function guardedPath(string $filename): string | |||||
| { | |||||
| // Prevent path traversal — only allow the basename. | |||||
| $safe = $this->tempDir . basename($filename); | |||||
| if (!file_exists($safe)) { | |||||
| throw new RuntimeException('Uploaded file not found. Please upload the file again.'); | |||||
| } | |||||
| return $safe; | |||||
| } | |||||
| private function cleanup(): void | |||||
| { | |||||
| $now = time(); | |||||
| foreach (glob($this->tempDir . '*') ?: [] as $file) { | |||||
| if (is_file($file) && $now - filemtime($file) > 3600) { | |||||
| @unlink($file); | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,335 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Services; | |||||
| use RuntimeException; | |||||
| class GoogleSheetImportService | |||||
| { | |||||
| /** | |||||
| * @return array{id: string, sheets: list<array{gid: string, title: string}>} | |||||
| */ | |||||
| public function sheets(string $url): array | |||||
| { | |||||
| $spreadsheetId = $this->spreadsheetId($url); | |||||
| $currentGid = $this->gidFromUrl($url); | |||||
| $sheets = []; | |||||
| // Strategy 1: v3 worksheets JSON feed — works for publicly published sheets. | |||||
| // Each entry's alternate link href contains the numeric gid. | |||||
| if ($sheets === []) { | |||||
| try { | |||||
| $feed = $this->fetch( | |||||
| 'https://spreadsheets.google.com/feeds/worksheets/' | |||||
| . rawurlencode($spreadsheetId) . '/public/basic?alt=json' | |||||
| ); | |||||
| $sheets = $this->extractSheetsFromFeed($feed); | |||||
| } catch (\Throwable) {} | |||||
| } | |||||
| // Strategy 2: htmlview URL — serves rendered HTML with tab links for | |||||
| // sheets shared "anyone with the link can view". | |||||
| if ($sheets === []) { | |||||
| try { | |||||
| $html = $this->fetch( | |||||
| 'https://docs.google.com/spreadsheets/d/' | |||||
| . rawurlencode($spreadsheetId) . '/htmlview' | |||||
| ); | |||||
| $sheets = $this->extractSheets($html); | |||||
| } catch (\Throwable) {} | |||||
| } | |||||
| // Strategy 3: edit URL JS-bootstrapped data — last resort. | |||||
| if ($sheets === []) { | |||||
| try { | |||||
| $html = $this->fetch( | |||||
| 'https://docs.google.com/spreadsheets/d/' | |||||
| . rawurlencode($spreadsheetId) . '/edit?usp=sharing' | |||||
| ); | |||||
| $sheets = $this->extractSheets($html); | |||||
| } catch (\Throwable) {} | |||||
| } | |||||
| // Fallback: if we know the gid from the URL, return a labelled placeholder. | |||||
| if ($sheets === [] && $currentGid !== null) { | |||||
| $sheets[] = ['gid' => $currentGid, 'title' => 'Sheet ' . $currentGid]; | |||||
| } | |||||
| if ($sheets === []) { | |||||
| $sheets[] = ['gid' => '0', 'title' => 'First sheet']; | |||||
| } | |||||
| return ['id' => $spreadsheetId, 'sheets' => $sheets]; | |||||
| } | |||||
| /** | |||||
| * @return array{headers: list<string>, rows: list<array<string, string>>} | |||||
| */ | |||||
| public function rows(string $url, string $gid): array | |||||
| { | |||||
| $spreadsheetId = $this->spreadsheetId($url); | |||||
| $csv = $this->fetch(sprintf( | |||||
| 'https://docs.google.com/spreadsheets/d/%s/export?format=csv&gid=%s', | |||||
| rawurlencode($spreadsheetId), | |||||
| rawurlencode($gid) | |||||
| )); | |||||
| return $this->parseCsv($csv); | |||||
| } | |||||
| public function spreadsheetId(string $url): string | |||||
| { | |||||
| $parts = parse_url($url); | |||||
| $host = strtolower((string) ($parts['host'] ?? '')); | |||||
| if (!in_array($host, ['docs.google.com', 'spreadsheets.google.com'], true)) { | |||||
| throw new RuntimeException('Enter a valid Google Sheets URL.'); | |||||
| } | |||||
| $path = (string) ($parts['path'] ?? ''); | |||||
| if (preg_match('#/spreadsheets/d/([a-zA-Z0-9_-]+)#', $path, $matches) !== 1) { | |||||
| throw new RuntimeException('The Google Sheets URL does not include a spreadsheet id.'); | |||||
| } | |||||
| return $matches[1]; | |||||
| } | |||||
| // ── Sheet extraction ────────────────────────────────────────────────────── | |||||
| /** | |||||
| * Parse the v3 JSON feed response. | |||||
| * | |||||
| * @return list<array{gid: string, title: string}> | |||||
| */ | |||||
| private function extractSheetsFromFeed(string $json): array | |||||
| { | |||||
| $data = json_decode($json, true); | |||||
| if (!is_array($data) || !isset($data['feed']['entry'])) { | |||||
| return []; | |||||
| } | |||||
| $sheets = []; | |||||
| foreach ((array) $data['feed']['entry'] as $entry) { | |||||
| $title = (string) ($entry['title']['$t'] ?? ''); | |||||
| if ($title === '') { | |||||
| continue; | |||||
| } | |||||
| // GID is embedded in the rel="alternate" link href as #gid=NNN or &gid=NNN | |||||
| $gid = null; | |||||
| foreach ((array) ($entry['link'] ?? []) as $link) { | |||||
| if (preg_match('/[#&]gid=(\d+)/', (string) ($link['href'] ?? ''), $m)) { | |||||
| $gid = $m[1]; | |||||
| break; | |||||
| } | |||||
| } | |||||
| if ($gid !== null && $this->looksLikeSheet($gid, $title) && !isset($sheets[$gid])) { | |||||
| $sheets[$gid] = ['gid' => $gid, 'title' => $title]; | |||||
| } | |||||
| } | |||||
| return array_values($sheets); | |||||
| } | |||||
| /** | |||||
| * Parse HTML from htmlview or edit URL for sheet tab data. | |||||
| * | |||||
| * @return list<array{gid: string, title: string}> | |||||
| */ | |||||
| private function extractSheets(string $html): array | |||||
| { | |||||
| $sheets = []; | |||||
| // ── HTML tab patterns (htmlview format) ─────────────────────────────── | |||||
| // Google renders tab links like: | |||||
| // <a href="#gid=123">Sheet Name</a> | |||||
| // <span data-id="123">Sheet Name</span> | |||||
| $htmlPatterns = [ | |||||
| '/<[^>]+href=["\'][^"\']*[#&]gid=(\d+)["\'][^>]*>\s*(?:<[^>]+>\s*)*([^<]{1,100}?)\s*(?:<|$)/i', | |||||
| '/data-id=["\'](\d+)["\'][^>]*>\s*([^<]{1,100}?)\s*</i', | |||||
| ]; | |||||
| foreach ($htmlPatterns as $pattern) { | |||||
| if (preg_match_all($pattern, $html, $matches, PREG_SET_ORDER) > 0) { | |||||
| foreach ($matches as $match) { | |||||
| $gid = $match[1]; | |||||
| $title = trim(html_entity_decode($match[2], ENT_QUOTES | ENT_HTML5, 'UTF-8')); | |||||
| if ($this->looksLikeSheet($gid, $title) && !isset($sheets[$gid])) { | |||||
| $sheets[$gid] = ['gid' => $gid, 'title' => $title]; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| if (!empty($sheets)) { | |||||
| return array_values($sheets); | |||||
| } | |||||
| // ── JavaScript JSON patterns (edit URL bootstrapped data) ───────────── | |||||
| // Distance increased to 600 chars to handle larger embedded JSON objects. | |||||
| $jsPatterns = [ | |||||
| '/"gid"\s*:\s*(\d+).{0,600}?"name"\s*:\s*"((?:\\\\.|[^"\\\\])+)"/s', | |||||
| '/"name"\s*:\s*"((?:\\\\.|[^"\\\\])+)".{0,600}?"gid"\s*:\s*(\d+)/s', | |||||
| '/"gid"\s*:\s*(\d+).{0,600}?"title"\s*:\s*"((?:\\\\.|[^"\\\\])+)"/s', | |||||
| '/"title"\s*:\s*"((?:\\\\.|[^"\\\\])+)".{0,600}?"gid"\s*:\s*(\d+)/s', | |||||
| '/\[\s*(\d+)\s*,\s*"((?:\\\\.|[^"\\\\])+)"/s', | |||||
| ]; | |||||
| foreach ($jsPatterns as $pattern) { | |||||
| if (preg_match_all($pattern, $html, $matches, PREG_SET_ORDER) > 0) { | |||||
| foreach ($matches as $match) { | |||||
| $first = (string) $match[1]; | |||||
| $second = (string) $match[2]; | |||||
| $gid = ctype_digit($first) ? $first : $second; | |||||
| $title = ctype_digit($first) ? $second : $first; | |||||
| $title = $this->decodeJsString($title); | |||||
| if (!$this->looksLikeSheet($gid, $title) || isset($sheets[$gid])) { | |||||
| continue; | |||||
| } | |||||
| $sheets[$gid] = ['gid' => $gid, 'title' => $title]; | |||||
| } | |||||
| if (!empty($sheets)) { | |||||
| break; | |||||
| } | |||||
| } | |||||
| } | |||||
| return array_values($sheets); | |||||
| } | |||||
| private function looksLikeSheet(string $gid, string $title): bool | |||||
| { | |||||
| if ($gid === '' || !ctype_digit($gid) || $title === '' || strlen($title) > 120) { | |||||
| return false; | |||||
| } | |||||
| return !str_contains($title, '<') | |||||
| && !str_contains($title, '{') | |||||
| && !str_contains(strtolower($title), 'http'); | |||||
| } | |||||
| private function decodeJsString(string $value): string | |||||
| { | |||||
| $decoded = json_decode('"' . str_replace('"', '\\"', $value) . '"', true); | |||||
| return is_string($decoded) ? $decoded : stripcslashes($value); | |||||
| } | |||||
| private function gidFromUrl(string $url): ?string | |||||
| { | |||||
| $fragment = parse_url($url, PHP_URL_FRAGMENT); | |||||
| $query = parse_url($url, PHP_URL_QUERY); | |||||
| foreach ([(string) $fragment, (string) $query] as $part) { | |||||
| if (preg_match('/(?:^|&)gid=(\d+)/', $part, $matches) === 1) { | |||||
| return $matches[1]; | |||||
| } | |||||
| } | |||||
| return null; | |||||
| } | |||||
| // ── HTTP fetch ──────────────────────────────────────────────────────────── | |||||
| private function fetch(string $url): string | |||||
| { | |||||
| $context = stream_context_create([ | |||||
| 'http' => [ | |||||
| 'method' => 'GET', | |||||
| 'timeout' => 15, | |||||
| 'ignore_errors' => true, | |||||
| 'follow_location' => true, | |||||
| 'max_redirects' => 5, | |||||
| 'header' => implode("\r\n", [ | |||||
| 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', | |||||
| 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', | |||||
| 'Accept-Language: en-US,en;q=0.5', | |||||
| ]) . "\r\n", | |||||
| ], | |||||
| ]); | |||||
| $content = @file_get_contents($url, false, $context); | |||||
| if (!is_string($content) || trim($content) === '') { | |||||
| throw new RuntimeException( | |||||
| 'Could not reach the Google Sheet. Check the URL and make sure the sheet is shared as "Anyone with the link can view".' | |||||
| ); | |||||
| } | |||||
| // Google returns a sign-in page when the sheet requires authentication. | |||||
| // Detect this by looking for the login shell markers present in every | |||||
| // Google auth redirect (~9 KB of CSS/JS with no actual sheet data). | |||||
| if ( | |||||
| str_contains($content, '.login,.request-storage-access') || | |||||
| str_contains($content, 'ServiceLogin') || | |||||
| str_contains($content, 'accounts.google.com/ServiceLogin') | |||||
| ) { | |||||
| throw new RuntimeException( | |||||
| 'Google returned a sign-in page. The spreadsheet must be shared as "Anyone with the link can view": ' | |||||
| . 'open the sheet → File → Share → Change to "Anyone with the link" → Viewer.' | |||||
| ); | |||||
| } | |||||
| return $content; | |||||
| } | |||||
| // ── CSV parsing ─────────────────────────────────────────────────────────── | |||||
| /** | |||||
| * @return array{headers: list<string>, rows: list<array<string, string>>} | |||||
| */ | |||||
| private function parseCsv(string $csv): array | |||||
| { | |||||
| $handle = fopen('php://temp', 'r+'); | |||||
| if ($handle === false) { | |||||
| throw new RuntimeException('Unable to parse sheet data.'); | |||||
| } | |||||
| fwrite($handle, $csv); | |||||
| rewind($handle); | |||||
| $headers = fgetcsv($handle); | |||||
| if ($headers === false) { | |||||
| fclose($handle); | |||||
| throw new RuntimeException('The selected sheet is empty.'); | |||||
| } | |||||
| $headers = array_map( | |||||
| static fn($h): string => trim((string) $h, " \t\n\r\0\x0B\xEF\xBB\xBF"), | |||||
| $headers | |||||
| ); | |||||
| $rows = []; | |||||
| while (($values = fgetcsv($handle)) !== false) { | |||||
| $row = []; | |||||
| $hasValue = false; | |||||
| foreach ($headers as $index => $header) { | |||||
| if ($header === '') { | |||||
| continue; | |||||
| } | |||||
| $value = trim((string) ($values[$index] ?? '')); | |||||
| $row[$header] = $value; | |||||
| $hasValue = $hasValue || $value !== ''; | |||||
| } | |||||
| if ($hasValue) { | |||||
| $rows[] = $row; | |||||
| } | |||||
| } | |||||
| fclose($handle); | |||||
| return ['headers' => $headers, 'rows' => $rows]; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,30 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\ViewModels; | |||||
| class CampaignTypeViewModel | |||||
| { | |||||
| public string $title = 'Campaign Types'; | |||||
| public bool $saved = false; | |||||
| public bool $deleted = false; | |||||
| /** | |||||
| * @var array{name: string, attributes: list<array{name: string, type: string}>} | |||||
| */ | |||||
| public array $form = [ | |||||
| 'name' => '', | |||||
| 'attributes' => [], | |||||
| ]; | |||||
| /** | |||||
| * @var array<string, list<string>> | |||||
| */ | |||||
| public array $errors = []; | |||||
| /** | |||||
| * @var array<string, mixed>|null | |||||
| */ | |||||
| public ?array $campaignType = null; | |||||
| } | |||||
| @@ -0,0 +1,38 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\ViewModels; | |||||
| class CampaignViewModel | |||||
| { | |||||
| public string $title = 'Campaigns'; | |||||
| public bool $saved = false; | |||||
| public bool $deleted = false; | |||||
| /** | |||||
| * @var array{name: string, campaign_type_id: int|string, attribute_values: array<string, string>} | |||||
| */ | |||||
| public array $form = [ | |||||
| 'campaign_type_id' => 0, | |||||
| 'attribute_values' => [], | |||||
| ]; | |||||
| /** | |||||
| * @var array<string, list<string>> | |||||
| */ | |||||
| public array $errors = []; | |||||
| /** | |||||
| * @var array<string, mixed>|null | |||||
| */ | |||||
| public ?array $campaign = null; | |||||
| /** | |||||
| * All campaign types with attributes decoded, used to populate the type | |||||
| * dropdown and drive the dynamic attribute fields. | |||||
| * | |||||
| * @var list<array{id: int, name: string, attributes: list<array{name: string, type: string}>}> | |||||
| */ | |||||
| public array $campaignTypes = []; | |||||
| } | |||||
| @@ -0,0 +1,13 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\ViewModels; | |||||
| class HomeIndexViewModel | |||||
| { | |||||
| public string $title = ''; | |||||
| public string $eyebrow = ''; | |||||
| public string $message = ''; | |||||
| public string $routeExample = ''; | |||||
| } | |||||
| @@ -0,0 +1,23 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\ViewModels; | |||||
| class JobTypeViewModel | |||||
| { | |||||
| public string $title = 'Job Types'; | |||||
| public bool $saved = false; | |||||
| public bool $deleted = false; | |||||
| public array $form = [ | |||||
| 'name' => '', | |||||
| 'attributes' => [], | |||||
| ]; | |||||
| /** @var array<string, list<string>> */ | |||||
| public array $errors = []; | |||||
| /** @var array<string, mixed>|null */ | |||||
| public ?array $jobType = null; | |||||
| } | |||||
| @@ -0,0 +1,36 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\ViewModels; | |||||
| class JobViewModel | |||||
| { | |||||
| public string $title = 'Jobs'; | |||||
| public bool $saved = false; | |||||
| public bool $deleted = false; | |||||
| public array $form = [ | |||||
| 'campaign_id' => 0, | |||||
| 'job_type_id' => 0, | |||||
| 'attribute_values' => [], | |||||
| ]; | |||||
| /** @var array<string, list<string>> */ | |||||
| public array $errors = []; | |||||
| /** @var array<string, mixed>|null */ | |||||
| public ?array $job = null; | |||||
| /** | |||||
| * Campaigns with campaign_type_name for the dropdown. | |||||
| * @var list<array<string, mixed>> | |||||
| */ | |||||
| public array $campaigns = []; | |||||
| /** | |||||
| * Job types with attributes decoded, for the type dropdown and dynamic fields. | |||||
| * @var list<array{id: int, name: string, attributes: list<array{name: string, type: string}>}> | |||||
| */ | |||||
| public array $jobTypes = []; | |||||
| } | |||||
| @@ -0,0 +1,106 @@ | |||||
| <script>window.__ctAttributes = <?= json_encode($model->form['attributes'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;</script> | |||||
| <section class="content-stack"> | |||||
| <div class="page-toolbar"> | |||||
| <div class="section-heading"> | |||||
| <h1><?= e($model->title) ?></h1> | |||||
| <p>Define a campaign type and the attributes that will describe it.</p> | |||||
| </div> | |||||
| <a class="button button-secondary" href="/campaign-types">← Back to list</a> | |||||
| </div> | |||||
| <section class="section-panel" x-data="campaignTypeForm(window.__ctAttributes)"> | |||||
| <?php if (isset($model->errors['_token'])): ?> | |||||
| <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | |||||
| <?php endif; ?> | |||||
| <form method="post" action="/campaign-types" class="ct-form" novalidate> | |||||
| <?= csrf_field() ?> | |||||
| <div class="form-section"> | |||||
| <label class="field field-full"> | |||||
| <span>Campaign type name <span class="required-mark">*</span></span> | |||||
| <input | |||||
| class="input<?= isset($model->errors['name']) ? ' input-error' : '' ?>" | |||||
| type="text" | |||||
| name="name" | |||||
| maxlength="255" | |||||
| value="<?= e($model->form['name']) ?>" | |||||
| required | |||||
| autofocus | |||||
| > | |||||
| <?php if (isset($model->errors['name'])): ?> | |||||
| <small class="field-error"><?= e($model->errors['name'][0]) ?></small> | |||||
| <?php endif; ?> | |||||
| </label> | |||||
| </div> | |||||
| <div class="form-section"> | |||||
| <div class="attributes-header"> | |||||
| <h3>Attributes</h3> | |||||
| <p class="attributes-hint">Add the fields that campaigns of this type will carry.</p> | |||||
| </div> | |||||
| <div class="attribute-list"> | |||||
| <template x-for="(attr, index) in attributes" :key="index"> | |||||
| <div class="attribute-row" | |||||
| draggable="true" | |||||
| x-on:dragstart="dragStart($event, index)" | |||||
| x-on:dragover.prevent="dragOver($event, index)" | |||||
| x-on:drop="drop($event, index)" | |||||
| x-on:dragend="dragEnd()" | |||||
| :class="{ 'is-dragging': dragIndex === index, 'is-drag-over': dragOverIndex === index && dragIndex !== index }"> | |||||
| <span class="attr-drag-handle" title="Drag to reorder">↕</span> | |||||
| <label class="field attribute-order-field"> | |||||
| <span>Order</span> | |||||
| <input class="input" type="number" | |||||
| :name="`attribute_order[${index}]`" | |||||
| x-model.number="attr.order" min="1"> | |||||
| </label> | |||||
| <label class="field attribute-name-field"> | |||||
| <span>Attribute name</span> | |||||
| <input | |||||
| class="input" | |||||
| type="text" | |||||
| :name="`attribute_name[${index}]`" | |||||
| x-model="attr.name" | |||||
| placeholder="e.g. Budget" | |||||
| maxlength="100" | |||||
| > | |||||
| </label> | |||||
| <label class="field attribute-type-field"> | |||||
| <span>Type</span> | |||||
| <select class="input" :name="`attribute_type[${index}]`" x-model="attr.type"> | |||||
| <option value="text">Text</option> | |||||
| <option value="number">Number</option> | |||||
| <option value="date">Date</option> | |||||
| <option value="boolean">True/False</option> | |||||
| </select> | |||||
| </label> | |||||
| <div class="attribute-remove"> | |||||
| <button | |||||
| type="button" | |||||
| class="button button-danger button-sm" | |||||
| x-on:click="removeAttribute(index)" | |||||
| title="Remove attribute" | |||||
| >×</button> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| </div> | |||||
| <button type="button" class="button button-secondary button-sm" x-on:click="addAttribute()"> | |||||
| + Add Attribute | |||||
| </button> | |||||
| </div> | |||||
| <div class="form-actions"> | |||||
| <button class="button button-primary" type="submit">Save Campaign Type</button> | |||||
| <a class="button button-secondary" href="/campaign-types">Cancel</a> | |||||
| </div> | |||||
| </form> | |||||
| </section> | |||||
| </section> | |||||
| @@ -0,0 +1,126 @@ | |||||
| <?php $campaignTypeId = (int) ($model->campaignType['id'] ?? 0); ?> | |||||
| <script>window.__ctAttributes = <?= json_encode($model->form['attributes'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;</script> | |||||
| <section class="content-stack"> | |||||
| <div class="page-toolbar"> | |||||
| <div class="section-heading"> | |||||
| <h1><?= e($model->title) ?></h1> | |||||
| <p>Update the name or attributes for this campaign type.</p> | |||||
| </div> | |||||
| <a class="button button-secondary" href="/campaign-types">← Back to list</a> | |||||
| </div> | |||||
| <?php if ($model->saved): ?> | |||||
| <div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)"> | |||||
| Campaign type updated successfully. | |||||
| </div> | |||||
| <?php endif; ?> | |||||
| <section class="section-panel" x-data="campaignTypeForm(window.__ctAttributes)"> | |||||
| <?php if (isset($model->errors['_token'])): ?> | |||||
| <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | |||||
| <?php endif; ?> | |||||
| <form method="post" action="/campaign-types/<?= e((string) $campaignTypeId) ?>/update" class="ct-form" novalidate> | |||||
| <?= csrf_field() ?> | |||||
| <div class="form-section"> | |||||
| <label class="field field-full"> | |||||
| <span>Campaign type name <span class="required-mark">*</span></span> | |||||
| <input | |||||
| class="input<?= isset($model->errors['name']) ? ' input-error' : '' ?>" | |||||
| type="text" | |||||
| name="name" | |||||
| maxlength="255" | |||||
| value="<?= e($model->form['name']) ?>" | |||||
| required | |||||
| autofocus | |||||
| > | |||||
| <?php if (isset($model->errors['name'])): ?> | |||||
| <small class="field-error"><?= e($model->errors['name'][0]) ?></small> | |||||
| <?php endif; ?> | |||||
| </label> | |||||
| </div> | |||||
| <div class="form-section"> | |||||
| <div class="attributes-header"> | |||||
| <h3>Attributes</h3> | |||||
| <p class="attributes-hint">Modify the fields that campaigns of this type will carry.</p> | |||||
| </div> | |||||
| <div class="attribute-list"> | |||||
| <template x-for="(attr, index) in attributes" :key="index"> | |||||
| <div class="attribute-row" | |||||
| draggable="true" | |||||
| x-on:dragstart="dragStart($event, index)" | |||||
| x-on:dragover.prevent="dragOver($event, index)" | |||||
| x-on:drop="drop($event, index)" | |||||
| x-on:dragend="dragEnd()" | |||||
| :class="{ 'is-dragging': dragIndex === index, 'is-drag-over': dragOverIndex === index && dragIndex !== index }"> | |||||
| <span class="attr-drag-handle" title="Drag to reorder">↕</span> | |||||
| <label class="field attribute-order-field"> | |||||
| <span>Order</span> | |||||
| <input class="input" type="number" | |||||
| :name="`attribute_order[${index}]`" | |||||
| x-model.number="attr.order" min="1"> | |||||
| </label> | |||||
| <label class="field attribute-name-field"> | |||||
| <span>Attribute name</span> | |||||
| <input | |||||
| class="input" | |||||
| type="text" | |||||
| :name="`attribute_name[${index}]`" | |||||
| x-model="attr.name" | |||||
| placeholder="e.g. Budget" | |||||
| maxlength="100" | |||||
| > | |||||
| </label> | |||||
| <label class="field attribute-type-field"> | |||||
| <span>Type</span> | |||||
| <select class="input" :name="`attribute_type[${index}]`" x-model="attr.type"> | |||||
| <option value="text">Text</option> | |||||
| <option value="number">Number</option> | |||||
| <option value="date">Date</option> | |||||
| <option value="boolean">True/False</option> | |||||
| </select> | |||||
| </label> | |||||
| <div class="attribute-remove"> | |||||
| <button | |||||
| type="button" | |||||
| class="button button-danger button-sm" | |||||
| x-on:click="removeAttribute(index)" | |||||
| title="Remove attribute" | |||||
| >×</button> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| </div> | |||||
| <button type="button" class="button button-secondary button-sm" x-on:click="addAttribute()"> | |||||
| + Add Attribute | |||||
| </button> | |||||
| </div> | |||||
| <div class="form-actions"> | |||||
| <button class="button button-primary" type="submit">Update Campaign Type</button> | |||||
| <a class="button button-secondary" href="/campaign-types">Cancel</a> | |||||
| </div> | |||||
| </form> | |||||
| <div class="delete-zone"> | |||||
| <h4>Delete this campaign type</h4> | |||||
| <p>This cannot be undone.</p> | |||||
| <form | |||||
| method="post" | |||||
| action="/campaign-types/<?= e((string) $campaignTypeId) ?>/delete" | |||||
| x-on:submit.prevent="confirmDelete($event)" | |||||
| > | |||||
| <?= csrf_field() ?> | |||||
| <button type="submit" class="button button-danger">Delete Campaign Type</button> | |||||
| </form> | |||||
| </div> | |||||
| </section> | |||||
| </section> | |||||
| @@ -0,0 +1,35 @@ | |||||
| <section class="content-stack" x-data="campaignTypeTable()"> | |||||
| <div class="page-toolbar"> | |||||
| <div class="section-heading"> | |||||
| <h1><?= e($model->title) ?></h1> | |||||
| <p>Manage campaign types and their configurable attributes.</p> | |||||
| </div> | |||||
| <a class="button button-primary" href="/campaign-types/create">+ New Campaign Type</a> | |||||
| </div> | |||||
| <?php if ($model->saved): ?> | |||||
| <div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)"> | |||||
| Campaign type saved successfully. | |||||
| </div> | |||||
| <?php endif; ?> | |||||
| <?php if ($model->deleted): ?> | |||||
| <div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)"> | |||||
| Campaign type deleted. | |||||
| </div> | |||||
| <?php endif; ?> | |||||
| <section class="section-panel"> | |||||
| <div class="panel-header"> | |||||
| <div> | |||||
| <h2>Campaign Type Directory</h2> | |||||
| <p>All campaign types with their attribute definitions.</p> | |||||
| </div> | |||||
| <button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button> | |||||
| </div> | |||||
| <div id="campaign-type-table" class="tabulator-host"></div> | |||||
| </section> | |||||
| </section> | |||||
| @@ -0,0 +1,104 @@ | |||||
| <script> | |||||
| window.__campaignTypes = <?= json_encode($model->campaignTypes, JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||||
| window.__initialTypeId = <?= json_encode($model->form['campaign_type_id'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||||
| window.__initialValues = <?= json_encode($model->form['attribute_values'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||||
| </script> | |||||
| <section class="content-stack"> | |||||
| <div class="page-toolbar"> | |||||
| <div class="section-heading"> | |||||
| <h1><?= e($model->title) ?></h1> | |||||
| <p>Choose a campaign type, enter a name, then fill in the type’s attribute values.</p> | |||||
| </div> | |||||
| <a class="button button-secondary" href="/campaigns">← Back to list</a> | |||||
| </div> | |||||
| <?php if (!$model->campaignTypes): ?> | |||||
| <div class="alert alert-error"> | |||||
| No campaign types have been defined yet. | |||||
| <a href="/campaign-types/create">Create a campaign type</a> before adding campaigns. | |||||
| </div> | |||||
| <?php else: ?> | |||||
| <section class="section-panel" x-data="campaignForm(window.__campaignTypes, window.__initialTypeId, window.__initialValues)"> | |||||
| <?php if (isset($model->errors['_token'])): ?> | |||||
| <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | |||||
| <?php endif; ?> | |||||
| <form method="post" action="/campaigns" class="ct-form" novalidate> | |||||
| <?= csrf_field() ?> | |||||
| <div class="form-section"> | |||||
| <label class="field field-full"> | |||||
| <span>Campaign type <span class="required-mark">*</span></span> | |||||
| <select | |||||
| class="input<?= isset($model->errors['campaign_type_id']) ? ' input-error' : '' ?>" | |||||
| name="campaign_type_id" | |||||
| x-model="selectedTypeId" | |||||
| x-on:change="onTypeChange()" | |||||
| required | |||||
| > | |||||
| <option value="0">— Select a campaign type —</option> | |||||
| <?php foreach ($model->campaignTypes as $type): ?> | |||||
| <option | |||||
| value="<?= e((string) $type['id']) ?>" | |||||
| <?= (int) $model->form['campaign_type_id'] === $type['id'] ? 'selected' : '' ?> | |||||
| ><?= e($type['name']) ?></option> | |||||
| <?php endforeach; ?> | |||||
| </select> | |||||
| <?php if (isset($model->errors['campaign_type_id'])): ?> | |||||
| <small class="field-error"><?= e($model->errors['campaign_type_id'][0]) ?></small> | |||||
| <?php endif; ?> | |||||
| </label> | |||||
| </div> | |||||
| <div class="form-section" x-show="currentAttributes.length > 0"> | |||||
| <div class="attributes-header"> | |||||
| <h3>Attribute values</h3> | |||||
| <p class="attributes-hint">Fields defined by the selected campaign type.</p> | |||||
| </div> | |||||
| <div class="form-grid"> | |||||
| <template x-for="attr in currentAttributes" :key="attr.name"> | |||||
| <label class="field"> | |||||
| <span x-text="attr.name"></span> | |||||
| <template x-if="attr.type === 'boolean'"> | |||||
| <select class="input" | |||||
| :name="`attribute_values[${attr.name}]`" | |||||
| x-on:change="attributeValues[attr.name] = $event.target.value"> | |||||
| <option value="" :selected="!attributeValues[attr.name]">— Select —</option> | |||||
| <option value="true" :selected="attributeValues[attr.name] === 'true'">True</option> | |||||
| <option value="false" :selected="attributeValues[attr.name] === 'false'">False</option> | |||||
| </select> | |||||
| </template> | |||||
| <template x-if="attr.type !== 'boolean'"> | |||||
| <input class="input" | |||||
| :type="inputType(attr.type)" | |||||
| :name="`attribute_values[${attr.name}]`" | |||||
| :value="attributeValues[attr.name] ?? ''" | |||||
| x-on:input="attributeValues[attr.name] = $event.target.value"> | |||||
| </template> | |||||
| </label> | |||||
| </template> | |||||
| </div> | |||||
| </div> | |||||
| <p class="attributes-hint" x-show="selectedTypeId && currentAttributes.length === 0"> | |||||
| This campaign type has no attributes defined. | |||||
| </p> | |||||
| <div class="form-actions"> | |||||
| <button class="button button-primary" type="submit">Save Campaign</button> | |||||
| <a class="button button-secondary" href="/campaigns">Cancel</a> | |||||
| </div> | |||||
| </form> | |||||
| </section> | |||||
| <?php endif; ?> | |||||
| </section> | |||||
| @@ -0,0 +1,151 @@ | |||||
| <?php $campaignId = (int) ($model->campaign['id'] ?? 0); ?> | |||||
| <script> | |||||
| window.__campaignTypes = <?= json_encode($model->campaignTypes, JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||||
| window.__initialTypeId = <?= json_encode($model->form['campaign_type_id'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||||
| window.__initialValues = <?= json_encode($model->form['attribute_values'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||||
| </script> | |||||
| <section class="content-stack"> | |||||
| <div class="page-toolbar"> | |||||
| <div class="section-heading"> | |||||
| <h1><?= e($model->title) ?></h1> | |||||
| <p>Update the campaign name, type, or attribute values.</p> | |||||
| </div> | |||||
| <a class="button button-secondary" href="/campaigns">← Back to list</a> | |||||
| </div> | |||||
| <?php if ($model->saved): ?> | |||||
| <div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)"> | |||||
| Campaign updated successfully. | |||||
| </div> | |||||
| <?php endif; ?> | |||||
| <section class="section-panel" x-data="campaignForm(window.__campaignTypes, window.__initialTypeId, window.__initialValues)"> | |||||
| <?php if (isset($model->errors['_token'])): ?> | |||||
| <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | |||||
| <?php endif; ?> | |||||
| <form method="post" action="/campaigns/<?= e((string) $campaignId) ?>/update" class="ct-form" novalidate> | |||||
| <?= csrf_field() ?> | |||||
| <div class="form-section"> | |||||
| <label class="field field-full"> | |||||
| <span>Campaign type <span class="required-mark">*</span></span> | |||||
| <select | |||||
| class="input<?= isset($model->errors['campaign_type_id']) ? ' input-error' : '' ?>" | |||||
| name="campaign_type_id" | |||||
| x-model="selectedTypeId" | |||||
| x-on:change="onTypeChange()" | |||||
| required | |||||
| > | |||||
| <option value="0">— Select a campaign type —</option> | |||||
| <?php foreach ($model->campaignTypes as $type): ?> | |||||
| <option | |||||
| value="<?= e((string) $type['id']) ?>" | |||||
| <?= (int) $model->form['campaign_type_id'] === $type['id'] ? 'selected' : '' ?> | |||||
| ><?= e($type['name']) ?></option> | |||||
| <?php endforeach; ?> | |||||
| </select> | |||||
| <?php if (isset($model->errors['campaign_type_id'])): ?> | |||||
| <small class="field-error"><?= e($model->errors['campaign_type_id'][0]) ?></small> | |||||
| <?php endif; ?> | |||||
| </label> | |||||
| </div> | |||||
| <div class="form-section" x-show="currentAttributes.length > 0"> | |||||
| <div class="attributes-header"> | |||||
| <h3>Attribute values</h3> | |||||
| <p class="attributes-hint">Fields defined by the selected campaign type.</p> | |||||
| </div> | |||||
| <div class="form-grid"> | |||||
| <template x-for="attr in currentAttributes" :key="attr.name"> | |||||
| <label class="field"> | |||||
| <span x-text="attr.name"></span> | |||||
| <template x-if="attr.type === 'boolean'"> | |||||
| <select class="input" | |||||
| :name="`attribute_values[${attr.name}]`" | |||||
| x-on:change="attributeValues[attr.name] = $event.target.value"> | |||||
| <option value="" :selected="!attributeValues[attr.name]">— Select —</option> | |||||
| <option value="true" :selected="attributeValues[attr.name] === 'true'">True</option> | |||||
| <option value="false" :selected="attributeValues[attr.name] === 'false'">False</option> | |||||
| </select> | |||||
| </template> | |||||
| <template x-if="attr.type !== 'boolean'"> | |||||
| <input class="input" | |||||
| :type="inputType(attr.type)" | |||||
| :name="`attribute_values[${attr.name}]`" | |||||
| :value="attributeValues[attr.name] ?? ''" | |||||
| x-on:input="attributeValues[attr.name] = $event.target.value"> | |||||
| </template> | |||||
| </label> | |||||
| </template> | |||||
| </div> | |||||
| </div> | |||||
| <p class="attributes-hint" x-show="selectedTypeId && currentAttributes.length === 0"> | |||||
| This campaign type has no attributes defined. | |||||
| </p> | |||||
| <div class="form-actions"> | |||||
| <button class="button button-primary" type="submit">Update Campaign</button> | |||||
| <a class="button button-secondary" href="/campaigns">Cancel</a> | |||||
| </div> | |||||
| </form> | |||||
| <div class="delete-zone"> | |||||
| <h4>Delete this campaign</h4> | |||||
| <p>This cannot be undone.</p> | |||||
| <form | |||||
| method="post" | |||||
| action="/campaigns/<?= e((string) $campaignId) ?>/delete" | |||||
| x-on:submit.prevent="confirmDelete($event)" | |||||
| > | |||||
| <?= csrf_field() ?> | |||||
| <button type="submit" class="button button-danger">Delete Campaign</button> | |||||
| </form> | |||||
| </div> | |||||
| </section> | |||||
| <section class="section-panel" x-data="campaignJobsTable(<?= $campaignId ?>)"> | |||||
| <div class="panel-header"> | |||||
| <div> | |||||
| <h2>Jobs</h2> | |||||
| <p>All jobs attached to this campaign.</p> | |||||
| </div> | |||||
| <div class="panel-actions"> | |||||
| <button class="button button-secondary button-sm" type="button" x-cloak x-show="!isVisible" x-on:click="showTable()"> | |||||
| Show Jobs | |||||
| </button> | |||||
| <a class="button button-primary button-sm" href="/jobs/create">+ New Job</a> | |||||
| <button class="button button-secondary button-sm" type="button" x-cloak x-show="isVisible" x-on:click="reloadTable()">Refresh</button> | |||||
| <button class="button button-secondary button-sm" type="button" x-cloak x-show="isVisible" x-on:click="hideTable()">Hide</button> | |||||
| </div> | |||||
| </div> | |||||
| <div x-cloak x-show="isVisible" x-transition.opacity> | |||||
| <div class="inline-indicator" x-show="isLoading">Loading jobs...</div> | |||||
| <div class="alert alert-error" x-show="errorMessage" x-text="errorMessage"></div> | |||||
| <div class="empty-state" x-show="!isLoading && !errorMessage && hasLoaded && groups.length === 0"> | |||||
| <p>No jobs are attached to this campaign.</p> | |||||
| </div> | |||||
| <div class="job-type-table-stack" x-show="groups.length > 0"> | |||||
| <template x-for="group in groups" :key="group.id"> | |||||
| <section class="job-type-table-group"> | |||||
| <div class="job-type-table-heading"> | |||||
| <h3 x-text="group.name"></h3> | |||||
| <span x-text="group.rows.length + (group.rows.length === 1 ? ' job' : ' jobs')"></span> | |||||
| </div> | |||||
| <div :id="group.elementId" class="tabulator-host"></div> | |||||
| </section> | |||||
| </template> | |||||
| </div> | |||||
| </div> | |||||
| </section> | |||||
| </section> | |||||
| @@ -0,0 +1,54 @@ | |||||
| <section class="content-stack" x-data="campaignTable()"> | |||||
| <div class="page-toolbar"> | |||||
| <div class="section-heading"> | |||||
| <h1><?= e($model->title) ?></h1> | |||||
| <p>Manage campaigns and their attribute values.</p> | |||||
| </div> | |||||
| <a class="button button-primary" href="/campaigns/create">+ New Campaign</a> | |||||
| </div> | |||||
| <?php if ($model->saved): ?> | |||||
| <div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)"> | |||||
| Campaign saved successfully. | |||||
| </div> | |||||
| <?php endif; ?> | |||||
| <?php if ($model->deleted): ?> | |||||
| <div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)"> | |||||
| Campaign deleted. | |||||
| </div> | |||||
| <?php endif; ?> | |||||
| <section class="section-panel"> | |||||
| <div class="panel-header"> | |||||
| <div> | |||||
| <h2>Campaign Directory</h2> | |||||
| <p>All campaigns with their type and attribute data.</p> | |||||
| </div> | |||||
| <button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button> | |||||
| </div> | |||||
| <div class="inline-indicator" x-cloak x-show="isLoading">Loading campaigns...</div> | |||||
| <div class="alert alert-error" x-cloak x-show="errorMessage" x-text="errorMessage"></div> | |||||
| <div id="campaign-table" class="tabulator-host"></div> | |||||
| </section> | |||||
| <section class="section-panel" x-cloak x-show="selectedCampaignId" x-transition.opacity> | |||||
| <div class="panel-header"> | |||||
| <div> | |||||
| <h2>Campaign Jobs</h2> | |||||
| <p x-text="selectedCampaignTitle"></p> | |||||
| </div> | |||||
| <div class="panel-actions"> | |||||
| <button class="button button-secondary button-sm" type="button" x-on:click="reloadJobsTable()">Refresh</button> | |||||
| <button class="button button-secondary button-sm" type="button" x-on:click="closeJobsTable()">Close</button> | |||||
| </div> | |||||
| </div> | |||||
| <div class="inline-indicator" x-show="isJobsLoading">Loading jobs...</div> | |||||
| <div class="alert alert-error" x-show="jobsErrorMessage" x-text="jobsErrorMessage"></div> | |||||
| <div id="campaign-jobs-drilldown-table" class="tabulator-host"></div> | |||||
| </section> | |||||
| </section> | |||||
| @@ -0,0 +1,14 @@ | |||||
| <section class="health-check"> | |||||
| <h1>Health Check</h1> | |||||
| <ul class="health-list"> | |||||
| <li><strong>PHP</strong> — <?= e(PHP_VERSION) ?> — OK</li> | |||||
| <li><strong>SQL Server</strong> — | |||||
| <?php if ($dbOk): ?> | |||||
| OK | |||||
| <?php else: ?> | |||||
| <span style="color:red">FAILED: <?= e($dbError ?? 'unknown error') ?></span> | |||||
| <?php endif; ?> | |||||
| </li> | |||||
| <li><strong>Environment</strong> — <?= e($appEnv) ?></li> | |||||
| </ul> | |||||
| </section> | |||||
| @@ -0,0 +1,39 @@ | |||||
| <section class="hero"> | |||||
| <div class="hero-copy"> | |||||
| <span class="eyebrow"><?= e($model->eyebrow) ?></span> | |||||
| <h1><?= e($model->title) ?></h1> | |||||
| <p class="hero-text"><?= e($model->message) ?></p> | |||||
| <div class="hero-actions"> | |||||
| <a class="button button-primary" href="<?= e($model->routeExample) ?>">Open Campaign Types</a> | |||||
| <a class="button button-secondary" href="#framework-highlights">See Highlights</a> | |||||
| </div> | |||||
| </div> | |||||
| <aside class="hero-panel" aria-label="Framework route example"> | |||||
| <p class="panel-label">Request Flow</p> | |||||
| <code>Browser -> public/index.php -> Dispatcher -> Router -> Controller -> View</code> | |||||
| <div class="route-callout"> | |||||
| <span>Campaign types</span> | |||||
| <a href="<?= e($model->routeExample) ?>"><?= e($model->routeExample) ?></a> | |||||
| </div> | |||||
| </aside> | |||||
| </section> | |||||
| <section class="feature-grid" id="framework-highlights"> | |||||
| <article class="feature-card"> | |||||
| <h2>Readable by design</h2> | |||||
| <p>Small files, explicit routing, and plain PHP views keep the framework approachable for day-to-day work.</p> | |||||
| </article> | |||||
| <article class="feature-card"> | |||||
| <h2>Classic MVC feel</h2> | |||||
| <p>Controllers, repositories, and view models stay separate so request handling remains predictable and easy to follow.</p> | |||||
| </article> | |||||
| <article class="feature-card"> | |||||
| <h2>SQL Server ready</h2> | |||||
| <p>Typed PHP 8.3 code, Composer autoloading, PDO access, and migration support make the project feel current without becoming heavyweight.</p> | |||||
| </article> | |||||
| </section> | |||||
| @@ -0,0 +1,89 @@ | |||||
| <script>window.__jtAttributes = <?= json_encode($model->form['attributes'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;</script> | |||||
| <section class="content-stack"> | |||||
| <div class="page-toolbar"> | |||||
| <div class="section-heading"> | |||||
| <h1><?= e($model->title) ?></h1> | |||||
| <p>Define a job type and the attributes that describe it.</p> | |||||
| </div> | |||||
| <a class="button button-secondary" href="/job-types">← Back to list</a> | |||||
| </div> | |||||
| <section class="section-panel" x-data="jobTypeForm(window.__jtAttributes)"> | |||||
| <?php if (isset($model->errors['_token'])): ?> | |||||
| <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | |||||
| <?php endif; ?> | |||||
| <form method="post" action="/job-types" class="ct-form" novalidate> | |||||
| <?= csrf_field() ?> | |||||
| <div class="form-section"> | |||||
| <label class="field field-full"> | |||||
| <span>Job type name <span class="required-mark">*</span></span> | |||||
| <input class="input<?= isset($model->errors['name']) ? ' input-error' : '' ?>" | |||||
| type="text" name="name" maxlength="255" | |||||
| value="<?= e($model->form['name']) ?>" required autofocus> | |||||
| <?php if (isset($model->errors['name'])): ?> | |||||
| <small class="field-error"><?= e($model->errors['name'][0]) ?></small> | |||||
| <?php endif; ?> | |||||
| </label> | |||||
| </div> | |||||
| <div class="form-section"> | |||||
| <div class="attributes-header"> | |||||
| <h3>Attributes</h3> | |||||
| <p class="attributes-hint">Fields that jobs of this type will carry.</p> | |||||
| </div> | |||||
| <div class="attribute-list"> | |||||
| <template x-for="(attr, index) in attributes" :key="index"> | |||||
| <div class="attribute-row" | |||||
| draggable="true" | |||||
| x-on:dragstart="dragStart($event, index)" | |||||
| x-on:dragover.prevent="dragOver($event, index)" | |||||
| x-on:drop="drop($event, index)" | |||||
| x-on:dragend="dragEnd()" | |||||
| :class="{ 'is-dragging': dragIndex === index, 'is-drag-over': dragOverIndex === index && dragIndex !== index }"> | |||||
| <span class="attr-drag-handle" title="Drag to reorder">↕</span> | |||||
| <label class="field attribute-order-field"> | |||||
| <span>Order</span> | |||||
| <input class="input" type="number" | |||||
| :name="`attribute_order[${index}]`" | |||||
| x-model.number="attr.order" min="1"> | |||||
| </label> | |||||
| <label class="field attribute-name-field"> | |||||
| <span>Attribute name</span> | |||||
| <input class="input" type="text" :name="`attribute_name[${index}]`" | |||||
| x-model="attr.name" placeholder="e.g. Priority" maxlength="100"> | |||||
| </label> | |||||
| <label class="field attribute-type-field"> | |||||
| <span>Type</span> | |||||
| <select class="input" :name="`attribute_type[${index}]`" x-model="attr.type"> | |||||
| <option value="text">Text</option> | |||||
| <option value="number">Number</option> | |||||
| <option value="date">Date</option> | |||||
| <option value="boolean">True/False</option> | |||||
| </select> | |||||
| </label> | |||||
| <div class="attribute-remove"> | |||||
| <button type="button" class="button button-danger button-sm" | |||||
| x-on:click="removeAttribute(index)" title="Remove">×</button> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| </div> | |||||
| <button type="button" class="button button-secondary button-sm" x-on:click="addAttribute()"> | |||||
| + Add Attribute | |||||
| </button> | |||||
| </div> | |||||
| <div class="form-actions"> | |||||
| <button class="button button-primary" type="submit">Save Job Type</button> | |||||
| <a class="button button-secondary" href="/job-types">Cancel</a> | |||||
| </div> | |||||
| </form> | |||||
| </section> | |||||
| </section> | |||||
| @@ -0,0 +1,106 @@ | |||||
| <?php $jobTypeId = (int) ($model->jobType['id'] ?? 0); ?> | |||||
| <script>window.__jtAttributes = <?= json_encode($model->form['attributes'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;</script> | |||||
| <section class="content-stack"> | |||||
| <div class="page-toolbar"> | |||||
| <div class="section-heading"> | |||||
| <h1><?= e($model->title) ?></h1> | |||||
| <p>Update this job type's name or attributes.</p> | |||||
| </div> | |||||
| <a class="button button-secondary" href="/job-types">← Back to list</a> | |||||
| </div> | |||||
| <?php if ($model->saved): ?> | |||||
| <div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)"> | |||||
| Job type updated successfully. | |||||
| </div> | |||||
| <?php endif; ?> | |||||
| <section class="section-panel" x-data="jobTypeForm(window.__jtAttributes)"> | |||||
| <?php if (isset($model->errors['_token'])): ?> | |||||
| <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | |||||
| <?php endif; ?> | |||||
| <form method="post" action="/job-types/<?= e((string) $jobTypeId) ?>/update" class="ct-form" novalidate> | |||||
| <?= csrf_field() ?> | |||||
| <div class="form-section"> | |||||
| <label class="field field-full"> | |||||
| <span>Job type name <span class="required-mark">*</span></span> | |||||
| <input class="input<?= isset($model->errors['name']) ? ' input-error' : '' ?>" | |||||
| type="text" name="name" maxlength="255" | |||||
| value="<?= e($model->form['name']) ?>" required autofocus> | |||||
| <?php if (isset($model->errors['name'])): ?> | |||||
| <small class="field-error"><?= e($model->errors['name'][0]) ?></small> | |||||
| <?php endif; ?> | |||||
| </label> | |||||
| </div> | |||||
| <div class="form-section"> | |||||
| <div class="attributes-header"> | |||||
| <h3>Attributes</h3> | |||||
| <p class="attributes-hint">Fields that jobs of this type will carry.</p> | |||||
| </div> | |||||
| <div class="attribute-list"> | |||||
| <template x-for="(attr, index) in attributes" :key="index"> | |||||
| <div class="attribute-row" | |||||
| draggable="true" | |||||
| x-on:dragstart="dragStart($event, index)" | |||||
| x-on:dragover.prevent="dragOver($event, index)" | |||||
| x-on:drop="drop($event, index)" | |||||
| x-on:dragend="dragEnd()" | |||||
| :class="{ 'is-dragging': dragIndex === index, 'is-drag-over': dragOverIndex === index && dragIndex !== index }"> | |||||
| <span class="attr-drag-handle" title="Drag to reorder">↕</span> | |||||
| <label class="field attribute-order-field"> | |||||
| <span>Order</span> | |||||
| <input class="input" type="number" | |||||
| :name="`attribute_order[${index}]`" | |||||
| x-model.number="attr.order" min="1"> | |||||
| </label> | |||||
| <label class="field attribute-name-field"> | |||||
| <span>Attribute name</span> | |||||
| <input class="input" type="text" :name="`attribute_name[${index}]`" | |||||
| x-model="attr.name" placeholder="e.g. Priority" maxlength="100"> | |||||
| </label> | |||||
| <label class="field attribute-type-field"> | |||||
| <span>Type</span> | |||||
| <select class="input" :name="`attribute_type[${index}]`" x-model="attr.type"> | |||||
| <option value="text">Text</option> | |||||
| <option value="number">Number</option> | |||||
| <option value="date">Date</option> | |||||
| <option value="boolean">True/False</option> | |||||
| </select> | |||||
| </label> | |||||
| <div class="attribute-remove"> | |||||
| <button type="button" class="button button-danger button-sm" | |||||
| x-on:click="removeAttribute(index)" title="Remove">×</button> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| </div> | |||||
| <button type="button" class="button button-secondary button-sm" x-on:click="addAttribute()"> | |||||
| + Add Attribute | |||||
| </button> | |||||
| </div> | |||||
| <div class="form-actions"> | |||||
| <button class="button button-primary" type="submit">Update Job Type</button> | |||||
| <a class="button button-secondary" href="/job-types">Cancel</a> | |||||
| </div> | |||||
| </form> | |||||
| <div class="delete-zone"> | |||||
| <h4>Delete this job type</h4> | |||||
| <p>This cannot be undone.</p> | |||||
| <form method="post" action="/job-types/<?= e((string) $jobTypeId) ?>/delete" | |||||
| x-on:submit.prevent="confirmDelete($event)"> | |||||
| <?= csrf_field() ?> | |||||
| <button type="submit" class="button button-danger">Delete Job Type</button> | |||||
| </form> | |||||
| </div> | |||||
| </section> | |||||
| </section> | |||||
| @@ -0,0 +1,34 @@ | |||||
| <section class="content-stack" x-data="jobTypeTable()"> | |||||
| <div class="page-toolbar"> | |||||
| <div class="section-heading"> | |||||
| <h1><?= e($model->title) ?></h1> | |||||
| <p>Manage job types and their configurable attributes.</p> | |||||
| </div> | |||||
| <a class="button button-primary" href="/job-types/create">+ New Job Type</a> | |||||
| </div> | |||||
| <?php if ($model->saved): ?> | |||||
| <div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)"> | |||||
| Job type saved successfully. | |||||
| </div> | |||||
| <?php endif; ?> | |||||
| <?php if ($model->deleted): ?> | |||||
| <div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)"> | |||||
| Job type deleted. | |||||
| </div> | |||||
| <?php endif; ?> | |||||
| <section class="section-panel"> | |||||
| <div class="panel-header"> | |||||
| <div> | |||||
| <h2>Job Type Directory</h2> | |||||
| <p>All job types with their attribute definitions.</p> | |||||
| </div> | |||||
| <button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button> | |||||
| </div> | |||||
| <div id="job-type-table" class="tabulator-host"></div> | |||||
| </section> | |||||
| </section> | |||||
| @@ -0,0 +1,152 @@ | |||||
| <?php | |||||
| $campaignId = (int) ($campaign['id'] ?? 0); | |||||
| $campaignTypeName = (string) ($campaign['campaign_type_name'] ?? ''); | |||||
| ?> | |||||
| <script> | |||||
| window.__campaignJobTypes = <?= json_encode($jobTypes ?? [], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||||
| </script> | |||||
| <section class="content-stack" x-data="campaignJobsPageTable(<?= $campaignId ?>, window.__campaignJobTypes)"> | |||||
| <div class="page-toolbar"> | |||||
| <div class="section-heading"> | |||||
| <h1>Campaign Jobs</h1> | |||||
| <p><?= e($campaignTypeName) ?> #<?= e((string) $campaignId) ?></p> | |||||
| </div> | |||||
| <div class="panel-actions"> | |||||
| <a class="button button-secondary" href="/campaigns">← Back to campaigns</a> | |||||
| <a class="button button-primary" href="/jobs/create">+ New Job</a> | |||||
| </div> | |||||
| </div> | |||||
| <section class="section-panel"> | |||||
| <div class="panel-header"> | |||||
| <h2>Import Jobs</h2> | |||||
| </div> | |||||
| <!-- Source tabs --> | |||||
| <div class="import-tabs"> | |||||
| <button type="button" | |||||
| class="import-tab" | |||||
| :class="importSource === 'sheets' ? 'is-active' : ''" | |||||
| x-on:click="importSource = 'sheets'">Google Sheets</button> | |||||
| <button type="button" | |||||
| class="import-tab" | |||||
| :class="importSource === 'file' ? 'is-active' : ''" | |||||
| x-on:click="importSource = 'file'">CSV / Excel</button> | |||||
| </div> | |||||
| <!-- Google Sheets panel --> | |||||
| <div x-show="importSource === 'sheets'"> | |||||
| <p class="attributes-hint" style="margin-bottom:1rem"> | |||||
| The spreadsheet must be shared as <strong>Anyone with the link can view</strong>. | |||||
| </p> | |||||
| <div class="import-grid"> | |||||
| <label class="field"> | |||||
| <span>Google Sheets URL</span> | |||||
| <input class="input" type="url" x-model="importSheetUrl" | |||||
| placeholder="https://docs.google.com/spreadsheets/d/..."> | |||||
| </label> | |||||
| <label class="field"> | |||||
| <span>Sheet</span> | |||||
| <select class="input" x-model="selectedSheetGid" :disabled="sheets.length === 0"> | |||||
| <option value="">Select a sheet</option> | |||||
| <template x-for="sheet in sheets" :key="sheet.gid"> | |||||
| <option :value="sheet.gid" x-text="sheet.title"></option> | |||||
| </template> | |||||
| </select> | |||||
| </label> | |||||
| <label class="field"> | |||||
| <span>Job type</span> | |||||
| <select class="input" x-model="selectedImportJobTypeId"> | |||||
| <option value="0">Select a job type</option> | |||||
| <template x-for="jt in jobTypes" :key="jt.id"> | |||||
| <option :value="jt.id" x-text="jt.name"></option> | |||||
| </template> | |||||
| </select> | |||||
| </label> | |||||
| </div> | |||||
| <div class="form-actions import-actions"> | |||||
| <button class="button button-secondary" type="button" | |||||
| x-on:click="connectGoogleSheet()" :disabled="isConnecting">Connect</button> | |||||
| <button class="button button-primary" type="button" | |||||
| x-on:click="importGoogleSheet()" | |||||
| :disabled="isImporting || !selectedSheetGid || Number(selectedImportJobTypeId) === 0"> | |||||
| Import | |||||
| </button> | |||||
| <span class="inline-indicator" x-show="isConnecting">Connecting...</span> | |||||
| <span class="inline-indicator" x-show="isImporting">Importing...</span> | |||||
| </div> | |||||
| </div> | |||||
| <!-- File upload panel --> | |||||
| <div x-show="importSource === 'file'"> | |||||
| <p class="attributes-hint" style="margin-bottom:1rem"> | |||||
| Export your sheet as <strong>CSV</strong> or <strong>Excel (.xlsx)</strong> from Google Sheets, then upload it here. | |||||
| </p> | |||||
| <div class="import-grid"> | |||||
| <label class="field"> | |||||
| <span>CSV or Excel file (.csv, .xlsx)</span> | |||||
| <input class="input" type="file" accept=".csv,.xlsx" | |||||
| x-ref="fileInput" x-on:change="onFileSelect($event)"> | |||||
| </label> | |||||
| <label class="field"> | |||||
| <span>Sheet</span> | |||||
| <select class="input" x-model="selectedFileSheetGid" :disabled="fileSheets.length === 0"> | |||||
| <option value="">Select a sheet</option> | |||||
| <template x-for="sheet in fileSheets" :key="sheet.gid"> | |||||
| <option :value="sheet.gid" x-text="sheet.title"></option> | |||||
| </template> | |||||
| </select> | |||||
| </label> | |||||
| <label class="field"> | |||||
| <span>Job type</span> | |||||
| <select class="input" x-model="selectedFileJobTypeId"> | |||||
| <option value="0">Select a job type</option> | |||||
| <template x-for="jt in jobTypes" :key="jt.id"> | |||||
| <option :value="jt.id" x-text="jt.name"></option> | |||||
| </template> | |||||
| </select> | |||||
| </label> | |||||
| </div> | |||||
| <div class="form-actions import-actions"> | |||||
| <button class="button button-secondary" type="button" | |||||
| x-on:click="loadFileSheets()" :disabled="isLoadingFile || !fileSelected"> | |||||
| Load Sheets | |||||
| </button> | |||||
| <button class="button button-primary" type="button" | |||||
| x-on:click="importFile()" | |||||
| :disabled="isImportingFile || !fileTempName || !selectedFileSheetGid || Number(selectedFileJobTypeId) === 0"> | |||||
| Import | |||||
| </button> | |||||
| <span class="inline-indicator" x-show="isLoadingFile">Reading file...</span> | |||||
| <span class="inline-indicator" x-show="isImportingFile">Importing...</span> | |||||
| </div> | |||||
| </div> | |||||
| <!-- Shared result messages --> | |||||
| <div class="alert alert-success" x-cloak x-show="importMessage" x-text="importMessage"></div> | |||||
| <div class="alert alert-error" x-cloak x-show="importErrorMessage" x-text="importErrorMessage"></div> | |||||
| </section> | |||||
| <section class="section-panel"> | |||||
| <div class="panel-header"> | |||||
| <div> | |||||
| <h2>Job Directory</h2> | |||||
| <p>All jobs in this campaign with job fields and attribute fields.</p> | |||||
| </div> | |||||
| <button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button> | |||||
| </div> | |||||
| <div class="inline-indicator" x-cloak x-show="isLoading">Loading jobs...</div> | |||||
| <div class="alert alert-error" x-cloak x-show="errorMessage" x-text="errorMessage"></div> | |||||
| <div id="campaign-jobs-page-table" class="tabulator-host"></div> | |||||
| </section> | |||||
| </section> | |||||
| @@ -0,0 +1,115 @@ | |||||
| <script> | |||||
| window.__jobTypes = <?= json_encode($model->jobTypes, JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||||
| window.__initialJtId = <?= json_encode($model->form['job_type_id'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||||
| window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||||
| </script> | |||||
| <section class="content-stack"> | |||||
| <div class="page-toolbar"> | |||||
| <div class="section-heading"> | |||||
| <h1><?= e($model->title) ?></h1> | |||||
| <p>Select a campaign and job type, then fill in the attribute values.</p> | |||||
| </div> | |||||
| <a class="button button-secondary" href="/jobs">← Back to list</a> | |||||
| </div> | |||||
| <?php if (!$model->campaigns): ?> | |||||
| <div class="alert alert-error"> | |||||
| No campaigns exist yet. <a href="/campaigns/create">Create a campaign</a> before adding jobs. | |||||
| </div> | |||||
| <?php elseif (!$model->jobTypes): ?> | |||||
| <div class="alert alert-error"> | |||||
| No job types exist yet. <a href="/job-types/create">Create a job type</a> before adding jobs. | |||||
| </div> | |||||
| <?php else: ?> | |||||
| <section class="section-panel" x-data="jobForm(window.__jobTypes, window.__initialJtId, window.__initialJtVals)"> | |||||
| <?php if (isset($model->errors['_token'])): ?> | |||||
| <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | |||||
| <?php endif; ?> | |||||
| <form method="post" action="/jobs" class="ct-form" novalidate> | |||||
| <?= csrf_field() ?> | |||||
| <div class="form-section"> | |||||
| <label class="field field-full"> | |||||
| <span>Campaign <span class="required-mark">*</span></span> | |||||
| <select class="input<?= isset($model->errors['campaign_id']) ? ' input-error' : '' ?>" | |||||
| name="campaign_id" required> | |||||
| <option value="0">— Select a campaign —</option> | |||||
| <?php foreach ($model->campaigns as $c): ?> | |||||
| <option value="<?= e((string) $c['id']) ?>" | |||||
| <?= (int) $model->form['campaign_id'] === (int) $c['id'] ? 'selected' : '' ?>> | |||||
| <?= e($c['campaign_type_name']) ?> #<?= e((string) $c['id']) ?> | |||||
| </option> | |||||
| <?php endforeach; ?> | |||||
| </select> | |||||
| <?php if (isset($model->errors['campaign_id'])): ?> | |||||
| <small class="field-error"><?= e($model->errors['campaign_id'][0]) ?></small> | |||||
| <?php endif; ?> | |||||
| </label> | |||||
| <label class="field field-full"> | |||||
| <span>Job type <span class="required-mark">*</span></span> | |||||
| <select class="input<?= isset($model->errors['job_type_id']) ? ' input-error' : '' ?>" | |||||
| name="job_type_id" x-model="selectedTypeId" x-on:change="onTypeChange()" required> | |||||
| <option value="0">— Select a job type —</option> | |||||
| <?php foreach ($model->jobTypes as $jt): ?> | |||||
| <option value="<?= e((string) $jt['id']) ?>" | |||||
| <?= (int) $model->form['job_type_id'] === $jt['id'] ? 'selected' : '' ?>> | |||||
| <?= e($jt['name']) ?> | |||||
| </option> | |||||
| <?php endforeach; ?> | |||||
| </select> | |||||
| <?php if (isset($model->errors['job_type_id'])): ?> | |||||
| <small class="field-error"><?= e($model->errors['job_type_id'][0]) ?></small> | |||||
| <?php endif; ?> | |||||
| </label> | |||||
| </div> | |||||
| <div class="form-section" x-show="currentAttributes.length > 0"> | |||||
| <div class="attributes-header"> | |||||
| <h3>Attribute values</h3> | |||||
| <p class="attributes-hint">Fields defined by the selected job type.</p> | |||||
| </div> | |||||
| <div class="form-grid"> | |||||
| <template x-for="attr in currentAttributes" :key="attr.name"> | |||||
| <label class="field"> | |||||
| <span x-text="attr.name"></span> | |||||
| <template x-if="attr.type === 'boolean'"> | |||||
| <select class="input" | |||||
| :name="`attribute_values[${attr.name}]`" | |||||
| x-on:change="attributeValues[attr.name] = $event.target.value"> | |||||
| <option value="" :selected="!attributeValues[attr.name]">— Select —</option> | |||||
| <option value="true" :selected="attributeValues[attr.name] === 'true'">True</option> | |||||
| <option value="false" :selected="attributeValues[attr.name] === 'false'">False</option> | |||||
| </select> | |||||
| </template> | |||||
| <template x-if="attr.type !== 'boolean'"> | |||||
| <input class="input" :type="inputType(attr.type)" | |||||
| :name="`attribute_values[${attr.name}]`" | |||||
| :value="attributeValues[attr.name] ?? ''" | |||||
| x-on:input="attributeValues[attr.name] = $event.target.value"> | |||||
| </template> | |||||
| </label> | |||||
| </template> | |||||
| </div> | |||||
| </div> | |||||
| <p class="attributes-hint" x-show="selectedTypeId && currentAttributes.length === 0"> | |||||
| This job type has no attributes defined. | |||||
| </p> | |||||
| <div class="form-actions"> | |||||
| <button class="button button-primary" type="submit">Save Job</button> | |||||
| <a class="button button-secondary" href="/jobs">Cancel</a> | |||||
| </div> | |||||
| </form> | |||||
| </section> | |||||
| <?php endif; ?> | |||||
| </section> | |||||
| @@ -0,0 +1,120 @@ | |||||
| <?php $jobId = (int) ($model->job['id'] ?? 0); ?> | |||||
| <script> | |||||
| window.__jobTypes = <?= json_encode($model->jobTypes, JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||||
| window.__initialJtId = <?= json_encode($model->form['job_type_id'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||||
| window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||||
| </script> | |||||
| <section class="content-stack"> | |||||
| <div class="page-toolbar"> | |||||
| <div class="section-heading"> | |||||
| <h1><?= e($model->title) ?></h1> | |||||
| <p>Update the campaign, job type, or attribute values for this job.</p> | |||||
| </div> | |||||
| <a class="button button-secondary" href="/jobs">← Back to list</a> | |||||
| </div> | |||||
| <?php if ($model->saved): ?> | |||||
| <div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)"> | |||||
| Job updated successfully. | |||||
| </div> | |||||
| <?php endif; ?> | |||||
| <section class="section-panel" x-data="jobForm(window.__jobTypes, window.__initialJtId, window.__initialJtVals)"> | |||||
| <?php if (isset($model->errors['_token'])): ?> | |||||
| <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | |||||
| <?php endif; ?> | |||||
| <form method="post" action="/jobs/<?= e((string) $jobId) ?>/update" class="ct-form" novalidate> | |||||
| <?= csrf_field() ?> | |||||
| <div class="form-section"> | |||||
| <label class="field field-full"> | |||||
| <span>Campaign <span class="required-mark">*</span></span> | |||||
| <select class="input<?= isset($model->errors['campaign_id']) ? ' input-error' : '' ?>" | |||||
| name="campaign_id" required> | |||||
| <option value="0">— Select a campaign —</option> | |||||
| <?php foreach ($model->campaigns as $c): ?> | |||||
| <option value="<?= e((string) $c['id']) ?>" | |||||
| <?= (int) $model->form['campaign_id'] === (int) $c['id'] ? 'selected' : '' ?>> | |||||
| <?= e($c['campaign_type_name']) ?> #<?= e((string) $c['id']) ?> | |||||
| </option> | |||||
| <?php endforeach; ?> | |||||
| </select> | |||||
| <?php if (isset($model->errors['campaign_id'])): ?> | |||||
| <small class="field-error"><?= e($model->errors['campaign_id'][0]) ?></small> | |||||
| <?php endif; ?> | |||||
| </label> | |||||
| <label class="field field-full"> | |||||
| <span>Job type <span class="required-mark">*</span></span> | |||||
| <select class="input<?= isset($model->errors['job_type_id']) ? ' input-error' : '' ?>" | |||||
| name="job_type_id" x-model="selectedTypeId" x-on:change="onTypeChange()" required> | |||||
| <option value="0">— Select a job type —</option> | |||||
| <?php foreach ($model->jobTypes as $jt): ?> | |||||
| <option value="<?= e((string) $jt['id']) ?>" | |||||
| <?= (int) $model->form['job_type_id'] === $jt['id'] ? 'selected' : '' ?>> | |||||
| <?= e($jt['name']) ?> | |||||
| </option> | |||||
| <?php endforeach; ?> | |||||
| </select> | |||||
| <?php if (isset($model->errors['job_type_id'])): ?> | |||||
| <small class="field-error"><?= e($model->errors['job_type_id'][0]) ?></small> | |||||
| <?php endif; ?> | |||||
| </label> | |||||
| </div> | |||||
| <div class="form-section" x-show="currentAttributes.length > 0"> | |||||
| <div class="attributes-header"> | |||||
| <h3>Attribute values</h3> | |||||
| <p class="attributes-hint">Fields defined by the selected job type.</p> | |||||
| </div> | |||||
| <div class="form-grid"> | |||||
| <template x-for="attr in currentAttributes" :key="attr.name"> | |||||
| <label class="field"> | |||||
| <span x-text="attr.name"></span> | |||||
| <template x-if="attr.type === 'boolean'"> | |||||
| <select class="input" | |||||
| :name="`attribute_values[${attr.name}]`" | |||||
| x-on:change="attributeValues[attr.name] = $event.target.value"> | |||||
| <option value="" :selected="!attributeValues[attr.name]">— Select —</option> | |||||
| <option value="true" :selected="attributeValues[attr.name] === 'true'">True</option> | |||||
| <option value="false" :selected="attributeValues[attr.name] === 'false'">False</option> | |||||
| </select> | |||||
| </template> | |||||
| <template x-if="attr.type !== 'boolean'"> | |||||
| <input class="input" :type="inputType(attr.type)" | |||||
| :name="`attribute_values[${attr.name}]`" | |||||
| :value="attributeValues[attr.name] ?? ''" | |||||
| x-on:input="attributeValues[attr.name] = $event.target.value"> | |||||
| </template> | |||||
| </label> | |||||
| </template> | |||||
| </div> | |||||
| </div> | |||||
| <p class="attributes-hint" x-show="selectedTypeId && currentAttributes.length === 0"> | |||||
| This job type has no attributes defined. | |||||
| </p> | |||||
| <div class="form-actions"> | |||||
| <button class="button button-primary" type="submit">Update Job</button> | |||||
| <a class="button button-secondary" href="/jobs">Cancel</a> | |||||
| </div> | |||||
| </form> | |||||
| <div class="delete-zone"> | |||||
| <h4>Delete this job</h4> | |||||
| <p>This cannot be undone.</p> | |||||
| <form method="post" action="/jobs/<?= e((string) $jobId) ?>/delete" | |||||
| x-on:submit.prevent="confirmDelete($event)"> | |||||
| <?= csrf_field() ?> | |||||
| <button type="submit" class="button button-danger">Delete Job</button> | |||||
| </form> | |||||
| </div> | |||||
| </section> | |||||
| </section> | |||||
| @@ -0,0 +1,34 @@ | |||||
| <section class="content-stack" x-data="jobTable()"> | |||||
| <div class="page-toolbar"> | |||||
| <div class="section-heading"> | |||||
| <h1><?= e($model->title) ?></h1> | |||||
| <p>Manage jobs across all campaigns.</p> | |||||
| </div> | |||||
| <a class="button button-primary" href="/jobs/create">+ New Job</a> | |||||
| </div> | |||||
| <?php if ($model->saved): ?> | |||||
| <div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)"> | |||||
| Job saved successfully. | |||||
| </div> | |||||
| <?php endif; ?> | |||||
| <?php if ($model->deleted): ?> | |||||
| <div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)"> | |||||
| Job deleted. | |||||
| </div> | |||||
| <?php endif; ?> | |||||
| <section class="section-panel"> | |||||
| <div class="panel-header"> | |||||
| <div> | |||||
| <h2>Job Directory</h2> | |||||
| <p>All jobs with their campaign and job type.</p> | |||||
| </div> | |||||
| <button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button> | |||||
| </div> | |||||
| <div id="job-table" class="tabulator-host"></div> | |||||
| </section> | |||||
| </section> | |||||
| @@ -0,0 +1,14 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| require __DIR__ . '/../partials/header.php'; | |||||
| ?> | |||||
| <main class="page-content"> | |||||
| <div class="container"> | |||||
| <?= $content ?> | |||||
| </div> | |||||
| </main> | |||||
| <?php require __DIR__ . '/../partials/footer.php'; ?> | |||||
| @@ -0,0 +1,9 @@ | |||||
| <footer class="site-footer"> | |||||
| <div class="container footer-inner"> | |||||
| <p>MindVisionCode PHP keeps the framework small, readable, and ready for real features.</p> | |||||
| <p>© <?= e((string) date('Y')) ?> MindVisionCode</p> | |||||
| </div> | |||||
| </footer> | |||||
| </div> | |||||
| </body> | |||||
| </html> | |||||
| @@ -0,0 +1,64 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| $navigationItems = [ | |||||
| ['label' => 'Home', 'href' => '/'], | |||||
| ['label' => 'Campaigns', 'href' => '/campaigns'], | |||||
| ['label' => 'Campaign Types', 'href' => '/campaign-types'], | |||||
| ['label' => 'Jobs', 'href' => '/jobs'], | |||||
| ['label' => 'Job Types', 'href' => '/job-types'], | |||||
| ]; | |||||
| $currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); | |||||
| $currentPath = is_string($currentPath) && $currentPath !== '' ? $currentPath : '/'; | |||||
| $jsVersion = filemtime(__DIR__ . '/../../../public/js/app.js') ?: time(); | |||||
| ?> | |||||
| <!DOCTYPE html> | |||||
| <html lang="en"> | |||||
| <head> | |||||
| <meta charset="UTF-8"> | |||||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |||||
| <title><?= e($pageTitle ?? 'Campaign Tracker') ?></title> | |||||
| <link rel="stylesheet" href="https://unpkg.com/tabulator-tables@6.3.1/dist/css/tabulator.min.css"> | |||||
| <link rel="stylesheet" href="<?= e(asset('css/site.css')) ?>"> | |||||
| <script>window.__csrf = '<?= e(csrf_token()) ?>';</script> | |||||
| <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js" integrity="sha384-H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V" crossorigin="anonymous" defer></script> | |||||
| <script src="https://unpkg.com/tabulator-tables@6.3.1/dist/js/tabulator.min.js" defer></script> | |||||
| <script src="<?= e(asset('js/app.js')) ?>?v=<?= e((string) $jsVersion) ?>" defer></script> | |||||
| <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> | |||||
| </head> | |||||
| <body> | |||||
| <div class="page-shell"> | |||||
| <header class="site-header"> | |||||
| <div class="container header-inner"> | |||||
| <a class="brand" href="/"> | |||||
| <span class="brand-mark">CT</span> | |||||
| <span class="brand-copy"> | |||||
| <strong>Campaign Tracker</strong> | |||||
| <small>PHP MVC</small> | |||||
| </span> | |||||
| </a> | |||||
| <nav class="site-nav" aria-label="Primary navigation"> | |||||
| <?php foreach ($navigationItems as $item): ?> | |||||
| <?php | |||||
| $isActive = $item['href'] === '/' | |||||
| ? $currentPath === '/' | |||||
| : str_starts_with($currentPath, $item['href']); | |||||
| ?> | |||||
| <a class="nav-link<?= $isActive ? ' is-active' : '' ?>" href="<?= e($item['href']) ?>"> | |||||
| <?= e($item['label']) ?> | |||||
| </a> | |||||
| <?php endforeach; ?> | |||||
| <?php if (auth()->check()): ?> | |||||
| <span class="nav-user"><?= e(auth()->user()?->displayName ?: auth()->user()?->username ?? '') ?></span> | |||||
| <form method="post" action="/logout" class="nav-logout-form"> | |||||
| <?= csrf_field() ?> | |||||
| <button type="submit" class="button button-secondary button-sm">Log out</button> | |||||
| </form> | |||||
| <?php endif; ?> | |||||
| </nav> | |||||
| </div> | |||||
| </header> | |||||
| @@ -0,0 +1,25 @@ | |||||
| { | |||||
| "name": "campaign-tracker/app", | |||||
| "description": "Campaign Tracker - PHP MVC web application.", | |||||
| "type": "project", | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "App\\": "app/", | |||||
| "Core\\": "core/" | |||||
| }, | |||||
| "files": [ | |||||
| "core/helpers.php" | |||||
| ] | |||||
| }, | |||||
| "scripts": { | |||||
| "migrate": "php scripts/migrate.php up", | |||||
| "migrate:down": "php scripts/migrate.php down", | |||||
| "migrate:status": "php scripts/migrate.php status", | |||||
| "migrate:fresh": "php scripts/migrate.php fresh", | |||||
| "migrate:fresh-seed": "php scripts/migrate.php fresh --seed" | |||||
| }, | |||||
| "require": { | |||||
| "league/oauth2-client": "^2.7", | |||||
| "stevenmaguire/oauth2-keycloak": "^6.1" | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,811 @@ | |||||
| { | |||||
| "_readme": [ | |||||
| "This file locks the dependencies of your project to a known state", | |||||
| "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", | |||||
| "This file is @generated automatically" | |||||
| ], | |||||
| "content-hash": "e0a7e85186173af020b116bd8f125d9b", | |||||
| "packages": [ | |||||
| { | |||||
| "name": "firebase/php-jwt", | |||||
| "version": "v7.0.5", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/googleapis/php-jwt.git", | |||||
| "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", | |||||
| "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "php": "^8.0" | |||||
| }, | |||||
| "require-dev": { | |||||
| "guzzlehttp/guzzle": "^7.4", | |||||
| "phpfastcache/phpfastcache": "^9.2", | |||||
| "phpspec/prophecy-phpunit": "^2.0", | |||||
| "phpunit/phpunit": "^9.5", | |||||
| "psr/cache": "^2.0||^3.0", | |||||
| "psr/http-client": "^1.0", | |||||
| "psr/http-factory": "^1.0" | |||||
| }, | |||||
| "suggest": { | |||||
| "ext-sodium": "Support EdDSA (Ed25519) signatures", | |||||
| "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" | |||||
| }, | |||||
| "type": "library", | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "Firebase\\JWT\\": "src" | |||||
| } | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "BSD-3-Clause" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "Neuman Vong", | |||||
| "email": "neuman+pear@twilio.com", | |||||
| "role": "Developer" | |||||
| }, | |||||
| { | |||||
| "name": "Anant Narayanan", | |||||
| "email": "anant@php.net", | |||||
| "role": "Developer" | |||||
| } | |||||
| ], | |||||
| "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", | |||||
| "homepage": "https://github.com/firebase/php-jwt", | |||||
| "keywords": [ | |||||
| "jwt", | |||||
| "php" | |||||
| ], | |||||
| "support": { | |||||
| "issues": "https://github.com/googleapis/php-jwt/issues", | |||||
| "source": "https://github.com/googleapis/php-jwt/tree/v7.0.5" | |||||
| }, | |||||
| "time": "2026-04-01T20:38:03+00:00" | |||||
| }, | |||||
| { | |||||
| "name": "guzzlehttp/guzzle", | |||||
| "version": "7.10.0", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/guzzle/guzzle.git", | |||||
| "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", | |||||
| "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "ext-json": "*", | |||||
| "guzzlehttp/promises": "^2.3", | |||||
| "guzzlehttp/psr7": "^2.8", | |||||
| "php": "^7.2.5 || ^8.0", | |||||
| "psr/http-client": "^1.0", | |||||
| "symfony/deprecation-contracts": "^2.2 || ^3.0" | |||||
| }, | |||||
| "provide": { | |||||
| "psr/http-client-implementation": "1.0" | |||||
| }, | |||||
| "require-dev": { | |||||
| "bamarni/composer-bin-plugin": "^1.8.2", | |||||
| "ext-curl": "*", | |||||
| "guzzle/client-integration-tests": "3.0.2", | |||||
| "php-http/message-factory": "^1.1", | |||||
| "phpunit/phpunit": "^8.5.39 || ^9.6.20", | |||||
| "psr/log": "^1.1 || ^2.0 || ^3.0" | |||||
| }, | |||||
| "suggest": { | |||||
| "ext-curl": "Required for CURL handler support", | |||||
| "ext-intl": "Required for Internationalized Domain Name (IDN) support", | |||||
| "psr/log": "Required for using the Log middleware" | |||||
| }, | |||||
| "type": "library", | |||||
| "extra": { | |||||
| "bamarni-bin": { | |||||
| "bin-links": true, | |||||
| "forward-command": false | |||||
| } | |||||
| }, | |||||
| "autoload": { | |||||
| "files": [ | |||||
| "src/functions_include.php" | |||||
| ], | |||||
| "psr-4": { | |||||
| "GuzzleHttp\\": "src/" | |||||
| } | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "MIT" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "Graham Campbell", | |||||
| "email": "hello@gjcampbell.co.uk", | |||||
| "homepage": "https://github.com/GrahamCampbell" | |||||
| }, | |||||
| { | |||||
| "name": "Michael Dowling", | |||||
| "email": "mtdowling@gmail.com", | |||||
| "homepage": "https://github.com/mtdowling" | |||||
| }, | |||||
| { | |||||
| "name": "Jeremy Lindblom", | |||||
| "email": "jeremeamia@gmail.com", | |||||
| "homepage": "https://github.com/jeremeamia" | |||||
| }, | |||||
| { | |||||
| "name": "George Mponos", | |||||
| "email": "gmponos@gmail.com", | |||||
| "homepage": "https://github.com/gmponos" | |||||
| }, | |||||
| { | |||||
| "name": "Tobias Nyholm", | |||||
| "email": "tobias.nyholm@gmail.com", | |||||
| "homepage": "https://github.com/Nyholm" | |||||
| }, | |||||
| { | |||||
| "name": "Márk Sági-Kazár", | |||||
| "email": "mark.sagikazar@gmail.com", | |||||
| "homepage": "https://github.com/sagikazarmark" | |||||
| }, | |||||
| { | |||||
| "name": "Tobias Schultze", | |||||
| "email": "webmaster@tubo-world.de", | |||||
| "homepage": "https://github.com/Tobion" | |||||
| } | |||||
| ], | |||||
| "description": "Guzzle is a PHP HTTP client library", | |||||
| "keywords": [ | |||||
| "client", | |||||
| "curl", | |||||
| "framework", | |||||
| "http", | |||||
| "http client", | |||||
| "psr-18", | |||||
| "psr-7", | |||||
| "rest", | |||||
| "web service" | |||||
| ], | |||||
| "support": { | |||||
| "issues": "https://github.com/guzzle/guzzle/issues", | |||||
| "source": "https://github.com/guzzle/guzzle/tree/7.10.0" | |||||
| }, | |||||
| "funding": [ | |||||
| { | |||||
| "url": "https://github.com/GrahamCampbell", | |||||
| "type": "github" | |||||
| }, | |||||
| { | |||||
| "url": "https://github.com/Nyholm", | |||||
| "type": "github" | |||||
| }, | |||||
| { | |||||
| "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", | |||||
| "type": "tidelift" | |||||
| } | |||||
| ], | |||||
| "time": "2025-08-23T22:36:01+00:00" | |||||
| }, | |||||
| { | |||||
| "name": "guzzlehttp/promises", | |||||
| "version": "2.3.0", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/guzzle/promises.git", | |||||
| "reference": "481557b130ef3790cf82b713667b43030dc9c957" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", | |||||
| "reference": "481557b130ef3790cf82b713667b43030dc9c957", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "php": "^7.2.5 || ^8.0" | |||||
| }, | |||||
| "require-dev": { | |||||
| "bamarni/composer-bin-plugin": "^1.8.2", | |||||
| "phpunit/phpunit": "^8.5.44 || ^9.6.25" | |||||
| }, | |||||
| "type": "library", | |||||
| "extra": { | |||||
| "bamarni-bin": { | |||||
| "bin-links": true, | |||||
| "forward-command": false | |||||
| } | |||||
| }, | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "GuzzleHttp\\Promise\\": "src/" | |||||
| } | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "MIT" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "Graham Campbell", | |||||
| "email": "hello@gjcampbell.co.uk", | |||||
| "homepage": "https://github.com/GrahamCampbell" | |||||
| }, | |||||
| { | |||||
| "name": "Michael Dowling", | |||||
| "email": "mtdowling@gmail.com", | |||||
| "homepage": "https://github.com/mtdowling" | |||||
| }, | |||||
| { | |||||
| "name": "Tobias Nyholm", | |||||
| "email": "tobias.nyholm@gmail.com", | |||||
| "homepage": "https://github.com/Nyholm" | |||||
| }, | |||||
| { | |||||
| "name": "Tobias Schultze", | |||||
| "email": "webmaster@tubo-world.de", | |||||
| "homepage": "https://github.com/Tobion" | |||||
| } | |||||
| ], | |||||
| "description": "Guzzle promises library", | |||||
| "keywords": [ | |||||
| "promise" | |||||
| ], | |||||
| "support": { | |||||
| "issues": "https://github.com/guzzle/promises/issues", | |||||
| "source": "https://github.com/guzzle/promises/tree/2.3.0" | |||||
| }, | |||||
| "funding": [ | |||||
| { | |||||
| "url": "https://github.com/GrahamCampbell", | |||||
| "type": "github" | |||||
| }, | |||||
| { | |||||
| "url": "https://github.com/Nyholm", | |||||
| "type": "github" | |||||
| }, | |||||
| { | |||||
| "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", | |||||
| "type": "tidelift" | |||||
| } | |||||
| ], | |||||
| "time": "2025-08-22T14:34:08+00:00" | |||||
| }, | |||||
| { | |||||
| "name": "guzzlehttp/psr7", | |||||
| "version": "2.9.0", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/guzzle/psr7.git", | |||||
| "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", | |||||
| "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "php": "^7.2.5 || ^8.0", | |||||
| "psr/http-factory": "^1.0", | |||||
| "psr/http-message": "^1.1 || ^2.0", | |||||
| "ralouphie/getallheaders": "^3.0" | |||||
| }, | |||||
| "provide": { | |||||
| "psr/http-factory-implementation": "1.0", | |||||
| "psr/http-message-implementation": "1.0" | |||||
| }, | |||||
| "require-dev": { | |||||
| "bamarni/composer-bin-plugin": "^1.8.2", | |||||
| "http-interop/http-factory-tests": "0.9.0", | |||||
| "jshttp/mime-db": "1.54.0.1", | |||||
| "phpunit/phpunit": "^8.5.44 || ^9.6.25" | |||||
| }, | |||||
| "suggest": { | |||||
| "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" | |||||
| }, | |||||
| "type": "library", | |||||
| "extra": { | |||||
| "bamarni-bin": { | |||||
| "bin-links": true, | |||||
| "forward-command": false | |||||
| } | |||||
| }, | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "GuzzleHttp\\Psr7\\": "src/" | |||||
| } | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "MIT" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "Graham Campbell", | |||||
| "email": "hello@gjcampbell.co.uk", | |||||
| "homepage": "https://github.com/GrahamCampbell" | |||||
| }, | |||||
| { | |||||
| "name": "Michael Dowling", | |||||
| "email": "mtdowling@gmail.com", | |||||
| "homepage": "https://github.com/mtdowling" | |||||
| }, | |||||
| { | |||||
| "name": "George Mponos", | |||||
| "email": "gmponos@gmail.com", | |||||
| "homepage": "https://github.com/gmponos" | |||||
| }, | |||||
| { | |||||
| "name": "Tobias Nyholm", | |||||
| "email": "tobias.nyholm@gmail.com", | |||||
| "homepage": "https://github.com/Nyholm" | |||||
| }, | |||||
| { | |||||
| "name": "Márk Sági-Kazár", | |||||
| "email": "mark.sagikazar@gmail.com", | |||||
| "homepage": "https://github.com/sagikazarmark" | |||||
| }, | |||||
| { | |||||
| "name": "Tobias Schultze", | |||||
| "email": "webmaster@tubo-world.de", | |||||
| "homepage": "https://github.com/Tobion" | |||||
| }, | |||||
| { | |||||
| "name": "Márk Sági-Kazár", | |||||
| "email": "mark.sagikazar@gmail.com", | |||||
| "homepage": "https://sagikazarmark.hu" | |||||
| } | |||||
| ], | |||||
| "description": "PSR-7 message implementation that also provides common utility methods", | |||||
| "keywords": [ | |||||
| "http", | |||||
| "message", | |||||
| "psr-7", | |||||
| "request", | |||||
| "response", | |||||
| "stream", | |||||
| "uri", | |||||
| "url" | |||||
| ], | |||||
| "support": { | |||||
| "issues": "https://github.com/guzzle/psr7/issues", | |||||
| "source": "https://github.com/guzzle/psr7/tree/2.9.0" | |||||
| }, | |||||
| "funding": [ | |||||
| { | |||||
| "url": "https://github.com/GrahamCampbell", | |||||
| "type": "github" | |||||
| }, | |||||
| { | |||||
| "url": "https://github.com/Nyholm", | |||||
| "type": "github" | |||||
| }, | |||||
| { | |||||
| "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", | |||||
| "type": "tidelift" | |||||
| } | |||||
| ], | |||||
| "time": "2026-03-10T16:41:02+00:00" | |||||
| }, | |||||
| { | |||||
| "name": "league/oauth2-client", | |||||
| "version": "2.9.0", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/thephpleague/oauth2-client.git", | |||||
| "reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/26e8c5da4f3d78cede7021e09b1330a0fc093d5e", | |||||
| "reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "ext-json": "*", | |||||
| "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", | |||||
| "php": "^7.1 || >=8.0.0 <8.6.0" | |||||
| }, | |||||
| "require-dev": { | |||||
| "mockery/mockery": "^1.3.5", | |||||
| "php-parallel-lint/php-parallel-lint": "^1.4", | |||||
| "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", | |||||
| "squizlabs/php_codesniffer": "^3.11" | |||||
| }, | |||||
| "type": "library", | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "League\\OAuth2\\Client\\": "src/" | |||||
| } | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "MIT" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "Alex Bilbie", | |||||
| "email": "hello@alexbilbie.com", | |||||
| "homepage": "http://www.alexbilbie.com", | |||||
| "role": "Developer" | |||||
| }, | |||||
| { | |||||
| "name": "Woody Gilk", | |||||
| "homepage": "https://github.com/shadowhand", | |||||
| "role": "Contributor" | |||||
| } | |||||
| ], | |||||
| "description": "OAuth 2.0 Client Library", | |||||
| "keywords": [ | |||||
| "Authentication", | |||||
| "SSO", | |||||
| "authorization", | |||||
| "identity", | |||||
| "idp", | |||||
| "oauth", | |||||
| "oauth2", | |||||
| "single sign on" | |||||
| ], | |||||
| "support": { | |||||
| "issues": "https://github.com/thephpleague/oauth2-client/issues", | |||||
| "source": "https://github.com/thephpleague/oauth2-client/tree/2.9.0" | |||||
| }, | |||||
| "time": "2025-11-25T22:17:17+00:00" | |||||
| }, | |||||
| { | |||||
| "name": "psr/http-client", | |||||
| "version": "1.0.3", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/php-fig/http-client.git", | |||||
| "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", | |||||
| "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "php": "^7.0 || ^8.0", | |||||
| "psr/http-message": "^1.0 || ^2.0" | |||||
| }, | |||||
| "type": "library", | |||||
| "extra": { | |||||
| "branch-alias": { | |||||
| "dev-master": "1.0.x-dev" | |||||
| } | |||||
| }, | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "Psr\\Http\\Client\\": "src/" | |||||
| } | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "MIT" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "PHP-FIG", | |||||
| "homepage": "https://www.php-fig.org/" | |||||
| } | |||||
| ], | |||||
| "description": "Common interface for HTTP clients", | |||||
| "homepage": "https://github.com/php-fig/http-client", | |||||
| "keywords": [ | |||||
| "http", | |||||
| "http-client", | |||||
| "psr", | |||||
| "psr-18" | |||||
| ], | |||||
| "support": { | |||||
| "source": "https://github.com/php-fig/http-client" | |||||
| }, | |||||
| "time": "2023-09-23T14:17:50+00:00" | |||||
| }, | |||||
| { | |||||
| "name": "psr/http-factory", | |||||
| "version": "1.1.0", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/php-fig/http-factory.git", | |||||
| "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", | |||||
| "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "php": ">=7.1", | |||||
| "psr/http-message": "^1.0 || ^2.0" | |||||
| }, | |||||
| "type": "library", | |||||
| "extra": { | |||||
| "branch-alias": { | |||||
| "dev-master": "1.0.x-dev" | |||||
| } | |||||
| }, | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "Psr\\Http\\Message\\": "src/" | |||||
| } | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "MIT" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "PHP-FIG", | |||||
| "homepage": "https://www.php-fig.org/" | |||||
| } | |||||
| ], | |||||
| "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", | |||||
| "keywords": [ | |||||
| "factory", | |||||
| "http", | |||||
| "message", | |||||
| "psr", | |||||
| "psr-17", | |||||
| "psr-7", | |||||
| "request", | |||||
| "response" | |||||
| ], | |||||
| "support": { | |||||
| "source": "https://github.com/php-fig/http-factory" | |||||
| }, | |||||
| "time": "2024-04-15T12:06:14+00:00" | |||||
| }, | |||||
| { | |||||
| "name": "psr/http-message", | |||||
| "version": "2.0", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/php-fig/http-message.git", | |||||
| "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", | |||||
| "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "php": "^7.2 || ^8.0" | |||||
| }, | |||||
| "type": "library", | |||||
| "extra": { | |||||
| "branch-alias": { | |||||
| "dev-master": "2.0.x-dev" | |||||
| } | |||||
| }, | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "Psr\\Http\\Message\\": "src/" | |||||
| } | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "MIT" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "PHP-FIG", | |||||
| "homepage": "https://www.php-fig.org/" | |||||
| } | |||||
| ], | |||||
| "description": "Common interface for HTTP messages", | |||||
| "homepage": "https://github.com/php-fig/http-message", | |||||
| "keywords": [ | |||||
| "http", | |||||
| "http-message", | |||||
| "psr", | |||||
| "psr-7", | |||||
| "request", | |||||
| "response" | |||||
| ], | |||||
| "support": { | |||||
| "source": "https://github.com/php-fig/http-message/tree/2.0" | |||||
| }, | |||||
| "time": "2023-04-04T09:54:51+00:00" | |||||
| }, | |||||
| { | |||||
| "name": "ralouphie/getallheaders", | |||||
| "version": "3.0.3", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/ralouphie/getallheaders.git", | |||||
| "reference": "120b605dfeb996808c31b6477290a714d356e822" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", | |||||
| "reference": "120b605dfeb996808c31b6477290a714d356e822", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "php": ">=5.6" | |||||
| }, | |||||
| "require-dev": { | |||||
| "php-coveralls/php-coveralls": "^2.1", | |||||
| "phpunit/phpunit": "^5 || ^6.5" | |||||
| }, | |||||
| "type": "library", | |||||
| "autoload": { | |||||
| "files": [ | |||||
| "src/getallheaders.php" | |||||
| ] | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "MIT" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "Ralph Khattar", | |||||
| "email": "ralph.khattar@gmail.com" | |||||
| } | |||||
| ], | |||||
| "description": "A polyfill for getallheaders.", | |||||
| "support": { | |||||
| "issues": "https://github.com/ralouphie/getallheaders/issues", | |||||
| "source": "https://github.com/ralouphie/getallheaders/tree/develop" | |||||
| }, | |||||
| "time": "2019-03-08T08:55:37+00:00" | |||||
| }, | |||||
| { | |||||
| "name": "stevenmaguire/oauth2-keycloak", | |||||
| "version": "6.1.1", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/stevenmaguire/oauth2-keycloak.git", | |||||
| "reference": "31bb3b1fa15b444212ed43facc898fafc7c2707a" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/stevenmaguire/oauth2-keycloak/zipball/31bb3b1fa15b444212ed43facc898fafc7c2707a", | |||||
| "reference": "31bb3b1fa15b444212ed43facc898fafc7c2707a", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "firebase/php-jwt": "^7.0", | |||||
| "league/oauth2-client": "^2.8", | |||||
| "php": "^8.0" | |||||
| }, | |||||
| "require-dev": { | |||||
| "mockery/mockery": "^1.6", | |||||
| "phpstan/phpstan": "^1.12", | |||||
| "phpunit/phpunit": "~9.6.4", | |||||
| "squizlabs/php_codesniffer": "~3.7.0" | |||||
| }, | |||||
| "type": "library", | |||||
| "extra": { | |||||
| "branch-alias": { | |||||
| "dev-master": "1.0.x-dev" | |||||
| } | |||||
| }, | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "Stevenmaguire\\OAuth2\\Client\\": "src/" | |||||
| } | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "MIT" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "Steven Maguire", | |||||
| "email": "stevenmaguire@gmail.com", | |||||
| "homepage": "https://github.com/stevenmaguire" | |||||
| } | |||||
| ], | |||||
| "description": "Keycloak OAuth 2.0 Client Provider for The PHP League OAuth2-Client", | |||||
| "keywords": [ | |||||
| "authorisation", | |||||
| "authorization", | |||||
| "client", | |||||
| "keycloak", | |||||
| "oauth", | |||||
| "oauth2" | |||||
| ], | |||||
| "support": { | |||||
| "issues": "https://github.com/stevenmaguire/oauth2-keycloak/issues", | |||||
| "source": "https://github.com/stevenmaguire/oauth2-keycloak/tree/6.1.1" | |||||
| }, | |||||
| "time": "2026-03-30T07:32:03+00:00" | |||||
| }, | |||||
| { | |||||
| "name": "symfony/deprecation-contracts", | |||||
| "version": "v3.7.0", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/symfony/deprecation-contracts.git", | |||||
| "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", | |||||
| "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "php": ">=8.1" | |||||
| }, | |||||
| "type": "library", | |||||
| "extra": { | |||||
| "thanks": { | |||||
| "url": "https://github.com/symfony/contracts", | |||||
| "name": "symfony/contracts" | |||||
| }, | |||||
| "branch-alias": { | |||||
| "dev-main": "3.7-dev" | |||||
| } | |||||
| }, | |||||
| "autoload": { | |||||
| "files": [ | |||||
| "function.php" | |||||
| ] | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "MIT" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "Nicolas Grekas", | |||||
| "email": "p@tchwork.com" | |||||
| }, | |||||
| { | |||||
| "name": "Symfony Community", | |||||
| "homepage": "https://symfony.com/contributors" | |||||
| } | |||||
| ], | |||||
| "description": "A generic function and convention to trigger deprecation notices", | |||||
| "homepage": "https://symfony.com", | |||||
| "support": { | |||||
| "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" | |||||
| }, | |||||
| "funding": [ | |||||
| { | |||||
| "url": "https://symfony.com/sponsor", | |||||
| "type": "custom" | |||||
| }, | |||||
| { | |||||
| "url": "https://github.com/fabpot", | |||||
| "type": "github" | |||||
| }, | |||||
| { | |||||
| "url": "https://github.com/nicolas-grekas", | |||||
| "type": "github" | |||||
| }, | |||||
| { | |||||
| "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", | |||||
| "type": "tidelift" | |||||
| } | |||||
| ], | |||||
| "time": "2026-04-13T15:52:40+00:00" | |||||
| } | |||||
| ], | |||||
| "packages-dev": [], | |||||
| "aliases": [], | |||||
| "minimum-stability": "stable", | |||||
| "stability-flags": {}, | |||||
| "prefer-stable": false, | |||||
| "prefer-lowest": false, | |||||
| "platform": {}, | |||||
| "platform-dev": {}, | |||||
| "plugin-api-version": "2.9.0" | |||||
| } | |||||
| @@ -0,0 +1,16 @@ | |||||
| <?php | |||||
| return [ | |||||
| 'dsn' => sprintf( | |||||
| 'sqlsrv:Server=%s,%s;Database=%s;TrustServerCertificate=1', | |||||
| env('DB_HOST', 'sqlserver'), | |||||
| env('DB_PORT', '1433'), | |||||
| env('DB_DATABASE', 'Campaign_Tracker') | |||||
| ), | |||||
| 'username' => env('DB_USERNAME', 'sa'), | |||||
| 'password' => env('DB_PASSWORD', ''), | |||||
| 'options' => [ | |||||
| PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, | |||||
| PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, | |||||
| ], | |||||
| ]; | |||||
| @@ -0,0 +1,57 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| use Exception; | |||||
| use ReflectionFunction; | |||||
| use ReflectionMethod; | |||||
| class App | |||||
| { | |||||
| protected array $bindings = []; | |||||
| public function bind(string $name, mixed $value): void | |||||
| { | |||||
| $this->bindings[$name] = $value; | |||||
| } | |||||
| public function get(string $name): mixed | |||||
| { | |||||
| return $this->bindings[$name] ?? null; | |||||
| } | |||||
| public function call(callable|array|string $handler, array $parameters = []): mixed | |||||
| { | |||||
| if (is_array($handler)) { | |||||
| return $this->callMethod($handler, $parameters); | |||||
| } | |||||
| if (is_callable($handler)) { | |||||
| return $this->callFunction($handler, $parameters); | |||||
| } | |||||
| throw new Exception('Invalid handler.'); | |||||
| } | |||||
| protected function callFunction(callable $handler, array $parameters): mixed | |||||
| { | |||||
| $reflection = new ReflectionFunction($handler); | |||||
| return $reflection->invokeArgs(array_values($parameters)); | |||||
| } | |||||
| protected function callMethod(array $handler, array $parameters): mixed | |||||
| { | |||||
| [$class, $method] = $handler; | |||||
| if (is_string($class)) { | |||||
| $class = new $class(); | |||||
| } | |||||
| $reflection = new ReflectionMethod($class, $method); | |||||
| return $reflection->invokeArgs($class, array_values($parameters)); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,28 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core\Auth; | |||||
| use Core\Request; | |||||
| use Core\Response; | |||||
| class AuthMiddleware | |||||
| { | |||||
| /** | |||||
| * Check authentication (and optionally a required permission). | |||||
| * Returns a redirect/error Response when the check fails, null when it passes. | |||||
| */ | |||||
| public function handle(Request $request, ?string $permission = null): ?Response | |||||
| { | |||||
| if (!auth()->check()) { | |||||
| return Response::redirect('/login'); | |||||
| } | |||||
| if ($permission !== null && !auth()->can($permission)) { | |||||
| return Response::notFound('You do not have permission to access this page.'); | |||||
| } | |||||
| return null; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,33 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core\Auth; | |||||
| class AuthUser | |||||
| { | |||||
| public function __construct( | |||||
| public readonly string $keycloakId, | |||||
| public readonly string $username, | |||||
| public readonly string $email, | |||||
| public readonly string $displayName, | |||||
| /** @var list<string> */ | |||||
| public readonly array $roles, | |||||
| /** @var list<string> */ | |||||
| public readonly array $permissions, | |||||
| ) {} | |||||
| public static function fromSession(array $authData): self | |||||
| { | |||||
| $u = $authData['user'] ?? []; | |||||
| return new self( | |||||
| keycloakId: (string) ($u['keycloak_id'] ?? ''), | |||||
| username: (string) ($u['username'] ?? ''), | |||||
| email: (string) ($u['email'] ?? ''), | |||||
| displayName: (string) ($u['display_name'] ?? ''), | |||||
| roles: (array) ($u['roles'] ?? []), | |||||
| permissions: (array) ($u['permissions'] ?? []), | |||||
| ); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,195 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core\Auth; | |||||
| use Core\Http\Session; | |||||
| use Stevenmaguire\OAuth2\Client\Provider\Keycloak; | |||||
| class KeycloakAuth | |||||
| { | |||||
| private Session $session; | |||||
| private PermissionService $permissions; | |||||
| private ?Keycloak $provider = null; | |||||
| /** Keycloak service-account roles to exclude from the app role list. */ | |||||
| private const SYSTEM_ROLES = ['uma_authorization', 'offline_access', 'account', 'view-profile', 'manage-account', 'manage-account-links']; | |||||
| public function __construct(Session $session, PermissionService $permissions) | |||||
| { | |||||
| $this->session = $session; | |||||
| $this->permissions = $permissions; | |||||
| } | |||||
| // ── Auth state ──────────────────────────────────────────────────────────── | |||||
| public function check(): bool | |||||
| { | |||||
| return ($this->session->get('auth', [])['is_authenticated'] ?? false) === true; | |||||
| } | |||||
| public function user(): ?AuthUser | |||||
| { | |||||
| return $this->check() ? AuthUser::fromSession($this->session->get('auth', [])) : null; | |||||
| } | |||||
| public function id(): ?string | |||||
| { | |||||
| return $this->user()?->keycloakId; | |||||
| } | |||||
| public function roles(): array | |||||
| { | |||||
| return $this->user()?->roles ?? []; | |||||
| } | |||||
| public function permissions(): array | |||||
| { | |||||
| return $this->user()?->permissions ?? []; | |||||
| } | |||||
| public function hasRole(string $role): bool | |||||
| { | |||||
| return in_array($role, $this->roles(), true); | |||||
| } | |||||
| public function can(string $permission): bool | |||||
| { | |||||
| return in_array($permission, $this->permissions(), true); | |||||
| } | |||||
| // ── Login flow ──────────────────────────────────────────────────────────── | |||||
| /** | |||||
| * Build the Keycloak authorization URL and store the state for CSRF validation. | |||||
| */ | |||||
| public function beginLogin(): string | |||||
| { | |||||
| $provider = $this->getProvider(); | |||||
| $authUrl = $provider->getAuthorizationUrl([ | |||||
| 'scope' => 'openid email profile', | |||||
| ]); | |||||
| $this->session->set('oauth_state', $provider->getState()); | |||||
| return $authUrl; | |||||
| } | |||||
| /** | |||||
| * Exchange the authorization code for tokens, populate the session. | |||||
| * | |||||
| * @throws \RuntimeException on state mismatch or token exchange failure | |||||
| */ | |||||
| public function handleCallback(string $code, string $returnedState): void | |||||
| { | |||||
| $storedState = $this->session->get('oauth_state'); | |||||
| $this->session->forget('oauth_state'); | |||||
| if ($storedState === null || !hash_equals((string) $storedState, $returnedState)) { | |||||
| throw new \RuntimeException('OAuth state mismatch — possible CSRF attempt.'); | |||||
| } | |||||
| $provider = $this->getProvider(); | |||||
| $token = $provider->getAccessToken('authorization_code', ['code' => $code]); | |||||
| // Decode the access-token JWT to extract realm/client role claims. | |||||
| // Signature verification is not needed here: the token arrived directly | |||||
| // from Keycloak over an authenticated server-to-server HTTPS exchange. | |||||
| $accessClaims = $this->decodeJwtPayload($token->getToken()); | |||||
| $idTokenRaw = (string) ($token->getValues()['id_token'] ?? ''); | |||||
| $realmRoles = (array) ($accessClaims['realm_access']['roles'] ?? []); | |||||
| $clientId = (string) env('KEYCLOAK_CLIENT_ID', ''); | |||||
| $clientRoles = (array) ($accessClaims['resource_access'][$clientId]['roles'] ?? []); | |||||
| $appRoles = array_values(array_filter( | |||||
| array_unique(array_merge($realmRoles, $clientRoles)), | |||||
| static fn(string $r): bool => !in_array($r, self::SYSTEM_ROLES, true) | |||||
| )); | |||||
| // Fetch the user-profile claims from the userinfo endpoint. | |||||
| $ownerData = $provider->getResourceOwner($token)->toArray(); | |||||
| $displayName = trim(($ownerData['given_name'] ?? '') . ' ' . ($ownerData['family_name'] ?? '')); | |||||
| if ($displayName === '') { | |||||
| $displayName = (string) ($ownerData['preferred_username'] ?? $ownerData['sub'] ?? ''); | |||||
| } | |||||
| // Prevent session fixation: regenerate ID before writing auth data. | |||||
| $this->session->regenerate(); | |||||
| $this->session->set('auth', [ | |||||
| 'is_authenticated' => true, | |||||
| 'user' => [ | |||||
| 'keycloak_id' => (string) ($ownerData['sub'] ?? $accessClaims['sub'] ?? ''), | |||||
| 'username' => (string) ($ownerData['preferred_username'] ?? ''), | |||||
| 'email' => (string) ($ownerData['email'] ?? ''), | |||||
| 'display_name' => $displayName, | |||||
| 'roles' => $appRoles, | |||||
| 'permissions' => $this->permissions->permissionsForRoles($appRoles), | |||||
| ], | |||||
| // id_token is stored solely to support Keycloak RP-initiated logout | |||||
| // (id_token_hint parameter). It is never used to derive identity or | |||||
| // resolve permissions. Do not pass it to the browser or log it. | |||||
| 'id_token' => $idTokenRaw !== '' ? $idTokenRaw : null, | |||||
| 'login_time' => time(), | |||||
| 'last_permission_refresh' => time(), | |||||
| ]); | |||||
| } | |||||
| // ── Logout ──────────────────────────────────────────────────────────────── | |||||
| /** | |||||
| * Destroy the local session and return the Keycloak RP-initiated logout URL. | |||||
| */ | |||||
| public function logout(): string | |||||
| { | |||||
| $idToken = $this->session->get('auth')['id_token'] ?? null; | |||||
| $redirectUri = (string) env('KEYCLOAK_LOGOUT_REDIRECT_URI', '/'); | |||||
| $base = rtrim((string) env('KEYCLOAK_BASE_URL', ''), '/'); | |||||
| $realm = (string) env('KEYCLOAK_REALM', ''); | |||||
| $this->session->destroy(); | |||||
| $params = ['post_logout_redirect_uri' => $redirectUri]; | |||||
| if ($idToken !== null) { | |||||
| $params['id_token_hint'] = $idToken; | |||||
| } | |||||
| return "{$base}/realms/{$realm}/protocol/openid-connect/logout?" . http_build_query($params); | |||||
| } | |||||
| // ── Internal ────────────────────────────────────────────────────────────── | |||||
| private function getProvider(): Keycloak | |||||
| { | |||||
| if ($this->provider === null) { | |||||
| $this->provider = new Keycloak([ | |||||
| 'authServerUrl' => rtrim((string) env('KEYCLOAK_BASE_URL', ''), '/'), | |||||
| 'realm' => (string) env('KEYCLOAK_REALM', ''), | |||||
| 'clientId' => (string) env('KEYCLOAK_CLIENT_ID', ''), | |||||
| 'clientSecret' => (string) env('KEYCLOAK_CLIENT_SECRET', ''), | |||||
| 'redirectUri' => (string) env('KEYCLOAK_REDIRECT_URI', ''), | |||||
| ]); | |||||
| } | |||||
| return $this->provider; | |||||
| } | |||||
| private function decodeJwtPayload(string $jwt): array | |||||
| { | |||||
| $parts = explode('.', $jwt); | |||||
| if (count($parts) !== 3) { | |||||
| return []; | |||||
| } | |||||
| $padded = str_pad(strtr($parts[1], '-_', '+/'), (int) ceil(strlen($parts[1]) / 4) * 4, '='); | |||||
| $decoded = base64_decode($padded, true); | |||||
| return $decoded !== false ? (json_decode($decoded, true) ?? []) : []; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,54 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core\Auth; | |||||
| class PermissionService | |||||
| { | |||||
| /** | |||||
| * Maps Keycloak roles to application permissions. | |||||
| * Edit this to match your access-control requirements. | |||||
| * | |||||
| * @var array<string, list<string>> | |||||
| */ | |||||
| private array $rolePermissions = [ | |||||
| 'admin' => [ | |||||
| 'users.view', | |||||
| 'users.create', | |||||
| 'users.edit', | |||||
| 'users.delete', | |||||
| 'settings.manage', | |||||
| ], | |||||
| 'manager' => [ | |||||
| 'users.view', | |||||
| 'reports.view', | |||||
| 'projects.manage', | |||||
| ], | |||||
| 'user' => [ | |||||
| 'dashboard.view', | |||||
| 'profile.view', | |||||
| 'profile.edit', | |||||
| ], | |||||
| ]; | |||||
| /** | |||||
| * @param list<string> $roles | |||||
| * @return list<string> | |||||
| */ | |||||
| public function permissionsForRoles(array $roles): array | |||||
| { | |||||
| $permissions = []; | |||||
| foreach ($roles as $role) { | |||||
| $permissions = array_merge($permissions, $this->rolePermissions[$role] ?? []); | |||||
| } | |||||
| return array_values(array_unique($permissions)); | |||||
| } | |||||
| public function hasPermission(array $roles, string $permission): bool | |||||
| { | |||||
| return in_array($permission, $this->permissionsForRoles($roles), true); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,35 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| abstract class Controller | |||||
| { | |||||
| protected function view(string $view, array $data = []): Response | |||||
| { | |||||
| return View::render($view, $data); | |||||
| } | |||||
| protected function fragment(string $view, array $data = [], int $status = 200, array $headers = []): Response | |||||
| { | |||||
| return View::fragment($view, $data, $status, $headers); | |||||
| } | |||||
| protected function redirect(string $url): Response | |||||
| { | |||||
| return Response::redirect($url); | |||||
| } | |||||
| protected function json(array $data): Response | |||||
| { | |||||
| return Response::json($data); | |||||
| } | |||||
| protected function requirePost(Request $request): void | |||||
| { | |||||
| if ($request->method() !== 'POST') { | |||||
| throw new \Exception('This action requires POST.'); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,57 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| use PDO; | |||||
| class Database | |||||
| { | |||||
| protected PDO $pdo; | |||||
| public function __construct(array $config) | |||||
| { | |||||
| $this->pdo = new PDO( | |||||
| $config['dsn'], | |||||
| $config['username'] ?? null, | |||||
| $config['password'] ?? null, | |||||
| $config['options'] ?? [] | |||||
| ); | |||||
| } | |||||
| public function pdo(): PDO | |||||
| { | |||||
| return $this->pdo; | |||||
| } | |||||
| public function query(string $sql, array $parameters = []): array | |||||
| { | |||||
| $statement = $this->pdo->prepare($sql); | |||||
| $statement->execute($parameters); | |||||
| return array_map([$this, 'resolveStreams'], $statement->fetchAll(PDO::FETCH_ASSOC)); | |||||
| } | |||||
| private function resolveStreams(array $row): array | |||||
| { | |||||
| return array_map( | |||||
| static fn($v) => is_resource($v) ? (stream_get_contents($v) ?: '') : $v, | |||||
| $row | |||||
| ); | |||||
| } | |||||
| public function first(string $sql, array $parameters = []): ?array | |||||
| { | |||||
| $rows = $this->query($sql, $parameters); | |||||
| return $rows[0] ?? null; | |||||
| } | |||||
| public function execute(string $sql, array $parameters = []): bool | |||||
| { | |||||
| $statement = $this->pdo->prepare($sql); | |||||
| return $statement->execute($parameters); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,70 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| use Core\Auth\AuthMiddleware; | |||||
| use Throwable; | |||||
| class Dispatcher | |||||
| { | |||||
| protected Router $router; | |||||
| protected App $app; | |||||
| public function __construct(Router $router, App $app) | |||||
| { | |||||
| $this->router = $router; | |||||
| $this->app = $app; | |||||
| } | |||||
| public function dispatch(Request $request): Response | |||||
| { | |||||
| Request::setCurrent($request); | |||||
| try { | |||||
| $route = $this->router->match($request->method(), $request->path()); | |||||
| if (!$route) { | |||||
| return Response::notFound('Page not found.'); | |||||
| } | |||||
| if ($route->getMiddleware() !== null) { | |||||
| $early = $this->runMiddleware($route->getMiddleware(), $request, $route->getRequiredPermission()); | |||||
| if ($early !== null) { | |||||
| return $early; | |||||
| } | |||||
| } | |||||
| $result = $route->dispatch($this->app); | |||||
| return $this->normalizeResponse($result); | |||||
| } catch (Throwable $e) { | |||||
| return Response::serverError($e->getMessage()); | |||||
| } finally { | |||||
| Request::clearCurrent(); | |||||
| } | |||||
| } | |||||
| protected function runMiddleware(string $name, Request $request, ?string $permission): ?Response | |||||
| { | |||||
| return match ($name) { | |||||
| 'auth' => (new AuthMiddleware())->handle($request, $permission), | |||||
| default => null, | |||||
| }; | |||||
| } | |||||
| protected function normalizeResponse(mixed $result): Response | |||||
| { | |||||
| if ($result instanceof Response) { | |||||
| return $result; | |||||
| } | |||||
| if (is_array($result)) { | |||||
| return Response::json($result); | |||||
| } | |||||
| return new Response((string) $result); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,73 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core\Http; | |||||
| class Session | |||||
| { | |||||
| public function start(): void | |||||
| { | |||||
| if (session_status() !== PHP_SESSION_NONE) { | |||||
| return; | |||||
| } | |||||
| $secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; | |||||
| session_set_cookie_params([ | |||||
| 'lifetime' => 0, | |||||
| 'path' => '/', | |||||
| 'domain' => '', | |||||
| 'secure' => $secure, | |||||
| 'httponly' => true, | |||||
| 'samesite' => 'Lax', | |||||
| ]); | |||||
| session_start(); | |||||
| } | |||||
| public function get(string $key, mixed $default = null): mixed | |||||
| { | |||||
| return $_SESSION[$key] ?? $default; | |||||
| } | |||||
| public function set(string $key, mixed $value): void | |||||
| { | |||||
| $_SESSION[$key] = $value; | |||||
| } | |||||
| public function has(string $key): bool | |||||
| { | |||||
| return isset($_SESSION[$key]); | |||||
| } | |||||
| public function forget(string $key): void | |||||
| { | |||||
| unset($_SESSION[$key]); | |||||
| } | |||||
| public function regenerate(): void | |||||
| { | |||||
| session_regenerate_id(true); | |||||
| } | |||||
| public function destroy(): void | |||||
| { | |||||
| $_SESSION = []; | |||||
| if (ini_get('session.use_cookies')) { | |||||
| $params = session_get_cookie_params(); | |||||
| setcookie( | |||||
| session_name(), | |||||
| '', | |||||
| time() - 42000, | |||||
| $params['path'], | |||||
| $params['domain'], | |||||
| $params['secure'], | |||||
| $params['httponly'] | |||||
| ); | |||||
| } | |||||
| session_destroy(); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,12 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| abstract class Migration | |||||
| { | |||||
| abstract public function up(Database $database): void; | |||||
| abstract public function down(Database $database): void; | |||||
| } | |||||
| @@ -0,0 +1,297 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| class MigrationManager | |||||
| { | |||||
| protected Database $database; | |||||
| protected string $path; | |||||
| public function __construct(Database $database, string $path) | |||||
| { | |||||
| $this->database = $database; | |||||
| $this->path = rtrim($path, '/'); | |||||
| } | |||||
| public function ensureTable(): void | |||||
| { | |||||
| $tableExists = $this->database->first( | |||||
| "SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'migrations'" | |||||
| ); | |||||
| if (!$tableExists) { | |||||
| $this->database->execute( | |||||
| 'CREATE TABLE migrations ( | |||||
| id INT IDENTITY(1,1) NOT NULL, | |||||
| migration NVARCHAR(255) NOT NULL, | |||||
| ran_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||||
| CONSTRAINT PK_migrations PRIMARY KEY (id), | |||||
| CONSTRAINT UQ_migrations_migration UNIQUE (migration) | |||||
| )' | |||||
| ); | |||||
| } | |||||
| $this->database->execute( | |||||
| 'DELETE FROM migrations | |||||
| WHERE id NOT IN ( | |||||
| SELECT MIN(id) | |||||
| FROM migrations | |||||
| GROUP BY migration | |||||
| )' | |||||
| ); | |||||
| $files = array_map('basename', $this->migrationFiles()); | |||||
| if ($files === []) { | |||||
| $this->database->execute('DELETE FROM migrations'); | |||||
| return; | |||||
| } | |||||
| $placeholders = []; | |||||
| $parameters = []; | |||||
| foreach ($files as $index => $file) { | |||||
| $placeholder = 'migration_' . $index; | |||||
| $placeholders[] = ':' . $placeholder; | |||||
| $parameters[$placeholder] = $file; | |||||
| } | |||||
| $this->database->execute( | |||||
| 'DELETE FROM migrations WHERE migration NOT IN (' . implode(', ', $placeholders) . ')', | |||||
| $parameters | |||||
| ); | |||||
| } | |||||
| public function status(): array | |||||
| { | |||||
| $this->ensureTable(); | |||||
| $ran = $this->database->query('SELECT migration, ran_at FROM migrations ORDER BY id ASC'); | |||||
| $ranByName = []; | |||||
| foreach ($ran as $row) { | |||||
| $ranByName[$row['migration']] = $row['ran_at']; | |||||
| } | |||||
| $files = $this->migrationFiles(); | |||||
| $status = []; | |||||
| foreach ($files as $file) { | |||||
| $name = basename($file); | |||||
| $status[] = [ | |||||
| 'migration' => $name, | |||||
| 'ran' => array_key_exists($name, $ranByName), | |||||
| 'ran_at' => $ranByName[$name] ?? null, | |||||
| ]; | |||||
| } | |||||
| return $status; | |||||
| } | |||||
| public function runPending(): array | |||||
| { | |||||
| $this->ensureTable(); | |||||
| $ran = $this->database->query('SELECT migration FROM migrations'); | |||||
| $ranNames = array_column($ran, 'migration'); | |||||
| $files = $this->migrationFiles(); | |||||
| $ranMigrations = []; | |||||
| foreach ($files as $file) { | |||||
| $name = basename($file); | |||||
| if (in_array($name, $ranNames, true)) { | |||||
| continue; | |||||
| } | |||||
| $migration = $this->loadMigration($file); | |||||
| $this->database->pdo()->beginTransaction(); | |||||
| try { | |||||
| $migration->up($this->database); | |||||
| $this->database->execute( | |||||
| 'INSERT INTO migrations (migration) | |||||
| SELECT :m1 WHERE NOT EXISTS (SELECT 1 FROM migrations WHERE migration = :m2)', | |||||
| ['m1' => $name, 'm2' => $name] | |||||
| ); | |||||
| $this->database->pdo()->commit(); | |||||
| $ranMigrations[] = $name; | |||||
| } catch (\Throwable $exception) { | |||||
| $this->database->pdo()->rollBack(); | |||||
| throw $exception; | |||||
| } | |||||
| } | |||||
| return $ranMigrations; | |||||
| } | |||||
| public function rollback(int $steps = 1): array | |||||
| { | |||||
| $this->ensureTable(); | |||||
| $steps = max(1, $steps); | |||||
| $applied = $this->database->query( | |||||
| "SELECT TOP ({$steps}) MAX(id) AS id, migration | |||||
| FROM migrations | |||||
| GROUP BY migration | |||||
| ORDER BY MAX(id) DESC" | |||||
| ); | |||||
| $rolledBack = []; | |||||
| foreach ($applied as $row) { | |||||
| $file = $this->path . '/' . $row['migration']; | |||||
| if (!file_exists($file)) { | |||||
| throw new \RuntimeException("Migration file not found for rollback: {$row['migration']}"); | |||||
| } | |||||
| $migration = $this->loadMigration($file); | |||||
| $this->database->pdo()->beginTransaction(); | |||||
| try { | |||||
| $migration->down($this->database); | |||||
| $this->database->execute( | |||||
| 'DELETE FROM migrations WHERE id = :id', | |||||
| ['id' => $row['id']] | |||||
| ); | |||||
| $this->database->pdo()->commit(); | |||||
| $rolledBack[] = $row['migration']; | |||||
| } catch (\Throwable $exception) { | |||||
| $this->database->pdo()->rollBack(); | |||||
| throw $exception; | |||||
| } | |||||
| } | |||||
| return $rolledBack; | |||||
| } | |||||
| public function fresh(): array | |||||
| { | |||||
| $this->ensureTable(); | |||||
| $files = array_reverse($this->migrationFiles()); | |||||
| $rolledBack = []; | |||||
| foreach ($files as $file) { | |||||
| $migration = $this->loadMigration($file); | |||||
| $name = basename($file); | |||||
| $this->database->pdo()->beginTransaction(); | |||||
| try { | |||||
| $migration->down($this->database); | |||||
| $this->database->pdo()->commit(); | |||||
| $rolledBack[] = $name; | |||||
| } catch (\Throwable $exception) { | |||||
| $this->database->pdo()->rollBack(); | |||||
| throw $exception; | |||||
| } | |||||
| } | |||||
| $this->database->execute('DELETE FROM migrations'); | |||||
| $ran = $this->runPending(); | |||||
| return [ | |||||
| 'rolled_back' => $rolledBack, | |||||
| 'migrated' => $ran, | |||||
| ]; | |||||
| } | |||||
| public function make(string $name): string | |||||
| { | |||||
| $slug = trim(strtolower(preg_replace('/[^a-zA-Z0-9]+/', '_', $name) ?? ''), '_'); | |||||
| if ($slug === '') { | |||||
| throw new \InvalidArgumentException('Migration name must contain letters or numbers.'); | |||||
| } | |||||
| if (!is_dir($this->path)) { | |||||
| mkdir($this->path, 0777, true); | |||||
| } | |||||
| $timestamp = date('Ymd_His'); | |||||
| $filename = $timestamp . '_' . $slug . '.php'; | |||||
| $path = $this->path . '/' . $filename; | |||||
| if (file_exists($path)) { | |||||
| throw new \RuntimeException("Migration already exists: {$filename}"); | |||||
| } | |||||
| $template = <<<PHP | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database \$database): void | |||||
| { | |||||
| // Write the forward migration here. | |||||
| } | |||||
| public function down(Database \$database): void | |||||
| { | |||||
| // Write the rollback migration here. | |||||
| } | |||||
| }; | |||||
| PHP; | |||||
| file_put_contents($path, $template . PHP_EOL); | |||||
| return $path; | |||||
| } | |||||
| private function migrationFiles(): array | |||||
| { | |||||
| $files = glob($this->path . '/*.php') ?: []; | |||||
| sort($files); | |||||
| return $files; | |||||
| } | |||||
| private function loadMigration(string $file): Migration | |||||
| { | |||||
| $migration = require $file; | |||||
| if ($migration instanceof Migration) { | |||||
| return $migration; | |||||
| } | |||||
| if (is_callable($migration)) { | |||||
| return new class ($migration, basename($file)) extends Migration | |||||
| { | |||||
| private $callback; | |||||
| private string $name; | |||||
| public function __construct(callable $callback, string $name) | |||||
| { | |||||
| $this->callback = $callback; | |||||
| $this->name = $name; | |||||
| } | |||||
| public function up(Database $database): void | |||||
| { | |||||
| ($this->callback)($database); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| throw new \RuntimeException("Migration {$this->name} cannot be rolled back because it has no down() method."); | |||||
| } | |||||
| }; | |||||
| } | |||||
| throw new \RuntimeException('Migration files must return a Migration instance.'); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,38 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| abstract class Repository | |||||
| { | |||||
| protected Database $database; | |||||
| protected string $table; | |||||
| protected string $primaryKey = 'id'; | |||||
| public function __construct(Database $database) | |||||
| { | |||||
| $this->database = $database; | |||||
| } | |||||
| public function find(int|string $id): ?array | |||||
| { | |||||
| return $this->database->first( | |||||
| "SELECT * FROM {$this->table} WHERE {$this->primaryKey} = :id", | |||||
| ['id' => $id] | |||||
| ); | |||||
| } | |||||
| public function all(): array | |||||
| { | |||||
| return $this->database->query("SELECT * FROM {$this->table}"); | |||||
| } | |||||
| public function delete(int|string $id): bool | |||||
| { | |||||
| return $this->database->execute( | |||||
| "DELETE FROM {$this->table} WHERE {$this->primaryKey} = :id", | |||||
| ['id' => $id] | |||||
| ); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,70 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| class Request | |||||
| { | |||||
| protected static ?self $current = null; | |||||
| protected string $method; | |||||
| protected string $uri; | |||||
| protected array $get; | |||||
| protected array $post; | |||||
| protected array $server; | |||||
| public function __construct(array $get, array $post, array $server) | |||||
| { | |||||
| $this->get = $get; | |||||
| $this->post = $post; | |||||
| $this->server = $server; | |||||
| $this->method = $server['REQUEST_METHOD'] ?? 'GET'; | |||||
| $this->uri = $server['REQUEST_URI'] ?? '/'; | |||||
| } | |||||
| public static function capture(): self | |||||
| { | |||||
| if (self::$current instanceof self) { | |||||
| return self::$current; | |||||
| } | |||||
| return new self($_GET, $_POST, $_SERVER); | |||||
| } | |||||
| public static function setCurrent(self $request): void | |||||
| { | |||||
| self::$current = $request; | |||||
| } | |||||
| public static function clearCurrent(): void | |||||
| { | |||||
| self::$current = null; | |||||
| } | |||||
| public function method(): string | |||||
| { | |||||
| return strtoupper($this->method); | |||||
| } | |||||
| public function path(): string | |||||
| { | |||||
| $path = parse_url($this->uri, PHP_URL_PATH); | |||||
| return $path ?: '/'; | |||||
| } | |||||
| public function input(string $key, mixed $default = null): mixed | |||||
| { | |||||
| return $this->post[$key] ?? $this->get[$key] ?? $default; | |||||
| } | |||||
| public function server(string $key, mixed $default = null): mixed | |||||
| { | |||||
| return $this->server[$key] ?? $default; | |||||
| } | |||||
| public function all(): array | |||||
| { | |||||
| return array_merge($this->get, $this->post); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,64 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| class Response | |||||
| { | |||||
| protected string $content; | |||||
| protected int $status; | |||||
| protected array $headers; | |||||
| public function __construct(string $content = '', int $status = 200, array $headers = []) | |||||
| { | |||||
| $this->content = $content; | |||||
| $this->status = $status; | |||||
| $this->headers = $headers; | |||||
| } | |||||
| public static function json(array $data, int $status = 200): self | |||||
| { | |||||
| return new self( | |||||
| json_encode($data, JSON_PRETTY_PRINT), | |||||
| $status, | |||||
| ['Content-Type' => 'application/json'] | |||||
| ); | |||||
| } | |||||
| public static function redirect(string $url): self | |||||
| { | |||||
| return new self('', 302, ['Location' => $url]); | |||||
| } | |||||
| public static function notFound(string $message = 'Not found'): self | |||||
| { | |||||
| return new self($message, 404); | |||||
| } | |||||
| public static function serverError(string $message = 'Server error'): self | |||||
| { | |||||
| return new self($message, 500); | |||||
| } | |||||
| public function send(): void | |||||
| { | |||||
| http_response_code($this->status); | |||||
| foreach ($this->headers as $name => $value) { | |||||
| header($name . ': ' . $value); | |||||
| } | |||||
| echo $this->content; | |||||
| } | |||||
| public function content(): string | |||||
| { | |||||
| return $this->content; | |||||
| } | |||||
| public function status(): int | |||||
| { | |||||
| return $this->status; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,80 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| class Route | |||||
| { | |||||
| protected string $method; | |||||
| protected string $path; | |||||
| protected mixed $handler; | |||||
| protected array $parameters = []; | |||||
| protected ?string $middleware = null; | |||||
| protected ?string $requiredPermission = null; | |||||
| public function __construct(string $method, string $path, mixed $handler) | |||||
| { | |||||
| $this->method = strtoupper($method); | |||||
| $this->path = $path; | |||||
| $this->handler = $handler; | |||||
| } | |||||
| // ── Fluent middleware / permission builders ─────────────────────────────── | |||||
| public function middleware(string $name): self | |||||
| { | |||||
| $this->middleware = $name; | |||||
| return $this; | |||||
| } | |||||
| public function permission(string $permission): self | |||||
| { | |||||
| $this->requiredPermission = $permission; | |||||
| return $this; | |||||
| } | |||||
| public function getMiddleware(): ?string | |||||
| { | |||||
| return $this->middleware; | |||||
| } | |||||
| public function getRequiredPermission(): ?string | |||||
| { | |||||
| return $this->requiredPermission; | |||||
| } | |||||
| // ── Matching ────────────────────────────────────────────────────────────── | |||||
| public function matches(string $method, string $path): bool | |||||
| { | |||||
| if (strtoupper($method) !== $this->method) { | |||||
| return false; | |||||
| } | |||||
| $routePattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '([^/]+)', $this->path); | |||||
| $routePattern = '#^' . $routePattern . '$#'; | |||||
| if (!preg_match($routePattern, $path, $matches)) { | |||||
| return false; | |||||
| } | |||||
| array_shift($matches); | |||||
| preg_match_all('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', $this->path, $names); | |||||
| $this->parameters = []; | |||||
| foreach ($names[1] as $index => $name) { | |||||
| $this->parameters[$name] = $matches[$index] ?? null; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| public function dispatch(App $app): mixed | |||||
| { | |||||
| return $app->call($this->handler, $this->parameters); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,39 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| class Router | |||||
| { | |||||
| protected array $routes = []; | |||||
| public function get(string $path, callable|array|string $handler): Route | |||||
| { | |||||
| return $this->add('GET', $path, $handler); | |||||
| } | |||||
| public function post(string $path, callable|array|string $handler): Route | |||||
| { | |||||
| return $this->add('POST', $path, $handler); | |||||
| } | |||||
| public function add(string $method, string $path, callable|array|string $handler): Route | |||||
| { | |||||
| $route = new Route($method, $path, $handler); | |||||
| $this->routes[] = $route; | |||||
| return $route; | |||||
| } | |||||
| public function match(string $method, string $path): ?Route | |||||
| { | |||||
| foreach ($this->routes as $route) { | |||||
| if ($route->matches($method, $path)) { | |||||
| return $route; | |||||
| } | |||||
| } | |||||
| return null; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,52 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| class Validator | |||||
| { | |||||
| protected array $errors = []; | |||||
| public function required(string $field, mixed $value, string $message = ''): self | |||||
| { | |||||
| if ($value === null || trim((string) $value) === '') { | |||||
| $this->errors[$field][] = $message ?: "{$field} is required."; | |||||
| } | |||||
| return $this; | |||||
| } | |||||
| public function maxLength(string $field, mixed $value, int $max, string $message = ''): self | |||||
| { | |||||
| if (strlen((string) $value) > $max) { | |||||
| $this->errors[$field][] = $message ?: "{$field} must be {$max} characters or fewer."; | |||||
| } | |||||
| return $this; | |||||
| } | |||||
| public function numeric(string $field, mixed $value, string $message = ''): self | |||||
| { | |||||
| if (!is_numeric($value)) { | |||||
| $this->errors[$field][] = $message ?: "{$field} must be numeric."; | |||||
| } | |||||
| return $this; | |||||
| } | |||||
| public function passes(): bool | |||||
| { | |||||
| return empty($this->errors); | |||||
| } | |||||
| public function fails(): bool | |||||
| { | |||||
| return !$this->passes(); | |||||
| } | |||||
| public function errors(): array | |||||
| { | |||||
| return $this->errors; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,68 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| class View | |||||
| { | |||||
| public static function render(string $view, array $data = []): Response | |||||
| { | |||||
| $content = self::renderContent($view, $data); | |||||
| $layoutPath = __DIR__ . '/../app/Views/layouts/app.php'; | |||||
| if (!file_exists($layoutPath)) { | |||||
| return new Response($content); | |||||
| } | |||||
| $pageTitle = self::resolvePageTitle($data); | |||||
| extract($data, EXTR_SKIP); | |||||
| ob_start(); | |||||
| require $layoutPath; | |||||
| $content = ob_get_clean(); | |||||
| return new Response($content); | |||||
| } | |||||
| public static function fragment(string $view, array $data = [], int $status = 200, array $headers = []): Response | |||||
| { | |||||
| return new Response(self::renderContent($view, $data), $status, $headers); | |||||
| } | |||||
| private static function renderContent(string $view, array $data): string | |||||
| { | |||||
| $path = __DIR__ . '/../app/Views/' . str_replace('.', '/', $view) . '.php'; | |||||
| if (!file_exists($path)) { | |||||
| throw new \Exception("View not found: {$view}"); | |||||
| } | |||||
| extract($data, EXTR_SKIP); | |||||
| ob_start(); | |||||
| require $path; | |||||
| return (string) ob_get_clean(); | |||||
| } | |||||
| private static function resolvePageTitle(array $data): string | |||||
| { | |||||
| if (isset($data['pageTitle']) && is_string($data['pageTitle']) && trim($data['pageTitle']) !== '') { | |||||
| return $data['pageTitle']; | |||||
| } | |||||
| if ( | |||||
| isset($data['model']) && | |||||
| is_object($data['model']) && | |||||
| property_exists($data['model'], 'title') && | |||||
| is_string($data['model']->title) && | |||||
| trim($data['model']->title) !== '' | |||||
| ) { | |||||
| return $data['model']->title; | |||||
| } | |||||
| return 'MindVisionCode PHP'; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,207 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\App; | |||||
| use Core\Auth\KeycloakAuth; | |||||
| use Core\Auth\PermissionService; | |||||
| use Core\Database; | |||||
| use Core\Http\Session; | |||||
| use Core\MigrationManager; | |||||
| use Core\Response; | |||||
| use Core\View; | |||||
| // ── Framework helpers ───────────────────────────────────────────────────────── | |||||
| function app(): App | |||||
| { | |||||
| static $app = null; | |||||
| if ($app === null) { | |||||
| $app = new App(); | |||||
| } | |||||
| return $app; | |||||
| } | |||||
| function view(string $view, array $data = []): Response | |||||
| { | |||||
| return View::render($view, $data); | |||||
| } | |||||
| function redirect(string $url): Response | |||||
| { | |||||
| return Response::redirect($url); | |||||
| } | |||||
| // ── Environment helpers ─────────────────────────────────────────────────────── | |||||
| function env(string $key, mixed $default = null): mixed | |||||
| { | |||||
| $value = $_ENV[$key] ?? getenv($key); | |||||
| return ($value === false) ? $default : $value; | |||||
| } | |||||
| function loadEnv(string $path): void | |||||
| { | |||||
| if (!file_exists($path)) { | |||||
| return; | |||||
| } | |||||
| foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { | |||||
| $line = trim($line); | |||||
| if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) { | |||||
| continue; | |||||
| } | |||||
| [$key, $value] = explode('=', $line, 2); | |||||
| $key = trim($key); | |||||
| $value = trim($value, " \t\"'"); | |||||
| if (!isset($_ENV[$key]) && getenv($key) === false) { | |||||
| $_ENV[$key] = $value; | |||||
| putenv("{$key}={$value}"); | |||||
| } | |||||
| } | |||||
| } | |||||
| // ── Session helper ──────────────────────────────────────────────────────────── | |||||
| function session(): Session | |||||
| { | |||||
| static $session = null; | |||||
| if ($session === null) { | |||||
| $session = new Session(); | |||||
| } | |||||
| return $session; | |||||
| } | |||||
| // ── Auth helper ─────────────────────────────────────────────────────────────── | |||||
| function auth(): KeycloakAuth | |||||
| { | |||||
| static $auth = null; | |||||
| if ($auth === null) { | |||||
| $auth = new KeycloakAuth(session(), new PermissionService()); | |||||
| } | |||||
| return $auth; | |||||
| } | |||||
| // ── Database helpers ────────────────────────────────────────────────────────── | |||||
| function ensureSqlServerDatabase(array $config): void | |||||
| { | |||||
| if (!str_starts_with($config['dsn'] ?? '', 'sqlsrv:')) { | |||||
| return; | |||||
| } | |||||
| preg_match('/Server=([^;]+)/', $config['dsn'], $serverMatch); | |||||
| preg_match('/Database=([^;]+)/', $config['dsn'], $dbMatch); | |||||
| if (empty($serverMatch[1]) || empty($dbMatch[1])) { | |||||
| return; | |||||
| } | |||||
| $server = $serverMatch[1]; | |||||
| $dbName = $dbMatch[1]; | |||||
| try { | |||||
| $masterPdo = new PDO( | |||||
| "sqlsrv:Server={$server};Database=master;LoginTimeout=5;TrustServerCertificate=1", | |||||
| $config['username'] ?? null, | |||||
| $config['password'] ?? null, | |||||
| [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] | |||||
| ); | |||||
| $check = $masterPdo->prepare('SELECT 1 FROM sys.databases WHERE name = ?'); | |||||
| $check->execute([$dbName]); | |||||
| if (!$check->fetch()) { | |||||
| $safeName = str_replace(']', ']]', $dbName); | |||||
| $masterPdo->exec("CREATE DATABASE [{$safeName}]"); | |||||
| } | |||||
| } catch (\Throwable) { | |||||
| // Let the main connection fail with its own error if master access fails | |||||
| } | |||||
| } | |||||
| function database(): Database | |||||
| { | |||||
| static $database = null; | |||||
| if ($database === null) { | |||||
| /** @var array<string, mixed> $config */ | |||||
| $config = require __DIR__ . '/../config/database.php'; | |||||
| ensureSqlServerDatabase($config); | |||||
| $database = new Database($config); | |||||
| } | |||||
| return $database; | |||||
| } | |||||
| function migration_manager(): MigrationManager | |||||
| { | |||||
| static $migrationManager = null; | |||||
| if ($migrationManager === null) { | |||||
| $migrationManager = new MigrationManager(database(), __DIR__ . '/../database/migrations'); | |||||
| } | |||||
| return $migrationManager; | |||||
| } | |||||
| // ── Session / CSRF helpers ──────────────────────────────────────────────────── | |||||
| function ensureSessionStarted(): void | |||||
| { | |||||
| if (session_status() === PHP_SESSION_NONE) { | |||||
| session_start(); | |||||
| } | |||||
| } | |||||
| function e(?string $value): string | |||||
| { | |||||
| return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); | |||||
| } | |||||
| function asset(string $path): string | |||||
| { | |||||
| return '/' . ltrim($path, '/'); | |||||
| } | |||||
| function csrf_token(): string | |||||
| { | |||||
| ensureSessionStarted(); | |||||
| if (!isset($_SESSION['_csrf_token']) || !is_string($_SESSION['_csrf_token'])) { | |||||
| $_SESSION['_csrf_token'] = bin2hex(random_bytes(32)); | |||||
| } | |||||
| return $_SESSION['_csrf_token']; | |||||
| } | |||||
| function csrf_field(): string | |||||
| { | |||||
| return '<input type="hidden" name="_token" value="' . e(csrf_token()) . '">'; | |||||
| } | |||||
| function verify_csrf_token(?string $token): bool | |||||
| { | |||||
| ensureSessionStarted(); | |||||
| if (!is_string($token) || $token === '') { | |||||
| return false; | |||||
| } | |||||
| $sessionToken = $_SESSION['_csrf_token'] ?? null; | |||||
| return is_string($sessionToken) && hash_equals($sessionToken, $token); | |||||
| } | |||||
| @@ -0,0 +1,37 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database $database): void | |||||
| { | |||||
| $tableExists = $database->first( | |||||
| "SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'campaign_type'" | |||||
| ); | |||||
| if ($tableExists) { | |||||
| return; | |||||
| } | |||||
| $database->execute( | |||||
| 'CREATE TABLE campaign_type ( | |||||
| id INT IDENTITY(1,1) NOT NULL, | |||||
| name NVARCHAR(255) NOT NULL, | |||||
| attributes NVARCHAR(MAX) NULL, | |||||
| created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||||
| updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||||
| CONSTRAINT PK_campaign_type PRIMARY KEY (id), | |||||
| CONSTRAINT UQ_campaign_type_name UNIQUE (name) | |||||
| )' | |||||
| ); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| $database->execute('DROP TABLE IF EXISTS campaign_type'); | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,44 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database $database): void | |||||
| { | |||||
| $tableExists = $database->first( | |||||
| "SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'campaign_type_audit'" | |||||
| ); | |||||
| if ($tableExists) { | |||||
| return; | |||||
| } | |||||
| // No foreign key on id: audit records must survive the deletion of the | |||||
| // campaign_type row they reference (that deletion itself is audited as 'D'). | |||||
| $database->execute( | |||||
| "CREATE TABLE campaign_type_audit ( | |||||
| audit_id INT IDENTITY(1,1) NOT NULL, | |||||
| id INT NOT NULL, | |||||
| action CHAR(1) NOT NULL, | |||||
| fields NVARCHAR(MAX) NOT NULL, | |||||
| username NVARCHAR(255) NOT NULL DEFAULT 'system', | |||||
| created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||||
| CONSTRAINT PK_campaign_type_audit PRIMARY KEY (audit_id), | |||||
| CONSTRAINT CHK_campaign_type_audit_action CHECK (action IN ('I','U','D','R')) | |||||
| )" | |||||
| ); | |||||
| $database->execute( | |||||
| 'CREATE INDEX IX_campaign_type_audit_id ON campaign_type_audit (id)' | |||||
| ); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| $database->execute('DROP TABLE IF EXISTS campaign_type_audit'); | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,40 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database $database): void | |||||
| { | |||||
| $tableExists = $database->first( | |||||
| "SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'campaign'" | |||||
| ); | |||||
| if ($tableExists) { | |||||
| return; | |||||
| } | |||||
| $database->execute( | |||||
| 'CREATE TABLE campaign ( | |||||
| id INT IDENTITY(1,1) NOT NULL, | |||||
| campaign_type_id INT NOT NULL, | |||||
| attribute_values NVARCHAR(MAX) NULL, | |||||
| created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||||
| updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||||
| CONSTRAINT PK_campaign PRIMARY KEY (id), | |||||
| CONSTRAINT FK_campaign_campaign_type FOREIGN KEY (campaign_type_id) | |||||
| REFERENCES campaign_type (id) | |||||
| ON UPDATE NO ACTION | |||||
| ON DELETE NO ACTION | |||||
| )' | |||||
| ); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| $database->execute('DROP TABLE IF EXISTS campaign'); | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,44 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database $database): void | |||||
| { | |||||
| $tableExists = $database->first( | |||||
| "SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'campaign_audit'" | |||||
| ); | |||||
| if ($tableExists) { | |||||
| return; | |||||
| } | |||||
| // No foreign key on id: audit records must survive deletion of the | |||||
| // campaign row they reference (that deletion is itself audited as 'D'). | |||||
| $database->execute( | |||||
| "CREATE TABLE campaign_audit ( | |||||
| audit_id INT IDENTITY(1,1) NOT NULL, | |||||
| id INT NOT NULL, | |||||
| action CHAR(1) NOT NULL, | |||||
| fields NVARCHAR(MAX) NOT NULL, | |||||
| username NVARCHAR(255) NOT NULL DEFAULT 'system', | |||||
| created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||||
| CONSTRAINT PK_campaign_audit PRIMARY KEY (audit_id), | |||||
| CONSTRAINT CHK_campaign_audit_action CHECK (action IN ('I','U','D','R')) | |||||
| )" | |||||
| ); | |||||
| $database->execute( | |||||
| 'CREATE INDEX IX_campaign_audit_id ON campaign_audit (id)' | |||||
| ); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| $database->execute('DROP TABLE IF EXISTS campaign_audit'); | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,37 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database $database): void | |||||
| { | |||||
| $tableExists = $database->first( | |||||
| "SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'job_type'" | |||||
| ); | |||||
| if ($tableExists) { | |||||
| return; | |||||
| } | |||||
| $database->execute( | |||||
| 'CREATE TABLE job_type ( | |||||
| id INT IDENTITY(1,1) NOT NULL, | |||||
| name NVARCHAR(255) NOT NULL, | |||||
| attributes NVARCHAR(MAX) NULL, | |||||
| created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||||
| updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||||
| CONSTRAINT PK_job_type PRIMARY KEY (id), | |||||
| CONSTRAINT UQ_job_type_name UNIQUE (name) | |||||
| )' | |||||
| ); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| $database->execute('DROP TABLE IF EXISTS job_type'); | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,42 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database $database): void | |||||
| { | |||||
| $tableExists = $database->first( | |||||
| "SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'job_type_audit'" | |||||
| ); | |||||
| if ($tableExists) { | |||||
| return; | |||||
| } | |||||
| $database->execute( | |||||
| "CREATE TABLE job_type_audit ( | |||||
| audit_id INT IDENTITY(1,1) NOT NULL, | |||||
| id INT NOT NULL, | |||||
| action CHAR(1) NOT NULL, | |||||
| fields NVARCHAR(MAX) NOT NULL, | |||||
| username NVARCHAR(255) NOT NULL DEFAULT 'system', | |||||
| created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||||
| CONSTRAINT PK_job_type_audit PRIMARY KEY (audit_id), | |||||
| CONSTRAINT CHK_job_type_audit_action CHECK (action IN ('I','U','D','R')) | |||||
| )" | |||||
| ); | |||||
| $database->execute( | |||||
| 'CREATE INDEX IX_job_type_audit_id ON job_type_audit (id)' | |||||
| ); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| $database->execute('DROP TABLE IF EXISTS job_type_audit'); | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,43 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database $database): void | |||||
| { | |||||
| $tableExists = $database->first( | |||||
| "SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'job'" | |||||
| ); | |||||
| if ($tableExists) { | |||||
| return; | |||||
| } | |||||
| $database->execute( | |||||
| 'CREATE TABLE job ( | |||||
| id INT IDENTITY(1,1) NOT NULL, | |||||
| campaign_id INT NOT NULL, | |||||
| job_type_id INT NOT NULL, | |||||
| attribute_values NVARCHAR(MAX) NULL, | |||||
| created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||||
| updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||||
| CONSTRAINT PK_job PRIMARY KEY (id), | |||||
| CONSTRAINT FK_job_campaign FOREIGN KEY (campaign_id) | |||||
| REFERENCES campaign (id) ON UPDATE NO ACTION ON DELETE NO ACTION, | |||||
| CONSTRAINT FK_job_job_type FOREIGN KEY (job_type_id) | |||||
| REFERENCES job_type (id) ON UPDATE NO ACTION ON DELETE NO ACTION | |||||
| )' | |||||
| ); | |||||
| $database->execute('CREATE INDEX IX_job_campaign_id ON job (campaign_id)'); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| $database->execute('DROP TABLE IF EXISTS job'); | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,40 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database $database): void | |||||
| { | |||||
| $tableExists = $database->first( | |||||
| "SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'job_audit'" | |||||
| ); | |||||
| if ($tableExists) { | |||||
| return; | |||||
| } | |||||
| $database->execute( | |||||
| "CREATE TABLE job_audit ( | |||||
| audit_id INT IDENTITY(1,1) NOT NULL, | |||||
| id INT NOT NULL, | |||||
| action CHAR(1) NOT NULL, | |||||
| fields NVARCHAR(MAX) NOT NULL, | |||||
| username NVARCHAR(255) NOT NULL DEFAULT 'system', | |||||
| created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||||
| CONSTRAINT PK_job_audit PRIMARY KEY (audit_id), | |||||
| CONSTRAINT CHK_job_audit_action CHECK (action IN ('I','U','D','R')) | |||||
| )" | |||||
| ); | |||||
| $database->execute('CREATE INDEX IX_job_audit_id ON job_audit (id)'); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| $database->execute('DROP TABLE IF EXISTS job_audit'); | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,46 @@ | |||||
| services: | |||||
| campaign-tracker-app: | |||||
| build: . | |||||
| container_name: campaign-tracker-app | |||||
| ports: | |||||
| - "8801:80" | |||||
| volumes: | |||||
| - .:/var/www/html | |||||
| environment: | |||||
| APP_ENV: local | |||||
| APP_DEBUG: "true" | |||||
| DB_HOST: sqlserver | |||||
| DB_PORT: 1433 | |||||
| DB_DATABASE: Campaign_Tracker | |||||
| DB_USERNAME: sa | |||||
| DB_PASSWORD: Dev_Password123! | |||||
| KEYCLOAK_BASE_URL: ${KEYCLOAK_BASE_URL:-} | |||||
| KEYCLOAK_REALM: ${KEYCLOAK_REALM:-} | |||||
| KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID:-} | |||||
| KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET:-} | |||||
| KEYCLOAK_REDIRECT_URI: ${KEYCLOAK_REDIRECT_URI:-} | |||||
| KEYCLOAK_LOGOUT_REDIRECT_URI: ${KEYCLOAK_LOGOUT_REDIRECT_URI:-} | |||||
| depends_on: | |||||
| sqlserver: | |||||
| condition: service_healthy | |||||
| sqlserver: | |||||
| image: mcr.microsoft.com/mssql/server:latest | |||||
| container_name: campaign-tracker-db | |||||
| environment: | |||||
| ACCEPT_EULA: "Y" | |||||
| SA_PASSWORD: Dev_Password123! | |||||
| MSSQL_PID: Developer | |||||
| ports: | |||||
| - "1433:1433" | |||||
| volumes: | |||||
| - sqlserver_data:/var/opt/mssql | |||||
| healthcheck: | |||||
| test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'Dev_Password123!' -Q 'SELECT 1' -b -C"] | |||||
| interval: 10s | |||||
| timeout: 5s | |||||
| retries: 10 | |||||
| start_period: 30s | |||||
| volumes: | |||||
| sqlserver_data: | |||||
| @@ -0,0 +1,12 @@ | |||||
| <VirtualHost *:80> | |||||
| DocumentRoot /var/www/html/public | |||||
| <Directory /var/www/html/public> | |||||
| AllowOverride All | |||||
| Require all granted | |||||
| Options -Indexes +FollowSymLinks | |||||
| </Directory> | |||||
| ErrorLog ${APACHE_LOG_DIR}/error.log | |||||
| CustomLog ${APACHE_LOG_DIR}/access.log combined | |||||
| </VirtualHost> | |||||
| @@ -0,0 +1,64 @@ | |||||
| # Campaign Tracker | |||||
| A PHP MVC web application backed by Microsoft SQL Server, hosted with Docker. | |||||
| ## Docker Quick Start | |||||
| ```bash | |||||
| docker compose up -d --build | |||||
| docker compose exec campaign-tracker-app composer install | |||||
| docker compose exec campaign-tracker-app php scripts/migrate.php up | |||||
| docker compose exec campaign-tracker-app php scripts/migrate.php status | |||||
| ``` | |||||
| Open: http://localhost:8801 | |||||
| Health check: http://localhost:8801/health | |||||
| > **Note:** SQL Server takes about 10–20 seconds to be ready after `docker compose up`. If the first `migrate up` fails with a connection error, wait a moment and retry. | |||||
| ## Environment | |||||
| Copy `.env.example` to `.env` for local development outside Docker: | |||||
| ```bash | |||||
| cp .env.example .env | |||||
| ``` | |||||
| Docker injects environment variables directly from `docker-compose.yml`, so `.env` is only needed when running PHP outside a container. | |||||
| ## Migrations | |||||
| ```bash | |||||
| php scripts/migrate.php up # run pending migrations | |||||
| php scripts/migrate.php down [steps] # roll back last N migrations | |||||
| php scripts/migrate.php status # show migration state | |||||
| php scripts/migrate.php make <name> # scaffold a new migration file | |||||
| php scripts/migrate.php fresh # roll back all and re-run | |||||
| php scripts/migrate.php fresh --seed # roll back all, re-run, seed data | |||||
| ``` | |||||
| ## Request Flow | |||||
| Browser → public/index.php → Request → Dispatcher → Router → Route → Controller → ViewModel/Repository → View → Response | |||||
| ## Main Folders | |||||
| - `core/` framework classes | |||||
| - `app/Controllers/` application controllers | |||||
| - `app/ViewModels/` view model classes | |||||
| - `app/Repositories/` data access classes | |||||
| - `app/Views/` PHP templates | |||||
| - `routes/web.php` route definitions | |||||
| - `database/migrations/` migrations | |||||
| - `scripts/` runnable PHP CLI scripts | |||||
| - `docker/` Docker support files | |||||
| ## Frontend Libraries | |||||
| - `Alpine.js` for lightweight page state and dynamic form interactions | |||||
| - `Tabulator` for the interactive data grid | |||||
| ## Flow chart | |||||
| See [`REQUEST_FLOW.md`](./REQUEST_FLOW.md) for a chart of how requests and responses move through the framework. | |||||
| @@ -0,0 +1,80 @@ | |||||
| # Request / Response Flow | |||||
| This chart shows how a browser request moves through the MindVisionCode framework and how a response is built and returned. | |||||
| ```text | |||||
| Browser Request | |||||
| | | |||||
| v | |||||
| public/index.php | |||||
| |-- loads autoload.php / vendor autoload | |||||
| |-- starts the session | |||||
| |-- creates App + Router | |||||
| |-- loads routes/web.php | |||||
| | | |||||
| v | |||||
| Request::capture() | |||||
| | | |||||
| v | |||||
| Dispatcher::dispatch() | |||||
| | | |||||
| +--> no route matched ----> Response::notFound() | |||||
| | | |||||
| +--> route matched -------> Route::dispatch() | |||||
| | | |||||
| v | |||||
| App::call() | |||||
| | | |||||
| +--> controller method | |||||
| | | | |||||
| | v | |||||
| | Controller action | |||||
| | | | |||||
| | +--> repository / service / view model | |||||
| | +--> Database::query() / execute() | |||||
| | +--> view() / json() / redirect() | |||||
| | | |||||
| +--> closure route | |||||
| | | |||||
| v | |||||
| direct response data | |||||
| Dispatcher::normalizeResponse() | |||||
| | | |||||
| +--> Response object --------> Response::send() | |||||
| +--> array ------------------> Response::json() --> Response::send() | |||||
| +--> string -----------------> Response::send() | |||||
| Final result: | |||||
| Browser receives HTML, JSON, or a redirect | |||||
| ``` | |||||
| ## Response building paths | |||||
| ### View response | |||||
| ```text | |||||
| Controller -> view() -> View::render() -> template -> layout -> Response | |||||
| ``` | |||||
| ### JSON response | |||||
| ```text | |||||
| Controller -> json() -> Response::json() -> Response::send() | |||||
| ``` | |||||
| ### Redirect response | |||||
| ```text | |||||
| Controller -> redirect() -> Response::redirect() -> Response::send() | |||||
| ``` | |||||
| ## Key classes | |||||
| - `public/index.php` bootstraps the app | |||||
| - `Core\Dispatcher` matches routes and handles errors | |||||
| - `Core\Route` extracts route parameters | |||||
| - `Core\App` invokes controller methods or closures | |||||
| - `Core\Controller` gives actions helper methods | |||||
| - `Core\View` renders templates into a layout | |||||
| - `Core\Response` sends the final output | |||||
| @@ -0,0 +1,5 @@ | |||||
| RewriteEngine On | |||||
| RewriteCond %{REQUEST_FILENAME} !-f | |||||
| RewriteCond %{REQUEST_FILENAME} !-d | |||||
| RewriteRule ^ index.php [QSA,L] | |||||
| @@ -0,0 +1,992 @@ | |||||
| :root { | |||||
| --page-background: #f4efe7; | |||||
| --surface: rgba(255, 252, 247, 0.88); | |||||
| --surface-strong: #fffdf8; | |||||
| --surface-border: rgba(26, 72, 64, 0.12); | |||||
| --text-primary: #143631; | |||||
| --text-secondary: #4f655f; | |||||
| --accent: #1d7a6d; | |||||
| --accent-strong: #135c52; | |||||
| --accent-soft: #daf1ec; | |||||
| --highlight: #ef7c4d; | |||||
| --shadow-soft: 0 18px 50px rgba(20, 54, 49, 0.1); | |||||
| --shadow-card: 0 20px 40px rgba(20, 54, 49, 0.08); | |||||
| } | |||||
| * { | |||||
| box-sizing: border-box; | |||||
| } | |||||
| [x-cloak] { | |||||
| display: none !important; | |||||
| } | |||||
| html { | |||||
| scroll-behavior: smooth; | |||||
| } | |||||
| body { | |||||
| margin: 0; | |||||
| min-height: 100vh; | |||||
| font-family: "Trebuchet MS", "Lucida Sans Unicode", "Lucida Grande", sans-serif; | |||||
| color: var(--text-primary); | |||||
| background: | |||||
| radial-gradient(circle at top left, rgba(239, 124, 77, 0.18), transparent 28%), | |||||
| radial-gradient(circle at top right, rgba(29, 122, 109, 0.18), transparent 32%), | |||||
| linear-gradient(180deg, #f8f2e8 0%, var(--page-background) 48%, #efe6da 100%); | |||||
| } | |||||
| a { | |||||
| color: inherit; | |||||
| } | |||||
| code { | |||||
| font-family: Consolas, "Courier New", monospace; | |||||
| } | |||||
| .page-shell { | |||||
| min-height: 100vh; | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| } | |||||
| .container { | |||||
| width: min(1120px, calc(100% - 2rem)); | |||||
| margin: 0 auto; | |||||
| } | |||||
| .site-header { | |||||
| position: sticky; | |||||
| top: 0; | |||||
| z-index: 20; | |||||
| backdrop-filter: blur(14px); | |||||
| background: rgba(248, 242, 232, 0.78); | |||||
| border-bottom: 1px solid rgba(20, 54, 49, 0.08); | |||||
| } | |||||
| .header-inner { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: space-between; | |||||
| gap: 1rem; | |||||
| padding: 1rem 0; | |||||
| } | |||||
| .brand { | |||||
| display: inline-flex; | |||||
| align-items: center; | |||||
| gap: 0.85rem; | |||||
| text-decoration: none; | |||||
| } | |||||
| .brand-mark { | |||||
| display: inline-flex; | |||||
| align-items: center; | |||||
| justify-content: center; | |||||
| width: 2.75rem; | |||||
| height: 2.75rem; | |||||
| border-radius: 0.95rem; | |||||
| background: linear-gradient(135deg, var(--accent), var(--highlight)); | |||||
| color: #fff; | |||||
| font-weight: 700; | |||||
| letter-spacing: 0.08em; | |||||
| box-shadow: var(--shadow-soft); | |||||
| } | |||||
| .brand-copy { | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| line-height: 1.1; | |||||
| } | |||||
| .brand-copy strong { | |||||
| font-size: 1rem; | |||||
| } | |||||
| .brand-copy small { | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.75rem; | |||||
| text-transform: uppercase; | |||||
| letter-spacing: 0.14em; | |||||
| } | |||||
| .site-nav { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| gap: 0.6rem; | |||||
| flex-wrap: wrap; | |||||
| } | |||||
| .nav-user { | |||||
| padding: 0 0.4rem; | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.88rem; | |||||
| font-weight: 600; | |||||
| } | |||||
| .nav-logout-form { | |||||
| display: contents; | |||||
| } | |||||
| .nav-link { | |||||
| text-decoration: none; | |||||
| color: var(--text-secondary); | |||||
| font-weight: 600; | |||||
| padding: 0.7rem 1rem; | |||||
| border-radius: 999px; | |||||
| transition: background-color 160ms ease, color 160ms ease, transform 160ms ease; | |||||
| } | |||||
| .nav-link:hover, | |||||
| .nav-link:focus-visible, | |||||
| .nav-link.is-active { | |||||
| color: var(--accent-strong); | |||||
| background: rgba(29, 122, 109, 0.12); | |||||
| transform: translateY(-1px); | |||||
| } | |||||
| .page-content { | |||||
| flex: 1; | |||||
| padding: 3.5rem 0 4rem; | |||||
| } | |||||
| .content-stack { | |||||
| display: grid; | |||||
| gap: 1.5rem; | |||||
| } | |||||
| .section-heading { | |||||
| max-width: 46rem; | |||||
| } | |||||
| .section-heading h1 { | |||||
| margin: 0.3rem 0 0.8rem; | |||||
| font-size: clamp(2.4rem, 5vw, 4rem); | |||||
| line-height: 1; | |||||
| letter-spacing: -0.04em; | |||||
| } | |||||
| .section-heading p { | |||||
| margin: 0; | |||||
| color: var(--text-secondary); | |||||
| line-height: 1.8; | |||||
| font-size: 1.05rem; | |||||
| } | |||||
| .hero { | |||||
| display: grid; | |||||
| grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.9fr); | |||||
| gap: 1.5rem; | |||||
| align-items: stretch; | |||||
| } | |||||
| .hero-copy, | |||||
| .hero-panel, | |||||
| .feature-card, | |||||
| .section-panel, | |||||
| .alert, | |||||
| .empty-state { | |||||
| background: var(--surface); | |||||
| border: 1px solid var(--surface-border); | |||||
| box-shadow: var(--shadow-card); | |||||
| } | |||||
| .hero-copy { | |||||
| padding: 3rem; | |||||
| border-radius: 2rem; | |||||
| } | |||||
| .eyebrow { | |||||
| display: inline-block; | |||||
| margin-bottom: 1rem; | |||||
| padding: 0.4rem 0.75rem; | |||||
| border-radius: 999px; | |||||
| background: var(--accent-soft); | |||||
| color: var(--accent-strong); | |||||
| font-size: 0.78rem; | |||||
| font-weight: 700; | |||||
| text-transform: uppercase; | |||||
| letter-spacing: 0.14em; | |||||
| } | |||||
| .hero h1 { | |||||
| margin: 0; | |||||
| font-size: clamp(2.8rem, 6vw, 4.8rem); | |||||
| line-height: 0.98; | |||||
| letter-spacing: -0.04em; | |||||
| } | |||||
| .hero-text { | |||||
| max-width: 44rem; | |||||
| margin: 1.25rem 0 0; | |||||
| font-size: 1.12rem; | |||||
| line-height: 1.8; | |||||
| color: var(--text-secondary); | |||||
| } | |||||
| .hero-actions { | |||||
| display: flex; | |||||
| flex-wrap: wrap; | |||||
| gap: 0.85rem; | |||||
| margin-top: 2rem; | |||||
| } | |||||
| .button { | |||||
| display: inline-flex; | |||||
| align-items: center; | |||||
| justify-content: center; | |||||
| padding: 0.9rem 1.35rem; | |||||
| border-radius: 999px; | |||||
| text-decoration: none; | |||||
| font-weight: 700; | |||||
| } | |||||
| .button-primary { | |||||
| background: linear-gradient(135deg, var(--accent), var(--accent-strong)); | |||||
| color: #fff; | |||||
| box-shadow: 0 18px 30px rgba(19, 92, 82, 0.25); | |||||
| } | |||||
| .button-secondary { | |||||
| background: rgba(29, 122, 109, 0.08); | |||||
| color: var(--accent-strong); | |||||
| } | |||||
| .hero-panel { | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| justify-content: space-between; | |||||
| padding: 2rem; | |||||
| border-radius: 1.8rem; | |||||
| } | |||||
| .panel-label { | |||||
| margin: 0 0 1rem; | |||||
| font-size: 0.78rem; | |||||
| font-weight: 700; | |||||
| letter-spacing: 0.16em; | |||||
| text-transform: uppercase; | |||||
| color: var(--text-secondary); | |||||
| } | |||||
| .hero-panel code { | |||||
| display: block; | |||||
| padding: 1rem 1.1rem; | |||||
| border-radius: 1.2rem; | |||||
| background: #173d37; | |||||
| color: #eefbf6; | |||||
| line-height: 1.7; | |||||
| white-space: normal; | |||||
| } | |||||
| .route-callout { | |||||
| margin-top: 1.5rem; | |||||
| padding: 1rem 1.1rem; | |||||
| border-radius: 1.2rem; | |||||
| background: var(--surface-strong); | |||||
| } | |||||
| .route-callout span { | |||||
| display: block; | |||||
| margin-bottom: 0.45rem; | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.92rem; | |||||
| } | |||||
| .route-callout a { | |||||
| color: var(--highlight); | |||||
| font-weight: 700; | |||||
| text-decoration: none; | |||||
| } | |||||
| .feature-grid { | |||||
| display: grid; | |||||
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |||||
| gap: 1.25rem; | |||||
| margin-top: 1.5rem; | |||||
| } | |||||
| .feature-card { | |||||
| padding: 1.75rem; | |||||
| border-radius: 1.6rem; | |||||
| } | |||||
| .feature-card h2 { | |||||
| margin-top: 0; | |||||
| margin-bottom: 0.8rem; | |||||
| font-size: 1.25rem; | |||||
| } | |||||
| .feature-card p { | |||||
| margin: 0; | |||||
| color: var(--text-secondary); | |||||
| line-height: 1.7; | |||||
| } | |||||
| .controls-panel, | |||||
| .table-shell { | |||||
| overflow: hidden; | |||||
| background: | |||||
| linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(248, 242, 232, 0.88)), | |||||
| var(--surface); | |||||
| } | |||||
| .controls-header { | |||||
| display: flex; | |||||
| align-items: flex-start; | |||||
| justify-content: space-between; | |||||
| gap: 1rem; | |||||
| } | |||||
| .search-row { | |||||
| display: grid; | |||||
| grid-template-columns: minmax(0, 1fr); | |||||
| } | |||||
| .field-full { | |||||
| width: 100%; | |||||
| } | |||||
| .section-panel { | |||||
| padding: 1.75rem; | |||||
| border-radius: 1.8rem; | |||||
| } | |||||
| .panel-header { | |||||
| display: flex; | |||||
| align-items: flex-start; | |||||
| justify-content: space-between; | |||||
| gap: 1rem; | |||||
| flex-wrap: wrap; | |||||
| margin-bottom: 1.5rem; | |||||
| } | |||||
| .panel-actions { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| gap: 0.5rem; | |||||
| flex-wrap: wrap; | |||||
| } | |||||
| .panel-header h2 { | |||||
| margin: 0 0 0.45rem; | |||||
| font-size: 1.45rem; | |||||
| } | |||||
| .panel-header p { | |||||
| margin: 0; | |||||
| color: var(--text-secondary); | |||||
| line-height: 1.7; | |||||
| } | |||||
| .job-type-table-stack { | |||||
| display: grid; | |||||
| gap: 1.5rem; | |||||
| } | |||||
| .job-type-table-group { | |||||
| display: grid; | |||||
| gap: 0.85rem; | |||||
| padding-top: 1.25rem; | |||||
| border-top: 1px solid rgba(20, 54, 49, 0.1); | |||||
| } | |||||
| .job-type-table-group:first-child { | |||||
| padding-top: 0; | |||||
| border-top: 0; | |||||
| } | |||||
| .job-type-table-heading { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: space-between; | |||||
| gap: 1rem; | |||||
| flex-wrap: wrap; | |||||
| } | |||||
| .job-type-table-heading h3 { | |||||
| margin: 0; | |||||
| font-size: 1.08rem; | |||||
| } | |||||
| .job-type-table-heading span { | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.86rem; | |||||
| font-weight: 700; | |||||
| } | |||||
| .form-grid { | |||||
| display: grid; | |||||
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |||||
| gap: 1rem; | |||||
| } | |||||
| /* Campaign jobs table — horizontal scroll inside the panel */ | |||||
| #campaign-jobs-page-table { | |||||
| overflow-x: auto; | |||||
| width: 100%; | |||||
| } | |||||
| .import-tabs { | |||||
| display: flex; | |||||
| gap: 0.25rem; | |||||
| margin-bottom: 1.25rem; | |||||
| border-bottom: 1px solid var(--surface-border); | |||||
| padding-bottom: 0; | |||||
| } | |||||
| .import-tab { | |||||
| padding: 0.55rem 1.1rem; | |||||
| border: none; | |||||
| background: none; | |||||
| cursor: pointer; | |||||
| font: inherit; | |||||
| font-weight: 600; | |||||
| color: var(--text-secondary); | |||||
| border-bottom: 2px solid transparent; | |||||
| margin-bottom: -1px; | |||||
| border-radius: 0; | |||||
| transition: color 120ms, border-color 120ms; | |||||
| } | |||||
| .import-tab:hover { color: var(--accent); } | |||||
| .import-tab.is-active { color: var(--accent-strong); border-bottom-color: var(--accent-strong); } | |||||
| .import-grid { | |||||
| display: grid; | |||||
| grid-template-columns: minmax(260px, 1.4fr) minmax(180px, 0.8fr) minmax(180px, 0.8fr); | |||||
| gap: 1rem; | |||||
| } | |||||
| .import-actions { | |||||
| margin-top: 1rem; | |||||
| flex-wrap: wrap; | |||||
| } | |||||
| .field { | |||||
| display: grid; | |||||
| gap: 0.45rem; | |||||
| font-weight: 600; | |||||
| } | |||||
| .field span { | |||||
| font-size: 0.96rem; | |||||
| } | |||||
| .input { | |||||
| width: 100%; | |||||
| padding: 0.95rem 1rem; | |||||
| border: 1px solid rgba(20, 54, 49, 0.16); | |||||
| border-radius: 1rem; | |||||
| background: rgba(255, 255, 255, 0.92); | |||||
| color: var(--text-primary); | |||||
| font: inherit; | |||||
| } | |||||
| .input:focus { | |||||
| outline: 2px solid rgba(29, 122, 109, 0.22); | |||||
| border-color: rgba(29, 122, 109, 0.45); | |||||
| } | |||||
| .field-error { | |||||
| color: #a43d1f; | |||||
| font-size: 0.88rem; | |||||
| font-weight: 600; | |||||
| } | |||||
| .form-actions { | |||||
| display: flex; | |||||
| justify-content: flex-start; | |||||
| align-items: center; | |||||
| gap: 0.85rem; | |||||
| } | |||||
| .button { | |||||
| border: 0; | |||||
| cursor: pointer; | |||||
| } | |||||
| .htmx-indicator { | |||||
| display: none; | |||||
| } | |||||
| .htmx-request .htmx-indicator, | |||||
| .htmx-request.htmx-indicator { | |||||
| display: inline-flex; | |||||
| } | |||||
| .inline-indicator { | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.9rem; | |||||
| font-weight: 600; | |||||
| } | |||||
| .alert, | |||||
| .empty-state { | |||||
| padding: 1rem 1.15rem; | |||||
| border-radius: 1.2rem; | |||||
| } | |||||
| .alert-success { | |||||
| background: rgba(218, 241, 236, 0.92); | |||||
| color: var(--accent-strong); | |||||
| } | |||||
| .alert-error { | |||||
| background: rgba(239, 124, 77, 0.14); | |||||
| color: #8f3518; | |||||
| } | |||||
| .empty-state p { | |||||
| margin: 0; | |||||
| color: var(--text-secondary); | |||||
| line-height: 1.7; | |||||
| } | |||||
| .empty-state p + p { | |||||
| margin-top: 0.45rem; | |||||
| } | |||||
| .stats-grid { | |||||
| display: grid; | |||||
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |||||
| gap: 0.9rem; | |||||
| } | |||||
| .stat-card { | |||||
| padding: 1rem; | |||||
| border-radius: 1.3rem; | |||||
| background: rgba(255, 255, 255, 0.72); | |||||
| border: 1px solid rgba(20, 54, 49, 0.08); | |||||
| } | |||||
| .stat-card span { | |||||
| display: block; | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.82rem; | |||||
| text-transform: uppercase; | |||||
| letter-spacing: 0.08em; | |||||
| } | |||||
| .stat-card strong { | |||||
| display: block; | |||||
| margin-top: 0.45rem; | |||||
| font-size: 1.7rem; | |||||
| line-height: 1; | |||||
| } | |||||
| .summary-feature { | |||||
| margin-top: 1rem; | |||||
| padding: 1.15rem; | |||||
| border-radius: 1.3rem; | |||||
| background: linear-gradient(135deg, rgba(29, 122, 109, 0.12), rgba(239, 124, 77, 0.12)); | |||||
| } | |||||
| .summary-label { | |||||
| display: block; | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.82rem; | |||||
| text-transform: uppercase; | |||||
| letter-spacing: 0.08em; | |||||
| } | |||||
| .summary-feature h3 { | |||||
| margin: 0.55rem 0 0.3rem; | |||||
| font-size: 1.35rem; | |||||
| } | |||||
| .summary-feature p { | |||||
| margin: 0; | |||||
| color: var(--text-secondary); | |||||
| } | |||||
| .table-toolbar { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: space-between; | |||||
| gap: 1rem; | |||||
| margin-bottom: 1rem; | |||||
| flex-wrap: wrap; | |||||
| padding: 0.9rem 1rem; | |||||
| border: 1px solid rgba(20, 54, 49, 0.08); | |||||
| border-radius: 1rem; | |||||
| background: rgba(255, 255, 255, 0.58); | |||||
| } | |||||
| .table-pill { | |||||
| display: inline-flex; | |||||
| align-items: center; | |||||
| padding: 0.5rem 0.8rem; | |||||
| border-radius: 999px; | |||||
| background: rgba(29, 122, 109, 0.12); | |||||
| color: var(--accent-strong); | |||||
| font-size: 0.82rem; | |||||
| font-weight: 700; | |||||
| letter-spacing: 0.04em; | |||||
| } | |||||
| .table-caption { | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.92rem; | |||||
| } | |||||
| .directory-panel .tabulator-host { | |||||
| min-height: 38rem; | |||||
| } | |||||
| .tabulator-host .tabulator { | |||||
| border: 1px solid var(--surface-border); | |||||
| border-radius: 1.35rem; | |||||
| overflow: hidden; | |||||
| background: rgba(255, 255, 255, 0.82); | |||||
| box-shadow: | |||||
| inset 0 1px 0 rgba(255, 255, 255, 0.5), | |||||
| 0 18px 35px rgba(20, 54, 49, 0.08); | |||||
| } | |||||
| .tabulator-host .tabulator-header { | |||||
| border-bottom: 1px solid rgba(20, 54, 49, 0.08); | |||||
| background: linear-gradient(180deg, rgba(29, 122, 109, 0.14), rgba(29, 122, 109, 0.08)); | |||||
| } | |||||
| .tabulator-host .tabulator-header .tabulator-col { | |||||
| min-height: 3.25rem; | |||||
| background: transparent; | |||||
| border-right: 1px solid rgba(20, 54, 49, 0.06); | |||||
| } | |||||
| .tabulator-host .tabulator-header .tabulator-col:last-child { | |||||
| border-right: 0; | |||||
| } | |||||
| .tabulator-host .tabulator-header .tabulator-col .tabulator-col-content { | |||||
| padding: 0.9rem 0.95rem 0.85rem; | |||||
| } | |||||
| .tabulator-host .tabulator-header .tabulator-col .tabulator-col-title { | |||||
| font-size: 0.78rem; | |||||
| font-weight: 800; | |||||
| letter-spacing: 0.08em; | |||||
| text-transform: uppercase; | |||||
| color: var(--accent-strong); | |||||
| } | |||||
| .tabulator-host .tabulator-col, | |||||
| .tabulator-host .tabulator-cell { | |||||
| border-right: 1px solid rgba(20, 54, 49, 0.06); | |||||
| } | |||||
| .tabulator-host .tabulator-row .tabulator-cell:last-child { | |||||
| border-right: 0; | |||||
| } | |||||
| .tabulator-host .tabulator-row { | |||||
| background: rgba(255, 255, 255, 0.96); | |||||
| border-bottom: 1px solid rgba(20, 54, 49, 0.06); | |||||
| transition: background-color 160ms ease, transform 160ms ease; | |||||
| } | |||||
| .tabulator-host .tabulator-row:nth-child(even) { | |||||
| background: rgba(248, 242, 232, 0.82); | |||||
| } | |||||
| .tabulator-host .tabulator-row:hover { | |||||
| background: rgba(218, 241, 236, 0.72); | |||||
| } | |||||
| .tabulator-host .tabulator-row.tabulator-selected { | |||||
| background: rgba(29, 122, 109, 0.18); | |||||
| } | |||||
| .tabulator-host .tabulator-cell { | |||||
| padding: 0.95rem 0.95rem; | |||||
| font-size: 0.96rem; | |||||
| line-height: 1.4; | |||||
| } | |||||
| .tabulator-host .tabulator-row .tabulator-cell:first-child { | |||||
| font-weight: 700; | |||||
| color: var(--text-primary); | |||||
| } | |||||
| .tabulator-host .tabulator-footer { | |||||
| padding: 0.55rem 0.7rem; | |||||
| background: rgba(255, 255, 255, 0.88); | |||||
| border-top: 1px solid rgba(20, 54, 49, 0.08); | |||||
| } | |||||
| .tabulator-host .tabulator-footer .tabulator-paginator { | |||||
| font-family: inherit; | |||||
| } | |||||
| .tabulator-host .tabulator-footer .tabulator-page { | |||||
| margin: 0 0.2rem; | |||||
| padding: 0.45rem 0.7rem; | |||||
| border: 1px solid rgba(20, 54, 49, 0.1); | |||||
| border-radius: 0.8rem; | |||||
| background: rgba(255, 255, 255, 0.9); | |||||
| color: var(--text-secondary); | |||||
| font-weight: 700; | |||||
| } | |||||
| .tabulator-host .tabulator-footer .tabulator-page.active, | |||||
| .tabulator-host .tabulator-footer .tabulator-page:hover { | |||||
| background: linear-gradient(135deg, var(--accent), var(--accent-strong)); | |||||
| border-color: transparent; | |||||
| color: #fff; | |||||
| } | |||||
| .tabulator-host .tabulator-footer .tabulator-page:disabled { | |||||
| opacity: 0.45; | |||||
| } | |||||
| .tabulator-host .tabulator-placeholder { | |||||
| padding: 2.5rem 1rem; | |||||
| color: var(--text-secondary); | |||||
| font-size: 1rem; | |||||
| font-weight: 600; | |||||
| } | |||||
| .site-footer { | |||||
| margin-top: auto; | |||||
| border-top: 1px solid rgba(20, 54, 49, 0.08); | |||||
| background: rgba(255, 252, 247, 0.72); | |||||
| } | |||||
| .footer-inner { | |||||
| display: flex; | |||||
| justify-content: space-between; | |||||
| gap: 1rem; | |||||
| padding: 1.25rem 0 2rem; | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.95rem; | |||||
| } | |||||
| .footer-inner p { | |||||
| margin: 0; | |||||
| } | |||||
| @media (max-width: 860px) { | |||||
| .header-inner, | |||||
| .footer-inner { | |||||
| flex-direction: column; | |||||
| align-items: flex-start; | |||||
| } | |||||
| .hero, | |||||
| .feature-grid { | |||||
| grid-template-columns: 1fr; | |||||
| } | |||||
| .controls-header, | |||||
| .table-toolbar { | |||||
| flex-direction: column; | |||||
| align-items: flex-start; | |||||
| } | |||||
| .hero-copy, | |||||
| .hero-panel { | |||||
| padding: 2rem; | |||||
| } | |||||
| .form-grid { | |||||
| grid-template-columns: 1fr; | |||||
| } | |||||
| .import-grid { | |||||
| grid-template-columns: 1fr; | |||||
| } | |||||
| .stats-grid { | |||||
| grid-template-columns: 1fr; | |||||
| } | |||||
| .page-content { | |||||
| padding-top: 2rem; | |||||
| } | |||||
| } | |||||
| @media (max-width: 560px) { | |||||
| .container { | |||||
| width: min(100% - 1.25rem, 1120px); | |||||
| } | |||||
| .site-nav { | |||||
| width: 100%; | |||||
| } | |||||
| .nav-link { | |||||
| width: 100%; | |||||
| text-align: center; | |||||
| } | |||||
| .hero h1 { | |||||
| font-size: 2.5rem; | |||||
| } | |||||
| } | |||||
| /* ── Campaign Types ─────────────────────────────────────────────────── */ | |||||
| .page-toolbar { | |||||
| display: flex; | |||||
| align-items: flex-start; | |||||
| justify-content: space-between; | |||||
| gap: 1.5rem; | |||||
| flex-wrap: wrap; | |||||
| } | |||||
| .page-toolbar .section-heading { | |||||
| margin: 0; | |||||
| } | |||||
| .page-toolbar .section-heading h1 { | |||||
| margin: 0 0 0.4rem; | |||||
| } | |||||
| .button-danger { | |||||
| background: linear-gradient(135deg, #c0392b, #962d22); | |||||
| color: #fff; | |||||
| box-shadow: 0 8px 20px rgba(192, 57, 43, 0.28); | |||||
| border: none; | |||||
| cursor: pointer; | |||||
| } | |||||
| .button-danger:hover, | |||||
| .button-danger:focus-visible { | |||||
| background: linear-gradient(135deg, #d44637, #c0392b); | |||||
| } | |||||
| .button-sm { | |||||
| padding: 0.4rem 0.85rem; | |||||
| font-size: 0.82rem; | |||||
| border-radius: 999px; | |||||
| border: none; | |||||
| cursor: pointer; | |||||
| } | |||||
| .ct-form { | |||||
| display: grid; | |||||
| gap: 2rem; | |||||
| } | |||||
| .form-section { | |||||
| display: grid; | |||||
| gap: 1rem; | |||||
| } | |||||
| .form-section h3 { | |||||
| margin: 0; | |||||
| font-size: 1.05rem; | |||||
| } | |||||
| .attributes-header { | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| gap: 0.25rem; | |||||
| } | |||||
| .attributes-hint { | |||||
| margin: 0; | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.9rem; | |||||
| } | |||||
| .attribute-list { | |||||
| display: grid; | |||||
| gap: 0.6rem; | |||||
| } | |||||
| .attribute-row { | |||||
| display: flex; | |||||
| align-items: flex-end; | |||||
| gap: 0.75rem; | |||||
| flex-wrap: wrap; | |||||
| } | |||||
| .attr-drag-handle { | |||||
| cursor: grab; | |||||
| padding: 0 0.3rem; | |||||
| color: var(--text-secondary); | |||||
| font-size: 1.25rem; | |||||
| user-select: none; | |||||
| align-self: flex-end; | |||||
| padding-bottom: 0.6rem; | |||||
| line-height: 1; | |||||
| } | |||||
| .attr-drag-handle:active { | |||||
| cursor: grabbing; | |||||
| } | |||||
| .attribute-row.is-dragging { | |||||
| opacity: 0.35; | |||||
| } | |||||
| .attribute-row.is-drag-over { | |||||
| outline: 2px dashed var(--accent); | |||||
| border-radius: 0.8rem; | |||||
| background: var(--accent-soft); | |||||
| } | |||||
| .attribute-order-field { | |||||
| flex: 0 0 5rem; | |||||
| min-width: 5rem; | |||||
| } | |||||
| .attribute-order-field .input { | |||||
| text-align: center; | |||||
| } | |||||
| .attribute-name-field { | |||||
| flex: 2; | |||||
| min-width: 160px; | |||||
| } | |||||
| .attribute-type-field { | |||||
| flex: 1; | |||||
| min-width: 110px; | |||||
| } | |||||
| .attribute-remove { | |||||
| padding-bottom: 0.1rem; | |||||
| } | |||||
| .field-full { | |||||
| width: 100%; | |||||
| } | |||||
| .input-error { | |||||
| border-color: #c0392b !important; | |||||
| } | |||||
| .required-mark { | |||||
| color: #c0392b; | |||||
| } | |||||
| .delete-zone { | |||||
| margin-top: 2.5rem; | |||||
| padding-top: 1.5rem; | |||||
| border-top: 1px solid rgba(192, 57, 43, 0.2); | |||||
| } | |||||
| .delete-zone h4 { | |||||
| margin: 0 0 0.35rem; | |||||
| color: #c0392b; | |||||
| font-size: 0.95rem; | |||||
| } | |||||
| .delete-zone p { | |||||
| margin: 0 0 1rem; | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.88rem; | |||||
| } | |||||
| .attr-summary { | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.88rem; | |||||
| } | |||||
| .attr-empty { | |||||
| color: var(--text-secondary); | |||||
| opacity: 0.45; | |||||
| } | |||||
| @@ -0,0 +1,26 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| require_once __DIR__ . '/../vendor/autoload.php'; | |||||
| loadEnv(__DIR__ . '/../.env'); | |||||
| // Start session with secure cookie settings before any output or auth checks. | |||||
| session()->start(); | |||||
| use Core\App; | |||||
| use Core\Dispatcher; | |||||
| use Core\Request; | |||||
| use Core\Router; | |||||
| $app = new App(); | |||||
| $router = new Router(); | |||||
| require_once __DIR__ . '/../routes/web.php'; | |||||
| $dispatcher = new Dispatcher($router, $app); | |||||
| $request = Request::capture(); | |||||
| $response = $dispatcher->dispatch($request); | |||||
| $response->send(); | |||||
| @@ -0,0 +1,66 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use App\Controllers\AuthController; | |||||
| use App\Controllers\CampaignController; | |||||
| use App\Controllers\CampaignTypeController; | |||||
| use App\Controllers\HealthController; | |||||
| use App\Controllers\HomeController; | |||||
| use App\Controllers\JobController; | |||||
| use App\Controllers\JobTypeController; | |||||
| // ── Auth (public) ───────────────────────────────────────────────────────────── | |||||
| $router->get('/login', [AuthController::class, 'login']); | |||||
| $router->get('/auth/callback', [AuthController::class, 'callback']); | |||||
| $router->get('/logout', [AuthController::class, 'logout']); | |||||
| $router->post('/logout', [AuthController::class, 'logout']); | |||||
| // ── Public ──────────────────────────────────────────────────────────────────── | |||||
| $router->get('/', [HomeController::class, 'index']); | |||||
| $router->get('/health', [HealthController::class, 'index']); | |||||
| $router->get('/users/{id}', [HomeController::class, 'user']); | |||||
| // ── Campaigns ───────────────────────────────────────────────────────────────── | |||||
| $router->get('/campaigns', [CampaignController::class, 'index']) ->middleware('auth'); | |||||
| $router->get('/campaigns/data', [CampaignController::class, 'data']) ->middleware('auth'); | |||||
| $router->get('/campaigns/create', [CampaignController::class, 'create']) ->middleware('auth'); | |||||
| $router->post('/campaigns', [CampaignController::class, 'store']) ->middleware('auth'); | |||||
| $router->get('/campaigns/{id}/jobs', [JobController::class, 'campaign']) | |||||
| ->middleware('auth'); | |||||
| $router->get('/campaigns/{id}/jobs/data', [JobController::class, 'dataForCampaign']) | |||||
| ->middleware('auth'); | |||||
| $router->post('/campaigns/{id}/jobs/import/sheets', [JobController::class, 'googleSheetsList'])->middleware('auth'); | |||||
| $router->post('/campaigns/{id}/jobs/import', [JobController::class, 'importGoogleSheet'])->middleware('auth'); | |||||
| $router->post('/campaigns/{id}/jobs/import/file/sheets', [JobController::class, 'fileSheetsList']) ->middleware('auth'); | |||||
| $router->post('/campaigns/{id}/jobs/import/file', [JobController::class, 'importFile']) ->middleware('auth'); | |||||
| $router->get('/campaigns/{id}/edit', [CampaignController::class, 'edit']) ->middleware('auth'); | |||||
| $router->post('/campaigns/{id}/update', [CampaignController::class, 'update']) ->middleware('auth'); | |||||
| $router->post('/campaigns/{id}/delete', [CampaignController::class, 'destroy'])->middleware('auth'); | |||||
| // ── Campaign Types ──────────────────────────────────────────────────────────── | |||||
| $router->get('/campaign-types', [CampaignTypeController::class, 'index']) ->middleware('auth'); | |||||
| $router->get('/campaign-types/data', [CampaignTypeController::class, 'data']) ->middleware('auth'); | |||||
| $router->get('/campaign-types/create', [CampaignTypeController::class, 'create']) ->middleware('auth'); | |||||
| $router->post('/campaign-types', [CampaignTypeController::class, 'store']) ->middleware('auth'); | |||||
| $router->get('/campaign-types/{id}/edit', [CampaignTypeController::class, 'edit']) ->middleware('auth'); | |||||
| $router->post('/campaign-types/{id}/update', [CampaignTypeController::class, 'update']) ->middleware('auth'); | |||||
| $router->post('/campaign-types/{id}/delete', [CampaignTypeController::class, 'destroy'])->middleware('auth'); | |||||
| // ── Jobs ────────────────────────────────────────────────────────────────────── | |||||
| $router->get('/jobs', [JobController::class, 'index']) ->middleware('auth'); | |||||
| $router->get('/jobs/data', [JobController::class, 'data']) ->middleware('auth'); | |||||
| $router->get('/jobs/create', [JobController::class, 'create']) ->middleware('auth'); | |||||
| $router->post('/jobs', [JobController::class, 'store']) ->middleware('auth'); | |||||
| $router->get('/jobs/{id}/edit', [JobController::class, 'edit']) ->middleware('auth'); | |||||
| $router->post('/jobs/{id}/update', [JobController::class, 'update']) ->middleware('auth'); | |||||
| $router->post('/jobs/{id}/delete', [JobController::class, 'destroy'])->middleware('auth'); | |||||
| // ── Job Types ───────────────────────────────────────────────────────────────── | |||||
| $router->get('/job-types', [JobTypeController::class, 'index']) ->middleware('auth'); | |||||
| $router->get('/job-types/data', [JobTypeController::class, 'data']) ->middleware('auth'); | |||||
| $router->get('/job-types/create', [JobTypeController::class, 'create']) ->middleware('auth'); | |||||
| $router->post('/job-types', [JobTypeController::class, 'store']) ->middleware('auth'); | |||||
| $router->get('/job-types/{id}/edit', [JobTypeController::class, 'edit']) ->middleware('auth'); | |||||
| $router->post('/job-types/{id}/update', [JobTypeController::class, 'update']) ->middleware('auth'); | |||||
| $router->post('/job-types/{id}/delete', [JobTypeController::class, 'destroy'])->middleware('auth'); | |||||
| @@ -0,0 +1,17 @@ | |||||
| # Scripts | |||||
| This directory holds project PHP scripts that are meant to be run from the command line. | |||||
| Examples: | |||||
| ```bash | |||||
| php scripts/migrate.php up | |||||
| php scripts/migrate.php status | |||||
| php scripts/migrate.php fresh | |||||
| ``` | |||||
| Guidelines: | |||||
| - Put CLI-only PHP entrypoints here. | |||||
| - Keep reusable logic in `core/`, `app/`, or `database/`. | |||||
| - Let scripts stay thin and call into application classes or helper functions. | |||||
| @@ -0,0 +1,102 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| require_once __DIR__ . '/../vendor/autoload.php'; | |||||
| loadEnv(__DIR__ . '/../.env'); | |||||
| $command = $argv[1] ?? 'help'; | |||||
| $options = array_slice($argv, 2); | |||||
| $manager = migration_manager(); | |||||
| try { | |||||
| switch ($command) { | |||||
| case 'up': | |||||
| $ran = $manager->runPending(); | |||||
| if ($ran === []) { | |||||
| echo "No pending migrations." . PHP_EOL; | |||||
| exit(0); | |||||
| } | |||||
| foreach ($ran as $migration) { | |||||
| echo "Migrated: {$migration}" . PHP_EOL; | |||||
| } | |||||
| echo 'Applied ' . count($ran) . ' migration(s).' . PHP_EOL; | |||||
| exit(0); | |||||
| case 'down': | |||||
| $steps = isset($argv[2]) ? max(1, (int) $argv[2]) : 1; | |||||
| $rolledBack = $manager->rollback($steps); | |||||
| if ($rolledBack === []) { | |||||
| echo "No applied migrations to roll back." . PHP_EOL; | |||||
| exit(0); | |||||
| } | |||||
| foreach ($rolledBack as $migration) { | |||||
| echo "Rolled back: {$migration}" . PHP_EOL; | |||||
| } | |||||
| echo 'Rolled back ' . count($rolledBack) . ' migration(s).' . PHP_EOL; | |||||
| exit(0); | |||||
| case 'status': | |||||
| $status = $manager->status(); | |||||
| if ($status === []) { | |||||
| echo "No migration files found." . PHP_EOL; | |||||
| exit(0); | |||||
| } | |||||
| foreach ($status as $row) { | |||||
| $state = $row['ran'] ? 'up' : 'pending'; | |||||
| $ranAt = $row['ran_at'] ?? '-'; | |||||
| echo str_pad($state, 10) . ' ' . $row['migration'] . ' ' . $ranAt . PHP_EOL; | |||||
| } | |||||
| exit(0); | |||||
| case 'make': | |||||
| case 'create': | |||||
| $name = $argv[2] ?? ''; | |||||
| if ($name === '') { | |||||
| throw new InvalidArgumentException('Provide a migration name. Example: php scripts/migrate.php make create_projects_table'); | |||||
| } | |||||
| $path = $manager->make($name); | |||||
| echo "Created migration: {$path}" . PHP_EOL; | |||||
| exit(0); | |||||
| case 'fresh': | |||||
| $result = $manager->fresh(); | |||||
| foreach ($result['rolled_back'] as $migration) { | |||||
| echo "Rolled back: {$migration}" . PHP_EOL; | |||||
| } | |||||
| foreach ($result['migrated'] as $migration) { | |||||
| echo "Migrated: {$migration}" . PHP_EOL; | |||||
| } | |||||
| echo "Fresh migration run complete." . PHP_EOL; | |||||
| exit(0); | |||||
| case 'help': | |||||
| default: | |||||
| echo "Migration CLI" . PHP_EOL; | |||||
| echo "Usage:" . PHP_EOL; | |||||
| echo " php scripts/migrate.php up" . PHP_EOL; | |||||
| echo " php scripts/migrate.php down [steps]" . PHP_EOL; | |||||
| echo " php scripts/migrate.php status" . PHP_EOL; | |||||
| echo " php scripts/migrate.php make <name>" . PHP_EOL; | |||||
| echo " php scripts/migrate.php fresh [--seed]" . PHP_EOL; | |||||
| exit(0); | |||||
| } | |||||
| } catch (Throwable $exception) { | |||||
| fwrite(STDERR, $exception->getMessage() . PHP_EOL); | |||||
| exit(1); | |||||
| } | |||||
| @@ -0,0 +1,113 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| require_once __DIR__ . '/../vendor/autoload.php'; | |||||
| use Core\App; | |||||
| use Core\Database; | |||||
| use Core\Dispatcher; | |||||
| use Core\MigrationManager; | |||||
| use Core\Request; | |||||
| use Core\Router; | |||||
| $tempMigrationPath = sys_get_temp_dir() . '/mvc_migrations_' . uniqid('', true); | |||||
| mkdir($tempMigrationPath, 0777, true); | |||||
| $migrationFile = $tempMigrationPath . '/20260509_120000_create_projects_table.php'; | |||||
| file_put_contents($migrationFile, <<<'PHP' | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database $database): void | |||||
| { | |||||
| $database->execute('CREATE TABLE projects (id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(100) NOT NULL)'); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| $database->execute('DROP TABLE IF EXISTS projects'); | |||||
| } | |||||
| }; | |||||
| PHP | |||||
| ); | |||||
| $memoryDatabase = new Database([ | |||||
| 'dsn' => 'sqlite::memory:', | |||||
| 'options' => [ | |||||
| PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, | |||||
| PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, | |||||
| ], | |||||
| ]); | |||||
| $migrationManager = new MigrationManager($memoryDatabase, $tempMigrationPath); | |||||
| $ran = $migrationManager->runPending(); | |||||
| if ($ran !== ['20260509_120000_create_projects_table.php']) { | |||||
| echo "FAIL: migration manager did not apply the expected migration\n"; | |||||
| exit(1); | |||||
| } | |||||
| $projectTable = $memoryDatabase->first("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'projects'"); | |||||
| if ($projectTable === null) { | |||||
| echo "FAIL: migration up() did not create the projects table\n"; | |||||
| exit(1); | |||||
| } | |||||
| $rolledBack = $migrationManager->rollback(); | |||||
| if ($rolledBack !== ['20260509_120000_create_projects_table.php']) { | |||||
| echo "FAIL: migration manager did not roll back the expected migration\n"; | |||||
| exit(1); | |||||
| } | |||||
| $projectTableAfterRollback = $memoryDatabase->first("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'projects'"); | |||||
| if ($projectTableAfterRollback !== null) { | |||||
| echo "FAIL: migration down() did not remove the projects table\n"; | |||||
| exit(1); | |||||
| } | |||||
| $createdMigrationPath = $migrationManager->make('create_tasks_table'); | |||||
| if (!file_exists($createdMigrationPath)) { | |||||
| echo "FAIL: migration manager did not create a migration file\n"; | |||||
| exit(1); | |||||
| } | |||||
| $router = new Router(); | |||||
| $app = new App(); | |||||
| (new MigrationManager(database(), __DIR__ . '/../database/migrations'))->runPending(); | |||||
| require_once __DIR__ . '/../routes/web.php'; | |||||
| $router->get('/hello/{name}', function (string $name) { | |||||
| return 'Hello, ' . $name; | |||||
| }); | |||||
| $request = new Request([], [], [ | |||||
| 'REQUEST_METHOD' => 'GET', | |||||
| 'REQUEST_URI' => '/hello/Daniel', | |||||
| ]); | |||||
| $response = (new Dispatcher($router, $app))->dispatch($request); | |||||
| if ($response->status() !== 200) { | |||||
| echo "FAIL: expected status 200\n"; | |||||
| exit(1); | |||||
| } | |||||
| if ($response->content() !== 'Hello, Daniel') { | |||||
| echo "FAIL: unexpected response content\n"; | |||||
| exit(1); | |||||
| } | |||||
| echo "PASS: migration manager and route dispatch work\n"; | |||||
Powered by TurnKey Linux.