| @@ -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.