From af50885a191bec1b0d37080a93fb7fc27fbf23dc Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Mon, 11 May 2026 16:45:30 -0400 Subject: [PATCH] init --- .claude/settings.local.json | 13 + .env.example | 19 + .gitignore | 47 + AGENTS.md | 910 ++++++++++++ Dockerfile | 21 + app/Controllers/AuthController.php | 52 + app/Controllers/CampaignController.php | 323 ++++ app/Controllers/CampaignTypeController.php | 293 ++++ app/Controllers/HealthController.php | 31 + app/Controllers/HomeController.php | 32 + app/Controllers/JobController.php | 557 +++++++ app/Controllers/JobTypeController.php | 254 ++++ app/Models/Campaign.php | 20 + app/Models/CampaignType.php | 19 + app/Models/Job.php | 18 + app/Models/JobType.php | 17 + app/Repositories/CampaignAuditRepository.php | 73 + app/Repositories/CampaignRepository.php | 91 ++ .../CampaignTypeAuditRepository.php | 73 + app/Repositories/CampaignTypeRepository.php | 56 + app/Repositories/JobAuditRepository.php | 41 + app/Repositories/JobRepository.php | 109 ++ app/Repositories/JobTypeAuditRepository.php | 41 + app/Repositories/JobTypeRepository.php | 52 + app/Services/FileImportService.php | 385 +++++ app/Services/GoogleSheetImportService.php | 335 +++++ app/ViewModels/CampaignTypeViewModel.php | 30 + app/ViewModels/CampaignViewModel.php | 38 + app/ViewModels/HomeIndexViewModel.php | 13 + app/ViewModels/JobTypeViewModel.php | 23 + app/ViewModels/JobViewModel.php | 36 + app/Views/campaign-types/create.php | 106 ++ app/Views/campaign-types/edit.php | 126 ++ app/Views/campaign-types/index.php | 35 + app/Views/campaigns/create.php | 104 ++ app/Views/campaigns/edit.php | 151 ++ app/Views/campaigns/index.php | 54 + app/Views/health/index.php | 14 + app/Views/home/index.php | 39 + app/Views/job-types/create.php | 89 ++ app/Views/job-types/edit.php | 106 ++ app/Views/job-types/index.php | 34 + app/Views/jobs/campaign.php | 152 ++ app/Views/jobs/create.php | 115 ++ app/Views/jobs/edit.php | 120 ++ app/Views/jobs/index.php | 34 + app/Views/layouts/app.php | 14 + app/Views/partials/footer.php | 9 + app/Views/partials/header.php | 64 + composer.json | 25 + composer.lock | 811 ++++++++++ config/database.php | 16 + core/App.php | 57 + core/Auth/AuthMiddleware.php | 28 + core/Auth/AuthUser.php | 33 + core/Auth/KeycloakAuth.php | 195 +++ core/Auth/PermissionService.php | 54 + core/Controller.php | 35 + core/Database.php | 57 + core/Dispatcher.php | 70 + core/Http/Session.php | 73 + core/Migration.php | 12 + core/MigrationManager.php | 297 ++++ core/Repository.php | 38 + core/Request.php | 70 + core/Response.php | 64 + core/Route.php | 80 + core/Router.php | 39 + core/Validator.php | 52 + core/View.php | 68 + core/helpers.php | 207 +++ ...0511_000001_create_campaign_type_table.php | 37 + ...00003_create_campaign_type_audit_table.php | 44 + .../20260511_000004_create_campaign_table.php | 40 + ...511_000005_create_campaign_audit_table.php | 44 + .../20260511_000006_create_job_type_table.php | 37 + ...511_000007_create_job_type_audit_table.php | 42 + .../20260511_000008_create_job_table.php | 43 + ...20260511_000009_create_job_audit_table.php | 40 + docker-compose.yml | 46 + docker/apache/vhost.conf | 12 + docs/README.md | 64 + docs/REQUEST_FLOW.md | 80 + public/.htaccess | 5 + public/css/site.css | 992 +++++++++++++ public/index.php | 26 + public/js/app.js | 1304 +++++++++++++++++ routes/web.php | 66 + scripts/README.md | 17 + scripts/migrate.php | 102 ++ tests/run.php | 113 ++ 91 files changed, 10923 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Dockerfile create mode 100644 app/Controllers/AuthController.php create mode 100644 app/Controllers/CampaignController.php create mode 100644 app/Controllers/CampaignTypeController.php create mode 100644 app/Controllers/HealthController.php create mode 100644 app/Controllers/HomeController.php create mode 100644 app/Controllers/JobController.php create mode 100644 app/Controllers/JobTypeController.php create mode 100644 app/Models/Campaign.php create mode 100644 app/Models/CampaignType.php create mode 100644 app/Models/Job.php create mode 100644 app/Models/JobType.php create mode 100644 app/Repositories/CampaignAuditRepository.php create mode 100644 app/Repositories/CampaignRepository.php create mode 100644 app/Repositories/CampaignTypeAuditRepository.php create mode 100644 app/Repositories/CampaignTypeRepository.php create mode 100644 app/Repositories/JobAuditRepository.php create mode 100644 app/Repositories/JobRepository.php create mode 100644 app/Repositories/JobTypeAuditRepository.php create mode 100644 app/Repositories/JobTypeRepository.php create mode 100644 app/Services/FileImportService.php create mode 100644 app/Services/GoogleSheetImportService.php create mode 100644 app/ViewModels/CampaignTypeViewModel.php create mode 100644 app/ViewModels/CampaignViewModel.php create mode 100644 app/ViewModels/HomeIndexViewModel.php create mode 100644 app/ViewModels/JobTypeViewModel.php create mode 100644 app/ViewModels/JobViewModel.php create mode 100644 app/Views/campaign-types/create.php create mode 100644 app/Views/campaign-types/edit.php create mode 100644 app/Views/campaign-types/index.php create mode 100644 app/Views/campaigns/create.php create mode 100644 app/Views/campaigns/edit.php create mode 100644 app/Views/campaigns/index.php create mode 100644 app/Views/health/index.php create mode 100644 app/Views/home/index.php create mode 100644 app/Views/job-types/create.php create mode 100644 app/Views/job-types/edit.php create mode 100644 app/Views/job-types/index.php create mode 100644 app/Views/jobs/campaign.php create mode 100644 app/Views/jobs/create.php create mode 100644 app/Views/jobs/edit.php create mode 100644 app/Views/jobs/index.php create mode 100644 app/Views/layouts/app.php create mode 100644 app/Views/partials/footer.php create mode 100644 app/Views/partials/header.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/database.php create mode 100644 core/App.php create mode 100644 core/Auth/AuthMiddleware.php create mode 100644 core/Auth/AuthUser.php create mode 100644 core/Auth/KeycloakAuth.php create mode 100644 core/Auth/PermissionService.php create mode 100644 core/Controller.php create mode 100644 core/Database.php create mode 100644 core/Dispatcher.php create mode 100644 core/Http/Session.php create mode 100644 core/Migration.php create mode 100644 core/MigrationManager.php create mode 100644 core/Repository.php create mode 100644 core/Request.php create mode 100644 core/Response.php create mode 100644 core/Route.php create mode 100644 core/Router.php create mode 100644 core/Validator.php create mode 100644 core/View.php create mode 100644 core/helpers.php create mode 100644 database/migrations/20260511_000001_create_campaign_type_table.php create mode 100644 database/migrations/20260511_000003_create_campaign_type_audit_table.php create mode 100644 database/migrations/20260511_000004_create_campaign_table.php create mode 100644 database/migrations/20260511_000005_create_campaign_audit_table.php create mode 100644 database/migrations/20260511_000006_create_job_type_table.php create mode 100644 database/migrations/20260511_000007_create_job_type_audit_table.php create mode 100644 database/migrations/20260511_000008_create_job_table.php create mode 100644 database/migrations/20260511_000009_create_job_audit_table.php create mode 100644 docker-compose.yml create mode 100644 docker/apache/vhost.conf create mode 100644 docs/README.md create mode 100644 docs/REQUEST_FLOW.md create mode 100644 public/.htaccess create mode 100644 public/css/site.css create mode 100644 public/index.php create mode 100644 public/js/app.js create mode 100644 routes/web.php create mode 100644 scripts/README.md create mode 100644 scripts/migrate.php create mode 100644 tests/run.php diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..0f87d20 --- /dev/null +++ b/.claude/settings.local.json @@ -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)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7d2bd1c --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f77171 --- /dev/null +++ b/.gitignore @@ -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* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..323a47e --- /dev/null +++ b/AGENTS.md @@ -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 `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 +

name()) ?>

+``` + +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 +

+ + +``` + +--- + +## 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 + */ +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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..210dd9d --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php new file mode 100644 index 0000000..8ded12a --- /dev/null +++ b/app/Controllers/AuthController.php @@ -0,0 +1,52 @@ +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()); + } +} diff --git a/app/Controllers/CampaignController.php b/app/Controllers/CampaignController.php new file mode 100644 index 0000000..1932f0b --- /dev/null +++ b/app/Controllers/CampaignController.php @@ -0,0 +1,323 @@ +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}> + */ + 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 }> $types + * @return list + */ + private function attributesForType(int $typeId, array $types): array + { + foreach ($types as $type) { + if ($type['id'] === $typeId) { + return $type['attributes']; + } + } + + return []; + } + + /** + * @param list }> $campaignTypes + * @return array{0: array{campaign_type_id: int|string, attribute_values: array}, 1: array>} + */ + 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 $row + * @return array + */ + 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()); + } +} diff --git a/app/Controllers/CampaignTypeController.php b/app/Controllers/CampaignTypeController.php new file mode 100644 index 0000000..0845bc4 --- /dev/null +++ b/app/Controllers/CampaignTypeController.php @@ -0,0 +1,293 @@ +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 $row + * @return array + */ + 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}, 1: array>} + */ + 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()); + } +} diff --git a/app/Controllers/HealthController.php b/app/Controllers/HealthController.php new file mode 100644 index 0000000..f7ed6d8 --- /dev/null +++ b/app/Controllers/HealthController.php @@ -0,0 +1,31 @@ +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'), + ]); + } +} diff --git a/app/Controllers/HomeController.php b/app/Controllers/HomeController.php new file mode 100644 index 0000000..4ef3ab8 --- /dev/null +++ b/app/Controllers/HomeController.php @@ -0,0 +1,32 @@ +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, + ]); + } +} diff --git a/app/Controllers/JobController.php b/app/Controllers/JobController.php new file mode 100644 index 0000000..d860d11 --- /dev/null +++ b/app/Controllers/JobController.php @@ -0,0 +1,557 @@ +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 $attributes + * @param list $headers + * @param list> $rows + * @return array{imported: int, skipped: int, matched_attributes: list} + */ + 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> $rows + * @return list> + */ + 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()); + } +} diff --git a/app/Controllers/JobTypeController.php b/app/Controllers/JobTypeController.php new file mode 100644 index 0000000..0767d4e --- /dev/null +++ b/app/Controllers/JobTypeController.php @@ -0,0 +1,254 @@ +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()); + } +} diff --git a/app/Models/Campaign.php b/app/Models/Campaign.php new file mode 100644 index 0000000..bd7bb55 --- /dev/null +++ b/app/Models/Campaign.php @@ -0,0 +1,20 @@ + + */ + public array $attributeValues = []; + + public ?string $createdAt = null; + public ?string $updatedAt = null; +} diff --git a/app/Models/CampaignType.php b/app/Models/CampaignType.php new file mode 100644 index 0000000..8d4a334 --- /dev/null +++ b/app/Models/CampaignType.php @@ -0,0 +1,19 @@ + + */ + public array $attributes = []; + + public ?string $createdAt = null; + public ?string $updatedAt = null; +} diff --git a/app/Models/Job.php b/app/Models/Job.php new file mode 100644 index 0000000..3c13580 --- /dev/null +++ b/app/Models/Job.php @@ -0,0 +1,18 @@ + */ + public array $attributeValues = []; + + public ?string $createdAt = null; + public ?string $updatedAt = null; +} diff --git a/app/Models/JobType.php b/app/Models/JobType.php new file mode 100644 index 0000000..9ca600e --- /dev/null +++ b/app/Models/JobType.php @@ -0,0 +1,17 @@ + */ + public array $attributes = []; + + public ?string $createdAt = null; + public ?string $updatedAt = null; +} diff --git a/app/Repositories/CampaignAuditRepository.php b/app/Repositories/CampaignAuditRepository.php new file mode 100644 index 0000000..756b956 --- /dev/null +++ b/app/Repositories/CampaignAuditRepository.php @@ -0,0 +1,73 @@ + $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> + */ + 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> + */ + 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" + ); + } +} diff --git a/app/Repositories/CampaignRepository.php b/app/Repositories/CampaignRepository.php new file mode 100644 index 0000000..5842dfe --- /dev/null +++ b/app/Repositories/CampaignRepository.php @@ -0,0 +1,91 @@ +> + */ + 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, + ] + ); + } +} diff --git a/app/Repositories/CampaignTypeAuditRepository.php b/app/Repositories/CampaignTypeAuditRepository.php new file mode 100644 index 0000000..0ddcee4 --- /dev/null +++ b/app/Repositories/CampaignTypeAuditRepository.php @@ -0,0 +1,73 @@ + $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> + */ + 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> + */ + 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" + ); + } +} diff --git a/app/Repositories/CampaignTypeRepository.php b/app/Repositories/CampaignTypeRepository.php new file mode 100644 index 0000000..f8bbc61 --- /dev/null +++ b/app/Repositories/CampaignTypeRepository.php @@ -0,0 +1,56 @@ +> + */ + 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, + ] + ); + } +} diff --git a/app/Repositories/JobAuditRepository.php b/app/Repositories/JobAuditRepository.php new file mode 100644 index 0000000..d06a8b3 --- /dev/null +++ b/app/Repositories/JobAuditRepository.php @@ -0,0 +1,41 @@ + $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> */ + 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] + ); + } +} diff --git a/app/Repositories/JobRepository.php b/app/Repositories/JobRepository.php new file mode 100644 index 0000000..d36a9a6 --- /dev/null +++ b/app/Repositories/JobRepository.php @@ -0,0 +1,109 @@ +> */ + 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> */ + 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, + ] + ); + } +} diff --git a/app/Repositories/JobTypeAuditRepository.php b/app/Repositories/JobTypeAuditRepository.php new file mode 100644 index 0000000..f3a6bb5 --- /dev/null +++ b/app/Repositories/JobTypeAuditRepository.php @@ -0,0 +1,41 @@ + $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> */ + 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] + ); + } +} diff --git a/app/Repositories/JobTypeRepository.php b/app/Repositories/JobTypeRepository.php new file mode 100644 index 0000000..c4ee5b6 --- /dev/null +++ b/app/Repositories/JobTypeRepository.php @@ -0,0 +1,52 @@ +> */ + 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, + ] + ); + } +} diff --git a/app/Services/FileImportService.php b/app/Services/FileImportService.php new file mode 100644 index 0000000..3d3e7ef --- /dev/null +++ b/app/Services/FileImportService.php @@ -0,0 +1,385 @@ +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 + */ + 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, rows: list>} + */ + 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 + */ + 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, rows: list>} + */ + 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 $sharedStrings + * @return array{headers: list, rows: list>} + */ + 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, rows: list>} + */ + 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); + } + } + } +} diff --git a/app/Services/GoogleSheetImportService.php b/app/Services/GoogleSheetImportService.php new file mode 100644 index 0000000..9e5ac59 --- /dev/null +++ b/app/Services/GoogleSheetImportService.php @@ -0,0 +1,335 @@ +} + */ + 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, rows: list>} + */ + 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 + */ + 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 + */ + private function extractSheets(string $html): array + { + $sheets = []; + + // ── HTML tab patterns (htmlview format) ─────────────────────────────── + // Google renders tab links like: + // Sheet Name + // Sheet Name + $htmlPatterns = [ + '/<[^>]+href=["\'][^"\']*[#&]gid=(\d+)["\'][^>]*>\s*(?:<[^>]+>\s*)*([^<]{1,100}?)\s*(?:<|$)/i', + '/data-id=["\'](\d+)["\'][^>]*>\s*([^<]{1,100}?)\s* 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, rows: list>} + */ + 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]; + } +} diff --git a/app/ViewModels/CampaignTypeViewModel.php b/app/ViewModels/CampaignTypeViewModel.php new file mode 100644 index 0000000..0f1446f --- /dev/null +++ b/app/ViewModels/CampaignTypeViewModel.php @@ -0,0 +1,30 @@ +} + */ + public array $form = [ + 'name' => '', + 'attributes' => [], + ]; + + /** + * @var array> + */ + public array $errors = []; + + /** + * @var array|null + */ + public ?array $campaignType = null; +} diff --git a/app/ViewModels/CampaignViewModel.php b/app/ViewModels/CampaignViewModel.php new file mode 100644 index 0000000..496b317 --- /dev/null +++ b/app/ViewModels/CampaignViewModel.php @@ -0,0 +1,38 @@ +} + */ + public array $form = [ + 'campaign_type_id' => 0, + 'attribute_values' => [], + ]; + + /** + * @var array> + */ + public array $errors = []; + + /** + * @var array|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}> + */ + public array $campaignTypes = []; +} diff --git a/app/ViewModels/HomeIndexViewModel.php b/app/ViewModels/HomeIndexViewModel.php new file mode 100644 index 0000000..458b11f --- /dev/null +++ b/app/ViewModels/HomeIndexViewModel.php @@ -0,0 +1,13 @@ + '', + 'attributes' => [], + ]; + + /** @var array> */ + public array $errors = []; + + /** @var array|null */ + public ?array $jobType = null; +} diff --git a/app/ViewModels/JobViewModel.php b/app/ViewModels/JobViewModel.php new file mode 100644 index 0000000..c1d91ce --- /dev/null +++ b/app/ViewModels/JobViewModel.php @@ -0,0 +1,36 @@ + 0, + 'job_type_id' => 0, + 'attribute_values' => [], + ]; + + /** @var array> */ + public array $errors = []; + + /** @var array|null */ + public ?array $job = null; + + /** + * Campaigns with campaign_type_name for the dropdown. + * @var list> + */ + public array $campaigns = []; + + /** + * Job types with attributes decoded, for the type dropdown and dynamic fields. + * @var list}> + */ + public array $jobTypes = []; +} diff --git a/app/Views/campaign-types/create.php b/app/Views/campaign-types/create.php new file mode 100644 index 0000000..6c280bf --- /dev/null +++ b/app/Views/campaign-types/create.php @@ -0,0 +1,106 @@ + +
+ +
+
+

title) ?>

+

Define a campaign type and the attributes that will describe it.

+
+ ← Back to list +
+ +
+ + errors['_token'])): ?> +
errors['_token'][0]) ?>
+ + +
+ + +
+ +
+ +
+
+

Attributes

+

Add the fields that campaigns of this type will carry.

+
+ +
+ +
+ + +
+ +
+ + Cancel +
+
+ +
+ +
diff --git a/app/Views/campaign-types/edit.php b/app/Views/campaign-types/edit.php new file mode 100644 index 0000000..e095f29 --- /dev/null +++ b/app/Views/campaign-types/edit.php @@ -0,0 +1,126 @@ +campaignType['id'] ?? 0); ?> + +
+ +
+
+

title) ?>

+

Update the name or attributes for this campaign type.

+
+ ← Back to list +
+ + saved): ?> +
+ Campaign type updated successfully. +
+ + +
+ + errors['_token'])): ?> +
errors['_token'][0]) ?>
+ + +
+ + +
+ +
+ +
+
+

Attributes

+

Modify the fields that campaigns of this type will carry.

+
+ +
+ +
+ + +
+ +
+ + Cancel +
+
+ +
+

Delete this campaign type

+

This cannot be undone.

+
+ + +
+
+ +
+ +
diff --git a/app/Views/campaign-types/index.php b/app/Views/campaign-types/index.php new file mode 100644 index 0000000..70fb31c --- /dev/null +++ b/app/Views/campaign-types/index.php @@ -0,0 +1,35 @@ +
+ +
+
+

title) ?>

+

Manage campaign types and their configurable attributes.

+
+ + New Campaign Type +
+ + saved): ?> +
+ Campaign type saved successfully. +
+ + + deleted): ?> +
+ Campaign type deleted. +
+ + +
+
+
+

Campaign Type Directory

+

All campaign types with their attribute definitions.

+
+ +
+ +
+
+ +
diff --git a/app/Views/campaigns/create.php b/app/Views/campaigns/create.php new file mode 100644 index 0000000..9b8daad --- /dev/null +++ b/app/Views/campaigns/create.php @@ -0,0 +1,104 @@ + + +
+ +
+
+

title) ?>

+

Choose a campaign type, enter a name, then fill in the type’s attribute values.

+
+ ← Back to list +
+ + campaignTypes): ?> +
+ No campaign types have been defined yet. + Create a campaign type before adding campaigns. +
+ + +
+ + errors['_token'])): ?> +
errors['_token'][0]) ?>
+ + +
+ + +
+ + + +
+ +
+
+

Attribute values

+

Fields defined by the selected campaign type.

+
+ +
+ +
+
+ +

+ This campaign type has no attributes defined. +

+ +
+ + Cancel +
+
+ +
+ + + +
diff --git a/app/Views/campaigns/edit.php b/app/Views/campaigns/edit.php new file mode 100644 index 0000000..3500b5b --- /dev/null +++ b/app/Views/campaigns/edit.php @@ -0,0 +1,151 @@ +campaign['id'] ?? 0); ?> + + +
+ +
+
+

title) ?>

+

Update the campaign name, type, or attribute values.

+
+ ← Back to list +
+ + saved): ?> +
+ Campaign updated successfully. +
+ + +
+ + errors['_token'])): ?> +
errors['_token'][0]) ?>
+ + +
+ + +
+ + + +
+ +
+
+

Attribute values

+

Fields defined by the selected campaign type.

+
+ +
+ +
+
+ +

+ This campaign type has no attributes defined. +

+ +
+ + Cancel +
+
+ +
+

Delete this campaign

+

This cannot be undone.

+
+ + +
+
+ +
+ +
+
+
+

Jobs

+

All jobs attached to this campaign.

+
+
+ + + New Job + + +
+
+
+
Loading jobs...
+
+
+

No jobs are attached to this campaign.

+
+ +
+ +
+
+
+ +
diff --git a/app/Views/campaigns/index.php b/app/Views/campaigns/index.php new file mode 100644 index 0000000..fad8b79 --- /dev/null +++ b/app/Views/campaigns/index.php @@ -0,0 +1,54 @@ +
+ +
+
+

title) ?>

+

Manage campaigns and their attribute values.

+
+ + New Campaign +
+ + saved): ?> +
+ Campaign saved successfully. +
+ + + deleted): ?> +
+ Campaign deleted. +
+ + +
+
+
+

Campaign Directory

+

All campaigns with their type and attribute data.

+
+ +
+ +
Loading campaigns...
+
+
+
+ +
+
+
+

Campaign Jobs

+

+
+
+ + +
+
+ +
Loading jobs...
+
+
+
+ +
diff --git a/app/Views/health/index.php b/app/Views/health/index.php new file mode 100644 index 0000000..8ed0efa --- /dev/null +++ b/app/Views/health/index.php @@ -0,0 +1,14 @@ +
+

Health Check

+
    +
  • PHP — OK
  • +
  • SQL Server — + + OK + + FAILED: + +
  • +
  • Environment
  • +
+
diff --git a/app/Views/home/index.php b/app/Views/home/index.php new file mode 100644 index 0000000..3820ef7 --- /dev/null +++ b/app/Views/home/index.php @@ -0,0 +1,39 @@ +
+
+ eyebrow) ?> +

title) ?>

+

message) ?>

+ + +
+ + +
+ +
+
+

Readable by design

+

Small files, explicit routing, and plain PHP views keep the framework approachable for day-to-day work.

+
+ +
+

Classic MVC feel

+

Controllers, repositories, and view models stay separate so request handling remains predictable and easy to follow.

+
+ +
+

SQL Server ready

+

Typed PHP 8.3 code, Composer autoloading, PDO access, and migration support make the project feel current without becoming heavyweight.

+
+
diff --git a/app/Views/job-types/create.php b/app/Views/job-types/create.php new file mode 100644 index 0000000..6def950 --- /dev/null +++ b/app/Views/job-types/create.php @@ -0,0 +1,89 @@ + + +
+ +
+
+

title) ?>

+

Define a job type and the attributes that describe it.

+
+ ← Back to list +
+ +
+ + errors['_token'])): ?> +
errors['_token'][0]) ?>
+ + +
+ + +
+ +
+ +
+
+

Attributes

+

Fields that jobs of this type will carry.

+
+
+ +
+ +
+ +
+ + Cancel +
+
+ +
+ +
diff --git a/app/Views/job-types/edit.php b/app/Views/job-types/edit.php new file mode 100644 index 0000000..3dc7a73 --- /dev/null +++ b/app/Views/job-types/edit.php @@ -0,0 +1,106 @@ +jobType['id'] ?? 0); ?> + + +
+ +
+
+

title) ?>

+

Update this job type's name or attributes.

+
+ ← Back to list +
+ + saved): ?> +
+ Job type updated successfully. +
+ + +
+ + errors['_token'])): ?> +
errors['_token'][0]) ?>
+ + +
+ + +
+ +
+ +
+
+

Attributes

+

Fields that jobs of this type will carry.

+
+
+ +
+ +
+ +
+ + Cancel +
+
+ +
+

Delete this job type

+

This cannot be undone.

+
+ + +
+
+ +
+ +
diff --git a/app/Views/job-types/index.php b/app/Views/job-types/index.php new file mode 100644 index 0000000..216fe10 --- /dev/null +++ b/app/Views/job-types/index.php @@ -0,0 +1,34 @@ +
+ +
+
+

title) ?>

+

Manage job types and their configurable attributes.

+
+ + New Job Type +
+ + saved): ?> +
+ Job type saved successfully. +
+ + + deleted): ?> +
+ Job type deleted. +
+ + +
+
+
+

Job Type Directory

+

All job types with their attribute definitions.

+
+ +
+
+
+ +
diff --git a/app/Views/jobs/campaign.php b/app/Views/jobs/campaign.php new file mode 100644 index 0000000..25764ef --- /dev/null +++ b/app/Views/jobs/campaign.php @@ -0,0 +1,152 @@ + + + +
+ +
+
+

Campaign Jobs

+

#

+
+ +
+ +
+
+

Import Jobs

+
+ + +
+ + +
+ + +
+

+ The spreadsheet must be shared as Anyone with the link can view. +

+
+ + + + + +
+ +
+ + + Connecting... + Importing... +
+
+ + +
+

+ Export your sheet as CSV or Excel (.xlsx) from Google Sheets, then upload it here. +

+
+ + + + + +
+ +
+ + + Reading file... + Importing... +
+
+ + +
+
+
+ +
+
+
+

Job Directory

+

All jobs in this campaign with job fields and attribute fields.

+
+ +
+ +
Loading jobs...
+
+
+
+ +
diff --git a/app/Views/jobs/create.php b/app/Views/jobs/create.php new file mode 100644 index 0000000..1f7684f --- /dev/null +++ b/app/Views/jobs/create.php @@ -0,0 +1,115 @@ + + +
+ +
+
+

title) ?>

+

Select a campaign and job type, then fill in the attribute values.

+
+ ← Back to list +
+ + campaigns): ?> +
+ No campaigns exist yet. Create a campaign before adding jobs. +
+ jobTypes): ?> +
+ No job types exist yet. Create a job type before adding jobs. +
+ + +
+ + errors['_token'])): ?> +
errors['_token'][0]) ?>
+ + +
+ + +
+ + + +
+ +
+
+

Attribute values

+

Fields defined by the selected job type.

+
+
+ +
+
+ +

+ This job type has no attributes defined. +

+ +
+ + Cancel +
+
+ +
+ + + +
diff --git a/app/Views/jobs/edit.php b/app/Views/jobs/edit.php new file mode 100644 index 0000000..c641e6b --- /dev/null +++ b/app/Views/jobs/edit.php @@ -0,0 +1,120 @@ +job['id'] ?? 0); ?> + + +
+ +
+
+

title) ?>

+

Update the campaign, job type, or attribute values for this job.

+
+ ← Back to list +
+ + saved): ?> +
+ Job updated successfully. +
+ + +
+ + errors['_token'])): ?> +
errors['_token'][0]) ?>
+ + +
+ + +
+ + + +
+ +
+
+

Attribute values

+

Fields defined by the selected job type.

+
+
+ +
+
+ +

+ This job type has no attributes defined. +

+ +
+ + Cancel +
+
+ +
+

Delete this job

+

This cannot be undone.

+
+ + +
+
+ +
+ +
diff --git a/app/Views/jobs/index.php b/app/Views/jobs/index.php new file mode 100644 index 0000000..2011a71 --- /dev/null +++ b/app/Views/jobs/index.php @@ -0,0 +1,34 @@ +
+ +
+
+

title) ?>

+

Manage jobs across all campaigns.

+
+ + New Job +
+ + saved): ?> +
+ Job saved successfully. +
+ + + deleted): ?> +
+ Job deleted. +
+ + +
+
+
+

Job Directory

+

All jobs with their campaign and job type.

+
+ +
+
+
+ +
diff --git a/app/Views/layouts/app.php b/app/Views/layouts/app.php new file mode 100644 index 0000000..5796585 --- /dev/null +++ b/app/Views/layouts/app.php @@ -0,0 +1,14 @@ + + +
+
+ +
+
+ + diff --git a/app/Views/partials/footer.php b/app/Views/partials/footer.php new file mode 100644 index 0000000..d2a38dd --- /dev/null +++ b/app/Views/partials/footer.php @@ -0,0 +1,9 @@ +
+ +
+ + + diff --git a/app/Views/partials/header.php b/app/Views/partials/header.php new file mode 100644 index 0000000..7d9bc73 --- /dev/null +++ b/app/Views/partials/header.php @@ -0,0 +1,64 @@ + '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(); +?> + + + + + + <?= e($pageTitle ?? 'Campaign Tracker') ?> + + + + + + + + + +
+ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a668679 --- /dev/null +++ b/composer.json @@ -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" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..eb59aeb --- /dev/null +++ b/composer.lock @@ -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" +} diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..a319444 --- /dev/null +++ b/config/database.php @@ -0,0 +1,16 @@ + 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, + ], +]; diff --git a/core/App.php b/core/App.php new file mode 100644 index 0000000..2ac8b9f --- /dev/null +++ b/core/App.php @@ -0,0 +1,57 @@ +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)); + } +} diff --git a/core/Auth/AuthMiddleware.php b/core/Auth/AuthMiddleware.php new file mode 100644 index 0000000..ed933df --- /dev/null +++ b/core/Auth/AuthMiddleware.php @@ -0,0 +1,28 @@ +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; + } +} diff --git a/core/Auth/AuthUser.php b/core/Auth/AuthUser.php new file mode 100644 index 0000000..aa8155d --- /dev/null +++ b/core/Auth/AuthUser.php @@ -0,0 +1,33 @@ + */ + public readonly array $roles, + /** @var list */ + 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'] ?? []), + ); + } +} diff --git a/core/Auth/KeycloakAuth.php b/core/Auth/KeycloakAuth.php new file mode 100644 index 0000000..03bf6dd --- /dev/null +++ b/core/Auth/KeycloakAuth.php @@ -0,0 +1,195 @@ +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) ?? []) : []; + } +} diff --git a/core/Auth/PermissionService.php b/core/Auth/PermissionService.php new file mode 100644 index 0000000..6b0bce5 --- /dev/null +++ b/core/Auth/PermissionService.php @@ -0,0 +1,54 @@ +> + */ + 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 $roles + * @return list + */ + 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); + } +} diff --git a/core/Controller.php b/core/Controller.php new file mode 100644 index 0000000..a73032e --- /dev/null +++ b/core/Controller.php @@ -0,0 +1,35 @@ +method() !== 'POST') { + throw new \Exception('This action requires POST.'); + } + } +} diff --git a/core/Database.php b/core/Database.php new file mode 100644 index 0000000..0cff089 --- /dev/null +++ b/core/Database.php @@ -0,0 +1,57 @@ +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); + } +} diff --git a/core/Dispatcher.php b/core/Dispatcher.php new file mode 100644 index 0000000..5f2bd22 --- /dev/null +++ b/core/Dispatcher.php @@ -0,0 +1,70 @@ +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); + } +} diff --git a/core/Http/Session.php b/core/Http/Session.php new file mode 100644 index 0000000..0e36732 --- /dev/null +++ b/core/Http/Session.php @@ -0,0 +1,73 @@ + 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(); + } +} diff --git a/core/Migration.php b/core/Migration.php new file mode 100644 index 0000000..4bc46ee --- /dev/null +++ b/core/Migration.php @@ -0,0 +1,12 @@ +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 = <<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.'); + } +} diff --git a/core/Repository.php b/core/Repository.php new file mode 100644 index 0000000..4c01c77 --- /dev/null +++ b/core/Repository.php @@ -0,0 +1,38 @@ +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] + ); + } +} diff --git a/core/Request.php b/core/Request.php new file mode 100644 index 0000000..c04f7e8 --- /dev/null +++ b/core/Request.php @@ -0,0 +1,70 @@ +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); + } +} diff --git a/core/Response.php b/core/Response.php new file mode 100644 index 0000000..afd3a0c --- /dev/null +++ b/core/Response.php @@ -0,0 +1,64 @@ +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; + } +} diff --git a/core/Route.php b/core/Route.php new file mode 100644 index 0000000..418882d --- /dev/null +++ b/core/Route.php @@ -0,0 +1,80 @@ +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); + } +} diff --git a/core/Router.php b/core/Router.php new file mode 100644 index 0000000..014250d --- /dev/null +++ b/core/Router.php @@ -0,0 +1,39 @@ +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; + } +} diff --git a/core/Validator.php b/core/Validator.php new file mode 100644 index 0000000..3e6f340 --- /dev/null +++ b/core/Validator.php @@ -0,0 +1,52 @@ +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; + } +} diff --git a/core/View.php b/core/View.php new file mode 100644 index 0000000..c823d3a --- /dev/null +++ b/core/View.php @@ -0,0 +1,68 @@ +title) && + trim($data['model']->title) !== '' + ) { + return $data['model']->title; + } + + return 'MindVisionCode PHP'; + } +} diff --git a/core/helpers.php b/core/helpers.php new file mode 100644 index 0000000..a1b77f8 --- /dev/null +++ b/core/helpers.php @@ -0,0 +1,207 @@ + 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 $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 ''; +} + +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); +} diff --git a/database/migrations/20260511_000001_create_campaign_type_table.php b/database/migrations/20260511_000001_create_campaign_type_table.php new file mode 100644 index 0000000..36d400d --- /dev/null +++ b/database/migrations/20260511_000001_create_campaign_type_table.php @@ -0,0 +1,37 @@ +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'); + } +}; diff --git a/database/migrations/20260511_000003_create_campaign_type_audit_table.php b/database/migrations/20260511_000003_create_campaign_type_audit_table.php new file mode 100644 index 0000000..fa50d32 --- /dev/null +++ b/database/migrations/20260511_000003_create_campaign_type_audit_table.php @@ -0,0 +1,44 @@ +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'); + } +}; diff --git a/database/migrations/20260511_000004_create_campaign_table.php b/database/migrations/20260511_000004_create_campaign_table.php new file mode 100644 index 0000000..986175c --- /dev/null +++ b/database/migrations/20260511_000004_create_campaign_table.php @@ -0,0 +1,40 @@ +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'); + } +}; diff --git a/database/migrations/20260511_000005_create_campaign_audit_table.php b/database/migrations/20260511_000005_create_campaign_audit_table.php new file mode 100644 index 0000000..033ca14 --- /dev/null +++ b/database/migrations/20260511_000005_create_campaign_audit_table.php @@ -0,0 +1,44 @@ +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'); + } +}; diff --git a/database/migrations/20260511_000006_create_job_type_table.php b/database/migrations/20260511_000006_create_job_type_table.php new file mode 100644 index 0000000..e2e8c42 --- /dev/null +++ b/database/migrations/20260511_000006_create_job_type_table.php @@ -0,0 +1,37 @@ +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'); + } +}; diff --git a/database/migrations/20260511_000007_create_job_type_audit_table.php b/database/migrations/20260511_000007_create_job_type_audit_table.php new file mode 100644 index 0000000..74916b8 --- /dev/null +++ b/database/migrations/20260511_000007_create_job_type_audit_table.php @@ -0,0 +1,42 @@ +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'); + } +}; diff --git a/database/migrations/20260511_000008_create_job_table.php b/database/migrations/20260511_000008_create_job_table.php new file mode 100644 index 0000000..b62b6cb --- /dev/null +++ b/database/migrations/20260511_000008_create_job_table.php @@ -0,0 +1,43 @@ +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'); + } +}; diff --git a/database/migrations/20260511_000009_create_job_audit_table.php b/database/migrations/20260511_000009_create_job_audit_table.php new file mode 100644 index 0000000..23af67c --- /dev/null +++ b/database/migrations/20260511_000009_create_job_audit_table.php @@ -0,0 +1,40 @@ +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'); + } +}; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0c6d250 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docker/apache/vhost.conf b/docker/apache/vhost.conf new file mode 100644 index 0000000..4b00b55 --- /dev/null +++ b/docker/apache/vhost.conf @@ -0,0 +1,12 @@ + + DocumentRoot /var/www/html/public + + + AllowOverride All + Require all granted + Options -Indexes +FollowSymLinks + + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b3fd0ee --- /dev/null +++ b/docs/README.md @@ -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 # 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. diff --git a/docs/REQUEST_FLOW.md b/docs/REQUEST_FLOW.md new file mode 100644 index 0000000..31ed9d1 --- /dev/null +++ b/docs/REQUEST_FLOW.md @@ -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 diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..8661356 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,5 @@ +RewriteEngine On + +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.php [QSA,L] diff --git a/public/css/site.css b/public/css/site.css new file mode 100644 index 0000000..606f944 --- /dev/null +++ b/public/css/site.css @@ -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; +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..5f32a35 --- /dev/null +++ b/public/index.php @@ -0,0 +1,26 @@ +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(); diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..5b11ae3 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,1304 @@ +// ── Shared util ─────────────────────────────────────────────────────────────── + +function _postDelete(action) { + const form = document.createElement('form'); + form.method = 'POST'; + form.action = action; + + const t = document.createElement('input'); + t.type = 'hidden'; + t.name = '_token'; + t.value = window.__csrf || ''; + form.appendChild(t); + + document.body.appendChild(form); + form.submit(); +} + +function _escapeHtml(value) { + return String(value).replace(/[&<>"']/g, function (char) { + return { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[char]; + }); +} + +// ── Campaign Type ───────────────────────────────────────────────────────────── + +window.campaignTypeTable = function () { + return { + table: null, + + init() { + this.initTable(); + }, + + initTable() { + const el = document.getElementById('campaign-type-table'); + if (!el || typeof Tabulator === 'undefined') { + return; + } + + this.table = new Tabulator(el, { + ajaxURL: '/campaign-types/data', + layout: 'fitColumns', + responsiveLayout: 'collapse', + pagination: true, + paginationMode: 'local', + paginationSize: 10, + movableColumns: true, + placeholder: 'No campaign types found.', + initialSort: [{ column: 'name', dir: 'asc' }], + columns: [ + { title: 'Name', field: 'name', minWidth: 200 }, + { + title: 'Attributes', + field: 'attributes_summary', + minWidth: 240, + formatter: function (cell) { + const v = cell.getValue(); + return v ? '' + _escapeHtml(v) + '' + : ''; + }, + }, + { title: 'Count', field: 'attribute_count', hozAlign: 'center', width: 80 }, + { title: 'Created', field: 'created_at', minWidth: 160 }, + { + title: 'Actions', + field: 'id', + width: 160, + hozAlign: 'center', + headerSort: false, + formatter: function (cell) { + const id = cell.getValue(); + return 'Edit ' + + ''; + }, + }, + ], + }); + }, + + reloadTable() { + if (!this.table) { + this.initTable(); + return; + } + this.table.setData('/campaign-types/data'); + }, + }; +}; + +window.deleteCampaignType = function (id) { + if (!confirm('Delete this campaign type? This cannot be undone.')) { + return; + } + _postDelete('/campaign-types/' + id + '/delete'); +}; + +window.campaignTypeForm = function (initialAttributes) { + return { + attributes: Array.isArray(initialAttributes) ? initialAttributes : [], + dragIndex: null, + dragOverIndex: null, + + addAttribute() { + this.attributes.push({ name: '', type: 'text', order: this.attributes.length + 1 }); + }, + + removeAttribute(index) { + this.attributes.splice(index, 1); + this.renumberOrder(); + }, + + renumberOrder() { + this.attributes.forEach(function (attr, i) { attr.order = i + 1; }); + }, + + dragStart(event, index) { + this.dragIndex = index; + event.dataTransfer.effectAllowed = 'move'; + }, + + dragOver(event, index) { + this.dragOverIndex = index; + }, + + drop(event, index) { + if (this.dragIndex !== null && this.dragIndex !== index) { + var moved = this.attributes.splice(this.dragIndex, 1)[0]; + this.attributes.splice(index, 0, moved); + this.renumberOrder(); + } + this.dragIndex = null; + this.dragOverIndex = null; + }, + + dragEnd() { + this.dragIndex = null; + this.dragOverIndex = null; + }, + + confirmDelete(event) { + if (confirm('Delete this campaign type? This cannot be undone.')) { + event.target.submit(); + } + }, + }; +}; + +// ── Campaign ────────────────────────────────────────────────────────────────── + +window.campaignTable = function () { + return { + table: null, + jobsTable: null, + isLoading: false, + isJobsLoading: false, + errorMessage: '', + jobsErrorMessage: '', + selectedCampaignId: null, + selectedCampaignTitle: '', + + init() { + this.loadTable(); + }, + + async loadTable() { + const el = document.getElementById('campaign-table'); + if (!el || typeof Tabulator === 'undefined' || this.isLoading) { + return; + } + + this.isLoading = true; + this.errorMessage = ''; + + try { + const response = await fetch('/campaigns/data', { + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error('Unable to load campaigns.'); + } + + const rows = await response.json(); + const campaignRows = Array.isArray(rows) ? rows : []; + const attributes = this.attributeColumnsForRows(campaignRows); + const tableRows = this.formatRows(campaignRows, attributes); + const columns = this.columnsForAttributes(attributes); + + if (!this.table) { + this.table = new Tabulator(el, { + data: tableRows, + layout: 'fitColumns', + responsiveLayout: 'collapse', + pagination: true, + paginationMode: 'local', + paginationSize: 10, + movableColumns: true, + placeholder: 'No campaigns found.', + initialSort: [{ column: 'campaign_type_name', dir: 'asc' }], + columns: columns, + }); + this.table.on('rowClick', (event, row) => this.goToCampaignJobs(event, row)); + } else { + this.table.setColumns(columns); + this.table.setData(tableRows); + } + } catch (error) { + this.errorMessage = error.message || 'Unable to load campaigns.'; + } finally { + this.isLoading = false; + } + }, + + attributeColumnsForRows(rows) { + const attributes = []; + + rows.forEach((row) => { + this.normalizeAttributes(row.campaign_type_attributes || []).forEach((attr) => { + if (!attributes.some((existing) => existing.name === attr.name)) { + attributes.push(attr); + } + }); + + Object.keys(row.attribute_values || {}).forEach((name) => { + if (!attributes.some((existing) => existing.name === name)) { + attributes.push({ name: name, type: 'text', order: attributes.length + 1 }); + } + }); + }); + + return attributes; + }, + + normalizeAttributes(attributes) { + return attributes + .filter((attr) => attr && attr.name) + .slice() + .sort((a, b) => (a.order || 0) - (b.order || 0)); + }, + + formatRows(rows, attributes) { + return rows.map((row) => { + const attributeValues = row.attribute_values || {}; + const tableRow = { + id: row.id, + campaign_type_name: row.campaign_type_name || '', + created_at: row.created_at || '', + }; + + attributes.forEach((attr, index) => { + tableRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? ''); + }); + + return tableRow; + }); + }, + + formatAttributeValue(value) { + if (value === null || value === undefined) { + return ''; + } + + if (Array.isArray(value) || typeof value === 'object') { + return JSON.stringify(value); + } + + return String(value); + }, + + columnsForAttributes(attributes) { + const columns = [ + { + title: 'Campaign Type', + field: 'campaign_type_name', + minWidth: 160, + headerFilter: 'input', + }, + ]; + + attributes.forEach((attr, index) => { + columns.push({ + title: attr.name, + field: 'attr_' + index, + minWidth: 150, + headerFilter: 'input', + formatter: function (cell) { + const value = cell.getValue(); + return value ? _escapeHtml(value) : ''; + }, + }); + }); + + columns.push( + { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' }, + { + title: 'Actions', + field: 'id', + width: 230, + hozAlign: 'center', + headerSort: false, + formatter: function (cell) { + const id = cell.getValue(); + return 'Jobs ' + + 'Edit ' + + ''; + }, + } + ); + + return columns; + }, + + goToCampaignJobs(event, row) { + const target = event.target; + if (target instanceof Element && target.closest('a, button')) { + return; + } + + window.location.href = '/campaigns/' + encodeURIComponent(row.getData().id) + '/jobs'; + }, + + reloadTable() { + this.loadTable(); + }, + + openCampaignJobs(campaign) { + this.selectedCampaignId = campaign.id; + this.selectedCampaignTitle = 'Campaign #' + campaign.id + ' - ' + campaign.campaign_type_name; + this.$nextTick(() => this.loadJobsTable()); + }, + + async reloadJobsTable() { + if (!this.selectedCampaignId) { + return; + } + + await this.loadJobsTable(); + }, + + closeJobsTable() { + this.selectedCampaignId = null; + this.selectedCampaignTitle = ''; + this.jobsErrorMessage = ''; + + if (this.jobsTable && typeof this.jobsTable.destroy === 'function') { + this.jobsTable.destroy(); + } + + this.jobsTable = null; + }, + + async loadJobsTable() { + const el = document.getElementById('campaign-jobs-drilldown-table'); + if (!el || typeof Tabulator === 'undefined' || this.isJobsLoading) { + return; + } + + this.isJobsLoading = true; + this.jobsErrorMessage = ''; + + try { + const response = await fetch('/campaigns/' + encodeURIComponent(this.selectedCampaignId) + '/jobs/data', { + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error('Unable to load campaign jobs.'); + } + + const rows = await response.json(); + const jobRows = Array.isArray(rows) ? rows : []; + const attributes = this.jobAttributeColumnsForRows(jobRows); + const tableRows = this.formatJobRows(jobRows, attributes); + const columns = this.jobColumnsForAttributes(attributes); + + if (!this.jobsTable) { + this.jobsTable = new Tabulator(el, { + data: tableRows, + layout: 'fitColumns', + responsiveLayout: 'collapse', + pagination: true, + paginationMode: 'local', + paginationSize: 10, + movableColumns: true, + placeholder: 'No jobs found for this campaign.', + initialSort: [{ column: 'job_type_name', dir: 'asc' }], + columns: columns, + }); + } else { + this.jobsTable.setColumns(columns); + this.jobsTable.setData(tableRows); + } + } catch (error) { + this.jobsErrorMessage = error.message || 'Unable to load campaign jobs.'; + } finally { + this.isJobsLoading = false; + } + }, + + jobAttributeColumnsForRows(rows) { + const attributes = []; + + rows.forEach((row) => { + this.normalizeAttributes(row.job_type_attributes || []).forEach((attr) => { + if (!attributes.some((existing) => existing.name === attr.name)) { + attributes.push(attr); + } + }); + + Object.keys(row.attribute_values || {}).forEach((name) => { + if (!attributes.some((existing) => existing.name === name)) { + attributes.push({ name: name, type: 'text', order: attributes.length + 1 }); + } + }); + }); + + return attributes; + }, + + formatJobRows(rows, attributes) { + return rows.map((row) => { + const attributeValues = row.attribute_values || {}; + const tableRow = { + id: row.id, + campaign_id: row.campaign_id || '', + job_type_id: row.job_type_id || '', + job_type_name: row.job_type_name || '', + created_at: row.created_at || '', + updated_at: row.updated_at || '', + edit_url: '/jobs/' + encodeURIComponent(row.id) + '/edit', + }; + + attributes.forEach((attr, index) => { + tableRow['job_attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? ''); + }); + + return tableRow; + }); + }, + + jobColumnsForAttributes(attributes) { + const columns = [ + { title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' }, + { title: 'Campaign ID', field: 'campaign_id', width: 120, hozAlign: 'center', headerFilter: 'input' }, + { title: 'Job Type ID', field: 'job_type_id', width: 120, hozAlign: 'center', headerFilter: 'input' }, + { title: 'Job Type', field: 'job_type_name', minWidth: 160, headerFilter: 'input' }, + ]; + + attributes.forEach((attr, index) => { + columns.push({ + title: attr.name, + field: 'job_attr_' + index, + minWidth: 150, + headerFilter: 'input', + formatter: function (cell) { + const value = cell.getValue(); + return value ? _escapeHtml(value) : ''; + }, + }); + }); + + columns.push( + { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' }, + { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' }, + { + title: 'Actions', + field: 'edit_url', + width: 90, + hozAlign: 'center', + headerSort: false, + formatter: function (cell) { + return 'Edit'; + }, + } + ); + + return columns; + }, + }; +}; + +window.deleteCampaign = function (id) { + if (!confirm('Delete this campaign? This cannot be undone.')) { + return; + } + _postDelete('/campaigns/' + id + '/delete'); +}; + +window.campaignJobsPageTable = function (campaignId, jobTypes) { + return { + table: null, + jobTypes: Array.isArray(jobTypes) ? jobTypes : [], + isLoading: false, + isConnecting: false, + isImporting: false, + errorMessage: '', + importSheetUrl: '', + sheets: [], + selectedSheetGid: '', + selectedImportJobTypeId: '0', + importMessage: '', + importErrorMessage: '', + + // File upload state + importSource: 'sheets', + fileSelected: false, + fileTempName: '', + fileSheets: [], + selectedFileSheetGid: '', + selectedFileJobTypeId: '0', + isLoadingFile: false, + isImportingFile: false, + + init() { + this.loadTable(); + }, + + dataUrl() { + return '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/data'; + }, + + sheetsUrl() { + return '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/import/sheets'; + }, + + importUrl() { + return '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/import'; + }, + + async loadTable() { + const el = document.getElementById('campaign-jobs-page-table'); + if (!el || typeof Tabulator === 'undefined' || this.isLoading) { + return; + } + + this.isLoading = true; + this.errorMessage = ''; + + try { + const response = await fetch(this.dataUrl(), { + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error('Unable to load campaign jobs.'); + } + + const rows = await response.json(); + const jobRows = Array.isArray(rows) ? rows : []; + const attributes = this.attributeColumnsForRows(jobRows); + const tableRows = this.formatRows(jobRows, attributes); + const columns = this.columnsForAttributes(attributes); + + if (!this.table) { + this.table = new Tabulator(el, { + data: tableRows, + layout: 'fitData', + maxHeight: '65vh', + pagination: true, + paginationMode: 'local', + paginationSize: 10, + movableColumns: true, + placeholder: 'No jobs found for this campaign.', + initialSort: [{ column: 'job_type_name', dir: 'asc' }], + columns: columns, + }); + } else { + this.table.setColumns(columns); + this.table.setData(tableRows); + } + } catch (error) { + this.errorMessage = error.message || 'Unable to load campaign jobs.'; + } finally { + this.isLoading = false; + } + }, + + attributeColumnsForRows(rows) { + const attributes = []; + + rows.forEach((row) => { + this.normalizeAttributes(row.job_type_attributes || []).forEach((attr) => { + if (!attributes.some((existing) => existing.name === attr.name)) { + attributes.push(attr); + } + }); + + Object.keys(row.attribute_values || {}).forEach((name) => { + if (!attributes.some((existing) => existing.name === name)) { + attributes.push({ name: name, type: 'text', order: attributes.length + 1 }); + } + }); + }); + + return attributes; + }, + + normalizeAttributes(attributes) { + return attributes + .filter((attr) => attr && attr.name) + .slice() + .sort((a, b) => (a.order || 0) - (b.order || 0)); + }, + + formatRows(rows, attributes) { + return rows.map((row) => { + const attributeValues = row.attribute_values || {}; + const tableRow = { + id: row.id, + campaign_id: row.campaign_id || '', + campaign_type_name: row.campaign_type_name || '', + job_type_id: row.job_type_id || '', + job_type_name: row.job_type_name || '', + created_at: row.created_at || '', + updated_at: row.updated_at || '', + edit_url: '/jobs/' + encodeURIComponent(row.id) + '/edit', + }; + + attributes.forEach((attr, index) => { + tableRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? ''); + }); + + return tableRow; + }); + }, + + formatAttributeValue(value) { + if (value === null || value === undefined) { + return ''; + } + + if (Array.isArray(value) || typeof value === 'object') { + return JSON.stringify(value); + } + + return String(value); + }, + + columnsForAttributes(attributes) { + const columns = [ + { title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' }, + { title: 'Campaign ID', field: 'campaign_id', width: 120, hozAlign: 'center', headerFilter: 'input' }, + { title: 'Campaign Type', field: 'campaign_type_name', minWidth: 160, headerFilter: 'input' }, + { title: 'Job Type ID', field: 'job_type_id', width: 120, hozAlign: 'center', headerFilter: 'input' }, + { title: 'Job Type', field: 'job_type_name', minWidth: 160, headerFilter: 'input' }, + ]; + + attributes.forEach((attr, index) => { + columns.push({ + title: attr.name, + field: 'attr_' + index, + minWidth: 150, + headerFilter: 'input', + formatter: function (cell) { + const value = cell.getValue(); + return value ? _escapeHtml(value) : ''; + }, + }); + }); + + columns.push( + { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' }, + { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' }, + { + title: 'Actions', + field: 'edit_url', + width: 90, + hozAlign: 'center', + headerSort: false, + formatter: function (cell) { + return 'Edit'; + }, + } + ); + + return columns; + }, + + reloadTable() { + this.loadTable(); + }, + + async connectGoogleSheet() { + this.isConnecting = true; + this.importMessage = ''; + this.importErrorMessage = ''; + this.sheets = []; + this.selectedSheetGid = ''; + + try { + const data = await this.postImportForm(this.sheetsUrl(), { + sheet_url: this.importSheetUrl, + }); + + this.sheets = Array.isArray(data.sheets) ? data.sheets : []; + if (this.sheets.length > 0) { + this.selectedSheetGid = this.sheets[0].gid; + this.importMessage = 'Connected. Select a sheet and job type to import.'; + } else { + this.importErrorMessage = 'No sheets were found in that Google Sheets file.'; + } + } catch (error) { + this.importErrorMessage = error.message || 'Unable to connect to Google Sheets.'; + } finally { + this.isConnecting = false; + } + }, + + async importGoogleSheet() { + this.isImporting = true; + this.importMessage = ''; + this.importErrorMessage = ''; + + try { + const data = await this.postImportForm(this.importUrl(), { + sheet_url: this.importSheetUrl, + sheet_gid: this.selectedSheetGid, + job_type_id: this.selectedImportJobTypeId, + }); + + const matched = Array.isArray(data.matched_attributes) ? data.matched_attributes.join(', ') : ''; + this.importMessage = 'Imported ' + data.imported + ' jobs. Skipped ' + data.skipped + ' empty rows.' + + (matched ? ' Matched: ' + matched + '.' : ''); + await this.loadTable(); + } catch (error) { + this.importErrorMessage = error.message || 'Unable to import Google Sheet.'; + } finally { + this.isImporting = false; + } + }, + + async postImportForm(url, fields) { + const body = new URLSearchParams(); + body.set('_token', window.__csrf || ''); + + Object.keys(fields).forEach((key) => { + body.set(key, fields[key] || ''); + }); + + const response = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }, + body: body.toString(), + }); + + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || 'Import failed.'); + } + + return data; + }, + + // ── File upload methods ─────────────────────────────────────────────── + + onFileSelect(event) { + this.fileSelected = event.target.files && event.target.files.length > 0; + this.fileTempName = ''; + this.fileSheets = []; + this.selectedFileSheetGid = ''; + this.importMessage = ''; + this.importErrorMessage = ''; + }, + + async loadFileSheets() { + this.isLoadingFile = true; + this.importMessage = ''; + this.importErrorMessage = ''; + this.fileTempName = ''; + this.fileSheets = []; + this.selectedFileSheetGid = ''; + + try { + const fileInput = this.$refs.fileInput; + if (!fileInput || !fileInput.files || fileInput.files.length === 0) { + throw new Error('No file selected.'); + } + + const form = new FormData(); + form.set('_token', window.__csrf || ''); + form.set('import_file', fileInput.files[0]); + + const url = '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/import/file/sheets'; + const response = await fetch(url, { + method: 'POST', + headers: { Accept: 'application/json' }, + body: form, + }); + + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || 'Could not read the file.'); + } + + this.fileTempName = data.temp_file || ''; + this.fileSheets = Array.isArray(data.sheets) ? data.sheets : []; + + if (this.fileSheets.length > 0) { + this.selectedFileSheetGid = this.fileSheets[0].gid; + this.importMessage = 'File loaded. Select a sheet and job type to import.'; + } else { + this.importErrorMessage = 'No sheets were found in the uploaded file.'; + } + } catch (error) { + this.importErrorMessage = error.message || 'Could not read the file.'; + } finally { + this.isLoadingFile = false; + } + }, + + async importFile() { + this.isImportingFile = true; + this.importMessage = ''; + this.importErrorMessage = ''; + + try { + const url = '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/import/file'; + const data = await this.postImportForm(url, { + temp_file: this.fileTempName, + sheet_gid: this.selectedFileSheetGid, + job_type_id: this.selectedFileJobTypeId, + }); + + const matched = Array.isArray(data.matched_attributes) ? data.matched_attributes.join(', ') : ''; + this.importMessage = 'Imported ' + data.imported + ' jobs. Skipped ' + data.skipped + ' empty rows.' + + (matched ? ' Matched: ' + matched + '.' : ''); + this.fileTempName = ''; + await this.loadTable(); + } catch (error) { + this.importErrorMessage = error.message || 'Import failed.'; + } finally { + this.isImportingFile = false; + } + }, + }; +}; + +window.campaignForm = function (types, initialTypeId, initialValues) { + return { + types: types, + selectedTypeId: String(initialTypeId || ''), + attributeValues: Object.assign({}, initialValues || {}), + + get currentType() { + var id = this.selectedTypeId; + if (!id) return null; + return this.types.find(function (t) { return String(t.id) === String(id); }) || null; + }, + + get currentAttributes() { + if (!this.currentType) return []; + return this.currentType.attributes.slice().sort(function (a, b) { + return (a.order || 0) - (b.order || 0); + }); + }, + + onTypeChange() { + this.attributeValues = {}; + }, + + inputType(attrType) { + return ['number', 'date'].includes(attrType) ? attrType : 'text'; + }, + + confirmDelete(event) { + if (confirm('Delete this campaign? This cannot be undone.')) { + event.target.submit(); + } + }, + }; +}; + +// Campaign Jobs + +window.campaignJobsTable = function (campaignId) { + return { + tables: {}, + groups: [], + isVisible: false, + isLoading: false, + hasLoaded: false, + errorMessage: '', + + dataUrl() { + return '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/data'; + }, + + async showTable() { + this.isVisible = true; + if (!this.hasLoaded) { + await this.loadGroups(); + } + }, + + hideTable() { + this.isVisible = false; + }, + + async reloadTable() { + this.isVisible = true; + await this.loadGroups(); + }, + + async loadGroups() { + this.isLoading = true; + this.errorMessage = ''; + this.destroyTables(); + + try { + const response = await fetch(this.dataUrl(), { + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error('Unable to load campaign jobs.'); + } + + const rows = await response.json(); + this.groups = this.groupRows(Array.isArray(rows) ? rows : []); + this.hasLoaded = true; + this.$nextTick(() => this.initTables()); + } catch (error) { + this.groups = []; + this.errorMessage = error.message || 'Unable to load campaign jobs.'; + } finally { + this.isLoading = false; + } + }, + + groupRows(rows) { + const groups = {}; + + rows.forEach((row) => { + const id = String(row.job_type_id || 0); + if (!groups[id]) { + groups[id] = { + id: id, + elementId: 'campaign-jobs-table-' + id, + name: row.job_type_name || 'Job Type #' + id, + attributes: this.normalizeAttributes(row.job_type_attributes || []), + rows: [], + }; + } + + const attributeValues = row.attribute_values || {}; + this.ensureAttributeColumns(groups[id], attributeValues); + + const gridRow = { + id: row.id, + created_at: row.created_at || '', + edit_url: '/jobs/' + encodeURIComponent(row.id) + '/edit', + }; + + groups[id].attributes.forEach((attr, index) => { + gridRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? ''); + }); + + groups[id].rows.push(gridRow); + }); + + return Object.values(groups).sort((a, b) => a.name.localeCompare(b.name)); + }, + + ensureAttributeColumns(group, attributeValues) { + Object.keys(attributeValues).forEach((name) => { + const exists = group.attributes.some((attr) => attr.name === name); + if (!exists) { + group.attributes.push({ + name: name, + type: 'text', + order: group.attributes.length + 1, + }); + } + }); + }, + + normalizeAttributes(attributes) { + return attributes + .filter((attr) => attr && attr.name) + .slice() + .sort((a, b) => (a.order || 0) - (b.order || 0)); + }, + + formatAttributeValue(value) { + if (value === null || value === undefined) { + return ''; + } + + if (Array.isArray(value) || typeof value === 'object') { + return JSON.stringify(value); + } + + return String(value); + }, + + initTables() { + if (typeof Tabulator === 'undefined') { + return; + } + + this.groups.forEach((group) => { + const el = document.getElementById(group.elementId); + if (!el || this.tables[group.id]) { + return; + } + + this.tables[group.id] = new Tabulator(el, { + data: group.rows, + layout: 'fitColumns', + responsiveLayout: 'collapse', + pagination: true, + paginationMode: 'local', + paginationSize: 5, + movableColumns: true, + placeholder: 'No jobs found for this job type.', + initialSort: [{ column: 'created_at', dir: 'desc' }], + columns: this.columnsForGroup(group), + }); + }); + }, + + columnsForGroup(group) { + const columns = group.attributes.map((attr, index) => ({ + title: attr.name, + field: 'attr_' + index, + minWidth: 150, + formatter: function (cell) { + const value = cell.getValue(); + return value ? _escapeHtml(value) : ''; + }, + })); + + if (columns.length === 0) { + columns.push({ title: 'Job ID', field: 'id', width: 90, hozAlign: 'center' }); + } + + columns.push( + { title: 'Created', field: 'created_at', minWidth: 160 }, + { + title: 'Actions', + field: 'edit_url', + width: 90, + hozAlign: 'center', + headerSort: false, + formatter: function (cell) { + return 'Edit'; + }, + } + ); + + return columns; + }, + + destroyTables() { + Object.values(this.tables).forEach((table) => { + if (table && typeof table.destroy === 'function') { + table.destroy(); + } + }); + this.tables = {}; + }, + }; +}; + +// Job Type + +window.jobTypeTable = function () { + return { + table: null, + + init() { + this.initTable(); + }, + + initTable() { + const el = document.getElementById('job-type-table'); + if (!el || typeof Tabulator === 'undefined') { + return; + } + + this.table = new Tabulator(el, { + ajaxURL: '/job-types/data', + layout: 'fitColumns', + responsiveLayout: 'collapse', + pagination: true, + paginationMode: 'local', + paginationSize: 10, + movableColumns: true, + placeholder: 'No job types found.', + initialSort: [{ column: 'name', dir: 'asc' }], + columns: [ + { title: 'Name', field: 'name', minWidth: 200 }, + { + title: 'Attributes', + field: 'attributes_summary', + minWidth: 240, + formatter: function (cell) { + const v = cell.getValue(); + return v ? '' + _escapeHtml(v) + '' + : ''; + }, + }, + { title: 'Count', field: 'attribute_count', hozAlign: 'center', width: 80 }, + { title: 'Created', field: 'created_at', minWidth: 160 }, + { + title: 'Actions', + field: 'id', + width: 160, + hozAlign: 'center', + headerSort: false, + formatter: function (cell) { + const id = cell.getValue(); + return 'Edit ' + + ''; + }, + }, + ], + }); + }, + + reloadTable() { + if (!this.table) { + this.initTable(); + return; + } + this.table.setData('/job-types/data'); + }, + }; +}; + +window.deleteJobType = function (id) { + if (!confirm('Delete this job type? This cannot be undone.')) { + return; + } + _postDelete('/job-types/' + id + '/delete'); +}; + +window.jobTypeForm = function (initialAttributes) { + return { + attributes: Array.isArray(initialAttributes) ? initialAttributes : [], + dragIndex: null, + dragOverIndex: null, + + addAttribute() { + this.attributes.push({ name: '', type: 'text', order: this.attributes.length + 1 }); + }, + + removeAttribute(index) { + this.attributes.splice(index, 1); + this.renumberOrder(); + }, + + renumberOrder() { + this.attributes.forEach(function (attr, i) { attr.order = i + 1; }); + }, + + dragStart(event, index) { + this.dragIndex = index; + event.dataTransfer.effectAllowed = 'move'; + }, + + dragOver(event, index) { + this.dragOverIndex = index; + }, + + drop(event, index) { + if (this.dragIndex !== null && this.dragIndex !== index) { + var moved = this.attributes.splice(this.dragIndex, 1)[0]; + this.attributes.splice(index, 0, moved); + this.renumberOrder(); + } + this.dragIndex = null; + this.dragOverIndex = null; + }, + + dragEnd() { + this.dragIndex = null; + this.dragOverIndex = null; + }, + + confirmDelete(event) { + if (confirm('Delete this job type? This cannot be undone.')) { + event.target.submit(); + } + }, + }; +}; + +// ── Job ─────────────────────────────────────────────────────────────────────── + +window.jobTable = function () { + return { + table: null, + + init() { + this.initTable(); + }, + + initTable() { + const el = document.getElementById('job-table'); + if (!el || typeof Tabulator === 'undefined') { + return; + } + + this.table = new Tabulator(el, { + ajaxURL: '/jobs/data', + layout: 'fitColumns', + responsiveLayout: 'collapse', + pagination: true, + paginationMode: 'local', + paginationSize: 10, + movableColumns: true, + placeholder: 'No jobs found.', + initialSort: [{ column: 'job_type_name', dir: 'asc' }], + columns: [ + { title: 'Campaign', field: 'campaign_type_name', minWidth: 160 }, + { title: 'Job Type', field: 'job_type_name', minWidth: 160 }, + { + title: 'Attributes', + field: 'attributes_summary', + minWidth: 220, + formatter: function (cell) { + const v = cell.getValue(); + return v ? '' + _escapeHtml(v) + '' + : ''; + }, + }, + { title: 'Created', field: 'created_at', minWidth: 160 }, + { + title: 'Actions', + field: 'id', + width: 160, + hozAlign: 'center', + headerSort: false, + formatter: function (cell) { + const id = cell.getValue(); + return 'Edit ' + + ''; + }, + }, + ], + }); + }, + + reloadTable() { + if (!this.table) { + this.initTable(); + return; + } + this.table.setData('/jobs/data'); + }, + }; +}; + +window.deleteJob = function (id) { + if (!confirm('Delete this job? This cannot be undone.')) { + return; + } + _postDelete('/jobs/' + id + '/delete'); +}; + +window.jobForm = function (jobTypes, initialTypeId, initialValues) { + return { + jobTypes: jobTypes, + selectedTypeId: String(initialTypeId || ''), + attributeValues: Object.assign({}, initialValues || {}), + + get currentType() { + var id = this.selectedTypeId; + if (!id) return null; + return this.jobTypes.find(function (t) { return String(t.id) === String(id); }) || null; + }, + + get currentAttributes() { + if (!this.currentType) return []; + return this.currentType.attributes.slice().sort(function (a, b) { + return (a.order || 0) - (b.order || 0); + }); + }, + + onTypeChange() { + this.attributeValues = {}; + }, + + inputType(attrType) { + return ['number', 'date'].includes(attrType) ? attrType : 'text'; + }, + + confirmDelete(event) { + if (confirm('Delete this job? This cannot be undone.')) { + event.target.submit(); + } + }, + }; +}; diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..d1da851 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,66 @@ +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'); diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..bfb9c3c --- /dev/null +++ b/scripts/README.md @@ -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. diff --git a/scripts/migrate.php b/scripts/migrate.php new file mode 100644 index 0000000..234c67f --- /dev/null +++ b/scripts/migrate.php @@ -0,0 +1,102 @@ +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 " . PHP_EOL; + echo " php scripts/migrate.php fresh [--seed]" . PHP_EOL; + exit(0); + } +} catch (Throwable $exception) { + fwrite(STDERR, $exception->getMessage() . PHP_EOL); + exit(1); +} diff --git a/tests/run.php b/tests/run.php new file mode 100644 index 0000000..dfc6615 --- /dev/null +++ b/tests/run.php @@ -0,0 +1,113 @@ +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";