Daniel Covington 1 dia atrás
commit
af50885a19
91 arquivos alterados com 10923 adições e 0 exclusões
  1. +13
    -0
      .claude/settings.local.json
  2. +19
    -0
      .env.example
  3. +47
    -0
      .gitignore
  4. +910
    -0
      AGENTS.md
  5. +21
    -0
      Dockerfile
  6. +52
    -0
      app/Controllers/AuthController.php
  7. +323
    -0
      app/Controllers/CampaignController.php
  8. +293
    -0
      app/Controllers/CampaignTypeController.php
  9. +31
    -0
      app/Controllers/HealthController.php
  10. +32
    -0
      app/Controllers/HomeController.php
  11. +557
    -0
      app/Controllers/JobController.php
  12. +254
    -0
      app/Controllers/JobTypeController.php
  13. +20
    -0
      app/Models/Campaign.php
  14. +19
    -0
      app/Models/CampaignType.php
  15. +18
    -0
      app/Models/Job.php
  16. +17
    -0
      app/Models/JobType.php
  17. +73
    -0
      app/Repositories/CampaignAuditRepository.php
  18. +91
    -0
      app/Repositories/CampaignRepository.php
  19. +73
    -0
      app/Repositories/CampaignTypeAuditRepository.php
  20. +56
    -0
      app/Repositories/CampaignTypeRepository.php
  21. +41
    -0
      app/Repositories/JobAuditRepository.php
  22. +109
    -0
      app/Repositories/JobRepository.php
  23. +41
    -0
      app/Repositories/JobTypeAuditRepository.php
  24. +52
    -0
      app/Repositories/JobTypeRepository.php
  25. +385
    -0
      app/Services/FileImportService.php
  26. +335
    -0
      app/Services/GoogleSheetImportService.php
  27. +30
    -0
      app/ViewModels/CampaignTypeViewModel.php
  28. +38
    -0
      app/ViewModels/CampaignViewModel.php
  29. +13
    -0
      app/ViewModels/HomeIndexViewModel.php
  30. +23
    -0
      app/ViewModels/JobTypeViewModel.php
  31. +36
    -0
      app/ViewModels/JobViewModel.php
  32. +106
    -0
      app/Views/campaign-types/create.php
  33. +126
    -0
      app/Views/campaign-types/edit.php
  34. +35
    -0
      app/Views/campaign-types/index.php
  35. +104
    -0
      app/Views/campaigns/create.php
  36. +151
    -0
      app/Views/campaigns/edit.php
  37. +54
    -0
      app/Views/campaigns/index.php
  38. +14
    -0
      app/Views/health/index.php
  39. +39
    -0
      app/Views/home/index.php
  40. +89
    -0
      app/Views/job-types/create.php
  41. +106
    -0
      app/Views/job-types/edit.php
  42. +34
    -0
      app/Views/job-types/index.php
  43. +152
    -0
      app/Views/jobs/campaign.php
  44. +115
    -0
      app/Views/jobs/create.php
  45. +120
    -0
      app/Views/jobs/edit.php
  46. +34
    -0
      app/Views/jobs/index.php
  47. +14
    -0
      app/Views/layouts/app.php
  48. +9
    -0
      app/Views/partials/footer.php
  49. +64
    -0
      app/Views/partials/header.php
  50. +25
    -0
      composer.json
  51. +811
    -0
      composer.lock
  52. +16
    -0
      config/database.php
  53. +57
    -0
      core/App.php
  54. +28
    -0
      core/Auth/AuthMiddleware.php
  55. +33
    -0
      core/Auth/AuthUser.php
  56. +195
    -0
      core/Auth/KeycloakAuth.php
  57. +54
    -0
      core/Auth/PermissionService.php
  58. +35
    -0
      core/Controller.php
  59. +57
    -0
      core/Database.php
  60. +70
    -0
      core/Dispatcher.php
  61. +73
    -0
      core/Http/Session.php
  62. +12
    -0
      core/Migration.php
  63. +297
    -0
      core/MigrationManager.php
  64. +38
    -0
      core/Repository.php
  65. +70
    -0
      core/Request.php
  66. +64
    -0
      core/Response.php
  67. +80
    -0
      core/Route.php
  68. +39
    -0
      core/Router.php
  69. +52
    -0
      core/Validator.php
  70. +68
    -0
      core/View.php
  71. +207
    -0
      core/helpers.php
  72. +37
    -0
      database/migrations/20260511_000001_create_campaign_type_table.php
  73. +44
    -0
      database/migrations/20260511_000003_create_campaign_type_audit_table.php
  74. +40
    -0
      database/migrations/20260511_000004_create_campaign_table.php
  75. +44
    -0
      database/migrations/20260511_000005_create_campaign_audit_table.php
  76. +37
    -0
      database/migrations/20260511_000006_create_job_type_table.php
  77. +42
    -0
      database/migrations/20260511_000007_create_job_type_audit_table.php
  78. +43
    -0
      database/migrations/20260511_000008_create_job_table.php
  79. +40
    -0
      database/migrations/20260511_000009_create_job_audit_table.php
  80. +46
    -0
      docker-compose.yml
  81. +12
    -0
      docker/apache/vhost.conf
  82. +64
    -0
      docs/README.md
  83. +80
    -0
      docs/REQUEST_FLOW.md
  84. +5
    -0
      public/.htaccess
  85. +992
    -0
      public/css/site.css
  86. +26
    -0
      public/index.php
  87. +1304
    -0
      public/js/app.js
  88. +66
    -0
      routes/web.php
  89. +17
    -0
      scripts/README.md
  90. +102
    -0
      scripts/migrate.php
  91. +113
    -0
      tests/run.php

+ 13
- 0
.claude/settings.local.json Ver arquivo

@@ -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)"
]
}
}

+ 19
- 0
.env.example Ver arquivo

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

+ 47
- 0
.gitignore Ver arquivo

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

+ 910
- 0
AGENTS.md Ver arquivo

@@ -0,0 +1,910 @@
# AGENT.md — PHP Coding Standard

This file defines the coding standards and working rules for AI agents and developers contributing to this PHP codebase. It is based on the principles from **PHP: The Right Way** and adapted into practical project instructions.

Source reference: https://phptherightway.com/

---

## 1. Core Philosophy

Write PHP that is:

- **Readable** before clever.
- **Secure by default**.
- **Consistent with community standards**.
- **Easy to test, debug, and refactor**.
- **Separated by responsibility**: routing, controllers, services, models, persistence, templates, and configuration should not be mixed together.

PHP does not have only one canonical “right way,” so prefer widely accepted standards, documented project conventions, and clear tradeoffs over personal style.

---

## 2. PHP Version Standard

Use the current stable PHP version supported by the project.

Default expectation:

```text
PHP 8.x+
```

Do not introduce code that depends on unsupported PHP versions unless the project explicitly targets a legacy runtime.

When adding a language feature, verify that it is supported by the project’s configured PHP version in `composer.json`.

Example:

```json
{
"require": {
"php": ">=8.2"
}
}
```

---

## 3. Coding Style

Follow recognized PHP standards unless the repository already defines stricter rules.

Preferred standards:

- **PSR-1**: Basic Coding Standard
- **PSR-12**: Extended Coding Style
- **PSR-4**: Autoloading

Use automated tooling rather than manual formatting arguments.

Recommended tools:

```bash
composer require --dev squizlabs/php_codesniffer
composer require --dev friendsofphp/php-cs-fixer
```

Example checks:

```bash
vendor/bin/phpcs --standard=PSR12 src tests
vendor/bin/php-cs-fixer fix --dry-run --diff
```

Example fix:

```bash
vendor/bin/php-cs-fixer fix
```

### Naming Rules

Use English names for code symbols and infrastructure.

Use:

```php
class InvoiceRepository
{
public function findByCustomerId(int $customerId): array
{
// ...
}
}
```

Avoid unclear abbreviations:

```php
class InvRepo
{
public function fbcid($cid)
{
// ...
}
}
```

### Formatting Rules

- Use `<?php` tags. Do not use short open tags.
- Use strict types at the top of new PHP files when practical:

```php
declare(strict_types=1);
```

- One class per file.
- Match namespaces to directory structure.
- Keep functions and methods small and focused.
- Prefer explicit visibility: `public`, `protected`, or `private`.
- Avoid global state unless required by the framework or legacy integration.

---

## 4. Project Structure

Prefer a predictable structure.

Example:

```text
project-root/
public/
index.php
src/
Controller/
Service/
Repository/
Entity/
ValueObject/
templates/
config/
tests/
var/
cache/
logs/
vendor/
composer.json
```

Rules:

- `public/` is the web root.
- Do not expose `src/`, `config/`, `tests/`, `vendor/`, or `.env` files through the web server.
- Put application code under `src/`.
- Put generated cache/log files under `var/` or another ignored runtime directory.
- Keep secrets outside the web root.

---

## 5. Namespaces and Autoloading

All new application classes must use namespaces.

Use PSR-4 autoloading through Composer.

Example `composer.json`:

```json
{
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
}
}
```

After changing autoload rules, run:

```bash
composer dump-autoload
```

Example class:

```php
<?php

declare(strict_types=1);

namespace App\Service;

final class InvoiceCalculator
{
public function calculateTotal(array $items): int
{
// Return cents, not floating-point dollars.
return array_sum(array_column($items, 'amountCents'));
}
}
```

---

## 6. Dependency Management

Use Composer for PHP dependencies.

Rules:

- Add packages with `composer require` or `composer require --dev`.
- Commit `composer.json` and `composer.lock` for applications.
- Do not manually copy vendor libraries into the project.
- Do not edit files under `vendor/`.
- Prefer maintained packages with clear documentation, tests, and recent releases.
- Remove unused packages.

Commands:

```bash
composer install
composer update vendor/package
composer audit
composer validate
```

Use `composer update` intentionally. Do not casually update every dependency in unrelated work.

---

## 7. Object-Oriented Design

Prefer clear object-oriented code for domain and application logic.

Use classes for cohesive behavior:

```php
final class CustomerName
{
public function __construct(private string $value)
{
if (trim($value) === '') {
throw new InvalidArgumentException('Customer name is required.');
}
}

public function value(): string
{
return $this->value;
}
}
```

Guidelines:

- Keep controllers thin.
- Put business rules in services or domain objects.
- Put persistence logic in repositories or data access classes.
- Use interfaces when multiple implementations are expected or when it improves testing.
- Avoid huge “utility” classes.
- Avoid magic methods unless they provide clear framework integration or a documented benefit.

---

## 8. Dependency Injection

Prefer dependency injection over creating dependencies inside classes.

Good:

```php
final class RegisterUser
{
public function __construct(
private UserRepository $users,
private PasswordHasher $passwords
) {
}

public function handle(string $email, string $plainPassword): void
{
$hash = $this->passwords->hash($plainPassword);
$this->users->create($email, $hash);
}
}
```

Avoid:

```php
final class RegisterUser
{
public function handle(string $email, string $plainPassword): void
{
$users = new UserRepository();
$passwords = new PasswordHasher();
// ...
}
}
```

Rules:

- Constructor injection is preferred for required dependencies.
- Do not use service locators casually.
- Do not hide dependencies in global variables.
- Keep dependency containers at application boundaries, not inside domain logic.

---

## 9. Database Access

Use PDO or a well-maintained database abstraction layer/ORM.

Never concatenate untrusted input into SQL.

Bad:

```php
$sql = "SELECT * FROM users WHERE id = " . $_GET['id'];
```

Good:

```php
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$user = $stmt->fetch(PDO::FETCH_ASSOC);
```

Rules:

- Use prepared statements and bound parameters.
- Validate input before using it in writes.
- Keep SQL out of templates.
- Keep database access out of controllers where practical.
- Use transactions when multiple writes must succeed or fail together.
- Do not rely only on client-side validation.
- Do not expose raw database errors to users.

Transaction example:

```php
$pdo->beginTransaction();

try {
$orders->create($order);
$auditLog->record('order.created', $order->id());
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
```

---

## 10. Input Validation and Output Escaping

Treat all external data as untrusted.

Untrusted data includes:

- `$_GET`
- `$_POST`
- `$_REQUEST`
- `$_COOKIE`
- `$_SERVER`
- uploaded files
- request bodies
- session values
- database values originally supplied by users
- third-party API responses

### Validate on Input

Example:

```php
$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);

if ($email === false || $email === null) {
throw new InvalidArgumentException('A valid email address is required.');
}
```

### Escape on Output

For HTML output:

```php
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
```

Usage:

```php
<p><?= e($user->name()) ?></p>
```

Rules:

- Escape based on context: HTML, attribute, JavaScript, CSS, URL, SQL, shell.
- Do not use the same escaping function for every context.
- Prefer template engines with automatic escaping when appropriate.
- Avoid allowing raw HTML from users. If required, sanitize with a proven whitelist sanitizer.
- Use `escapeshellarg()` when passing controlled values to shell commands, and avoid shell execution when possible.
- Never trust file paths supplied by users. Reject path traversal values such as `../`, `/`, `\`, and null bytes.

---

## 11. Passwords and Authentication

Never store plain-text passwords.

Use PHP’s password API:

```php
$hash = password_hash($plainPassword, PASSWORD_DEFAULT);

if (! password_verify($plainPassword, $hash)) {
throw new RuntimeException('Invalid credentials.');
}
```

Rules:

- Use `password_hash()` for new password hashes.
- Use `password_verify()` for login checks.
- Use `password_needs_rehash()` when algorithm/cost settings change.
- Do not create your own password hashing algorithm.
- Do not use general-purpose hashes like `md5`, `sha1`, or raw `sha256` for passwords.
- Rate-limit login attempts.
- Regenerate session IDs after login.
- Use secure, HTTP-only, SameSite cookies for sessions.

---

## 12. Serialization and Data Exchange

Do not call `unserialize()` on untrusted data.

Prefer JSON for data exchange:

```php
$data = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
$json = json_encode($data, JSON_THROW_ON_ERROR);
```

Rules:

- Use `JSON_THROW_ON_ERROR` for new code.
- Validate decoded data before using it.
- Avoid PHP serialization for data that crosses trust boundaries.

---

## 13. Configuration and Secrets

Rules:

- Keep secrets out of source control.
- Do not commit passwords, API keys, private keys, tokens, or production DSNs.
- Store configuration outside the public web root.
- Use environment variables or ignored local config files for secrets.
- Provide a safe example file such as `.env.example`.

Example `.gitignore` entries:

```text
.env
.env.local
/config/local.php
/var/cache/
/var/log/
/vendor/
```

Example `.env.example`:

```text
APP_ENV=local
APP_DEBUG=true
DATABASE_URL=mysql://user:password@localhost:3306/app
```

---

## 14. Error Handling and Logging

Use exceptions for exceptional failure paths.

Development:

- Show errors locally.
- Log errors.
- Use Xdebug when debugging complex issues.

Production:

- Do not display errors to users.
- Log errors to a secure log destination.
- Return safe, generic error messages.
- Preserve enough context in logs for troubleshooting.

Do not leak:

- stack traces to users
- SQL statements with secrets
- environment variables
- full filesystem paths
- tokens or passwords

Example:

```php
try {
$service->handle($request);
} catch (Throwable $e) {
$logger->error('Order processing failed.', [
'exception' => $e,
'requestId' => $requestId,
]);

http_response_code(500);
echo 'An unexpected error occurred.';
}
```

---

## 15. Templates and Views

Keep presentation separate from business logic.

Rules:

- Do not query the database from templates.
- Do not place business rules in templates.
- Escape output by default.
- Prefer simple view models or arrays passed into templates.
- Use a template engine with automatic escaping when it fits the project.

Plain PHP template example:

```php
<h1><?= e($pageTitle) ?></h1>

<ul>
<?php foreach ($users as $user): ?>
<li><?= e($user->name()) ?></li>
<?php endforeach; ?>
</ul>
```

---

## 16. HTTP and Web Application Rules

Rules:

- Use the front controller pattern where appropriate.
- Keep routing separate from business logic.
- Validate request methods.
- Use CSRF protection for state-changing forms.
- Use proper HTTP status codes.
- Redirect after successful POST to avoid duplicate form submission.
- Do not trust headers such as `X-Forwarded-For` unless configured behind a trusted proxy.

Example POST guard:

```php
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Method Not Allowed');
}
```

---

## 17. Security Checklist

Before completing any feature, verify:

- [ ] All external input is validated.
- [ ] All output is escaped for the correct context.
- [ ] SQL uses prepared statements or safe query builders.
- [ ] Authentication and authorization are checked server-side.
- [ ] Secrets are not committed.
- [ ] Errors are not exposed in production responses.
- [ ] File uploads validate size, extension, MIME type, and storage path.
- [ ] Passwords use `password_hash()` and `password_verify()`.
- [ ] CSRF protection exists for state-changing requests.
- [ ] Dangerous functions are avoided or justified: `eval`, `exec`, `shell_exec`, `system`, `passthru`, `unserialize`.
- [ ] Dependencies have no known vulnerabilities according to `composer audit`.

---

## 18. Testing Standard

Automated tests are expected for new behavior.

Preferred tools:

- PHPUnit
- Pest, if the project already uses it

Rules:

- Add or update tests with every behavior change.
- Cover success paths and failure paths.
- Unit-test business logic.
- Integration-test database and framework wiring where useful.
- Functional-test important user flows.
- Avoid relying on `var_dump()` or manual browser testing as the only verification.

Example PHPUnit test:

```php
final class InvoiceCalculatorTest extends TestCase
{
public function testItCalculatesTotalInCents(): void
{
$calculator = new InvoiceCalculator();

$total = $calculator->calculateTotal([
['amountCents' => 1000],
['amountCents' => 2500],
]);

self::assertSame(3500, $total);
}
}
```

Run tests:

```bash
vendor/bin/phpunit
```

---

## 19. Static Analysis and Quality Gates

Use static analysis when available.

Recommended tools:

```bash
composer require --dev phpstan/phpstan
composer require --dev vimeo/psalm
```

Common quality commands:

```bash
composer validate
composer audit
vendor/bin/phpcs --standard=PSR12 src tests
vendor/bin/phpunit
vendor/bin/phpstan analyse src tests
```

Do not ignore tool failures without documenting why.

---

## 20. Documentation

Use PHPDoc where it adds clarity, especially for arrays, generics-like structures, complex return values, and public APIs.

Good:

```php
/**
* @return list<Customer>
*/
public function findActiveCustomers(): array
{
// ...
}
```

Avoid noisy comments that repeat the code:

```php
// Increment i by one.
$i++;
```

Rules:

- Explain why, not just what.
- Document non-obvious tradeoffs.
- Keep README setup instructions current.
- Update examples when behavior changes.

---

## 21. Performance and Caching

Rules:

- Measure before optimizing.
- Avoid unnecessary database queries in loops.
- Use pagination for large result sets.
- Cache expensive reads where appropriate.
- Use OPcache in production.
- Do not cache user-specific sensitive data in shared caches without a clear key strategy.

---

## 22. Agent Workflow

When modifying this codebase, the AI agent must:

1. Inspect existing project conventions before adding new patterns.
2. Prefer small, focused changes.
3. Preserve public behavior unless explicitly asked to change it.
4. Add or update tests when behavior changes.
5. Run relevant checks when possible.
6. Explain any checks that could not be run.
7. Avoid introducing new dependencies unless they solve a clear problem.
8. Never place secrets in code, tests, fixtures, logs, or documentation.
9. Keep generated code consistent with this file.
10. Leave the repository better organized than it was found.

---

## 23. Pull Request / Review Checklist

Before considering work complete:

- [ ] Code follows PSR-12 or project-specific style.
- [ ] Namespaces and autoloading are correct.
- [ ] Composer files are valid.
- [ ] No unrelated dependency updates were introduced.
- [ ] New behavior is tested.
- [ ] Existing tests pass.
- [ ] SQL is parameterized.
- [ ] User input is validated.
- [ ] Output is escaped.
- [ ] No secrets are committed.
- [ ] Errors are handled safely.
- [ ] Documentation was updated where needed.

---

## 24. Legacy PHP Exception Policy

If this project contains legacy PHP:

- Do not rewrite large areas without approval.
- Add tests around legacy behavior before refactoring.
- Improve safety incrementally.
- Replace deprecated patterns as touched.
- Avoid mixing modernization with unrelated feature work.
- Document any compatibility constraints.

Legacy code should still move toward:

- Composer autoloading
- Namespaces
- PDO/prepared statements
- Centralized configuration
- Automated tests
- Safer error handling

---

## 25. Non-Negotiable Rules

The agent must not:

- Commit secrets.
- Build SQL using untrusted string concatenation.
- Store plain-text passwords.
- Use `md5`, `sha1`, or raw fast hashes for passwords.
- Display production errors to users.
- `unserialize()` untrusted data.
- Put database queries in templates.
- Edit files under `vendor/`.
- Add dependencies without a clear reason.
- Ignore failing tests or quality checks without explanation.

---

## 26. Recommended Composer Scripts

A project may include scripts like this:

```json
{
"scripts": {
"test": "phpunit",
"style": "phpcs --standard=PSR12 src tests",
"style:fix": "php-cs-fixer fix",
"analyse": "phpstan analyse src tests",
"quality": [
"@style",
"@test",
"@analyse"
]
}
}
```

Then run:

```bash
composer quality
```

---

## 27. Final Instruction to Coding Agents

When in doubt, choose the boring, obvious, secure PHP solution:

- Composer-managed dependencies
- PSR-style code
- Namespaced classes
- Dependency injection
- PDO prepared statements
- Escaped output
- Tested behavior
- Clear errors and logs
- No secrets in source control


## Project Overview

This project is a small PHP MVC framework called MindVisionCode PHP.

It is intentionally inspired by a Classic ASP MVC framework style:

- Central dispatcher
- Controllers and actions
- ViewModels
- Repository classes
- Simple validation
- Database migrations
- Small, readable files
- Minimal dependencies

Do not turn this into Laravel, Symfony, Slim, or another large framework.

## Tech Stack

- PHP 8.2+
- Composer
- PSR-4 autoloading
- PDO
- PHP views
- Optional SQLite/MySQL/SQL Server through PDO

## Development Commands

Install dependencies:

```bash
composer install
```

Regenerate autoload files:

```bash
composer dump-autoload
```

Run local server:

```bash
php -S localhost:8000 -t public
```

Run basic tests:

```bash
php tests/run.php
```

## Coding Rules

- Keep code simple and readable.
- Prefer small classes.
- Use typed properties and return types where practical.
- Avoid hidden magic.
- Do not add dependencies without a clear reason.
- Preserve the framework style.
- Explain any architectural changes.

## Request Flow

Browser → public/index.php → Request → Dispatcher → Router → Route → Controller → ViewModel/Repository → View → Response

## Creating Table
When you create tables and code for a table you will need a corrisponding _audit table with audit_id , id that refrences the object id , an action R I U D , the fields in json , username and created at

+ 21
- 0
Dockerfile Ver arquivo

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

+ 52
- 0
app/Controllers/AuthController.php Ver arquivo

@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace App\Controllers;

use Core\Controller;
use Core\Request;
use Core\Response;

class AuthController extends Controller
{
public function login(): Response
{
if (auth()->check()) {
return $this->redirect('/');
}

return $this->redirect(auth()->beginLogin());
}

public function callback(): Response
{
$request = Request::capture();
$error = (string) $request->input('error', '');

if ($error !== '') {
$desc = (string) $request->input('error_description', $error);
return new Response('Authentication error: ' . e($desc), 400);
}

$code = (string) $request->input('code', '');
$state = (string) $request->input('state', '');

if ($code === '' || $state === '') {
return $this->redirect('/login');
}

try {
auth()->handleCallback($code, $state);
} catch (\Throwable $e) {
return new Response('Login failed: ' . e($e->getMessage()), 400);
}

return $this->redirect('/');
}

public function logout(): Response
{
return $this->redirect(auth()->logout());
}
}

+ 323
- 0
app/Controllers/CampaignController.php Ver arquivo

@@ -0,0 +1,323 @@
<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Models\Campaign;
use App\Repositories\CampaignAuditRepository;
use App\Repositories\CampaignRepository;
use App\Repositories\CampaignTypeRepository;
use App\ViewModels\CampaignViewModel;
use Core\Controller;
use Core\Request;
use Core\Response;
use Core\Validator;

class CampaignController extends Controller
{
public function index(): Response
{
$request = Request::capture();
$model = new CampaignViewModel();
$model->saved = $request->input('saved') === '1';
$model->deleted = $request->input('deleted') === '1';

return $this->view('campaigns.index', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

public function data(): Response
{
$rows = $this->repo()->allWithType();

$data = array_map(static function (array $row): array {
$attrValues = [];
$campaignTypeAttributes = [];

if (!empty($row['attribute_values'])) {
$attrValues = json_decode((string) $row['attribute_values'], true) ?? [];
}

if (!empty($row['campaign_type_attributes'])) {
$campaignTypeAttributes = json_decode((string) $row['campaign_type_attributes'], true) ?? [];
}

$summary = implode(', ', array_map(
static fn($k, $v) => "{$k}: {$v}",
array_keys($attrValues),
array_values($attrValues)
));

return [
'id' => (int) $row['id'],
'campaign_type_id' => (int) $row['campaign_type_id'],
'campaign_type_name' => (string) $row['campaign_type_name'],
'campaign_type_attributes' => $campaignTypeAttributes,
'attribute_values' => $attrValues,
'attributes_summary' => $summary,
'created_at' => (string) $row['created_at'],
];
}, $rows);

return $this->json($data);
}

public function create(): Response
{
$model = new CampaignViewModel();
$model->title = 'New Campaign';
$model->campaignTypes = $this->loadCampaignTypes();

return $this->view('campaigns.create', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

public function store(): Response
{
$request = Request::capture();
$model = new CampaignViewModel();
$model->title = 'New Campaign';
$model->campaignTypes = $this->loadCampaignTypes();

[$form, $errors] = $this->validateForm($request, $model->campaignTypes);

if (!empty($errors)) {
$model->form = $form;
$model->errors = $errors;

return $this->view('campaigns.create', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

$campaign = new Campaign();
$campaign->campaignTypeId = (int) $form['campaign_type_id'];
$campaign->attributeValues = $form['attribute_values'];

$this->repo()->create($campaign);

// Audit: I — query back the inserted row to capture the generated id.
$inserted = $this->repo()->findLatestByType($campaign->campaignTypeId);
if ($inserted !== null) {
$this->auditRepo()->log(
(int) $inserted['id'],
'I',
$this->toAuditFields($inserted),
$this->currentUsername()
);
}

return $this->redirect('/campaigns?saved=1');
}

public function edit(string $id): Response
{
$row = $this->repo()->findWithType((int) $id);

if ($row === null) {
return $this->redirect('/campaigns');
}

$storedValues = [];

if (!empty($row['attribute_values'])) {
$storedValues = json_decode((string) $row['attribute_values'], true) ?? [];
}

$model = new CampaignViewModel();
$model->title = 'Edit Campaign';
$model->campaign = $row;
$model->saved = Request::capture()->input('saved') === '1';
$model->campaignTypes = $this->loadCampaignTypes();
$model->form = [
'campaign_type_id' => (int) $row['campaign_type_id'],
'attribute_values' => $storedValues,
];

return $this->view('campaigns.edit', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

public function update(string $id): Response
{
$before = $this->repo()->findWithType((int) $id);

if ($before === null) {
return $this->redirect('/campaigns');
}

$request = Request::capture();
$model = new CampaignViewModel();
$model->title = 'Edit Campaign';
$model->campaign = $before;
$model->campaignTypes = $this->loadCampaignTypes();

[$form, $errors] = $this->validateForm($request, $model->campaignTypes);

if (!empty($errors)) {
$model->form = $form;
$model->errors = $errors;

return $this->view('campaigns.edit', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

$campaign = new Campaign();
$campaign->id = (int) $id;
$campaign->campaignTypeId = (int) $form['campaign_type_id'];
$campaign->attributeValues = $form['attribute_values'];

$this->repo()->update($campaign);

// Audit: U — capture before and after snapshots.
$after = $this->repo()->findWithType((int) $id);
$this->auditRepo()->log(
(int) $id,
'U',
[
'before' => $this->toAuditFields($before),
'after' => $this->toAuditFields($after ?? []),
],
$this->currentUsername()
);

return $this->redirect('/campaigns/' . $id . '/edit?saved=1');
}

public function destroy(string $id): Response
{
$row = $this->repo()->find((int) $id);

if ($row !== null) {
$this->repo()->delete((int) $id);

// Audit: D — snapshot of the row at the moment of deletion.
$this->auditRepo()->log(
(int) $row['id'],
'D',
$this->toAuditFields($row),
$this->currentUsername()
);
}

return $this->redirect('/campaigns?deleted=1');
}

// ── Helpers ───────────────────────────────────────────────────────────────

/**
* @return list<array{id: int, name: string, attributes: list<array{name: string, type: string}>}>
*/
private function loadCampaignTypes(): array
{
return array_map(static function (array $type): array {
return [
'id' => (int) $type['id'],
'name' => (string) $type['name'],
'attributes' => json_decode((string) ($type['attributes'] ?? '[]'), true) ?? [],
];
}, $this->ctRepo()->allOrderedByName());
}

/**
* @param list<array{id: int, name: string, attributes: list<array{name: string, type: string}> }> $types
* @return list<array{name: string, type: string}>
*/
private function attributesForType(int $typeId, array $types): array
{
foreach ($types as $type) {
if ($type['id'] === $typeId) {
return $type['attributes'];
}
}

return [];
}

/**
* @param list<array{id: int, name: string, attributes: list<array{name: string, type: string}> }> $campaignTypes
* @return array{0: array{campaign_type_id: int|string, attribute_values: array<string, string>}, 1: array<string, list<string>>}
*/
private function validateForm(Request $request, array $campaignTypes): array
{
$campaignTypeId = (int) $request->input('campaign_type_id', 0);
$submittedValues = (array) ($request->input('attribute_values') ?? []);

$errors = [];

if (!verify_csrf_token((string) $request->input('_token', ''))) {
$errors['_token'][] = 'Your form session expired. Please refresh the page and try again.';
}

if ($campaignTypeId === 0) {
$errors['campaign_type_id'][] = 'Please select a campaign type.';
}

// Build attribute values — only keep keys that belong to the selected type.
$typeAttributes = $this->attributesForType($campaignTypeId, $campaignTypes);
$attributeValues = [];

foreach ($typeAttributes as $attr) {
$attributeValues[$attr['name']] = trim((string) ($submittedValues[$attr['name']] ?? ''));
}

$form = [
'campaign_type_id' => $campaignTypeId,
'attribute_values' => $attributeValues,
];

return [$form, $errors];
}

/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function toAuditFields(array $row): array
{
$attrValues = [];

if (!empty($row['attribute_values'])) {
$raw = $row['attribute_values'];
$attrValues = is_string($raw) ? (json_decode($raw, true) ?? []) : (array) $raw;
}

return [
'campaign_type_id' => (int) ($row['campaign_type_id'] ?? 0),
'campaign_type_name' => (string) ($row['campaign_type_name'] ?? ''),
'attribute_values' => $attrValues,
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}

private function currentUsername(): string
{
return auth()->user()?->username ?? 'system';
}

private function repo(): CampaignRepository
{
return new CampaignRepository(database());
}

private function auditRepo(): CampaignAuditRepository
{
return new CampaignAuditRepository(database());
}

private function ctRepo(): CampaignTypeRepository
{
return new CampaignTypeRepository(database());
}
}

+ 293
- 0
app/Controllers/CampaignTypeController.php Ver arquivo

@@ -0,0 +1,293 @@
<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Models\CampaignType;
use App\Repositories\CampaignTypeAuditRepository;
use App\Repositories\CampaignTypeRepository;
use App\ViewModels\CampaignTypeViewModel;
use Core\Controller;
use Core\Request;
use Core\Response;
use Core\Validator;

class CampaignTypeController extends Controller
{
public function index(): Response
{
$request = Request::capture();
$model = new CampaignTypeViewModel();
$model->saved = $request->input('saved') === '1';
$model->deleted = $request->input('deleted') === '1';

return $this->view('campaign-types.index', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

public function data(): Response
{
$rows = $this->repo()->allOrderedByName();

$data = array_map(static function (array $row): array {
$attrs = [];

if (!empty($row['attributes'])) {
$attrs = json_decode((string) $row['attributes'], true) ?? [];
}

return [
'id' => (int) $row['id'],
'name' => (string) $row['name'],
'attribute_count' => count($attrs),
'attributes_summary' => implode(', ', array_column($attrs, 'name')),
'created_at' => (string) $row['created_at'],
];
}, $rows);

return $this->json($data);
}

public function create(): Response
{
$model = new CampaignTypeViewModel();
$model->title = 'New Campaign Type';

return $this->view('campaign-types.create', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

public function store(): Response
{
$request = Request::capture();
[$form, $errors] = $this->validateForm($request);

if (empty($errors) && $this->repo()->findByName($form['name']) !== null) {
$errors['name'][] = 'A campaign type with that name already exists.';
}

if (!empty($errors)) {
$model = new CampaignTypeViewModel();
$model->title = 'New Campaign Type';
$model->form = $form;
$model->errors = $errors;

return $this->view('campaign-types.create', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

$campaignType = new CampaignType();
$campaignType->name = $form['name'];
$campaignType->attributes = $form['attributes'];

$this->repo()->create($campaignType);

// Audit: I — capture the newly inserted row (query by name to get the generated id).
$inserted = $this->repo()->findByName($form['name']);
if ($inserted !== null) {
$this->auditRepo()->log(
(int) $inserted['id'],
'I',
$this->toAuditFields($inserted),
$this->currentUsername()
);
}

return $this->redirect('/campaign-types?saved=1');
}

public function edit(string $id): Response
{
$row = $this->repo()->find((int) $id);

if ($row === null) {
return $this->redirect('/campaign-types');
}

$model = new CampaignTypeViewModel();
$model->title = 'Edit Campaign Type';
$model->campaignType = $row;
$model->saved = Request::capture()->input('saved') === '1';
$model->form = [
'name' => (string) $row['name'],
'attributes' => json_decode((string) ($row['attributes'] ?? '[]'), true) ?? [],
];

return $this->view('campaign-types.edit', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

public function update(string $id): Response
{
$before = $this->repo()->find((int) $id);

if ($before === null) {
return $this->redirect('/campaign-types');
}

$request = Request::capture();
[$form, $errors] = $this->validateForm($request);

if (empty($errors)) {
$existing = $this->repo()->findByName($form['name']);

if ($existing !== null && (int) $existing['id'] !== (int) $id) {
$errors['name'][] = 'A campaign type with that name already exists.';
}
}

if (!empty($errors)) {
$model = new CampaignTypeViewModel();
$model->title = 'Edit Campaign Type';
$model->campaignType = $before;
$model->form = $form;
$model->errors = $errors;

return $this->view('campaign-types.edit', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

$campaignType = new CampaignType();
$campaignType->id = (int) $id;
$campaignType->name = $form['name'];
$campaignType->attributes = $form['attributes'];

$this->repo()->update($campaignType);

// Audit: U — capture before and after snapshots.
$after = $this->repo()->find((int) $id);
$this->auditRepo()->log(
(int) $id,
'U',
[
'before' => $this->toAuditFields($before),
'after' => $this->toAuditFields($after ?? []),
],
$this->currentUsername()
);

return $this->redirect('/campaign-types/' . $id . '/edit?saved=1');
}

public function destroy(string $id): Response
{
$row = $this->repo()->find((int) $id);

if ($row !== null) {
$this->repo()->delete((int) $id);

// Audit: D — snapshot of the row at the moment of deletion.
$this->auditRepo()->log(
(int) $row['id'],
'D',
$this->toAuditFields($row),
$this->currentUsername()
);
}

return $this->redirect('/campaign-types?deleted=1');
}

// ── Helpers ───────────────────────────────────────────────────────────────

/**
* Build the fields payload for an audit entry.
* The attributes column is decoded so the audit JSON nests cleanly.
*
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function toAuditFields(array $row): array
{
$attrs = [];

if (!empty($row['attributes'])) {
$raw = $row['attributes'];
$attrs = is_string($raw) ? (json_decode($raw, true) ?? []) : (array) $raw;
}

return [
'name' => (string) ($row['name'] ?? ''),
'attributes' => $attrs,
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}

private function currentUsername(): string
{
return auth()->user()?->username ?? 'system';
}

/**
* @return array{0: array{name: string, attributes: list<array{name: string, type: string}>}, 1: array<string, list<string>>}
*/
private function validateForm(Request $request): array
{
$name = trim((string) $request->input('name', ''));
$attributeNames = (array) ($request->input('attribute_name') ?? []);
$attributeTypes = (array) ($request->input('attribute_type') ?? []);
$attributeOrders = (array) ($request->input('attribute_order') ?? []);

$errors = [];

if (!verify_csrf_token((string) $request->input('_token', ''))) {
$errors['_token'][] = 'Your form session expired. Please refresh the page and try again.';
}

$validator = (new Validator())
->required('name', $name, 'Campaign type name is required.')
->maxLength('name', $name, 255, 'Name must be 255 characters or fewer.');

$errors = array_merge($errors, $validator->errors());

$attributes = [];

foreach ($attributeNames as $i => $attrName) {
$attrName = trim((string) $attrName);
$attrType = trim((string) ($attributeTypes[$i] ?? 'text'));

if ($attrName === '') {
continue;
}

$attributes[] = [
'name' => $attrName,
'type' => in_array($attrType, ['text', 'number', 'date', 'boolean'], true) ? $attrType : 'text',
'order' => isset($attributeOrders[$i]) && (string) $attributeOrders[$i] !== ''
? max(1, (int) $attributeOrders[$i])
: count($attributes) + 1,
];
}

// Sort by the user-supplied order, then renumber sequentially so storage is always clean.
usort($attributes, static fn(array $a, array $b): int => $a['order'] <=> $b['order']);
foreach ($attributes as $seq => &$attr) {
$attr['order'] = $seq + 1;
}
unset($attr);

return [['name' => $name, 'attributes' => $attributes], $errors];
}

private function repo(): CampaignTypeRepository
{
return new CampaignTypeRepository(database());
}

private function auditRepo(): CampaignTypeAuditRepository
{
return new CampaignTypeAuditRepository(database());
}
}

+ 31
- 0
app/Controllers/HealthController.php Ver arquivo

@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace App\Controllers;

use Core\Controller;
use Core\Response;

class HealthController extends Controller
{
public function index(): Response
{
$dbOk = false;
$dbError = null;

try {
database()->first('SELECT 1 AS ping');
$dbOk = true;
} catch (\Throwable $e) {
$dbError = $e->getMessage();
}

return $this->view('health.index', [
'pageTitle' => 'Health Check — Campaign Tracker',
'dbOk' => $dbOk,
'dbError' => $dbError,
'appEnv' => env('APP_ENV', 'unknown'),
]);
}
}

+ 32
- 0
app/Controllers/HomeController.php Ver arquivo

@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace App\Controllers;

use App\ViewModels\HomeIndexViewModel;
use Core\Controller;

class HomeController extends Controller
{
public function index()
{
$model = new HomeIndexViewModel();
$model->title = 'Campaign Tracker';
$model->eyebrow = 'PHP MVC application';
$model->message = 'Manage campaign types and their configurable attributes using a lightweight PHP MVC stack backed by SQL Server.';
$model->routeExample = '/campaign-types';

return $this->view('home.index', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

public function user(string $id)
{
return $this->json([
'userId' => $id,
]);
}
}

+ 557
- 0
app/Controllers/JobController.php Ver arquivo

@@ -0,0 +1,557 @@
<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Models\Job;
use App\Repositories\CampaignRepository;
use App\Repositories\JobAuditRepository;
use App\Repositories\JobRepository;
use App\Repositories\JobTypeRepository;
use App\Services\FileImportService;
use App\Services\GoogleSheetImportService;
use App\ViewModels\JobViewModel;
use Core\Controller;
use Core\Request;
use Core\Response;
use Core\Validator;

class JobController extends Controller
{
public function index(): Response
{
$request = Request::capture();
$model = new JobViewModel();
$model->saved = $request->input('saved') === '1';
$model->deleted = $request->input('deleted') === '1';

return $this->view('jobs.index', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

public function data(): Response
{
return $this->json($this->formatJobRows($this->repo()->allWithDetails()));
}

public function dataForCampaign(string $campaignId): Response
{
return $this->json($this->formatJobRows(
$this->repo()->allWithDetailsForCampaign((int) $campaignId)
));
}

public function campaign(string $campaignId): Response
{
$campaign = $this->campaignRepo()->findWithType((int) $campaignId);

if ($campaign === null) {
return $this->redirect('/campaigns');
}

return $this->view('jobs.campaign', [
'campaign' => $campaign,
'jobTypes' => $this->loadJobTypes(),
'pageTitle' => 'Campaign #' . $campaignId . ' Jobs',
]);
}

public function googleSheetsList(string $campaignId): Response
{
if ($this->campaignRepo()->find((int) $campaignId) === null) {
return Response::json(['error' => 'Campaign not found.'], 404);
}

$request = Request::capture();
if (!verify_csrf_token((string) $request->input('_token', ''))) {
return Response::json(['error' => 'Your form session expired. Please refresh and try again.'], 419);
}

$url = trim((string) $request->input('sheet_url', ''));
if ($url === '') {
return Response::json(['error' => 'Enter a Google Sheets URL.'], 422);
}

try {
return Response::json($this->googleSheets()->sheets($url));
} catch (\Throwable $e) {
return Response::json(['error' => $e->getMessage()], 422);
}
}

public function importGoogleSheet(string $campaignId): Response
{
if ($this->campaignRepo()->find((int) $campaignId) === null) {
return Response::json(['error' => 'Campaign not found.'], 404);
}

$request = Request::capture();
if (!verify_csrf_token((string) $request->input('_token', ''))) {
return Response::json(['error' => 'Your form session expired. Please refresh and try again.'], 419);
}

$url = trim((string) $request->input('sheet_url', ''));
$gid = trim((string) $request->input('sheet_gid', ''));
$jobTypeId = (int) $request->input('job_type_id', 0);
$jobType = $this->jtRepo()->find($jobTypeId);

if ($url === '' || $gid === '' || $jobType === null) {
return Response::json(['error' => 'Select a Google Sheets file, sheet, and job type.'], 422);
}

try {
$sheet = $this->googleSheets()->rows($url, $gid);
$attributes = json_decode((string) ($jobType['attributes'] ?? '[]'), true) ?? [];
$result = $this->importRows(
(int) $campaignId,
$jobTypeId,
$attributes,
$sheet['headers'],
$sheet['rows']
);

return Response::json($result);
} catch (\Throwable $e) {
return Response::json(['error' => $e->getMessage()], 422);
}
}

public function create(): Response
{
$model = new JobViewModel();
$model->title = 'New Job';
$model->campaigns = $this->loadCampaigns();
$model->jobTypes = $this->loadJobTypes();

return $this->view('jobs.create', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

public function store(): Response
{
$request = Request::capture();
$model = new JobViewModel();
$model->title = 'New Job';
$model->campaigns = $this->loadCampaigns();
$model->jobTypes = $this->loadJobTypes();

[$form, $errors] = $this->validateForm($request, $model->jobTypes);

if (!empty($errors)) {
$model->form = $form;
$model->errors = $errors;

return $this->view('jobs.create', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

$job = new Job();
$job->campaignId = (int) $form['campaign_id'];
$job->jobTypeId = (int) $form['job_type_id'];
$job->attributeValues = $form['attribute_values'];

$this->repo()->create($job);

$inserted = $this->repo()->findLatestByCampaignAndType($job->campaignId, $job->jobTypeId);
if ($inserted !== null) {
$this->auditRepo()->log((int) $inserted['id'], 'I', $this->toAuditFields($inserted), $this->currentUsername());
}

return $this->redirect('/jobs?saved=1');
}

public function edit(string $id): Response
{
$row = $this->repo()->findWithDetails((int) $id);

if ($row === null) {
return $this->redirect('/jobs');
}

$storedValues = !empty($row['attribute_values'])
? (json_decode((string) $row['attribute_values'], true) ?? [])
: [];

$model = new JobViewModel();
$model->title = 'Edit Job';
$model->job = $row;
$model->saved = Request::capture()->input('saved') === '1';
$model->campaigns = $this->loadCampaigns();
$model->jobTypes = $this->loadJobTypes();
$model->form = [
'campaign_id' => (int) $row['campaign_id'],
'job_type_id' => (int) $row['job_type_id'],
'attribute_values' => $storedValues,
];

return $this->view('jobs.edit', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

public function update(string $id): Response
{
$before = $this->repo()->findWithDetails((int) $id);

if ($before === null) {
return $this->redirect('/jobs');
}

$request = Request::capture();
$model = new JobViewModel();
$model->title = 'Edit Job';
$model->job = $before;
$model->campaigns = $this->loadCampaigns();
$model->jobTypes = $this->loadJobTypes();

[$form, $errors] = $this->validateForm($request, $model->jobTypes);

if (!empty($errors)) {
$model->form = $form;
$model->errors = $errors;

return $this->view('jobs.edit', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

$job = new Job();
$job->id = (int) $id;
$job->campaignId = (int) $form['campaign_id'];
$job->jobTypeId = (int) $form['job_type_id'];
$job->attributeValues = $form['attribute_values'];

$this->repo()->update($job);

$after = $this->repo()->findWithDetails((int) $id);
$this->auditRepo()->log((int) $id, 'U', [
'before' => $this->toAuditFields($before),
'after' => $this->toAuditFields($after ?? []),
], $this->currentUsername());

return $this->redirect('/jobs/' . $id . '/edit?saved=1');
}

public function destroy(string $id): Response
{
$row = $this->repo()->findWithDetails((int) $id);

if ($row !== null) {
$this->repo()->delete((int) $id);
$this->auditRepo()->log((int) $row['id'], 'D', $this->toAuditFields($row), $this->currentUsername());
}

return $this->redirect('/jobs?deleted=1');
}

// ── Helpers ───────────────────────────────────────────────────────────────

private function loadCampaigns(): array
{
return $this->campaignRepo()->allWithType();
}

private function loadJobTypes(): array
{
return array_map(static function (array $t): array {
return [
'id' => (int) $t['id'],
'name' => (string) $t['name'],
'attributes' => json_decode((string) ($t['attributes'] ?? '[]'), true) ?? [],
];
}, $this->jtRepo()->allOrderedByName());
}

private function attributesForType(int $typeId, array $types): array
{
foreach ($types as $type) {
if ($type['id'] === $typeId) return $type['attributes'];
}
return [];
}

/**
* @param list<array{name?: string, type?: string, order?: int}> $attributes
* @param list<string> $headers
* @param list<array<string, string>> $rows
* @return array{imported: int, skipped: int, matched_attributes: list<string>}
*/
private function importRows(int $campaignId, int $jobTypeId, array $attributes, array $headers, array $rows): array
{
$headersByName = [];
foreach ($headers as $header) {
$normalized = $this->normalizeImportHeader($header);
if ($normalized !== '') {
$headersByName[$normalized] = $header;
}
}

$matchedAttributes = [];
foreach ($attributes as $attribute) {
$name = trim((string) ($attribute['name'] ?? ''));
if ($name === '') {
continue;
}

$header = $headersByName[$this->normalizeImportHeader($name)] ?? null;
if ($header !== null) {
$matchedAttributes[$name] = $header;
}
}

if ($matchedAttributes === []) {
throw new \RuntimeException('No sheet headers matched the selected job type attributes.');
}

$imported = 0;
$skipped = 0;

foreach ($rows as $row) {
$attributeValues = [];
$hasValue = false;

foreach ($matchedAttributes as $attributeName => $header) {
$value = trim((string) ($row[$header] ?? ''));
$attributeValues[$attributeName] = $value;
$hasValue = $hasValue || $value !== '';
}

if (!$hasValue) {
$skipped++;
continue;
}

$job = new Job();
$job->campaignId = $campaignId;
$job->jobTypeId = $jobTypeId;
$job->attributeValues = $attributeValues;

$this->repo()->create($job);

$inserted = $this->repo()->findLatestByCampaignAndType($campaignId, $jobTypeId);
if ($inserted !== null) {
$this->auditRepo()->log(
(int) $inserted['id'],
'I',
$this->toAuditFields($inserted),
$this->currentUsername()
);
}

$imported++;
}

return [
'imported' => $imported,
'skipped' => $skipped,
'matched_attributes' => array_keys($matchedAttributes),
];
}

private function normalizeImportHeader(string $value): string
{
$value = strtolower(trim($value));
$value = preg_replace('/[^a-z0-9]+/', ' ', $value) ?? '';

return trim(preg_replace('/\s+/', ' ', $value) ?? '');
}

private function googleSheets(): GoogleSheetImportService
{
return new GoogleSheetImportService();
}

private function fileImport(): FileImportService
{
return new FileImportService();
}

// ── File upload import ────────────────────────────────────────────────────

public function fileSheetsList(string $campaignId): Response
{
if ($this->campaignRepo()->find((int) $campaignId) === null) {
return Response::json(['error' => 'Campaign not found.'], 404);
}

$request = Request::capture();
if (!verify_csrf_token((string) $request->input('_token', ''))) {
return Response::json(['error' => 'Your form session expired. Please refresh and try again.'], 419);
}

$upload = $_FILES['import_file'] ?? null;
if ($upload === null || ($upload['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
return Response::json(['error' => 'No file was uploaded.'], 422);
}

try {
$service = $this->fileImport();
$filename = $service->store($upload);
$sheets = $service->sheets($filename);

return Response::json(['temp_file' => $filename, 'sheets' => $sheets]);
} catch (\Throwable $e) {
return Response::json(['error' => $e->getMessage()], 422);
}
}

public function importFile(string $campaignId): Response
{
if ($this->campaignRepo()->find((int) $campaignId) === null) {
return Response::json(['error' => 'Campaign not found.'], 404);
}

$request = Request::capture();
if (!verify_csrf_token((string) $request->input('_token', ''))) {
return Response::json(['error' => 'Your form session expired. Please refresh and try again.'], 419);
}

$tempFile = basename(trim((string) $request->input('temp_file', '')));
$gid = trim((string) $request->input('sheet_gid', '0'));
$jobTypeId = (int) $request->input('job_type_id', 0);
$jobType = $this->jtRepo()->find($jobTypeId);

if ($tempFile === '' || $jobType === null) {
return Response::json(['error' => 'Select a file, sheet, and job type.'], 422);
}

try {
$service = $this->fileImport();
$sheet = $service->rows($tempFile, $gid);
$attributes = json_decode((string) ($jobType['attributes'] ?? '[]'), true) ?? [];

$result = $this->importRows(
(int) $campaignId,
$jobTypeId,
$attributes,
$sheet['headers'],
$sheet['rows']
);

$service->delete($tempFile);

return Response::json($result);
} catch (\Throwable $e) {
return Response::json(['error' => $e->getMessage()], 422);
}
}

/**
* @param list<array<string, mixed>> $rows
* @return list<array<string, mixed>>
*/
private function formatJobRows(array $rows): array
{
return array_map(static function (array $row): array {
$attrValues = !empty($row['attribute_values'])
? (json_decode((string) $row['attribute_values'], true) ?? [])
: [];
$jobTypeAttributes = !empty($row['job_type_attributes'])
? (json_decode((string) $row['job_type_attributes'], true) ?? [])
: [];

$summary = implode(', ', array_map(
static fn($k, $v) => "{$k}: {$v}",
array_keys($attrValues),
array_values($attrValues)
));

return [
'id' => (int) $row['id'],
'campaign_id' => (int) $row['campaign_id'],
'campaign_type_name' => (string) $row['campaign_type_name'],
'job_type_id' => (int) $row['job_type_id'],
'job_type_name' => (string) $row['job_type_name'],
'job_type_attributes' => $jobTypeAttributes,
'attribute_values' => $attrValues,
'attributes_summary' => $summary,
'created_at' => (string) $row['created_at'],
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}, $rows);
}

private function validateForm(Request $request, array $jobTypes): array
{
$campaignId = (int) $request->input('campaign_id', 0);
$jobTypeId = (int) $request->input('job_type_id', 0);
$submittedValues = (array) ($request->input('attribute_values') ?? []);
$errors = [];

if (!verify_csrf_token((string) $request->input('_token', ''))) {
$errors['_token'][] = 'Your form session expired. Please refresh and try again.';
}

if ($campaignId === 0) {
$errors['campaign_id'][] = 'Please select a campaign.';
}

if ($jobTypeId === 0) {
$errors['job_type_id'][] = 'Please select a job type.';
}

$typeAttributes = $this->attributesForType($jobTypeId, $jobTypes);
$attributeValues = [];
foreach ($typeAttributes as $attr) {
$attributeValues[$attr['name']] = trim((string) ($submittedValues[$attr['name']] ?? ''));
}

return [
['campaign_id' => $campaignId, 'job_type_id' => $jobTypeId, 'attribute_values' => $attributeValues],
$errors,
];
}

private function toAuditFields(array $row): array
{
$attrValues = [];
if (!empty($row['attribute_values'])) {
$raw = $row['attribute_values'];
$attrValues = is_string($raw) ? (json_decode($raw, true) ?? []) : (array) $raw;
}

return [
'campaign_id' => (int) ($row['campaign_id'] ?? 0),
'campaign_type_name' => (string) ($row['campaign_type_name'] ?? ''),
'job_type_id' => (int) ($row['job_type_id'] ?? 0),
'job_type_name' => (string) ($row['job_type_name'] ?? ''),
'attribute_values' => $attrValues,
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}

private function currentUsername(): string
{
return auth()->user()?->username ?? 'system';
}

private function repo(): JobRepository
{
return new JobRepository(database());
}

private function auditRepo(): JobAuditRepository
{
return new JobAuditRepository(database());
}

private function campaignRepo(): CampaignRepository
{
return new CampaignRepository(database());
}

private function jtRepo(): JobTypeRepository
{
return new JobTypeRepository(database());
}
}

+ 254
- 0
app/Controllers/JobTypeController.php Ver arquivo

@@ -0,0 +1,254 @@
<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Models\JobType;
use App\Repositories\JobTypeAuditRepository;
use App\Repositories\JobTypeRepository;
use App\ViewModels\JobTypeViewModel;
use Core\Controller;
use Core\Request;
use Core\Response;
use Core\Validator;

class JobTypeController extends Controller
{
public function index(): Response
{
$request = Request::capture();
$model = new JobTypeViewModel();
$model->saved = $request->input('saved') === '1';
$model->deleted = $request->input('deleted') === '1';

return $this->view('job-types.index', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

public function data(): Response
{
$rows = $this->repo()->allOrderedByName();

$data = array_map(static function (array $row): array {
$attrs = !empty($row['attributes'])
? (json_decode((string) $row['attributes'], true) ?? [])
: [];

return [
'id' => (int) $row['id'],
'name' => (string) $row['name'],
'attribute_count' => count($attrs),
'attributes_summary' => implode(', ', array_column($attrs, 'name')),
'created_at' => (string) $row['created_at'],
];
}, $rows);

return $this->json($data);
}

public function create(): Response
{
$model = new JobTypeViewModel();
$model->title = 'New Job Type';

return $this->view('job-types.create', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

public function store(): Response
{
$request = Request::capture();
[$form, $errors] = $this->validateForm($request);

if (empty($errors) && $this->repo()->findByName($form['name']) !== null) {
$errors['name'][] = 'A job type with that name already exists.';
}

if (!empty($errors)) {
$model = new JobTypeViewModel();
$model->title = 'New Job Type';
$model->form = $form;
$model->errors = $errors;

return $this->view('job-types.create', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

$jobType = new JobType();
$jobType->name = $form['name'];
$jobType->attributes = $form['attributes'];

$this->repo()->create($jobType);

$inserted = $this->repo()->findByName($form['name']);
if ($inserted !== null) {
$this->auditRepo()->log((int) $inserted['id'], 'I', $this->toAuditFields($inserted), $this->currentUsername());
}

return $this->redirect('/job-types?saved=1');
}

public function edit(string $id): Response
{
$row = $this->repo()->find((int) $id);

if ($row === null) {
return $this->redirect('/job-types');
}

$model = new JobTypeViewModel();
$model->title = 'Edit Job Type';
$model->jobType = $row;
$model->saved = Request::capture()->input('saved') === '1';
$model->form = [
'name' => (string) $row['name'],
'attributes' => json_decode((string) ($row['attributes'] ?? '[]'), true) ?? [],
];

return $this->view('job-types.edit', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

public function update(string $id): Response
{
$row = $this->repo()->find((int) $id);

if ($row === null) {
return $this->redirect('/job-types');
}

$request = Request::capture();
[$form, $errors] = $this->validateForm($request);

if (empty($errors)) {
$existing = $this->repo()->findByName($form['name']);
if ($existing !== null && (int) $existing['id'] !== (int) $id) {
$errors['name'][] = 'A job type with that name already exists.';
}
}

if (!empty($errors)) {
$model = new JobTypeViewModel();
$model->title = 'Edit Job Type';
$model->jobType = $row;
$model->form = $form;
$model->errors = $errors;

return $this->view('job-types.edit', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

$before = $row;
$jobType = new JobType();
$jobType->id = (int) $id;
$jobType->name = $form['name'];
$jobType->attributes = $form['attributes'];

$this->repo()->update($jobType);

$after = $this->repo()->find((int) $id);
$this->auditRepo()->log((int) $id, 'U', [
'before' => $this->toAuditFields($before),
'after' => $this->toAuditFields($after ?? []),
], $this->currentUsername());

return $this->redirect('/job-types/' . $id . '/edit?saved=1');
}

public function destroy(string $id): Response
{
$row = $this->repo()->find((int) $id);

if ($row !== null) {
$this->repo()->delete((int) $id);
$this->auditRepo()->log((int) $row['id'], 'D', $this->toAuditFields($row), $this->currentUsername());
}

return $this->redirect('/job-types?deleted=1');
}

// ── Helpers ───────────────────────────────────────────────────────────────

private function toAuditFields(array $row): array
{
$attrs = [];
if (!empty($row['attributes'])) {
$raw = $row['attributes'];
$attrs = is_string($raw) ? (json_decode($raw, true) ?? []) : (array) $raw;
}

return [
'name' => (string) ($row['name'] ?? ''),
'attributes' => $attrs,
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}

private function currentUsername(): string
{
return auth()->user()?->username ?? 'system';
}

private function validateForm(Request $request): array
{
$name = trim((string) $request->input('name', ''));
$attributeNames = (array) ($request->input('attribute_name') ?? []);
$attributeTypes = (array) ($request->input('attribute_type') ?? []);
$attributeOrders = (array) ($request->input('attribute_order') ?? []);
$errors = [];

if (!verify_csrf_token((string) $request->input('_token', ''))) {
$errors['_token'][] = 'Your form session expired. Please refresh and try again.';
}

$errors = array_merge($errors, (new Validator())
->required('name', $name, 'Job type name is required.')
->maxLength('name', $name, 255, 'Name must be 255 characters or fewer.')
->errors());

$attributes = [];

foreach ($attributeNames as $i => $attrName) {
$attrName = trim((string) $attrName);
$attrType = trim((string) ($attributeTypes[$i] ?? 'text'));
if ($attrName === '') continue;
$attributes[] = [
'name' => $attrName,
'type' => in_array($attrType, ['text', 'number', 'date', 'boolean'], true) ? $attrType : 'text',
'order' => isset($attributeOrders[$i]) && (string) $attributeOrders[$i] !== ''
? max(1, (int) $attributeOrders[$i])
: count($attributes) + 1,
];
}

usort($attributes, static fn(array $a, array $b): int => $a['order'] <=> $b['order']);
foreach ($attributes as $seq => &$attr) {
$attr['order'] = $seq + 1;
}
unset($attr);

return [['name' => $name, 'attributes' => $attributes], $errors];
}

private function repo(): JobTypeRepository
{
return new JobTypeRepository(database());
}

private function auditRepo(): JobTypeAuditRepository
{
return new JobTypeAuditRepository(database());
}
}

+ 20
- 0
app/Models/Campaign.php Ver arquivo

@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace App\Models;

class Campaign
{
public ?int $id = null;
public int $campaignTypeId = 0;

/**
* Key → value pairs matching the parent campaign type's attribute names.
* @var array<string, string>
*/
public array $attributeValues = [];

public ?string $createdAt = null;
public ?string $updatedAt = null;
}

+ 19
- 0
app/Models/CampaignType.php Ver arquivo

@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace App\Models;

class CampaignType
{
public ?int $id = null;
public string $name = '';

/**
* @var list<array{name: string, type: string}>
*/
public array $attributes = [];

public ?string $createdAt = null;
public ?string $updatedAt = null;
}

+ 18
- 0
app/Models/Job.php Ver arquivo

@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace App\Models;

class Job
{
public ?int $id = null;
public int $campaignId = 0;
public int $jobTypeId = 0;

/** @var array<string, string> */
public array $attributeValues = [];

public ?string $createdAt = null;
public ?string $updatedAt = null;
}

+ 17
- 0
app/Models/JobType.php Ver arquivo

@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace App\Models;

class JobType
{
public ?int $id = null;
public string $name = '';

/** @var list<array{name: string, type: string}> */
public array $attributes = [];

public ?string $createdAt = null;
public ?string $updatedAt = null;
}

+ 73
- 0
app/Repositories/CampaignAuditRepository.php Ver arquivo

@@ -0,0 +1,73 @@
<?php

declare(strict_types=1);

namespace App\Repositories;

use Core\Repository;

/**
* Writes and reads entries in campaign_audit.
*
* Action codes:
* I – record was inserted (created)
* U – record was updated (fields contains {"before":{...},"after":{...}})
* D – record was deleted (snapshot of the row at time of deletion)
* R – record was restored after a previous deletion
*/
class CampaignAuditRepository extends Repository
{
protected string $table = 'campaign_audit';
protected string $primaryKey = 'audit_id';

/**
* Write one audit entry.
*
* @param array<string, mixed> $fields Snapshot or before/after payload.
*/
public function log(int $campaignId, string $action, array $fields, string $username): void
{
$this->database->execute(
"INSERT INTO campaign_audit (id, action, fields, username)
VALUES (:id, :action, :fields, :username)",
[
'id' => $campaignId,
'action' => $action,
'fields' => json_encode($fields, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'username' => $username,
]
);
}

/**
* All audit entries for one campaign, oldest first.
*
* @return list<array<string, mixed>>
*/
public function forRecord(int $campaignId): array
{
return $this->database->query(
'SELECT audit_id, id, action, fields, username, created_at
FROM campaign_audit
WHERE id = :id
ORDER BY audit_id ASC',
['id' => $campaignId]
);
}

/**
* Most recent audit entries across all campaigns.
*
* @return list<array<string, mixed>>
*/
public function recent(int $limit = 50): array
{
$limit = max(1, $limit);

return $this->database->query(
"SELECT TOP ({$limit}) audit_id, id, action, fields, username, created_at
FROM campaign_audit
ORDER BY audit_id DESC"
);
}
}

+ 91
- 0
app/Repositories/CampaignRepository.php Ver arquivo

@@ -0,0 +1,91 @@
<?php

declare(strict_types=1);

namespace App\Repositories;

use App\Models\Campaign;
use Core\Repository;

class CampaignRepository extends Repository
{
protected string $table = 'campaign';
protected string $primaryKey = 'id';

/**
* All campaigns joined with their campaign type name, ordered by id desc.
*
* @return list<array<string, mixed>>
*/
public function allWithType(): array
{
return $this->database->query(
'SELECT c.id, c.campaign_type_id, c.attribute_values,
c.created_at, c.updated_at,
ct.name AS campaign_type_name,
ct.attributes AS campaign_type_attributes
FROM campaign c
INNER JOIN campaign_type ct ON c.campaign_type_id = ct.id
ORDER BY c.id DESC'
);
}

/**
* Single campaign joined with its campaign type name and attributes.
*/
public function findWithType(int $id): ?array
{
return $this->database->first(
'SELECT c.id, c.campaign_type_id, c.attribute_values,
c.created_at, c.updated_at,
ct.name AS campaign_type_name,
ct.attributes AS campaign_type_attributes
FROM campaign c
INNER JOIN campaign_type ct ON c.campaign_type_id = ct.id
WHERE c.id = :id',
['id' => $id]
);
}

/**
* Return the most recently inserted campaign for a given type.
* Used after an INSERT to retrieve the generated id for audit logging.
*/
public function findLatestByType(int $typeId): ?array
{
return $this->database->first(
'SELECT TOP (1) * FROM campaign
WHERE campaign_type_id = :type_id
ORDER BY id DESC',
['type_id' => $typeId]
);
}

public function create(Campaign $campaign): bool
{
return $this->database->execute(
'INSERT INTO campaign (campaign_type_id, attribute_values)
VALUES (:campaign_type_id, :attribute_values)',
[
'campaign_type_id' => $campaign->campaignTypeId,
'attribute_values' => json_encode($campaign->attributeValues, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
]
);
}

public function update(Campaign $campaign): bool
{
return $this->database->execute(
'UPDATE campaign
SET campaign_type_id = :campaign_type_id,
attribute_values = :attribute_values,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id',
[
'campaign_type_id' => $campaign->campaignTypeId,
'attribute_values' => json_encode($campaign->attributeValues, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'id' => $campaign->id,
]
);
}
}

+ 73
- 0
app/Repositories/CampaignTypeAuditRepository.php Ver arquivo

@@ -0,0 +1,73 @@
<?php

declare(strict_types=1);

namespace App\Repositories;

use Core\Repository;

/**
* Writes and reads entries in campaign_type_audit.
*
* Action codes:
* I – record was inserted (created)
* U – record was updated (fields contains {"before":{...},"after":{...}})
* D – record was deleted (snapshot of the row at time of deletion)
* R – record was restored after a previous deletion
*/
class CampaignTypeAuditRepository extends Repository
{
protected string $table = 'campaign_type_audit';
protected string $primaryKey = 'audit_id';

/**
* Write one audit entry.
*
* @param array<string, mixed> $fields Snapshot or before/after payload.
*/
public function log(int $campaignTypeId, string $action, array $fields, string $username): void
{
$this->database->execute(
"INSERT INTO campaign_type_audit (id, action, fields, username)
VALUES (:id, :action, :fields, :username)",
[
'id' => $campaignTypeId,
'action' => $action,
'fields' => json_encode($fields, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'username' => $username,
]
);
}

/**
* All audit entries for one campaign type, oldest first.
*
* @return list<array<string, mixed>>
*/
public function forRecord(int $campaignTypeId): array
{
return $this->database->query(
'SELECT audit_id, id, action, fields, username, created_at
FROM campaign_type_audit
WHERE id = :id
ORDER BY audit_id ASC',
['id' => $campaignTypeId]
);
}

/**
* Most recent audit entries across all campaign types.
*
* @return list<array<string, mixed>>
*/
public function recent(int $limit = 50): array
{
$limit = max(1, $limit);

return $this->database->query(
"SELECT TOP ({$limit}) audit_id, id, action, fields, username, created_at
FROM campaign_type_audit
ORDER BY audit_id DESC"
);
}
}

+ 56
- 0
app/Repositories/CampaignTypeRepository.php Ver arquivo

@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace App\Repositories;

use App\Models\CampaignType;
use Core\Repository;

class CampaignTypeRepository extends Repository
{
protected string $table = 'campaign_type';

/**
* @return list<array<string, mixed>>
*/
public function allOrderedByName(): array
{
return $this->database->query(
'SELECT * FROM campaign_type ORDER BY name ASC'
);
}

public function findByName(string $name): ?array
{
return $this->database->first(
'SELECT * FROM campaign_type WHERE name = :name',
['name' => $name]
);
}

public function create(CampaignType $campaignType): bool
{
return $this->database->execute(
'INSERT INTO campaign_type (name, attributes) VALUES (:name, :attributes)',
[
'name' => $campaignType->name,
'attributes' => json_encode($campaignType->attributes, JSON_THROW_ON_ERROR),
]
);
}

public function update(CampaignType $campaignType): bool
{
return $this->database->execute(
'UPDATE campaign_type
SET name = :name, attributes = :attributes, updated_at = CURRENT_TIMESTAMP
WHERE id = :id',
[
'name' => $campaignType->name,
'attributes' => json_encode($campaignType->attributes, JSON_THROW_ON_ERROR),
'id' => $campaignType->id,
]
);
}
}

+ 41
- 0
app/Repositories/JobAuditRepository.php Ver arquivo

@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace App\Repositories;

use Core\Repository;

/**
* Action codes: I Insert · U Update · D Delete · R Restore
*/
class JobAuditRepository extends Repository
{
protected string $table = 'job_audit';
protected string $primaryKey = 'audit_id';

/** @param array<string, mixed> $fields */
public function log(int $jobId, string $action, array $fields, string $username): void
{
$this->database->execute(
"INSERT INTO job_audit (id, action, fields, username)
VALUES (:id, :action, :fields, :username)",
[
'id' => $jobId,
'action' => $action,
'fields' => json_encode($fields, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'username' => $username,
]
);
}

/** @return list<array<string, mixed>> */
public function forRecord(int $jobId): array
{
return $this->database->query(
'SELECT audit_id, id, action, fields, username, created_at
FROM job_audit WHERE id = :id ORDER BY audit_id ASC',
['id' => $jobId]
);
}
}

+ 109
- 0
app/Repositories/JobRepository.php Ver arquivo

@@ -0,0 +1,109 @@
<?php

declare(strict_types=1);

namespace App\Repositories;

use App\Models\Job;
use Core\Repository;

class JobRepository extends Repository
{
protected string $table = 'job';
protected string $primaryKey = 'id';

/** @return list<array<string, mixed>> */
public function allWithDetails(): array
{
return $this->database->query(
'SELECT j.id, j.campaign_id, j.job_type_id, j.attribute_values,
j.created_at, j.updated_at,
ct.name AS campaign_type_name,
jt.name AS job_type_name,
jt.attributes AS job_type_attributes
FROM job j
INNER JOIN campaign c ON j.campaign_id = c.id
INNER JOIN campaign_type ct ON c.campaign_type_id = ct.id
INNER JOIN job_type jt ON j.job_type_id = jt.id
ORDER BY j.id DESC'
);
}

/** @return list<array<string, mixed>> */
public function allWithDetailsForCampaign(int $campaignId): array
{
return $this->database->query(
'SELECT j.id, j.campaign_id, j.job_type_id, j.attribute_values,
j.created_at, j.updated_at,
ct.name AS campaign_type_name,
jt.name AS job_type_name,
jt.attributes AS job_type_attributes
FROM job j
INNER JOIN campaign c ON j.campaign_id = c.id
INNER JOIN campaign_type ct ON c.campaign_type_id = ct.id
INNER JOIN job_type jt ON j.job_type_id = jt.id
WHERE j.campaign_id = :campaign_id
ORDER BY j.id DESC',
['campaign_id' => $campaignId]
);
}

public function findWithDetails(int $id): ?array
{
return $this->database->first(
'SELECT j.id, j.campaign_id, j.job_type_id, j.attribute_values,
j.created_at, j.updated_at,
ct.name AS campaign_type_name,
jt.name AS job_type_name,
jt.attributes AS job_type_attributes
FROM job j
INNER JOIN campaign c ON j.campaign_id = c.id
INNER JOIN campaign_type ct ON c.campaign_type_id = ct.id
INNER JOIN job_type jt ON j.job_type_id = jt.id
WHERE j.id = :id',
['id' => $id]
);
}

/** Used after INSERT to recover the generated id for audit logging. */
public function findLatestByCampaignAndType(int $campaignId, int $jobTypeId): ?array
{
return $this->database->first(
'SELECT TOP (1) * FROM job
WHERE campaign_id = :campaign_id AND job_type_id = :job_type_id
ORDER BY id DESC',
['campaign_id' => $campaignId, 'job_type_id' => $jobTypeId]
);
}

public function create(Job $job): bool
{
return $this->database->execute(
'INSERT INTO job (campaign_id, job_type_id, attribute_values)
VALUES (:campaign_id, :job_type_id, :attribute_values)',
[
'campaign_id' => $job->campaignId,
'job_type_id' => $job->jobTypeId,
'attribute_values' => json_encode($job->attributeValues, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
]
);
}

public function update(Job $job): bool
{
return $this->database->execute(
'UPDATE job
SET campaign_id = :campaign_id,
job_type_id = :job_type_id,
attribute_values = :attribute_values,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id',
[
'campaign_id' => $job->campaignId,
'job_type_id' => $job->jobTypeId,
'attribute_values' => json_encode($job->attributeValues, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'id' => $job->id,
]
);
}
}

+ 41
- 0
app/Repositories/JobTypeAuditRepository.php Ver arquivo

@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace App\Repositories;

use Core\Repository;

/**
* Action codes: I Insert · U Update · D Delete · R Restore
*/
class JobTypeAuditRepository extends Repository
{
protected string $table = 'job_type_audit';
protected string $primaryKey = 'audit_id';

/** @param array<string, mixed> $fields */
public function log(int $jobTypeId, string $action, array $fields, string $username): void
{
$this->database->execute(
"INSERT INTO job_type_audit (id, action, fields, username)
VALUES (:id, :action, :fields, :username)",
[
'id' => $jobTypeId,
'action' => $action,
'fields' => json_encode($fields, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'username' => $username,
]
);
}

/** @return list<array<string, mixed>> */
public function forRecord(int $jobTypeId): array
{
return $this->database->query(
'SELECT audit_id, id, action, fields, username, created_at
FROM job_type_audit WHERE id = :id ORDER BY audit_id ASC',
['id' => $jobTypeId]
);
}
}

+ 52
- 0
app/Repositories/JobTypeRepository.php Ver arquivo

@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace App\Repositories;

use App\Models\JobType;
use Core\Repository;

class JobTypeRepository extends Repository
{
protected string $table = 'job_type';

/** @return list<array<string, mixed>> */
public function allOrderedByName(): array
{
return $this->database->query('SELECT * FROM job_type ORDER BY name ASC');
}

public function findByName(string $name): ?array
{
return $this->database->first(
'SELECT * FROM job_type WHERE name = :name',
['name' => $name]
);
}

public function create(JobType $jobType): bool
{
return $this->database->execute(
'INSERT INTO job_type (name, attributes) VALUES (:name, :attributes)',
[
'name' => $jobType->name,
'attributes' => json_encode($jobType->attributes, JSON_THROW_ON_ERROR),
]
);
}

public function update(JobType $jobType): bool
{
return $this->database->execute(
'UPDATE job_type
SET name = :name, attributes = :attributes, updated_at = CURRENT_TIMESTAMP
WHERE id = :id',
[
'name' => $jobType->name,
'attributes' => json_encode($jobType->attributes, JSON_THROW_ON_ERROR),
'id' => $jobType->id,
]
);
}
}

+ 385
- 0
app/Services/FileImportService.php Ver arquivo

@@ -0,0 +1,385 @@
<?php

declare(strict_types=1);

namespace App\Services;

use RuntimeException;

/**
* Reads job row data from an uploaded CSV or Excel (.xlsx) file.
* No external libraries — CSV uses fgetcsv, xlsx uses ZipArchive + SimpleXML.
*/
class FileImportService
{
private string $tempDir;

public function __construct()
{
$this->tempDir = rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . 'ct_imports' . DIRECTORY_SEPARATOR;

if (!is_dir($this->tempDir)) {
mkdir($this->tempDir, 0700, true);
}
}

// ── Upload ────────────────────────────────────────────────────────────────

/**
* Move an uploaded file to the temp store and return its assigned filename.
*
* @param array{name: string, tmp_name: string, error: int} $upload $_FILES entry
*/
public function store(array $upload): string
{
if (($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('File upload failed (error code ' . $upload['error'] . ').');
}

$ext = strtolower(pathinfo((string) $upload['name'], PATHINFO_EXTENSION));

if (!in_array($ext, ['csv', 'xlsx'], true)) {
throw new RuntimeException('Only CSV (.csv) and Excel (.xlsx) files are supported.');
}

$filename = bin2hex(random_bytes(16)) . '.' . $ext;
$dest = $this->tempDir . $filename;

if (!move_uploaded_file((string) $upload['tmp_name'], $dest)) {
throw new RuntimeException('Could not save the uploaded file.');
}

$this->cleanup();

return $filename;
}

// ── Sheet list ────────────────────────────────────────────────────────────

/**
* Returns the sheets in the file in the same shape as GoogleSheetImportService.
*
* @return list<array{gid: string, title: string}>
*/
public function sheets(string $filename): array
{
$path = $this->guardedPath($filename);
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));

if ($ext === 'csv') {
return [['gid' => '0', 'title' => 'CSV data']];
}

return $this->xlsxSheets($path);
}

// ── Row data ──────────────────────────────────────────────────────────────

/**
* Returns rows for the given sheet.
* For CSV the gid is ignored (only one sheet).
* For xlsx the gid is the 0-based sheet index returned by sheets().
*
* @return array{headers: list<string>, rows: list<array<string, string>>}
*/
public function rows(string $filename, string $gid): array
{
$path = $this->guardedPath($filename);
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));

if ($ext === 'csv') {
return $this->parseCsv((string) file_get_contents($path));
}

return $this->xlsxRows($path, (int) $gid);
}

public function delete(string $filename): void
{
$path = $this->tempDir . basename($filename);
if (file_exists($path)) {
@unlink($path);
}
}

// ── xlsx parsing ──────────────────────────────────────────────────────────

/**
* @return list<array{gid: string, title: string}>
*/
private function xlsxSheets(string $path): array
{
$zip = $this->openZip($path);

try {
$xml = $zip->getFromName('xl/workbook.xml');
if ($xml === false) {
throw new RuntimeException('Invalid xlsx file — workbook.xml not found.');
}

$wb = simplexml_load_string($xml);
$sheets = [];
$index = 0;

foreach ($wb->sheets->sheet as $sheet) {
$title = trim((string) $sheet['name']);
if ($title !== '') {
$sheets[] = ['gid' => (string) $index, 'title' => $title];
$index++;
}
}

return $sheets;
} finally {
$zip->close();
}
}

/**
* @return array{headers: list<string>, rows: list<array<string, string>>}
*/
private function xlsxRows(string $path, int $sheetIndex): array
{
$zip = $this->openZip($path);

try {
// Shared strings table
$sharedStrings = [];
$ssXml = $zip->getFromName('xl/sharedStrings.xml');
if ($ssXml !== false) {
$ss = simplexml_load_string($ssXml);
foreach ($ss->si as $si) {
if (isset($si->t)) {
$sharedStrings[] = (string) $si->t;
} else {
$text = '';
foreach ($si->r as $r) {
$text .= (string) $r->t;
}
$sharedStrings[] = $text;
}
}
}

// Resolve sheet file from workbook rels
$sheetFile = $this->xlsxSheetFile($zip, $sheetIndex);
if ($sheetFile === null) {
throw new RuntimeException("Sheet index {$sheetIndex} not found in this file.");
}

$sheetXml = $zip->getFromName($sheetFile);
if ($sheetXml === false) {
throw new RuntimeException("Cannot read sheet data.");
}

return $this->parseSheetXml($sheetXml, $sharedStrings);
} finally {
$zip->close();
}
}

private function xlsxSheetFile(\ZipArchive $zip, int $sheetIndex): ?string
{
$wbXml = $zip->getFromName('xl/workbook.xml');
$relXml = $zip->getFromName('xl/_rels/workbook.xml.rels');

if ($wbXml === false || $relXml === false) {
return null;
}

$wb = simplexml_load_string($wbXml);
$rel = simplexml_load_string($relXml);

// Build rId → target map
$relMap = [];
foreach ($rel->Relationship as $r) {
$relMap[(string) $r['Id']] = (string) $r['Target'];
}

$index = 0;
foreach ($wb->sheets->sheet as $sheet) {
if ($index === $sheetIndex) {
// r:id attribute lives in the "r" namespace
$rAttrs = $sheet->attributes('r', true);
$rId = $rAttrs ? (string) $rAttrs['id'] : (string) $sheet['r:id'];

if (isset($relMap[$rId])) {
$target = $relMap[$rId];
return str_starts_with($target, '/') ? ltrim($target, '/') : 'xl/' . $target;
}
}
$index++;
}

return null;
}

/**
* @param list<string> $sharedStrings
* @return array{headers: list<string>, rows: list<array<string, string>>}
*/
private function parseSheetXml(string $xml, array $sharedStrings): array
{
$sheet = simplexml_load_string($xml);
$rawRows = [];
$maxCol = 0;

foreach ($sheet->sheetData->row as $row) {
$rowIdx = (int) $row['r'] - 1;
$cells = [];

foreach ($row->c as $cell) {
$ref = (string) $cell['r'];
$type = (string) $cell['t'];
$value = '';

if (isset($cell->v)) {
$v = (string) $cell->v;
if ($type === 's') {
$value = $sharedStrings[(int) $v] ?? '';
} elseif ($type === 'inlineStr' && isset($cell->is->t)) {
$value = (string) $cell->is->t;
} else {
$value = $v;
}
}

$colIdx = $this->colIndex(preg_replace('/\d/', '', $ref) ?? '');
$cells[$colIdx] = $value;
$maxCol = max($maxCol, $colIdx);
}

$rawRows[$rowIdx] = $cells;
}

if (empty($rawRows)) {
return ['headers' => [], 'rows' => []];
}

$minRow = min(array_keys($rawRows));
$headers = [];
for ($c = 0; $c <= $maxCol; $c++) {
$headers[] = trim((string) ($rawRows[$minRow][$c] ?? ''));
}

$allRowIndexes = array_keys($rawRows);
sort($allRowIndexes);
$rows = [];

foreach ($allRowIndexes as $ri) {
if ($ri === $minRow) continue;

$row = [];
$hasValue = false;

foreach ($headers as $c => $header) {
if ($header === '') continue;
$value = trim((string) ($rawRows[$ri][$c] ?? ''));
$row[$header] = $value;
$hasValue = $hasValue || $value !== '';
}

if ($hasValue) {
$rows[] = $row;
}
}

return ['headers' => $headers, 'rows' => $rows];
}

private function colIndex(string $col): int
{
$col = strtoupper($col);
$index = 0;

for ($i = 0, $len = strlen($col); $i < $len; $i++) {
$index = $index * 26 + (ord($col[$i]) - 64);
}

return $index - 1;
}

// ── CSV parsing ───────────────────────────────────────────────────────────

/**
* @return array{headers: list<string>, rows: list<array<string, string>>}
*/
private function parseCsv(string $csv): array
{
$handle = fopen('php://temp', 'r+');
if ($handle === false) {
throw new RuntimeException('Unable to parse the CSV file.');
}

fwrite($handle, $csv);
rewind($handle);

$headers = fgetcsv($handle);
if ($headers === false) {
fclose($handle);
throw new RuntimeException('The CSV file is empty.');
}

$headers = array_map(
static fn($h): string => trim((string) $h, " \t\n\r\0\x0B\xEF\xBB\xBF"),
$headers
);

$rows = [];
while (($values = fgetcsv($handle)) !== false) {
$row = [];
$hasValue = false;

foreach ($headers as $i => $header) {
if ($header === '') continue;
$value = trim((string) ($values[$i] ?? ''));
$row[$header] = $value;
$hasValue = $hasValue || $value !== '';
}

if ($hasValue) {
$rows[] = $row;
}
}

fclose($handle);

return ['headers' => $headers, 'rows' => $rows];
}

// ── Helpers ───────────────────────────────────────────────────────────────

private function openZip(string $path): \ZipArchive
{
$zip = new \ZipArchive();
$result = $zip->open($path);

if ($result !== true) {
throw new RuntimeException('Cannot open the file. Make sure it is a valid .xlsx file (error ' . $result . ').');
}

return $zip;
}

private function guardedPath(string $filename): string
{
// Prevent path traversal — only allow the basename.
$safe = $this->tempDir . basename($filename);

if (!file_exists($safe)) {
throw new RuntimeException('Uploaded file not found. Please upload the file again.');
}

return $safe;
}

private function cleanup(): void
{
$now = time();
foreach (glob($this->tempDir . '*') ?: [] as $file) {
if (is_file($file) && $now - filemtime($file) > 3600) {
@unlink($file);
}
}
}
}

+ 335
- 0
app/Services/GoogleSheetImportService.php Ver arquivo

@@ -0,0 +1,335 @@
<?php

declare(strict_types=1);

namespace App\Services;

use RuntimeException;

class GoogleSheetImportService
{
/**
* @return array{id: string, sheets: list<array{gid: string, title: string}>}
*/
public function sheets(string $url): array
{
$spreadsheetId = $this->spreadsheetId($url);
$currentGid = $this->gidFromUrl($url);
$sheets = [];

// Strategy 1: v3 worksheets JSON feed — works for publicly published sheets.
// Each entry's alternate link href contains the numeric gid.
if ($sheets === []) {
try {
$feed = $this->fetch(
'https://spreadsheets.google.com/feeds/worksheets/'
. rawurlencode($spreadsheetId) . '/public/basic?alt=json'
);
$sheets = $this->extractSheetsFromFeed($feed);
} catch (\Throwable) {}
}

// Strategy 2: htmlview URL — serves rendered HTML with tab links for
// sheets shared "anyone with the link can view".
if ($sheets === []) {
try {
$html = $this->fetch(
'https://docs.google.com/spreadsheets/d/'
. rawurlencode($spreadsheetId) . '/htmlview'
);
$sheets = $this->extractSheets($html);
} catch (\Throwable) {}
}

// Strategy 3: edit URL JS-bootstrapped data — last resort.
if ($sheets === []) {
try {
$html = $this->fetch(
'https://docs.google.com/spreadsheets/d/'
. rawurlencode($spreadsheetId) . '/edit?usp=sharing'
);
$sheets = $this->extractSheets($html);
} catch (\Throwable) {}
}

// Fallback: if we know the gid from the URL, return a labelled placeholder.
if ($sheets === [] && $currentGid !== null) {
$sheets[] = ['gid' => $currentGid, 'title' => 'Sheet ' . $currentGid];
}

if ($sheets === []) {
$sheets[] = ['gid' => '0', 'title' => 'First sheet'];
}

return ['id' => $spreadsheetId, 'sheets' => $sheets];
}

/**
* @return array{headers: list<string>, rows: list<array<string, string>>}
*/
public function rows(string $url, string $gid): array
{
$spreadsheetId = $this->spreadsheetId($url);
$csv = $this->fetch(sprintf(
'https://docs.google.com/spreadsheets/d/%s/export?format=csv&gid=%s',
rawurlencode($spreadsheetId),
rawurlencode($gid)
));

return $this->parseCsv($csv);
}

public function spreadsheetId(string $url): string
{
$parts = parse_url($url);
$host = strtolower((string) ($parts['host'] ?? ''));

if (!in_array($host, ['docs.google.com', 'spreadsheets.google.com'], true)) {
throw new RuntimeException('Enter a valid Google Sheets URL.');
}

$path = (string) ($parts['path'] ?? '');
if (preg_match('#/spreadsheets/d/([a-zA-Z0-9_-]+)#', $path, $matches) !== 1) {
throw new RuntimeException('The Google Sheets URL does not include a spreadsheet id.');
}

return $matches[1];
}

// ── Sheet extraction ──────────────────────────────────────────────────────

/**
* Parse the v3 JSON feed response.
*
* @return list<array{gid: string, title: string}>
*/
private function extractSheetsFromFeed(string $json): array
{
$data = json_decode($json, true);

if (!is_array($data) || !isset($data['feed']['entry'])) {
return [];
}

$sheets = [];

foreach ((array) $data['feed']['entry'] as $entry) {
$title = (string) ($entry['title']['$t'] ?? '');

if ($title === '') {
continue;
}

// GID is embedded in the rel="alternate" link href as #gid=NNN or &gid=NNN
$gid = null;
foreach ((array) ($entry['link'] ?? []) as $link) {
if (preg_match('/[#&]gid=(\d+)/', (string) ($link['href'] ?? ''), $m)) {
$gid = $m[1];
break;
}
}

if ($gid !== null && $this->looksLikeSheet($gid, $title) && !isset($sheets[$gid])) {
$sheets[$gid] = ['gid' => $gid, 'title' => $title];
}
}

return array_values($sheets);
}

/**
* Parse HTML from htmlview or edit URL for sheet tab data.
*
* @return list<array{gid: string, title: string}>
*/
private function extractSheets(string $html): array
{
$sheets = [];

// ── HTML tab patterns (htmlview format) ───────────────────────────────
// Google renders tab links like:
// <a href="#gid=123">Sheet Name</a>
// <span data-id="123">Sheet Name</span>
$htmlPatterns = [
'/<[^>]+href=["\'][^"\']*[#&]gid=(\d+)["\'][^>]*>\s*(?:<[^>]+>\s*)*([^<]{1,100}?)\s*(?:<|$)/i',
'/data-id=["\'](\d+)["\'][^>]*>\s*([^<]{1,100}?)\s*</i',
];

foreach ($htmlPatterns as $pattern) {
if (preg_match_all($pattern, $html, $matches, PREG_SET_ORDER) > 0) {
foreach ($matches as $match) {
$gid = $match[1];
$title = trim(html_entity_decode($match[2], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($this->looksLikeSheet($gid, $title) && !isset($sheets[$gid])) {
$sheets[$gid] = ['gid' => $gid, 'title' => $title];
}
}
}
}

if (!empty($sheets)) {
return array_values($sheets);
}

// ── JavaScript JSON patterns (edit URL bootstrapped data) ─────────────
// Distance increased to 600 chars to handle larger embedded JSON objects.
$jsPatterns = [
'/"gid"\s*:\s*(\d+).{0,600}?"name"\s*:\s*"((?:\\\\.|[^"\\\\])+)"/s',
'/"name"\s*:\s*"((?:\\\\.|[^"\\\\])+)".{0,600}?"gid"\s*:\s*(\d+)/s',
'/"gid"\s*:\s*(\d+).{0,600}?"title"\s*:\s*"((?:\\\\.|[^"\\\\])+)"/s',
'/"title"\s*:\s*"((?:\\\\.|[^"\\\\])+)".{0,600}?"gid"\s*:\s*(\d+)/s',
'/\[\s*(\d+)\s*,\s*"((?:\\\\.|[^"\\\\])+)"/s',
];

foreach ($jsPatterns as $pattern) {
if (preg_match_all($pattern, $html, $matches, PREG_SET_ORDER) > 0) {
foreach ($matches as $match) {
$first = (string) $match[1];
$second = (string) $match[2];
$gid = ctype_digit($first) ? $first : $second;
$title = ctype_digit($first) ? $second : $first;
$title = $this->decodeJsString($title);

if (!$this->looksLikeSheet($gid, $title) || isset($sheets[$gid])) {
continue;
}

$sheets[$gid] = ['gid' => $gid, 'title' => $title];
}

if (!empty($sheets)) {
break;
}
}
}

return array_values($sheets);
}

private function looksLikeSheet(string $gid, string $title): bool
{
if ($gid === '' || !ctype_digit($gid) || $title === '' || strlen($title) > 120) {
return false;
}

return !str_contains($title, '<')
&& !str_contains($title, '{')
&& !str_contains(strtolower($title), 'http');
}

private function decodeJsString(string $value): string
{
$decoded = json_decode('"' . str_replace('"', '\\"', $value) . '"', true);

return is_string($decoded) ? $decoded : stripcslashes($value);
}

private function gidFromUrl(string $url): ?string
{
$fragment = parse_url($url, PHP_URL_FRAGMENT);
$query = parse_url($url, PHP_URL_QUERY);

foreach ([(string) $fragment, (string) $query] as $part) {
if (preg_match('/(?:^|&)gid=(\d+)/', $part, $matches) === 1) {
return $matches[1];
}
}

return null;
}

// ── HTTP fetch ────────────────────────────────────────────────────────────

private function fetch(string $url): string
{
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 15,
'ignore_errors' => true,
'follow_location' => true,
'max_redirects' => 5,
'header' => implode("\r\n", [
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language: en-US,en;q=0.5',
]) . "\r\n",
],
]);

$content = @file_get_contents($url, false, $context);

if (!is_string($content) || trim($content) === '') {
throw new RuntimeException(
'Could not reach the Google Sheet. Check the URL and make sure the sheet is shared as "Anyone with the link can view".'
);
}

// Google returns a sign-in page when the sheet requires authentication.
// Detect this by looking for the login shell markers present in every
// Google auth redirect (~9 KB of CSS/JS with no actual sheet data).
if (
str_contains($content, '.login,.request-storage-access') ||
str_contains($content, 'ServiceLogin') ||
str_contains($content, 'accounts.google.com/ServiceLogin')
) {
throw new RuntimeException(
'Google returned a sign-in page. The spreadsheet must be shared as "Anyone with the link can view": '
. 'open the sheet → File → Share → Change to "Anyone with the link" → Viewer.'
);
}

return $content;
}

// ── CSV parsing ───────────────────────────────────────────────────────────

/**
* @return array{headers: list<string>, rows: list<array<string, string>>}
*/
private function parseCsv(string $csv): array
{
$handle = fopen('php://temp', 'r+');
if ($handle === false) {
throw new RuntimeException('Unable to parse sheet data.');
}

fwrite($handle, $csv);
rewind($handle);

$headers = fgetcsv($handle);
if ($headers === false) {
fclose($handle);
throw new RuntimeException('The selected sheet is empty.');
}

$headers = array_map(
static fn($h): string => trim((string) $h, " \t\n\r\0\x0B\xEF\xBB\xBF"),
$headers
);

$rows = [];
while (($values = fgetcsv($handle)) !== false) {
$row = [];
$hasValue = false;

foreach ($headers as $index => $header) {
if ($header === '') {
continue;
}

$value = trim((string) ($values[$index] ?? ''));
$row[$header] = $value;
$hasValue = $hasValue || $value !== '';
}

if ($hasValue) {
$rows[] = $row;
}
}

fclose($handle);

return ['headers' => $headers, 'rows' => $rows];
}
}

+ 30
- 0
app/ViewModels/CampaignTypeViewModel.php Ver arquivo

@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace App\ViewModels;

class CampaignTypeViewModel
{
public string $title = 'Campaign Types';
public bool $saved = false;
public bool $deleted = false;

/**
* @var array{name: string, attributes: list<array{name: string, type: string}>}
*/
public array $form = [
'name' => '',
'attributes' => [],
];

/**
* @var array<string, list<string>>
*/
public array $errors = [];

/**
* @var array<string, mixed>|null
*/
public ?array $campaignType = null;
}

+ 38
- 0
app/ViewModels/CampaignViewModel.php Ver arquivo

@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace App\ViewModels;

class CampaignViewModel
{
public string $title = 'Campaigns';
public bool $saved = false;
public bool $deleted = false;

/**
* @var array{name: string, campaign_type_id: int|string, attribute_values: array<string, string>}
*/
public array $form = [
'campaign_type_id' => 0,
'attribute_values' => [],
];

/**
* @var array<string, list<string>>
*/
public array $errors = [];

/**
* @var array<string, mixed>|null
*/
public ?array $campaign = null;

/**
* All campaign types with attributes decoded, used to populate the type
* dropdown and drive the dynamic attribute fields.
*
* @var list<array{id: int, name: string, attributes: list<array{name: string, type: string}>}>
*/
public array $campaignTypes = [];
}

+ 13
- 0
app/ViewModels/HomeIndexViewModel.php Ver arquivo

@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace App\ViewModels;

class HomeIndexViewModel
{
public string $title = '';
public string $eyebrow = '';
public string $message = '';
public string $routeExample = '';
}

+ 23
- 0
app/ViewModels/JobTypeViewModel.php Ver arquivo

@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace App\ViewModels;

class JobTypeViewModel
{
public string $title = 'Job Types';
public bool $saved = false;
public bool $deleted = false;

public array $form = [
'name' => '',
'attributes' => [],
];

/** @var array<string, list<string>> */
public array $errors = [];

/** @var array<string, mixed>|null */
public ?array $jobType = null;
}

+ 36
- 0
app/ViewModels/JobViewModel.php Ver arquivo

@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace App\ViewModels;

class JobViewModel
{
public string $title = 'Jobs';
public bool $saved = false;
public bool $deleted = false;

public array $form = [
'campaign_id' => 0,
'job_type_id' => 0,
'attribute_values' => [],
];

/** @var array<string, list<string>> */
public array $errors = [];

/** @var array<string, mixed>|null */
public ?array $job = null;

/**
* Campaigns with campaign_type_name for the dropdown.
* @var list<array<string, mixed>>
*/
public array $campaigns = [];

/**
* Job types with attributes decoded, for the type dropdown and dynamic fields.
* @var list<array{id: int, name: string, attributes: list<array{name: string, type: string}>}>
*/
public array $jobTypes = [];
}

+ 106
- 0
app/Views/campaign-types/create.php Ver arquivo

@@ -0,0 +1,106 @@
<script>window.__ctAttributes = <?= json_encode($model->form['attributes'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;</script>
<section class="content-stack">

<div class="page-toolbar">
<div class="section-heading">
<h1><?= e($model->title) ?></h1>
<p>Define a campaign type and the attributes that will describe it.</p>
</div>
<a class="button button-secondary" href="/campaign-types">&larr; Back to list</a>
</div>

<section class="section-panel" x-data="campaignTypeForm(window.__ctAttributes)">

<?php if (isset($model->errors['_token'])): ?>
<div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div>
<?php endif; ?>

<form method="post" action="/campaign-types" class="ct-form" novalidate>
<?= csrf_field() ?>

<div class="form-section">
<label class="field field-full">
<span>Campaign type name <span class="required-mark">*</span></span>
<input
class="input<?= isset($model->errors['name']) ? ' input-error' : '' ?>"
type="text"
name="name"
maxlength="255"
value="<?= e($model->form['name']) ?>"
required
autofocus
>
<?php if (isset($model->errors['name'])): ?>
<small class="field-error"><?= e($model->errors['name'][0]) ?></small>
<?php endif; ?>
</label>
</div>

<div class="form-section">
<div class="attributes-header">
<h3>Attributes</h3>
<p class="attributes-hint">Add the fields that campaigns of this type will carry.</p>
</div>

<div class="attribute-list">
<template x-for="(attr, index) in attributes" :key="index">
<div class="attribute-row"
draggable="true"
x-on:dragstart="dragStart($event, index)"
x-on:dragover.prevent="dragOver($event, index)"
x-on:drop="drop($event, index)"
x-on:dragend="dragEnd()"
:class="{ 'is-dragging': dragIndex === index, 'is-drag-over': dragOverIndex === index && dragIndex !== index }">
<span class="attr-drag-handle" title="Drag to reorder">&#8597;</span>
<label class="field attribute-order-field">
<span>Order</span>
<input class="input" type="number"
:name="`attribute_order[${index}]`"
x-model.number="attr.order" min="1">
</label>
<label class="field attribute-name-field">
<span>Attribute name</span>
<input
class="input"
type="text"
:name="`attribute_name[${index}]`"
x-model="attr.name"
placeholder="e.g. Budget"
maxlength="100"
>
</label>
<label class="field attribute-type-field">
<span>Type</span>
<select class="input" :name="`attribute_type[${index}]`" x-model="attr.type">
<option value="text">Text</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="boolean">True/False</option>
</select>
</label>
<div class="attribute-remove">
<button
type="button"
class="button button-danger button-sm"
x-on:click="removeAttribute(index)"
title="Remove attribute"
>&times;</button>
</div>
</div>
</template>
</div>

<button type="button" class="button button-secondary button-sm" x-on:click="addAttribute()">
+ Add Attribute
</button>
</div>

<div class="form-actions">
<button class="button button-primary" type="submit">Save Campaign Type</button>
<a class="button button-secondary" href="/campaign-types">Cancel</a>
</div>
</form>

</section>

</section>

+ 126
- 0
app/Views/campaign-types/edit.php Ver arquivo

@@ -0,0 +1,126 @@
<?php $campaignTypeId = (int) ($model->campaignType['id'] ?? 0); ?>
<script>window.__ctAttributes = <?= json_encode($model->form['attributes'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;</script>
<section class="content-stack">

<div class="page-toolbar">
<div class="section-heading">
<h1><?= e($model->title) ?></h1>
<p>Update the name or attributes for this campaign type.</p>
</div>
<a class="button button-secondary" href="/campaign-types">&larr; Back to list</a>
</div>

<?php if ($model->saved): ?>
<div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)">
Campaign type updated successfully.
</div>
<?php endif; ?>

<section class="section-panel" x-data="campaignTypeForm(window.__ctAttributes)">

<?php if (isset($model->errors['_token'])): ?>
<div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div>
<?php endif; ?>

<form method="post" action="/campaign-types/<?= e((string) $campaignTypeId) ?>/update" class="ct-form" novalidate>
<?= csrf_field() ?>

<div class="form-section">
<label class="field field-full">
<span>Campaign type name <span class="required-mark">*</span></span>
<input
class="input<?= isset($model->errors['name']) ? ' input-error' : '' ?>"
type="text"
name="name"
maxlength="255"
value="<?= e($model->form['name']) ?>"
required
autofocus
>
<?php if (isset($model->errors['name'])): ?>
<small class="field-error"><?= e($model->errors['name'][0]) ?></small>
<?php endif; ?>
</label>
</div>

<div class="form-section">
<div class="attributes-header">
<h3>Attributes</h3>
<p class="attributes-hint">Modify the fields that campaigns of this type will carry.</p>
</div>

<div class="attribute-list">
<template x-for="(attr, index) in attributes" :key="index">
<div class="attribute-row"
draggable="true"
x-on:dragstart="dragStart($event, index)"
x-on:dragover.prevent="dragOver($event, index)"
x-on:drop="drop($event, index)"
x-on:dragend="dragEnd()"
:class="{ 'is-dragging': dragIndex === index, 'is-drag-over': dragOverIndex === index && dragIndex !== index }">
<span class="attr-drag-handle" title="Drag to reorder">&#8597;</span>
<label class="field attribute-order-field">
<span>Order</span>
<input class="input" type="number"
:name="`attribute_order[${index}]`"
x-model.number="attr.order" min="1">
</label>
<label class="field attribute-name-field">
<span>Attribute name</span>
<input
class="input"
type="text"
:name="`attribute_name[${index}]`"
x-model="attr.name"
placeholder="e.g. Budget"
maxlength="100"
>
</label>
<label class="field attribute-type-field">
<span>Type</span>
<select class="input" :name="`attribute_type[${index}]`" x-model="attr.type">
<option value="text">Text</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="boolean">True/False</option>
</select>
</label>
<div class="attribute-remove">
<button
type="button"
class="button button-danger button-sm"
x-on:click="removeAttribute(index)"
title="Remove attribute"
>&times;</button>
</div>
</div>
</template>
</div>

<button type="button" class="button button-secondary button-sm" x-on:click="addAttribute()">
+ Add Attribute
</button>
</div>

<div class="form-actions">
<button class="button button-primary" type="submit">Update Campaign Type</button>
<a class="button button-secondary" href="/campaign-types">Cancel</a>
</div>
</form>

<div class="delete-zone">
<h4>Delete this campaign type</h4>
<p>This cannot be undone.</p>
<form
method="post"
action="/campaign-types/<?= e((string) $campaignTypeId) ?>/delete"
x-on:submit.prevent="confirmDelete($event)"
>
<?= csrf_field() ?>
<button type="submit" class="button button-danger">Delete Campaign Type</button>
</form>
</div>

</section>

</section>

+ 35
- 0
app/Views/campaign-types/index.php Ver arquivo

@@ -0,0 +1,35 @@
<section class="content-stack" x-data="campaignTypeTable()">

<div class="page-toolbar">
<div class="section-heading">
<h1><?= e($model->title) ?></h1>
<p>Manage campaign types and their configurable attributes.</p>
</div>
<a class="button button-primary" href="/campaign-types/create">+ New Campaign Type</a>
</div>

<?php if ($model->saved): ?>
<div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)">
Campaign type saved successfully.
</div>
<?php endif; ?>

<?php if ($model->deleted): ?>
<div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)">
Campaign type deleted.
</div>
<?php endif; ?>

<section class="section-panel">
<div class="panel-header">
<div>
<h2>Campaign Type Directory</h2>
<p>All campaign types with their attribute definitions.</p>
</div>
<button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button>
</div>

<div id="campaign-type-table" class="tabulator-host"></div>
</section>

</section>

+ 104
- 0
app/Views/campaigns/create.php Ver arquivo

@@ -0,0 +1,104 @@
<script>
window.__campaignTypes = <?= json_encode($model->campaignTypes, JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;
window.__initialTypeId = <?= json_encode($model->form['campaign_type_id'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;
window.__initialValues = <?= json_encode($model->form['attribute_values'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;
</script>

<section class="content-stack">

<div class="page-toolbar">
<div class="section-heading">
<h1><?= e($model->title) ?></h1>
<p>Choose a campaign type, enter a name, then fill in the type&rsquo;s attribute values.</p>
</div>
<a class="button button-secondary" href="/campaigns">&larr; Back to list</a>
</div>

<?php if (!$model->campaignTypes): ?>
<div class="alert alert-error">
No campaign types have been defined yet.
<a href="/campaign-types/create">Create a campaign type</a> before adding campaigns.
</div>
<?php else: ?>

<section class="section-panel" x-data="campaignForm(window.__campaignTypes, window.__initialTypeId, window.__initialValues)">

<?php if (isset($model->errors['_token'])): ?>
<div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div>
<?php endif; ?>

<form method="post" action="/campaigns" class="ct-form" novalidate>
<?= csrf_field() ?>

<div class="form-section">
<label class="field field-full">
<span>Campaign type <span class="required-mark">*</span></span>
<select
class="input<?= isset($model->errors['campaign_type_id']) ? ' input-error' : '' ?>"
name="campaign_type_id"
x-model="selectedTypeId"
x-on:change="onTypeChange()"
required
>
<option value="0">— Select a campaign type —</option>
<?php foreach ($model->campaignTypes as $type): ?>
<option
value="<?= e((string) $type['id']) ?>"
<?= (int) $model->form['campaign_type_id'] === $type['id'] ? 'selected' : '' ?>
><?= e($type['name']) ?></option>
<?php endforeach; ?>
</select>
<?php if (isset($model->errors['campaign_type_id'])): ?>
<small class="field-error"><?= e($model->errors['campaign_type_id'][0]) ?></small>
<?php endif; ?>
</label>


</div>

<div class="form-section" x-show="currentAttributes.length > 0">
<div class="attributes-header">
<h3>Attribute values</h3>
<p class="attributes-hint">Fields defined by the selected campaign type.</p>
</div>

<div class="form-grid">
<template x-for="attr in currentAttributes" :key="attr.name">
<label class="field">
<span x-text="attr.name"></span>
<template x-if="attr.type === 'boolean'">
<select class="input"
:name="`attribute_values[${attr.name}]`"
x-on:change="attributeValues[attr.name] = $event.target.value">
<option value="" :selected="!attributeValues[attr.name]">— Select —</option>
<option value="true" :selected="attributeValues[attr.name] === 'true'">True</option>
<option value="false" :selected="attributeValues[attr.name] === 'false'">False</option>
</select>
</template>
<template x-if="attr.type !== 'boolean'">
<input class="input"
:type="inputType(attr.type)"
:name="`attribute_values[${attr.name}]`"
:value="attributeValues[attr.name] ?? ''"
x-on:input="attributeValues[attr.name] = $event.target.value">
</template>
</label>
</template>
</div>
</div>

<p class="attributes-hint" x-show="selectedTypeId && currentAttributes.length === 0">
This campaign type has no attributes defined.
</p>

<div class="form-actions">
<button class="button button-primary" type="submit">Save Campaign</button>
<a class="button button-secondary" href="/campaigns">Cancel</a>
</div>
</form>

</section>

<?php endif; ?>

</section>

+ 151
- 0
app/Views/campaigns/edit.php Ver arquivo

@@ -0,0 +1,151 @@
<?php $campaignId = (int) ($model->campaign['id'] ?? 0); ?>
<script>
window.__campaignTypes = <?= json_encode($model->campaignTypes, JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;
window.__initialTypeId = <?= json_encode($model->form['campaign_type_id'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;
window.__initialValues = <?= json_encode($model->form['attribute_values'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;
</script>

<section class="content-stack">

<div class="page-toolbar">
<div class="section-heading">
<h1><?= e($model->title) ?></h1>
<p>Update the campaign name, type, or attribute values.</p>
</div>
<a class="button button-secondary" href="/campaigns">&larr; Back to list</a>
</div>

<?php if ($model->saved): ?>
<div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)">
Campaign updated successfully.
</div>
<?php endif; ?>

<section class="section-panel" x-data="campaignForm(window.__campaignTypes, window.__initialTypeId, window.__initialValues)">

<?php if (isset($model->errors['_token'])): ?>
<div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div>
<?php endif; ?>

<form method="post" action="/campaigns/<?= e((string) $campaignId) ?>/update" class="ct-form" novalidate>
<?= csrf_field() ?>

<div class="form-section">
<label class="field field-full">
<span>Campaign type <span class="required-mark">*</span></span>
<select
class="input<?= isset($model->errors['campaign_type_id']) ? ' input-error' : '' ?>"
name="campaign_type_id"
x-model="selectedTypeId"
x-on:change="onTypeChange()"
required
>
<option value="0">— Select a campaign type —</option>
<?php foreach ($model->campaignTypes as $type): ?>
<option
value="<?= e((string) $type['id']) ?>"
<?= (int) $model->form['campaign_type_id'] === $type['id'] ? 'selected' : '' ?>
><?= e($type['name']) ?></option>
<?php endforeach; ?>
</select>
<?php if (isset($model->errors['campaign_type_id'])): ?>
<small class="field-error"><?= e($model->errors['campaign_type_id'][0]) ?></small>
<?php endif; ?>
</label>


</div>

<div class="form-section" x-show="currentAttributes.length > 0">
<div class="attributes-header">
<h3>Attribute values</h3>
<p class="attributes-hint">Fields defined by the selected campaign type.</p>
</div>

<div class="form-grid">
<template x-for="attr in currentAttributes" :key="attr.name">
<label class="field">
<span x-text="attr.name"></span>
<template x-if="attr.type === 'boolean'">
<select class="input"
:name="`attribute_values[${attr.name}]`"
x-on:change="attributeValues[attr.name] = $event.target.value">
<option value="" :selected="!attributeValues[attr.name]">— Select —</option>
<option value="true" :selected="attributeValues[attr.name] === 'true'">True</option>
<option value="false" :selected="attributeValues[attr.name] === 'false'">False</option>
</select>
</template>
<template x-if="attr.type !== 'boolean'">
<input class="input"
:type="inputType(attr.type)"
:name="`attribute_values[${attr.name}]`"
:value="attributeValues[attr.name] ?? ''"
x-on:input="attributeValues[attr.name] = $event.target.value">
</template>
</label>
</template>
</div>
</div>

<p class="attributes-hint" x-show="selectedTypeId && currentAttributes.length === 0">
This campaign type has no attributes defined.
</p>

<div class="form-actions">
<button class="button button-primary" type="submit">Update Campaign</button>
<a class="button button-secondary" href="/campaigns">Cancel</a>
</div>
</form>

<div class="delete-zone">
<h4>Delete this campaign</h4>
<p>This cannot be undone.</p>
<form
method="post"
action="/campaigns/<?= e((string) $campaignId) ?>/delete"
x-on:submit.prevent="confirmDelete($event)"
>
<?= csrf_field() ?>
<button type="submit" class="button button-danger">Delete Campaign</button>
</form>
</div>

</section>

<section class="section-panel" x-data="campaignJobsTable(<?= $campaignId ?>)">
<div class="panel-header">
<div>
<h2>Jobs</h2>
<p>All jobs attached to this campaign.</p>
</div>
<div class="panel-actions">
<button class="button button-secondary button-sm" type="button" x-cloak x-show="!isVisible" x-on:click="showTable()">
Show Jobs
</button>
<a class="button button-primary button-sm" href="/jobs/create">+ New Job</a>
<button class="button button-secondary button-sm" type="button" x-cloak x-show="isVisible" x-on:click="reloadTable()">Refresh</button>
<button class="button button-secondary button-sm" type="button" x-cloak x-show="isVisible" x-on:click="hideTable()">Hide</button>
</div>
</div>
<div x-cloak x-show="isVisible" x-transition.opacity>
<div class="inline-indicator" x-show="isLoading">Loading jobs...</div>
<div class="alert alert-error" x-show="errorMessage" x-text="errorMessage"></div>
<div class="empty-state" x-show="!isLoading && !errorMessage && hasLoaded && groups.length === 0">
<p>No jobs are attached to this campaign.</p>
</div>

<div class="job-type-table-stack" x-show="groups.length > 0">
<template x-for="group in groups" :key="group.id">
<section class="job-type-table-group">
<div class="job-type-table-heading">
<h3 x-text="group.name"></h3>
<span x-text="group.rows.length + (group.rows.length === 1 ? ' job' : ' jobs')"></span>
</div>
<div :id="group.elementId" class="tabulator-host"></div>
</section>
</template>
</div>
</div>
</section>

</section>

+ 54
- 0
app/Views/campaigns/index.php Ver arquivo

@@ -0,0 +1,54 @@
<section class="content-stack" x-data="campaignTable()">

<div class="page-toolbar">
<div class="section-heading">
<h1><?= e($model->title) ?></h1>
<p>Manage campaigns and their attribute values.</p>
</div>
<a class="button button-primary" href="/campaigns/create">+ New Campaign</a>
</div>

<?php if ($model->saved): ?>
<div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)">
Campaign saved successfully.
</div>
<?php endif; ?>

<?php if ($model->deleted): ?>
<div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)">
Campaign deleted.
</div>
<?php endif; ?>

<section class="section-panel">
<div class="panel-header">
<div>
<h2>Campaign Directory</h2>
<p>All campaigns with their type and attribute data.</p>
</div>
<button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button>
</div>

<div class="inline-indicator" x-cloak x-show="isLoading">Loading campaigns...</div>
<div class="alert alert-error" x-cloak x-show="errorMessage" x-text="errorMessage"></div>
<div id="campaign-table" class="tabulator-host"></div>
</section>

<section class="section-panel" x-cloak x-show="selectedCampaignId" x-transition.opacity>
<div class="panel-header">
<div>
<h2>Campaign Jobs</h2>
<p x-text="selectedCampaignTitle"></p>
</div>
<div class="panel-actions">
<button class="button button-secondary button-sm" type="button" x-on:click="reloadJobsTable()">Refresh</button>
<button class="button button-secondary button-sm" type="button" x-on:click="closeJobsTable()">Close</button>
</div>
</div>

<div class="inline-indicator" x-show="isJobsLoading">Loading jobs...</div>
<div class="alert alert-error" x-show="jobsErrorMessage" x-text="jobsErrorMessage"></div>
<div id="campaign-jobs-drilldown-table" class="tabulator-host"></div>
</section>

</section>

+ 14
- 0
app/Views/health/index.php Ver arquivo

@@ -0,0 +1,14 @@
<section class="health-check">
<h1>Health Check</h1>
<ul class="health-list">
<li><strong>PHP</strong> &mdash; <?= e(PHP_VERSION) ?> &mdash; OK</li>
<li><strong>SQL Server</strong> &mdash;
<?php if ($dbOk): ?>
OK
<?php else: ?>
<span style="color:red">FAILED: <?= e($dbError ?? 'unknown error') ?></span>
<?php endif; ?>
</li>
<li><strong>Environment</strong> &mdash; <?= e($appEnv) ?></li>
</ul>
</section>

+ 39
- 0
app/Views/home/index.php Ver arquivo

@@ -0,0 +1,39 @@
<section class="hero">
<div class="hero-copy">
<span class="eyebrow"><?= e($model->eyebrow) ?></span>
<h1><?= e($model->title) ?></h1>
<p class="hero-text"><?= e($model->message) ?></p>

<div class="hero-actions">
<a class="button button-primary" href="<?= e($model->routeExample) ?>">Open Campaign Types</a>
<a class="button button-secondary" href="#framework-highlights">See Highlights</a>
</div>
</div>

<aside class="hero-panel" aria-label="Framework route example">
<p class="panel-label">Request Flow</p>
<code>Browser -> public/index.php -> Dispatcher -> Router -> Controller -> View</code>

<div class="route-callout">
<span>Campaign types</span>
<a href="<?= e($model->routeExample) ?>"><?= e($model->routeExample) ?></a>
</div>
</aside>
</section>

<section class="feature-grid" id="framework-highlights">
<article class="feature-card">
<h2>Readable by design</h2>
<p>Small files, explicit routing, and plain PHP views keep the framework approachable for day-to-day work.</p>
</article>

<article class="feature-card">
<h2>Classic MVC feel</h2>
<p>Controllers, repositories, and view models stay separate so request handling remains predictable and easy to follow.</p>
</article>

<article class="feature-card">
<h2>SQL Server ready</h2>
<p>Typed PHP 8.3 code, Composer autoloading, PDO access, and migration support make the project feel current without becoming heavyweight.</p>
</article>
</section>

+ 89
- 0
app/Views/job-types/create.php Ver arquivo

@@ -0,0 +1,89 @@
<script>window.__jtAttributes = <?= json_encode($model->form['attributes'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;</script>

<section class="content-stack">

<div class="page-toolbar">
<div class="section-heading">
<h1><?= e($model->title) ?></h1>
<p>Define a job type and the attributes that describe it.</p>
</div>
<a class="button button-secondary" href="/job-types">&larr; Back to list</a>
</div>

<section class="section-panel" x-data="jobTypeForm(window.__jtAttributes)">

<?php if (isset($model->errors['_token'])): ?>
<div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div>
<?php endif; ?>

<form method="post" action="/job-types" class="ct-form" novalidate>
<?= csrf_field() ?>

<div class="form-section">
<label class="field field-full">
<span>Job type name <span class="required-mark">*</span></span>
<input class="input<?= isset($model->errors['name']) ? ' input-error' : '' ?>"
type="text" name="name" maxlength="255"
value="<?= e($model->form['name']) ?>" required autofocus>
<?php if (isset($model->errors['name'])): ?>
<small class="field-error"><?= e($model->errors['name'][0]) ?></small>
<?php endif; ?>
</label>
</div>

<div class="form-section">
<div class="attributes-header">
<h3>Attributes</h3>
<p class="attributes-hint">Fields that jobs of this type will carry.</p>
</div>
<div class="attribute-list">
<template x-for="(attr, index) in attributes" :key="index">
<div class="attribute-row"
draggable="true"
x-on:dragstart="dragStart($event, index)"
x-on:dragover.prevent="dragOver($event, index)"
x-on:drop="drop($event, index)"
x-on:dragend="dragEnd()"
:class="{ 'is-dragging': dragIndex === index, 'is-drag-over': dragOverIndex === index && dragIndex !== index }">
<span class="attr-drag-handle" title="Drag to reorder">&#8597;</span>
<label class="field attribute-order-field">
<span>Order</span>
<input class="input" type="number"
:name="`attribute_order[${index}]`"
x-model.number="attr.order" min="1">
</label>
<label class="field attribute-name-field">
<span>Attribute name</span>
<input class="input" type="text" :name="`attribute_name[${index}]`"
x-model="attr.name" placeholder="e.g. Priority" maxlength="100">
</label>
<label class="field attribute-type-field">
<span>Type</span>
<select class="input" :name="`attribute_type[${index}]`" x-model="attr.type">
<option value="text">Text</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="boolean">True/False</option>
</select>
</label>
<div class="attribute-remove">
<button type="button" class="button button-danger button-sm"
x-on:click="removeAttribute(index)" title="Remove">&times;</button>
</div>
</div>
</template>
</div>
<button type="button" class="button button-secondary button-sm" x-on:click="addAttribute()">
+ Add Attribute
</button>
</div>

<div class="form-actions">
<button class="button button-primary" type="submit">Save Job Type</button>
<a class="button button-secondary" href="/job-types">Cancel</a>
</div>
</form>

</section>

</section>

+ 106
- 0
app/Views/job-types/edit.php Ver arquivo

@@ -0,0 +1,106 @@
<?php $jobTypeId = (int) ($model->jobType['id'] ?? 0); ?>
<script>window.__jtAttributes = <?= json_encode($model->form['attributes'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;</script>

<section class="content-stack">

<div class="page-toolbar">
<div class="section-heading">
<h1><?= e($model->title) ?></h1>
<p>Update this job type's name or attributes.</p>
</div>
<a class="button button-secondary" href="/job-types">&larr; Back to list</a>
</div>

<?php if ($model->saved): ?>
<div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)">
Job type updated successfully.
</div>
<?php endif; ?>

<section class="section-panel" x-data="jobTypeForm(window.__jtAttributes)">

<?php if (isset($model->errors['_token'])): ?>
<div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div>
<?php endif; ?>

<form method="post" action="/job-types/<?= e((string) $jobTypeId) ?>/update" class="ct-form" novalidate>
<?= csrf_field() ?>

<div class="form-section">
<label class="field field-full">
<span>Job type name <span class="required-mark">*</span></span>
<input class="input<?= isset($model->errors['name']) ? ' input-error' : '' ?>"
type="text" name="name" maxlength="255"
value="<?= e($model->form['name']) ?>" required autofocus>
<?php if (isset($model->errors['name'])): ?>
<small class="field-error"><?= e($model->errors['name'][0]) ?></small>
<?php endif; ?>
</label>
</div>

<div class="form-section">
<div class="attributes-header">
<h3>Attributes</h3>
<p class="attributes-hint">Fields that jobs of this type will carry.</p>
</div>
<div class="attribute-list">
<template x-for="(attr, index) in attributes" :key="index">
<div class="attribute-row"
draggable="true"
x-on:dragstart="dragStart($event, index)"
x-on:dragover.prevent="dragOver($event, index)"
x-on:drop="drop($event, index)"
x-on:dragend="dragEnd()"
:class="{ 'is-dragging': dragIndex === index, 'is-drag-over': dragOverIndex === index && dragIndex !== index }">
<span class="attr-drag-handle" title="Drag to reorder">&#8597;</span>
<label class="field attribute-order-field">
<span>Order</span>
<input class="input" type="number"
:name="`attribute_order[${index}]`"
x-model.number="attr.order" min="1">
</label>
<label class="field attribute-name-field">
<span>Attribute name</span>
<input class="input" type="text" :name="`attribute_name[${index}]`"
x-model="attr.name" placeholder="e.g. Priority" maxlength="100">
</label>
<label class="field attribute-type-field">
<span>Type</span>
<select class="input" :name="`attribute_type[${index}]`" x-model="attr.type">
<option value="text">Text</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="boolean">True/False</option>
</select>
</label>
<div class="attribute-remove">
<button type="button" class="button button-danger button-sm"
x-on:click="removeAttribute(index)" title="Remove">&times;</button>
</div>
</div>
</template>
</div>
<button type="button" class="button button-secondary button-sm" x-on:click="addAttribute()">
+ Add Attribute
</button>
</div>

<div class="form-actions">
<button class="button button-primary" type="submit">Update Job Type</button>
<a class="button button-secondary" href="/job-types">Cancel</a>
</div>
</form>

<div class="delete-zone">
<h4>Delete this job type</h4>
<p>This cannot be undone.</p>
<form method="post" action="/job-types/<?= e((string) $jobTypeId) ?>/delete"
x-on:submit.prevent="confirmDelete($event)">
<?= csrf_field() ?>
<button type="submit" class="button button-danger">Delete Job Type</button>
</form>
</div>

</section>

</section>

+ 34
- 0
app/Views/job-types/index.php Ver arquivo

@@ -0,0 +1,34 @@
<section class="content-stack" x-data="jobTypeTable()">

<div class="page-toolbar">
<div class="section-heading">
<h1><?= e($model->title) ?></h1>
<p>Manage job types and their configurable attributes.</p>
</div>
<a class="button button-primary" href="/job-types/create">+ New Job Type</a>
</div>

<?php if ($model->saved): ?>
<div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)">
Job type saved successfully.
</div>
<?php endif; ?>

<?php if ($model->deleted): ?>
<div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)">
Job type deleted.
</div>
<?php endif; ?>

<section class="section-panel">
<div class="panel-header">
<div>
<h2>Job Type Directory</h2>
<p>All job types with their attribute definitions.</p>
</div>
<button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button>
</div>
<div id="job-type-table" class="tabulator-host"></div>
</section>

</section>

+ 152
- 0
app/Views/jobs/campaign.php Ver arquivo

@@ -0,0 +1,152 @@
<?php
$campaignId = (int) ($campaign['id'] ?? 0);
$campaignTypeName = (string) ($campaign['campaign_type_name'] ?? '');
?>
<script>
window.__campaignJobTypes = <?= json_encode($jobTypes ?? [], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;
</script>

<section class="content-stack" x-data="campaignJobsPageTable(<?= $campaignId ?>, window.__campaignJobTypes)">

<div class="page-toolbar">
<div class="section-heading">
<h1>Campaign Jobs</h1>
<p><?= e($campaignTypeName) ?> #<?= e((string) $campaignId) ?></p>
</div>
<div class="panel-actions">
<a class="button button-secondary" href="/campaigns">&larr; Back to campaigns</a>
<a class="button button-primary" href="/jobs/create">+ New Job</a>
</div>
</div>

<section class="section-panel">
<div class="panel-header">
<h2>Import Jobs</h2>
</div>

<!-- Source tabs -->
<div class="import-tabs">
<button type="button"
class="import-tab"
:class="importSource === 'sheets' ? 'is-active' : ''"
x-on:click="importSource = 'sheets'">Google Sheets</button>
<button type="button"
class="import-tab"
:class="importSource === 'file' ? 'is-active' : ''"
x-on:click="importSource = 'file'">CSV / Excel</button>
</div>

<!-- Google Sheets panel -->
<div x-show="importSource === 'sheets'">
<p class="attributes-hint" style="margin-bottom:1rem">
The spreadsheet must be shared as <strong>Anyone with the link can view</strong>.
</p>
<div class="import-grid">
<label class="field">
<span>Google Sheets URL</span>
<input class="input" type="url" x-model="importSheetUrl"
placeholder="https://docs.google.com/spreadsheets/d/...">
</label>

<label class="field">
<span>Sheet</span>
<select class="input" x-model="selectedSheetGid" :disabled="sheets.length === 0">
<option value="">Select a sheet</option>
<template x-for="sheet in sheets" :key="sheet.gid">
<option :value="sheet.gid" x-text="sheet.title"></option>
</template>
</select>
</label>

<label class="field">
<span>Job type</span>
<select class="input" x-model="selectedImportJobTypeId">
<option value="0">Select a job type</option>
<template x-for="jt in jobTypes" :key="jt.id">
<option :value="jt.id" x-text="jt.name"></option>
</template>
</select>
</label>
</div>

<div class="form-actions import-actions">
<button class="button button-secondary" type="button"
x-on:click="connectGoogleSheet()" :disabled="isConnecting">Connect</button>
<button class="button button-primary" type="button"
x-on:click="importGoogleSheet()"
:disabled="isImporting || !selectedSheetGid || Number(selectedImportJobTypeId) === 0">
Import
</button>
<span class="inline-indicator" x-show="isConnecting">Connecting...</span>
<span class="inline-indicator" x-show="isImporting">Importing...</span>
</div>
</div>

<!-- File upload panel -->
<div x-show="importSource === 'file'">
<p class="attributes-hint" style="margin-bottom:1rem">
Export your sheet as <strong>CSV</strong> or <strong>Excel (.xlsx)</strong> from Google Sheets, then upload it here.
</p>
<div class="import-grid">
<label class="field">
<span>CSV or Excel file (.csv, .xlsx)</span>
<input class="input" type="file" accept=".csv,.xlsx"
x-ref="fileInput" x-on:change="onFileSelect($event)">
</label>

<label class="field">
<span>Sheet</span>
<select class="input" x-model="selectedFileSheetGid" :disabled="fileSheets.length === 0">
<option value="">Select a sheet</option>
<template x-for="sheet in fileSheets" :key="sheet.gid">
<option :value="sheet.gid" x-text="sheet.title"></option>
</template>
</select>
</label>

<label class="field">
<span>Job type</span>
<select class="input" x-model="selectedFileJobTypeId">
<option value="0">Select a job type</option>
<template x-for="jt in jobTypes" :key="jt.id">
<option :value="jt.id" x-text="jt.name"></option>
</template>
</select>
</label>
</div>

<div class="form-actions import-actions">
<button class="button button-secondary" type="button"
x-on:click="loadFileSheets()" :disabled="isLoadingFile || !fileSelected">
Load Sheets
</button>
<button class="button button-primary" type="button"
x-on:click="importFile()"
:disabled="isImportingFile || !fileTempName || !selectedFileSheetGid || Number(selectedFileJobTypeId) === 0">
Import
</button>
<span class="inline-indicator" x-show="isLoadingFile">Reading file...</span>
<span class="inline-indicator" x-show="isImportingFile">Importing...</span>
</div>
</div>

<!-- Shared result messages -->
<div class="alert alert-success" x-cloak x-show="importMessage" x-text="importMessage"></div>
<div class="alert alert-error" x-cloak x-show="importErrorMessage" x-text="importErrorMessage"></div>
</section>

<section class="section-panel">
<div class="panel-header">
<div>
<h2>Job Directory</h2>
<p>All jobs in this campaign with job fields and attribute fields.</p>
</div>
<button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button>
</div>

<div class="inline-indicator" x-cloak x-show="isLoading">Loading jobs...</div>
<div class="alert alert-error" x-cloak x-show="errorMessage" x-text="errorMessage"></div>
<div id="campaign-jobs-page-table" class="tabulator-host"></div>
</section>

</section>

+ 115
- 0
app/Views/jobs/create.php Ver arquivo

@@ -0,0 +1,115 @@
<script>
window.__jobTypes = <?= json_encode($model->jobTypes, JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;
window.__initialJtId = <?= json_encode($model->form['job_type_id'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;
window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;
</script>

<section class="content-stack">

<div class="page-toolbar">
<div class="section-heading">
<h1><?= e($model->title) ?></h1>
<p>Select a campaign and job type, then fill in the attribute values.</p>
</div>
<a class="button button-secondary" href="/jobs">&larr; Back to list</a>
</div>

<?php if (!$model->campaigns): ?>
<div class="alert alert-error">
No campaigns exist yet. <a href="/campaigns/create">Create a campaign</a> before adding jobs.
</div>
<?php elseif (!$model->jobTypes): ?>
<div class="alert alert-error">
No job types exist yet. <a href="/job-types/create">Create a job type</a> before adding jobs.
</div>
<?php else: ?>

<section class="section-panel" x-data="jobForm(window.__jobTypes, window.__initialJtId, window.__initialJtVals)">

<?php if (isset($model->errors['_token'])): ?>
<div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div>
<?php endif; ?>

<form method="post" action="/jobs" class="ct-form" novalidate>
<?= csrf_field() ?>

<div class="form-section">
<label class="field field-full">
<span>Campaign <span class="required-mark">*</span></span>
<select class="input<?= isset($model->errors['campaign_id']) ? ' input-error' : '' ?>"
name="campaign_id" required>
<option value="0">— Select a campaign —</option>
<?php foreach ($model->campaigns as $c): ?>
<option value="<?= e((string) $c['id']) ?>"
<?= (int) $model->form['campaign_id'] === (int) $c['id'] ? 'selected' : '' ?>>
<?= e($c['campaign_type_name']) ?> #<?= e((string) $c['id']) ?>
</option>
<?php endforeach; ?>
</select>
<?php if (isset($model->errors['campaign_id'])): ?>
<small class="field-error"><?= e($model->errors['campaign_id'][0]) ?></small>
<?php endif; ?>
</label>

<label class="field field-full">
<span>Job type <span class="required-mark">*</span></span>
<select class="input<?= isset($model->errors['job_type_id']) ? ' input-error' : '' ?>"
name="job_type_id" x-model="selectedTypeId" x-on:change="onTypeChange()" required>
<option value="0">— Select a job type —</option>
<?php foreach ($model->jobTypes as $jt): ?>
<option value="<?= e((string) $jt['id']) ?>"
<?= (int) $model->form['job_type_id'] === $jt['id'] ? 'selected' : '' ?>>
<?= e($jt['name']) ?>
</option>
<?php endforeach; ?>
</select>
<?php if (isset($model->errors['job_type_id'])): ?>
<small class="field-error"><?= e($model->errors['job_type_id'][0]) ?></small>
<?php endif; ?>
</label>
</div>

<div class="form-section" x-show="currentAttributes.length > 0">
<div class="attributes-header">
<h3>Attribute values</h3>
<p class="attributes-hint">Fields defined by the selected job type.</p>
</div>
<div class="form-grid">
<template x-for="attr in currentAttributes" :key="attr.name">
<label class="field">
<span x-text="attr.name"></span>
<template x-if="attr.type === 'boolean'">
<select class="input"
:name="`attribute_values[${attr.name}]`"
x-on:change="attributeValues[attr.name] = $event.target.value">
<option value="" :selected="!attributeValues[attr.name]">— Select —</option>
<option value="true" :selected="attributeValues[attr.name] === 'true'">True</option>
<option value="false" :selected="attributeValues[attr.name] === 'false'">False</option>
</select>
</template>
<template x-if="attr.type !== 'boolean'">
<input class="input" :type="inputType(attr.type)"
:name="`attribute_values[${attr.name}]`"
:value="attributeValues[attr.name] ?? ''"
x-on:input="attributeValues[attr.name] = $event.target.value">
</template>
</label>
</template>
</div>
</div>

<p class="attributes-hint" x-show="selectedTypeId && currentAttributes.length === 0">
This job type has no attributes defined.
</p>

<div class="form-actions">
<button class="button button-primary" type="submit">Save Job</button>
<a class="button button-secondary" href="/jobs">Cancel</a>
</div>
</form>

</section>

<?php endif; ?>

</section>

+ 120
- 0
app/Views/jobs/edit.php Ver arquivo

@@ -0,0 +1,120 @@
<?php $jobId = (int) ($model->job['id'] ?? 0); ?>
<script>
window.__jobTypes = <?= json_encode($model->jobTypes, JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;
window.__initialJtId = <?= json_encode($model->form['job_type_id'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;
window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;
</script>

<section class="content-stack">

<div class="page-toolbar">
<div class="section-heading">
<h1><?= e($model->title) ?></h1>
<p>Update the campaign, job type, or attribute values for this job.</p>
</div>
<a class="button button-secondary" href="/jobs">&larr; Back to list</a>
</div>

<?php if ($model->saved): ?>
<div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)">
Job updated successfully.
</div>
<?php endif; ?>

<section class="section-panel" x-data="jobForm(window.__jobTypes, window.__initialJtId, window.__initialJtVals)">

<?php if (isset($model->errors['_token'])): ?>
<div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div>
<?php endif; ?>

<form method="post" action="/jobs/<?= e((string) $jobId) ?>/update" class="ct-form" novalidate>
<?= csrf_field() ?>

<div class="form-section">
<label class="field field-full">
<span>Campaign <span class="required-mark">*</span></span>
<select class="input<?= isset($model->errors['campaign_id']) ? ' input-error' : '' ?>"
name="campaign_id" required>
<option value="0">— Select a campaign —</option>
<?php foreach ($model->campaigns as $c): ?>
<option value="<?= e((string) $c['id']) ?>"
<?= (int) $model->form['campaign_id'] === (int) $c['id'] ? 'selected' : '' ?>>
<?= e($c['campaign_type_name']) ?> #<?= e((string) $c['id']) ?>
</option>
<?php endforeach; ?>
</select>
<?php if (isset($model->errors['campaign_id'])): ?>
<small class="field-error"><?= e($model->errors['campaign_id'][0]) ?></small>
<?php endif; ?>
</label>

<label class="field field-full">
<span>Job type <span class="required-mark">*</span></span>
<select class="input<?= isset($model->errors['job_type_id']) ? ' input-error' : '' ?>"
name="job_type_id" x-model="selectedTypeId" x-on:change="onTypeChange()" required>
<option value="0">— Select a job type —</option>
<?php foreach ($model->jobTypes as $jt): ?>
<option value="<?= e((string) $jt['id']) ?>"
<?= (int) $model->form['job_type_id'] === $jt['id'] ? 'selected' : '' ?>>
<?= e($jt['name']) ?>
</option>
<?php endforeach; ?>
</select>
<?php if (isset($model->errors['job_type_id'])): ?>
<small class="field-error"><?= e($model->errors['job_type_id'][0]) ?></small>
<?php endif; ?>
</label>
</div>

<div class="form-section" x-show="currentAttributes.length > 0">
<div class="attributes-header">
<h3>Attribute values</h3>
<p class="attributes-hint">Fields defined by the selected job type.</p>
</div>
<div class="form-grid">
<template x-for="attr in currentAttributes" :key="attr.name">
<label class="field">
<span x-text="attr.name"></span>
<template x-if="attr.type === 'boolean'">
<select class="input"
:name="`attribute_values[${attr.name}]`"
x-on:change="attributeValues[attr.name] = $event.target.value">
<option value="" :selected="!attributeValues[attr.name]">— Select —</option>
<option value="true" :selected="attributeValues[attr.name] === 'true'">True</option>
<option value="false" :selected="attributeValues[attr.name] === 'false'">False</option>
</select>
</template>
<template x-if="attr.type !== 'boolean'">
<input class="input" :type="inputType(attr.type)"
:name="`attribute_values[${attr.name}]`"
:value="attributeValues[attr.name] ?? ''"
x-on:input="attributeValues[attr.name] = $event.target.value">
</template>
</label>
</template>
</div>
</div>

<p class="attributes-hint" x-show="selectedTypeId && currentAttributes.length === 0">
This job type has no attributes defined.
</p>

<div class="form-actions">
<button class="button button-primary" type="submit">Update Job</button>
<a class="button button-secondary" href="/jobs">Cancel</a>
</div>
</form>

<div class="delete-zone">
<h4>Delete this job</h4>
<p>This cannot be undone.</p>
<form method="post" action="/jobs/<?= e((string) $jobId) ?>/delete"
x-on:submit.prevent="confirmDelete($event)">
<?= csrf_field() ?>
<button type="submit" class="button button-danger">Delete Job</button>
</form>
</div>

</section>

</section>

+ 34
- 0
app/Views/jobs/index.php Ver arquivo

@@ -0,0 +1,34 @@
<section class="content-stack" x-data="jobTable()">

<div class="page-toolbar">
<div class="section-heading">
<h1><?= e($model->title) ?></h1>
<p>Manage jobs across all campaigns.</p>
</div>
<a class="button button-primary" href="/jobs/create">+ New Job</a>
</div>

<?php if ($model->saved): ?>
<div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)">
Job saved successfully.
</div>
<?php endif; ?>

<?php if ($model->deleted): ?>
<div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)">
Job deleted.
</div>
<?php endif; ?>

<section class="section-panel">
<div class="panel-header">
<div>
<h2>Job Directory</h2>
<p>All jobs with their campaign and job type.</p>
</div>
<button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button>
</div>
<div id="job-table" class="tabulator-host"></div>
</section>

</section>

+ 14
- 0
app/Views/layouts/app.php Ver arquivo

@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

require __DIR__ . '/../partials/header.php';
?>

<main class="page-content">
<div class="container">
<?= $content ?>
</div>
</main>

<?php require __DIR__ . '/../partials/footer.php'; ?>

+ 9
- 0
app/Views/partials/footer.php Ver arquivo

@@ -0,0 +1,9 @@
<footer class="site-footer">
<div class="container footer-inner">
<p>MindVisionCode PHP keeps the framework small, readable, and ready for real features.</p>
<p>&copy; <?= e((string) date('Y')) ?> MindVisionCode</p>
</div>
</footer>
</div>
</body>
</html>

+ 64
- 0
app/Views/partials/header.php Ver arquivo

@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

$navigationItems = [
['label' => 'Home', 'href' => '/'],
['label' => 'Campaigns', 'href' => '/campaigns'],
['label' => 'Campaign Types', 'href' => '/campaign-types'],
['label' => 'Jobs', 'href' => '/jobs'],
['label' => 'Job Types', 'href' => '/job-types'],
];

$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$currentPath = is_string($currentPath) && $currentPath !== '' ? $currentPath : '/';
$jsVersion = filemtime(__DIR__ . '/../../../public/js/app.js') ?: time();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($pageTitle ?? 'Campaign Tracker') ?></title>
<link rel="stylesheet" href="https://unpkg.com/tabulator-tables@6.3.1/dist/css/tabulator.min.css">
<link rel="stylesheet" href="<?= e(asset('css/site.css')) ?>">
<script>window.__csrf = '<?= e(csrf_token()) ?>';</script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js" integrity="sha384-H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V" crossorigin="anonymous" defer></script>
<script src="https://unpkg.com/tabulator-tables@6.3.1/dist/js/tabulator.min.js" defer></script>
<script src="<?= e(asset('js/app.js')) ?>?v=<?= e((string) $jsVersion) ?>" defer></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
<div class="page-shell">
<header class="site-header">
<div class="container header-inner">
<a class="brand" href="/">
<span class="brand-mark">CT</span>
<span class="brand-copy">
<strong>Campaign Tracker</strong>
<small>PHP MVC</small>
</span>
</a>

<nav class="site-nav" aria-label="Primary navigation">
<?php foreach ($navigationItems as $item): ?>
<?php
$isActive = $item['href'] === '/'
? $currentPath === '/'
: str_starts_with($currentPath, $item['href']);
?>
<a class="nav-link<?= $isActive ? ' is-active' : '' ?>" href="<?= e($item['href']) ?>">
<?= e($item['label']) ?>
</a>
<?php endforeach; ?>

<?php if (auth()->check()): ?>
<span class="nav-user"><?= e(auth()->user()?->displayName ?: auth()->user()?->username ?? '') ?></span>
<form method="post" action="/logout" class="nav-logout-form">
<?= csrf_field() ?>
<button type="submit" class="button button-secondary button-sm">Log out</button>
</form>
<?php endif; ?>
</nav>
</div>
</header>

+ 25
- 0
composer.json Ver arquivo

@@ -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"
}
}

+ 811
- 0
composer.lock Ver arquivo

@@ -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"
}

+ 16
- 0
config/database.php Ver arquivo

@@ -0,0 +1,16 @@
<?php

return [
'dsn' => sprintf(
'sqlsrv:Server=%s,%s;Database=%s;TrustServerCertificate=1',
env('DB_HOST', 'sqlserver'),
env('DB_PORT', '1433'),
env('DB_DATABASE', 'Campaign_Tracker')
),
'username' => env('DB_USERNAME', 'sa'),
'password' => env('DB_PASSWORD', ''),
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
],
];

+ 57
- 0
core/App.php Ver arquivo

@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace Core;

use Exception;
use ReflectionFunction;
use ReflectionMethod;

class App
{
protected array $bindings = [];

public function bind(string $name, mixed $value): void
{
$this->bindings[$name] = $value;
}

public function get(string $name): mixed
{
return $this->bindings[$name] ?? null;
}

public function call(callable|array|string $handler, array $parameters = []): mixed
{
if (is_array($handler)) {
return $this->callMethod($handler, $parameters);
}

if (is_callable($handler)) {
return $this->callFunction($handler, $parameters);
}

throw new Exception('Invalid handler.');
}

protected function callFunction(callable $handler, array $parameters): mixed
{
$reflection = new ReflectionFunction($handler);

return $reflection->invokeArgs(array_values($parameters));
}

protected function callMethod(array $handler, array $parameters): mixed
{
[$class, $method] = $handler;

if (is_string($class)) {
$class = new $class();
}

$reflection = new ReflectionMethod($class, $method);

return $reflection->invokeArgs($class, array_values($parameters));
}
}

+ 28
- 0
core/Auth/AuthMiddleware.php Ver arquivo

@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Core\Auth;

use Core\Request;
use Core\Response;

class AuthMiddleware
{
/**
* Check authentication (and optionally a required permission).
* Returns a redirect/error Response when the check fails, null when it passes.
*/
public function handle(Request $request, ?string $permission = null): ?Response
{
if (!auth()->check()) {
return Response::redirect('/login');
}

if ($permission !== null && !auth()->can($permission)) {
return Response::notFound('You do not have permission to access this page.');
}

return null;
}
}

+ 33
- 0
core/Auth/AuthUser.php Ver arquivo

@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Core\Auth;

class AuthUser
{
public function __construct(
public readonly string $keycloakId,
public readonly string $username,
public readonly string $email,
public readonly string $displayName,
/** @var list<string> */
public readonly array $roles,
/** @var list<string> */
public readonly array $permissions,
) {}

public static function fromSession(array $authData): self
{
$u = $authData['user'] ?? [];

return new self(
keycloakId: (string) ($u['keycloak_id'] ?? ''),
username: (string) ($u['username'] ?? ''),
email: (string) ($u['email'] ?? ''),
displayName: (string) ($u['display_name'] ?? ''),
roles: (array) ($u['roles'] ?? []),
permissions: (array) ($u['permissions'] ?? []),
);
}
}

+ 195
- 0
core/Auth/KeycloakAuth.php Ver arquivo

@@ -0,0 +1,195 @@
<?php

declare(strict_types=1);

namespace Core\Auth;

use Core\Http\Session;
use Stevenmaguire\OAuth2\Client\Provider\Keycloak;

class KeycloakAuth
{
private Session $session;
private PermissionService $permissions;
private ?Keycloak $provider = null;

/** Keycloak service-account roles to exclude from the app role list. */
private const SYSTEM_ROLES = ['uma_authorization', 'offline_access', 'account', 'view-profile', 'manage-account', 'manage-account-links'];

public function __construct(Session $session, PermissionService $permissions)
{
$this->session = $session;
$this->permissions = $permissions;
}

// ── Auth state ────────────────────────────────────────────────────────────

public function check(): bool
{
return ($this->session->get('auth', [])['is_authenticated'] ?? false) === true;
}

public function user(): ?AuthUser
{
return $this->check() ? AuthUser::fromSession($this->session->get('auth', [])) : null;
}

public function id(): ?string
{
return $this->user()?->keycloakId;
}

public function roles(): array
{
return $this->user()?->roles ?? [];
}

public function permissions(): array
{
return $this->user()?->permissions ?? [];
}

public function hasRole(string $role): bool
{
return in_array($role, $this->roles(), true);
}

public function can(string $permission): bool
{
return in_array($permission, $this->permissions(), true);
}

// ── Login flow ────────────────────────────────────────────────────────────

/**
* Build the Keycloak authorization URL and store the state for CSRF validation.
*/
public function beginLogin(): string
{
$provider = $this->getProvider();

$authUrl = $provider->getAuthorizationUrl([
'scope' => 'openid email profile',
]);

$this->session->set('oauth_state', $provider->getState());

return $authUrl;
}

/**
* Exchange the authorization code for tokens, populate the session.
*
* @throws \RuntimeException on state mismatch or token exchange failure
*/
public function handleCallback(string $code, string $returnedState): void
{
$storedState = $this->session->get('oauth_state');
$this->session->forget('oauth_state');

if ($storedState === null || !hash_equals((string) $storedState, $returnedState)) {
throw new \RuntimeException('OAuth state mismatch — possible CSRF attempt.');
}

$provider = $this->getProvider();
$token = $provider->getAccessToken('authorization_code', ['code' => $code]);

// Decode the access-token JWT to extract realm/client role claims.
// Signature verification is not needed here: the token arrived directly
// from Keycloak over an authenticated server-to-server HTTPS exchange.
$accessClaims = $this->decodeJwtPayload($token->getToken());
$idTokenRaw = (string) ($token->getValues()['id_token'] ?? '');

$realmRoles = (array) ($accessClaims['realm_access']['roles'] ?? []);
$clientId = (string) env('KEYCLOAK_CLIENT_ID', '');
$clientRoles = (array) ($accessClaims['resource_access'][$clientId]['roles'] ?? []);

$appRoles = array_values(array_filter(
array_unique(array_merge($realmRoles, $clientRoles)),
static fn(string $r): bool => !in_array($r, self::SYSTEM_ROLES, true)
));

// Fetch the user-profile claims from the userinfo endpoint.
$ownerData = $provider->getResourceOwner($token)->toArray();
$displayName = trim(($ownerData['given_name'] ?? '') . ' ' . ($ownerData['family_name'] ?? ''));

if ($displayName === '') {
$displayName = (string) ($ownerData['preferred_username'] ?? $ownerData['sub'] ?? '');
}

// Prevent session fixation: regenerate ID before writing auth data.
$this->session->regenerate();

$this->session->set('auth', [
'is_authenticated' => true,
'user' => [
'keycloak_id' => (string) ($ownerData['sub'] ?? $accessClaims['sub'] ?? ''),
'username' => (string) ($ownerData['preferred_username'] ?? ''),
'email' => (string) ($ownerData['email'] ?? ''),
'display_name' => $displayName,
'roles' => $appRoles,
'permissions' => $this->permissions->permissionsForRoles($appRoles),
],
// id_token is stored solely to support Keycloak RP-initiated logout
// (id_token_hint parameter). It is never used to derive identity or
// resolve permissions. Do not pass it to the browser or log it.
'id_token' => $idTokenRaw !== '' ? $idTokenRaw : null,
'login_time' => time(),
'last_permission_refresh' => time(),
]);
}

// ── Logout ────────────────────────────────────────────────────────────────

/**
* Destroy the local session and return the Keycloak RP-initiated logout URL.
*/
public function logout(): string
{
$idToken = $this->session->get('auth')['id_token'] ?? null;
$redirectUri = (string) env('KEYCLOAK_LOGOUT_REDIRECT_URI', '/');
$base = rtrim((string) env('KEYCLOAK_BASE_URL', ''), '/');
$realm = (string) env('KEYCLOAK_REALM', '');

$this->session->destroy();

$params = ['post_logout_redirect_uri' => $redirectUri];

if ($idToken !== null) {
$params['id_token_hint'] = $idToken;
}

return "{$base}/realms/{$realm}/protocol/openid-connect/logout?" . http_build_query($params);
}

// ── Internal ──────────────────────────────────────────────────────────────

private function getProvider(): Keycloak
{
if ($this->provider === null) {
$this->provider = new Keycloak([
'authServerUrl' => rtrim((string) env('KEYCLOAK_BASE_URL', ''), '/'),
'realm' => (string) env('KEYCLOAK_REALM', ''),
'clientId' => (string) env('KEYCLOAK_CLIENT_ID', ''),
'clientSecret' => (string) env('KEYCLOAK_CLIENT_SECRET', ''),
'redirectUri' => (string) env('KEYCLOAK_REDIRECT_URI', ''),
]);
}

return $this->provider;
}

private function decodeJwtPayload(string $jwt): array
{
$parts = explode('.', $jwt);

if (count($parts) !== 3) {
return [];
}

$padded = str_pad(strtr($parts[1], '-_', '+/'), (int) ceil(strlen($parts[1]) / 4) * 4, '=');
$decoded = base64_decode($padded, true);

return $decoded !== false ? (json_decode($decoded, true) ?? []) : [];
}
}

+ 54
- 0
core/Auth/PermissionService.php Ver arquivo

@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace Core\Auth;

class PermissionService
{
/**
* Maps Keycloak roles to application permissions.
* Edit this to match your access-control requirements.
*
* @var array<string, list<string>>
*/
private array $rolePermissions = [
'admin' => [
'users.view',
'users.create',
'users.edit',
'users.delete',
'settings.manage',
],
'manager' => [
'users.view',
'reports.view',
'projects.manage',
],
'user' => [
'dashboard.view',
'profile.view',
'profile.edit',
],
];

/**
* @param list<string> $roles
* @return list<string>
*/
public function permissionsForRoles(array $roles): array
{
$permissions = [];

foreach ($roles as $role) {
$permissions = array_merge($permissions, $this->rolePermissions[$role] ?? []);
}

return array_values(array_unique($permissions));
}

public function hasPermission(array $roles, string $permission): bool
{
return in_array($permission, $this->permissionsForRoles($roles), true);
}
}

+ 35
- 0
core/Controller.php Ver arquivo

@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace Core;

abstract class Controller
{
protected function view(string $view, array $data = []): Response
{
return View::render($view, $data);
}

protected function fragment(string $view, array $data = [], int $status = 200, array $headers = []): Response
{
return View::fragment($view, $data, $status, $headers);
}

protected function redirect(string $url): Response
{
return Response::redirect($url);
}

protected function json(array $data): Response
{
return Response::json($data);
}

protected function requirePost(Request $request): void
{
if ($request->method() !== 'POST') {
throw new \Exception('This action requires POST.');
}
}
}

+ 57
- 0
core/Database.php Ver arquivo

@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace Core;

use PDO;

class Database
{
protected PDO $pdo;

public function __construct(array $config)
{
$this->pdo = new PDO(
$config['dsn'],
$config['username'] ?? null,
$config['password'] ?? null,
$config['options'] ?? []
);
}

public function pdo(): PDO
{
return $this->pdo;
}

public function query(string $sql, array $parameters = []): array
{
$statement = $this->pdo->prepare($sql);
$statement->execute($parameters);

return array_map([$this, 'resolveStreams'], $statement->fetchAll(PDO::FETCH_ASSOC));
}

private function resolveStreams(array $row): array
{
return array_map(
static fn($v) => is_resource($v) ? (stream_get_contents($v) ?: '') : $v,
$row
);
}

public function first(string $sql, array $parameters = []): ?array
{
$rows = $this->query($sql, $parameters);

return $rows[0] ?? null;
}

public function execute(string $sql, array $parameters = []): bool
{
$statement = $this->pdo->prepare($sql);

return $statement->execute($parameters);
}
}

+ 70
- 0
core/Dispatcher.php Ver arquivo

@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace Core;

use Core\Auth\AuthMiddleware;
use Throwable;

class Dispatcher
{
protected Router $router;
protected App $app;

public function __construct(Router $router, App $app)
{
$this->router = $router;
$this->app = $app;
}

public function dispatch(Request $request): Response
{
Request::setCurrent($request);

try {
$route = $this->router->match($request->method(), $request->path());

if (!$route) {
return Response::notFound('Page not found.');
}

if ($route->getMiddleware() !== null) {
$early = $this->runMiddleware($route->getMiddleware(), $request, $route->getRequiredPermission());

if ($early !== null) {
return $early;
}
}

$result = $route->dispatch($this->app);

return $this->normalizeResponse($result);
} catch (Throwable $e) {
return Response::serverError($e->getMessage());
} finally {
Request::clearCurrent();
}
}

protected function runMiddleware(string $name, Request $request, ?string $permission): ?Response
{
return match ($name) {
'auth' => (new AuthMiddleware())->handle($request, $permission),
default => null,
};
}

protected function normalizeResponse(mixed $result): Response
{
if ($result instanceof Response) {
return $result;
}

if (is_array($result)) {
return Response::json($result);
}

return new Response((string) $result);
}
}

+ 73
- 0
core/Http/Session.php Ver arquivo

@@ -0,0 +1,73 @@
<?php

declare(strict_types=1);

namespace Core\Http;

class Session
{
public function start(): void
{
if (session_status() !== PHP_SESSION_NONE) {
return;
}

$secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';

session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);

session_start();
}

public function get(string $key, mixed $default = null): mixed
{
return $_SESSION[$key] ?? $default;
}

public function set(string $key, mixed $value): void
{
$_SESSION[$key] = $value;
}

public function has(string $key): bool
{
return isset($_SESSION[$key]);
}

public function forget(string $key): void
{
unset($_SESSION[$key]);
}

public function regenerate(): void
{
session_regenerate_id(true);
}

public function destroy(): void
{
$_SESSION = [];

if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(
session_name(),
'',
time() - 42000,
$params['path'],
$params['domain'],
$params['secure'],
$params['httponly']
);
}

session_destroy();
}
}

+ 12
- 0
core/Migration.php Ver arquivo

@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Core;

abstract class Migration
{
abstract public function up(Database $database): void;

abstract public function down(Database $database): void;
}

+ 297
- 0
core/MigrationManager.php Ver arquivo

@@ -0,0 +1,297 @@
<?php

declare(strict_types=1);

namespace Core;

class MigrationManager
{
protected Database $database;
protected string $path;

public function __construct(Database $database, string $path)
{
$this->database = $database;
$this->path = rtrim($path, '/');
}

public function ensureTable(): void
{
$tableExists = $this->database->first(
"SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'migrations'"
);

if (!$tableExists) {
$this->database->execute(
'CREATE TABLE migrations (
id INT IDENTITY(1,1) NOT NULL,
migration NVARCHAR(255) NOT NULL,
ran_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT PK_migrations PRIMARY KEY (id),
CONSTRAINT UQ_migrations_migration UNIQUE (migration)
)'
);
}

$this->database->execute(
'DELETE FROM migrations
WHERE id NOT IN (
SELECT MIN(id)
FROM migrations
GROUP BY migration
)'
);

$files = array_map('basename', $this->migrationFiles());

if ($files === []) {
$this->database->execute('DELETE FROM migrations');
return;
}

$placeholders = [];
$parameters = [];

foreach ($files as $index => $file) {
$placeholder = 'migration_' . $index;
$placeholders[] = ':' . $placeholder;
$parameters[$placeholder] = $file;
}

$this->database->execute(
'DELETE FROM migrations WHERE migration NOT IN (' . implode(', ', $placeholders) . ')',
$parameters
);
}

public function status(): array
{
$this->ensureTable();

$ran = $this->database->query('SELECT migration, ran_at FROM migrations ORDER BY id ASC');
$ranByName = [];

foreach ($ran as $row) {
$ranByName[$row['migration']] = $row['ran_at'];
}

$files = $this->migrationFiles();

$status = [];

foreach ($files as $file) {
$name = basename($file);
$status[] = [
'migration' => $name,
'ran' => array_key_exists($name, $ranByName),
'ran_at' => $ranByName[$name] ?? null,
];
}

return $status;
}

public function runPending(): array
{
$this->ensureTable();

$ran = $this->database->query('SELECT migration FROM migrations');
$ranNames = array_column($ran, 'migration');
$files = $this->migrationFiles();
$ranMigrations = [];

foreach ($files as $file) {
$name = basename($file);

if (in_array($name, $ranNames, true)) {
continue;
}

$migration = $this->loadMigration($file);

$this->database->pdo()->beginTransaction();

try {
$migration->up($this->database);

$this->database->execute(
'INSERT INTO migrations (migration)
SELECT :m1 WHERE NOT EXISTS (SELECT 1 FROM migrations WHERE migration = :m2)',
['m1' => $name, 'm2' => $name]
);

$this->database->pdo()->commit();
$ranMigrations[] = $name;
} catch (\Throwable $exception) {
$this->database->pdo()->rollBack();
throw $exception;
}
}

return $ranMigrations;
}

public function rollback(int $steps = 1): array
{
$this->ensureTable();

$steps = max(1, $steps);
$applied = $this->database->query(
"SELECT TOP ({$steps}) MAX(id) AS id, migration
FROM migrations
GROUP BY migration
ORDER BY MAX(id) DESC"
);
$rolledBack = [];

foreach ($applied as $row) {
$file = $this->path . '/' . $row['migration'];

if (!file_exists($file)) {
throw new \RuntimeException("Migration file not found for rollback: {$row['migration']}");
}

$migration = $this->loadMigration($file);
$this->database->pdo()->beginTransaction();

try {
$migration->down($this->database);

$this->database->execute(
'DELETE FROM migrations WHERE id = :id',
['id' => $row['id']]
);

$this->database->pdo()->commit();
$rolledBack[] = $row['migration'];
} catch (\Throwable $exception) {
$this->database->pdo()->rollBack();
throw $exception;
}
}

return $rolledBack;
}

public function fresh(): array
{
$this->ensureTable();

$files = array_reverse($this->migrationFiles());
$rolledBack = [];

foreach ($files as $file) {
$migration = $this->loadMigration($file);
$name = basename($file);

$this->database->pdo()->beginTransaction();

try {
$migration->down($this->database);
$this->database->pdo()->commit();
$rolledBack[] = $name;
} catch (\Throwable $exception) {
$this->database->pdo()->rollBack();
throw $exception;
}
}

$this->database->execute('DELETE FROM migrations');
$ran = $this->runPending();

return [
'rolled_back' => $rolledBack,
'migrated' => $ran,
];
}

public function make(string $name): string
{
$slug = trim(strtolower(preg_replace('/[^a-zA-Z0-9]+/', '_', $name) ?? ''), '_');

if ($slug === '') {
throw new \InvalidArgumentException('Migration name must contain letters or numbers.');
}

if (!is_dir($this->path)) {
mkdir($this->path, 0777, true);
}

$timestamp = date('Ymd_His');
$filename = $timestamp . '_' . $slug . '.php';
$path = $this->path . '/' . $filename;

if (file_exists($path)) {
throw new \RuntimeException("Migration already exists: {$filename}");
}

$template = <<<PHP
<?php

declare(strict_types=1);

use Core\Database;
use Core\Migration;

return new class extends Migration
{
public function up(Database \$database): void
{
// Write the forward migration here.
}

public function down(Database \$database): void
{
// Write the rollback migration here.
}
};
PHP;

file_put_contents($path, $template . PHP_EOL);

return $path;
}

private function migrationFiles(): array
{
$files = glob($this->path . '/*.php') ?: [];
sort($files);

return $files;
}

private function loadMigration(string $file): Migration
{
$migration = require $file;

if ($migration instanceof Migration) {
return $migration;
}

if (is_callable($migration)) {
return new class ($migration, basename($file)) extends Migration
{
private $callback;
private string $name;

public function __construct(callable $callback, string $name)
{
$this->callback = $callback;
$this->name = $name;
}

public function up(Database $database): void
{
($this->callback)($database);
}

public function down(Database $database): void
{
throw new \RuntimeException("Migration {$this->name} cannot be rolled back because it has no down() method.");
}
};
}

throw new \RuntimeException('Migration files must return a Migration instance.');
}
}

+ 38
- 0
core/Repository.php Ver arquivo

@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Core;

abstract class Repository
{
protected Database $database;
protected string $table;
protected string $primaryKey = 'id';

public function __construct(Database $database)
{
$this->database = $database;
}

public function find(int|string $id): ?array
{
return $this->database->first(
"SELECT * FROM {$this->table} WHERE {$this->primaryKey} = :id",
['id' => $id]
);
}

public function all(): array
{
return $this->database->query("SELECT * FROM {$this->table}");
}

public function delete(int|string $id): bool
{
return $this->database->execute(
"DELETE FROM {$this->table} WHERE {$this->primaryKey} = :id",
['id' => $id]
);
}
}

+ 70
- 0
core/Request.php Ver arquivo

@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace Core;

class Request
{
protected static ?self $current = null;
protected string $method;
protected string $uri;
protected array $get;
protected array $post;
protected array $server;

public function __construct(array $get, array $post, array $server)
{
$this->get = $get;
$this->post = $post;
$this->server = $server;
$this->method = $server['REQUEST_METHOD'] ?? 'GET';
$this->uri = $server['REQUEST_URI'] ?? '/';
}

public static function capture(): self
{
if (self::$current instanceof self) {
return self::$current;
}

return new self($_GET, $_POST, $_SERVER);
}

public static function setCurrent(self $request): void
{
self::$current = $request;
}

public static function clearCurrent(): void
{
self::$current = null;
}

public function method(): string
{
return strtoupper($this->method);
}

public function path(): string
{
$path = parse_url($this->uri, PHP_URL_PATH);

return $path ?: '/';
}

public function input(string $key, mixed $default = null): mixed
{
return $this->post[$key] ?? $this->get[$key] ?? $default;
}

public function server(string $key, mixed $default = null): mixed
{
return $this->server[$key] ?? $default;
}

public function all(): array
{
return array_merge($this->get, $this->post);
}
}

+ 64
- 0
core/Response.php Ver arquivo

@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace Core;

class Response
{
protected string $content;
protected int $status;
protected array $headers;

public function __construct(string $content = '', int $status = 200, array $headers = [])
{
$this->content = $content;
$this->status = $status;
$this->headers = $headers;
}

public static function json(array $data, int $status = 200): self
{
return new self(
json_encode($data, JSON_PRETTY_PRINT),
$status,
['Content-Type' => 'application/json']
);
}

public static function redirect(string $url): self
{
return new self('', 302, ['Location' => $url]);
}

public static function notFound(string $message = 'Not found'): self
{
return new self($message, 404);
}

public static function serverError(string $message = 'Server error'): self
{
return new self($message, 500);
}

public function send(): void
{
http_response_code($this->status);

foreach ($this->headers as $name => $value) {
header($name . ': ' . $value);
}

echo $this->content;
}

public function content(): string
{
return $this->content;
}

public function status(): int
{
return $this->status;
}
}

+ 80
- 0
core/Route.php Ver arquivo

@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

namespace Core;

class Route
{
protected string $method;
protected string $path;
protected mixed $handler;
protected array $parameters = [];
protected ?string $middleware = null;
protected ?string $requiredPermission = null;

public function __construct(string $method, string $path, mixed $handler)
{
$this->method = strtoupper($method);
$this->path = $path;
$this->handler = $handler;
}

// ── Fluent middleware / permission builders ───────────────────────────────

public function middleware(string $name): self
{
$this->middleware = $name;

return $this;
}

public function permission(string $permission): self
{
$this->requiredPermission = $permission;

return $this;
}

public function getMiddleware(): ?string
{
return $this->middleware;
}

public function getRequiredPermission(): ?string
{
return $this->requiredPermission;
}

// ── Matching ──────────────────────────────────────────────────────────────

public function matches(string $method, string $path): bool
{
if (strtoupper($method) !== $this->method) {
return false;
}

$routePattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '([^/]+)', $this->path);
$routePattern = '#^' . $routePattern . '$#';

if (!preg_match($routePattern, $path, $matches)) {
return false;
}

array_shift($matches);
preg_match_all('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', $this->path, $names);

$this->parameters = [];

foreach ($names[1] as $index => $name) {
$this->parameters[$name] = $matches[$index] ?? null;
}

return true;
}

public function dispatch(App $app): mixed
{
return $app->call($this->handler, $this->parameters);
}
}

+ 39
- 0
core/Router.php Ver arquivo

@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Core;

class Router
{
protected array $routes = [];

public function get(string $path, callable|array|string $handler): Route
{
return $this->add('GET', $path, $handler);
}

public function post(string $path, callable|array|string $handler): Route
{
return $this->add('POST', $path, $handler);
}

public function add(string $method, string $path, callable|array|string $handler): Route
{
$route = new Route($method, $path, $handler);
$this->routes[] = $route;

return $route;
}

public function match(string $method, string $path): ?Route
{
foreach ($this->routes as $route) {
if ($route->matches($method, $path)) {
return $route;
}
}

return null;
}
}

+ 52
- 0
core/Validator.php Ver arquivo

@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace Core;

class Validator
{
protected array $errors = [];

public function required(string $field, mixed $value, string $message = ''): self
{
if ($value === null || trim((string) $value) === '') {
$this->errors[$field][] = $message ?: "{$field} is required.";
}

return $this;
}

public function maxLength(string $field, mixed $value, int $max, string $message = ''): self
{
if (strlen((string) $value) > $max) {
$this->errors[$field][] = $message ?: "{$field} must be {$max} characters or fewer.";
}

return $this;
}

public function numeric(string $field, mixed $value, string $message = ''): self
{
if (!is_numeric($value)) {
$this->errors[$field][] = $message ?: "{$field} must be numeric.";
}

return $this;
}

public function passes(): bool
{
return empty($this->errors);
}

public function fails(): bool
{
return !$this->passes();
}

public function errors(): array
{
return $this->errors;
}
}

+ 68
- 0
core/View.php Ver arquivo

@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

namespace Core;

class View
{
public static function render(string $view, array $data = []): Response
{
$content = self::renderContent($view, $data);
$layoutPath = __DIR__ . '/../app/Views/layouts/app.php';

if (!file_exists($layoutPath)) {
return new Response($content);
}

$pageTitle = self::resolvePageTitle($data);

extract($data, EXTR_SKIP);

ob_start();
require $layoutPath;
$content = ob_get_clean();

return new Response($content);
}

public static function fragment(string $view, array $data = [], int $status = 200, array $headers = []): Response
{
return new Response(self::renderContent($view, $data), $status, $headers);
}

private static function renderContent(string $view, array $data): string
{
$path = __DIR__ . '/../app/Views/' . str_replace('.', '/', $view) . '.php';

if (!file_exists($path)) {
throw new \Exception("View not found: {$view}");
}

extract($data, EXTR_SKIP);

ob_start();
require $path;

return (string) ob_get_clean();
}

private static function resolvePageTitle(array $data): string
{
if (isset($data['pageTitle']) && is_string($data['pageTitle']) && trim($data['pageTitle']) !== '') {
return $data['pageTitle'];
}

if (
isset($data['model']) &&
is_object($data['model']) &&
property_exists($data['model'], 'title') &&
is_string($data['model']->title) &&
trim($data['model']->title) !== ''
) {
return $data['model']->title;
}

return 'MindVisionCode PHP';
}
}

+ 207
- 0
core/helpers.php Ver arquivo

@@ -0,0 +1,207 @@
<?php

declare(strict_types=1);

use Core\App;
use Core\Auth\KeycloakAuth;
use Core\Auth\PermissionService;
use Core\Database;
use Core\Http\Session;
use Core\MigrationManager;
use Core\Response;
use Core\View;

// ── Framework helpers ─────────────────────────────────────────────────────────

function app(): App
{
static $app = null;

if ($app === null) {
$app = new App();
}

return $app;
}

function view(string $view, array $data = []): Response
{
return View::render($view, $data);
}

function redirect(string $url): Response
{
return Response::redirect($url);
}

// ── Environment helpers ───────────────────────────────────────────────────────

function env(string $key, mixed $default = null): mixed
{
$value = $_ENV[$key] ?? getenv($key);

return ($value === false) ? $default : $value;
}

function loadEnv(string $path): void
{
if (!file_exists($path)) {
return;
}

foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);

if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
continue;
}

[$key, $value] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value, " \t\"'");

if (!isset($_ENV[$key]) && getenv($key) === false) {
$_ENV[$key] = $value;
putenv("{$key}={$value}");
}
}
}

// ── Session helper ────────────────────────────────────────────────────────────

function session(): Session
{
static $session = null;

if ($session === null) {
$session = new Session();
}

return $session;
}

// ── Auth helper ───────────────────────────────────────────────────────────────

function auth(): KeycloakAuth
{
static $auth = null;

if ($auth === null) {
$auth = new KeycloakAuth(session(), new PermissionService());
}

return $auth;
}

// ── Database helpers ──────────────────────────────────────────────────────────

function ensureSqlServerDatabase(array $config): void
{
if (!str_starts_with($config['dsn'] ?? '', 'sqlsrv:')) {
return;
}

preg_match('/Server=([^;]+)/', $config['dsn'], $serverMatch);
preg_match('/Database=([^;]+)/', $config['dsn'], $dbMatch);

if (empty($serverMatch[1]) || empty($dbMatch[1])) {
return;
}

$server = $serverMatch[1];
$dbName = $dbMatch[1];

try {
$masterPdo = new PDO(
"sqlsrv:Server={$server};Database=master;LoginTimeout=5;TrustServerCertificate=1",
$config['username'] ?? null,
$config['password'] ?? null,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);

$check = $masterPdo->prepare('SELECT 1 FROM sys.databases WHERE name = ?');
$check->execute([$dbName]);

if (!$check->fetch()) {
$safeName = str_replace(']', ']]', $dbName);
$masterPdo->exec("CREATE DATABASE [{$safeName}]");
}
} catch (\Throwable) {
// Let the main connection fail with its own error if master access fails
}
}

function database(): Database
{
static $database = null;

if ($database === null) {
/** @var array<string, mixed> $config */
$config = require __DIR__ . '/../config/database.php';

ensureSqlServerDatabase($config);

$database = new Database($config);
}

return $database;
}

function migration_manager(): MigrationManager
{
static $migrationManager = null;

if ($migrationManager === null) {
$migrationManager = new MigrationManager(database(), __DIR__ . '/../database/migrations');
}

return $migrationManager;
}

// ── Session / CSRF helpers ────────────────────────────────────────────────────

function ensureSessionStarted(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}

function e(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function asset(string $path): string
{
return '/' . ltrim($path, '/');
}

function csrf_token(): string
{
ensureSessionStarted();

if (!isset($_SESSION['_csrf_token']) || !is_string($_SESSION['_csrf_token'])) {
$_SESSION['_csrf_token'] = bin2hex(random_bytes(32));
}

return $_SESSION['_csrf_token'];
}

function csrf_field(): string
{
return '<input type="hidden" name="_token" value="' . e(csrf_token()) . '">';
}

function verify_csrf_token(?string $token): bool
{
ensureSessionStarted();

if (!is_string($token) || $token === '') {
return false;
}

$sessionToken = $_SESSION['_csrf_token'] ?? null;

return is_string($sessionToken) && hash_equals($sessionToken, $token);
}

+ 37
- 0
database/migrations/20260511_000001_create_campaign_type_table.php Ver arquivo

@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

use Core\Database;
use Core\Migration;

return new class extends Migration
{
public function up(Database $database): void
{
$tableExists = $database->first(
"SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'campaign_type'"
);

if ($tableExists) {
return;
}

$database->execute(
'CREATE TABLE campaign_type (
id INT IDENTITY(1,1) NOT NULL,
name NVARCHAR(255) NOT NULL,
attributes NVARCHAR(MAX) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT PK_campaign_type PRIMARY KEY (id),
CONSTRAINT UQ_campaign_type_name UNIQUE (name)
)'
);
}

public function down(Database $database): void
{
$database->execute('DROP TABLE IF EXISTS campaign_type');
}
};

+ 44
- 0
database/migrations/20260511_000003_create_campaign_type_audit_table.php Ver arquivo

@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

use Core\Database;
use Core\Migration;

return new class extends Migration
{
public function up(Database $database): void
{
$tableExists = $database->first(
"SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'campaign_type_audit'"
);

if ($tableExists) {
return;
}

// No foreign key on id: audit records must survive the deletion of the
// campaign_type row they reference (that deletion itself is audited as 'D').
$database->execute(
"CREATE TABLE campaign_type_audit (
audit_id INT IDENTITY(1,1) NOT NULL,
id INT NOT NULL,
action CHAR(1) NOT NULL,
fields NVARCHAR(MAX) NOT NULL,
username NVARCHAR(255) NOT NULL DEFAULT 'system',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT PK_campaign_type_audit PRIMARY KEY (audit_id),
CONSTRAINT CHK_campaign_type_audit_action CHECK (action IN ('I','U','D','R'))
)"
);

$database->execute(
'CREATE INDEX IX_campaign_type_audit_id ON campaign_type_audit (id)'
);
}

public function down(Database $database): void
{
$database->execute('DROP TABLE IF EXISTS campaign_type_audit');
}
};

+ 40
- 0
database/migrations/20260511_000004_create_campaign_table.php Ver arquivo

@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

use Core\Database;
use Core\Migration;

return new class extends Migration
{
public function up(Database $database): void
{
$tableExists = $database->first(
"SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'campaign'"
);

if ($tableExists) {
return;
}

$database->execute(
'CREATE TABLE campaign (
id INT IDENTITY(1,1) NOT NULL,
campaign_type_id INT NOT NULL,
attribute_values NVARCHAR(MAX) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT PK_campaign PRIMARY KEY (id),
CONSTRAINT FK_campaign_campaign_type FOREIGN KEY (campaign_type_id)
REFERENCES campaign_type (id)
ON UPDATE NO ACTION
ON DELETE NO ACTION
)'
);
}

public function down(Database $database): void
{
$database->execute('DROP TABLE IF EXISTS campaign');
}
};

+ 44
- 0
database/migrations/20260511_000005_create_campaign_audit_table.php Ver arquivo

@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

use Core\Database;
use Core\Migration;

return new class extends Migration
{
public function up(Database $database): void
{
$tableExists = $database->first(
"SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'campaign_audit'"
);

if ($tableExists) {
return;
}

// No foreign key on id: audit records must survive deletion of the
// campaign row they reference (that deletion is itself audited as 'D').
$database->execute(
"CREATE TABLE campaign_audit (
audit_id INT IDENTITY(1,1) NOT NULL,
id INT NOT NULL,
action CHAR(1) NOT NULL,
fields NVARCHAR(MAX) NOT NULL,
username NVARCHAR(255) NOT NULL DEFAULT 'system',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT PK_campaign_audit PRIMARY KEY (audit_id),
CONSTRAINT CHK_campaign_audit_action CHECK (action IN ('I','U','D','R'))
)"
);

$database->execute(
'CREATE INDEX IX_campaign_audit_id ON campaign_audit (id)'
);
}

public function down(Database $database): void
{
$database->execute('DROP TABLE IF EXISTS campaign_audit');
}
};

+ 37
- 0
database/migrations/20260511_000006_create_job_type_table.php Ver arquivo

@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

use Core\Database;
use Core\Migration;

return new class extends Migration
{
public function up(Database $database): void
{
$tableExists = $database->first(
"SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'job_type'"
);

if ($tableExists) {
return;
}

$database->execute(
'CREATE TABLE job_type (
id INT IDENTITY(1,1) NOT NULL,
name NVARCHAR(255) NOT NULL,
attributes NVARCHAR(MAX) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT PK_job_type PRIMARY KEY (id),
CONSTRAINT UQ_job_type_name UNIQUE (name)
)'
);
}

public function down(Database $database): void
{
$database->execute('DROP TABLE IF EXISTS job_type');
}
};

+ 42
- 0
database/migrations/20260511_000007_create_job_type_audit_table.php Ver arquivo

@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

use Core\Database;
use Core\Migration;

return new class extends Migration
{
public function up(Database $database): void
{
$tableExists = $database->first(
"SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'job_type_audit'"
);

if ($tableExists) {
return;
}

$database->execute(
"CREATE TABLE job_type_audit (
audit_id INT IDENTITY(1,1) NOT NULL,
id INT NOT NULL,
action CHAR(1) NOT NULL,
fields NVARCHAR(MAX) NOT NULL,
username NVARCHAR(255) NOT NULL DEFAULT 'system',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT PK_job_type_audit PRIMARY KEY (audit_id),
CONSTRAINT CHK_job_type_audit_action CHECK (action IN ('I','U','D','R'))
)"
);

$database->execute(
'CREATE INDEX IX_job_type_audit_id ON job_type_audit (id)'
);
}

public function down(Database $database): void
{
$database->execute('DROP TABLE IF EXISTS job_type_audit');
}
};

+ 43
- 0
database/migrations/20260511_000008_create_job_table.php Ver arquivo

@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

use Core\Database;
use Core\Migration;

return new class extends Migration
{
public function up(Database $database): void
{
$tableExists = $database->first(
"SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'job'"
);

if ($tableExists) {
return;
}

$database->execute(
'CREATE TABLE job (
id INT IDENTITY(1,1) NOT NULL,
campaign_id INT NOT NULL,
job_type_id INT NOT NULL,
attribute_values NVARCHAR(MAX) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT PK_job PRIMARY KEY (id),
CONSTRAINT FK_job_campaign FOREIGN KEY (campaign_id)
REFERENCES campaign (id) ON UPDATE NO ACTION ON DELETE NO ACTION,
CONSTRAINT FK_job_job_type FOREIGN KEY (job_type_id)
REFERENCES job_type (id) ON UPDATE NO ACTION ON DELETE NO ACTION
)'
);

$database->execute('CREATE INDEX IX_job_campaign_id ON job (campaign_id)');
}

public function down(Database $database): void
{
$database->execute('DROP TABLE IF EXISTS job');
}
};

+ 40
- 0
database/migrations/20260511_000009_create_job_audit_table.php Ver arquivo

@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

use Core\Database;
use Core\Migration;

return new class extends Migration
{
public function up(Database $database): void
{
$tableExists = $database->first(
"SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'job_audit'"
);

if ($tableExists) {
return;
}

$database->execute(
"CREATE TABLE job_audit (
audit_id INT IDENTITY(1,1) NOT NULL,
id INT NOT NULL,
action CHAR(1) NOT NULL,
fields NVARCHAR(MAX) NOT NULL,
username NVARCHAR(255) NOT NULL DEFAULT 'system',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT PK_job_audit PRIMARY KEY (audit_id),
CONSTRAINT CHK_job_audit_action CHECK (action IN ('I','U','D','R'))
)"
);

$database->execute('CREATE INDEX IX_job_audit_id ON job_audit (id)');
}

public function down(Database $database): void
{
$database->execute('DROP TABLE IF EXISTS job_audit');
}
};

+ 46
- 0
docker-compose.yml Ver arquivo

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

+ 12
- 0
docker/apache/vhost.conf Ver arquivo

@@ -0,0 +1,12 @@
<VirtualHost *:80>
DocumentRoot /var/www/html/public

<Directory /var/www/html/public>
AllowOverride All
Require all granted
Options -Indexes +FollowSymLinks
</Directory>

ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>

+ 64
- 0
docs/README.md Ver arquivo

@@ -0,0 +1,64 @@
# Campaign Tracker

A PHP MVC web application backed by Microsoft SQL Server, hosted with Docker.

## Docker Quick Start

```bash
docker compose up -d --build
docker compose exec campaign-tracker-app composer install
docker compose exec campaign-tracker-app php scripts/migrate.php up
docker compose exec campaign-tracker-app php scripts/migrate.php status
```

Open: http://localhost:8801

Health check: http://localhost:8801/health

> **Note:** SQL Server takes about 10–20 seconds to be ready after `docker compose up`. If the first `migrate up` fails with a connection error, wait a moment and retry.

## Environment

Copy `.env.example` to `.env` for local development outside Docker:

```bash
cp .env.example .env
```

Docker injects environment variables directly from `docker-compose.yml`, so `.env` is only needed when running PHP outside a container.

## Migrations

```bash
php scripts/migrate.php up # run pending migrations
php scripts/migrate.php down [steps] # roll back last N migrations
php scripts/migrate.php status # show migration state
php scripts/migrate.php make <name> # scaffold a new migration file
php scripts/migrate.php fresh # roll back all and re-run
php scripts/migrate.php fresh --seed # roll back all, re-run, seed data
```

## Request Flow

Browser → public/index.php → Request → Dispatcher → Router → Route → Controller → ViewModel/Repository → View → Response

## Main Folders

- `core/` framework classes
- `app/Controllers/` application controllers
- `app/ViewModels/` view model classes
- `app/Repositories/` data access classes
- `app/Views/` PHP templates
- `routes/web.php` route definitions
- `database/migrations/` migrations
- `scripts/` runnable PHP CLI scripts
- `docker/` Docker support files

## Frontend Libraries

- `Alpine.js` for lightweight page state and dynamic form interactions
- `Tabulator` for the interactive data grid

## Flow chart

See [`REQUEST_FLOW.md`](./REQUEST_FLOW.md) for a chart of how requests and responses move through the framework.

+ 80
- 0
docs/REQUEST_FLOW.md Ver arquivo

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

+ 5
- 0
public/.htaccess Ver arquivo

@@ -0,0 +1,5 @@
RewriteEngine On

RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

+ 992
- 0
public/css/site.css Ver arquivo

@@ -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;
}

+ 26
- 0
public/index.php Ver arquivo

@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

loadEnv(__DIR__ . '/../.env');

// Start session with secure cookie settings before any output or auth checks.
session()->start();

use Core\App;
use Core\Dispatcher;
use Core\Request;
use Core\Router;

$app = new App();
$router = new Router();

require_once __DIR__ . '/../routes/web.php';

$dispatcher = new Dispatcher($router, $app);
$request = Request::capture();
$response = $dispatcher->dispatch($request);

$response->send();

+ 1304
- 0
public/js/app.js
Diferenças do arquivo suprimidas por serem muito extensas
Ver arquivo


+ 66
- 0
routes/web.php Ver arquivo

@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

use App\Controllers\AuthController;
use App\Controllers\CampaignController;
use App\Controllers\CampaignTypeController;
use App\Controllers\HealthController;
use App\Controllers\HomeController;
use App\Controllers\JobController;
use App\Controllers\JobTypeController;

// ── Auth (public) ─────────────────────────────────────────────────────────────
$router->get('/login', [AuthController::class, 'login']);
$router->get('/auth/callback', [AuthController::class, 'callback']);
$router->get('/logout', [AuthController::class, 'logout']);
$router->post('/logout', [AuthController::class, 'logout']);

// ── Public ────────────────────────────────────────────────────────────────────
$router->get('/', [HomeController::class, 'index']);
$router->get('/health', [HealthController::class, 'index']);
$router->get('/users/{id}', [HomeController::class, 'user']);

// ── Campaigns ─────────────────────────────────────────────────────────────────
$router->get('/campaigns', [CampaignController::class, 'index']) ->middleware('auth');
$router->get('/campaigns/data', [CampaignController::class, 'data']) ->middleware('auth');
$router->get('/campaigns/create', [CampaignController::class, 'create']) ->middleware('auth');
$router->post('/campaigns', [CampaignController::class, 'store']) ->middleware('auth');
$router->get('/campaigns/{id}/jobs', [JobController::class, 'campaign'])
->middleware('auth');
$router->get('/campaigns/{id}/jobs/data', [JobController::class, 'dataForCampaign'])
->middleware('auth');
$router->post('/campaigns/{id}/jobs/import/sheets', [JobController::class, 'googleSheetsList'])->middleware('auth');
$router->post('/campaigns/{id}/jobs/import', [JobController::class, 'importGoogleSheet'])->middleware('auth');
$router->post('/campaigns/{id}/jobs/import/file/sheets', [JobController::class, 'fileSheetsList']) ->middleware('auth');
$router->post('/campaigns/{id}/jobs/import/file', [JobController::class, 'importFile']) ->middleware('auth');
$router->get('/campaigns/{id}/edit', [CampaignController::class, 'edit']) ->middleware('auth');
$router->post('/campaigns/{id}/update', [CampaignController::class, 'update']) ->middleware('auth');
$router->post('/campaigns/{id}/delete', [CampaignController::class, 'destroy'])->middleware('auth');

// ── Campaign Types ────────────────────────────────────────────────────────────
$router->get('/campaign-types', [CampaignTypeController::class, 'index']) ->middleware('auth');
$router->get('/campaign-types/data', [CampaignTypeController::class, 'data']) ->middleware('auth');
$router->get('/campaign-types/create', [CampaignTypeController::class, 'create']) ->middleware('auth');
$router->post('/campaign-types', [CampaignTypeController::class, 'store']) ->middleware('auth');
$router->get('/campaign-types/{id}/edit', [CampaignTypeController::class, 'edit']) ->middleware('auth');
$router->post('/campaign-types/{id}/update', [CampaignTypeController::class, 'update']) ->middleware('auth');
$router->post('/campaign-types/{id}/delete', [CampaignTypeController::class, 'destroy'])->middleware('auth');

// ── Jobs ──────────────────────────────────────────────────────────────────────
$router->get('/jobs', [JobController::class, 'index']) ->middleware('auth');
$router->get('/jobs/data', [JobController::class, 'data']) ->middleware('auth');
$router->get('/jobs/create', [JobController::class, 'create']) ->middleware('auth');
$router->post('/jobs', [JobController::class, 'store']) ->middleware('auth');
$router->get('/jobs/{id}/edit', [JobController::class, 'edit']) ->middleware('auth');
$router->post('/jobs/{id}/update', [JobController::class, 'update']) ->middleware('auth');
$router->post('/jobs/{id}/delete', [JobController::class, 'destroy'])->middleware('auth');

// ── Job Types ─────────────────────────────────────────────────────────────────
$router->get('/job-types', [JobTypeController::class, 'index']) ->middleware('auth');
$router->get('/job-types/data', [JobTypeController::class, 'data']) ->middleware('auth');
$router->get('/job-types/create', [JobTypeController::class, 'create']) ->middleware('auth');
$router->post('/job-types', [JobTypeController::class, 'store']) ->middleware('auth');
$router->get('/job-types/{id}/edit', [JobTypeController::class, 'edit']) ->middleware('auth');
$router->post('/job-types/{id}/update', [JobTypeController::class, 'update']) ->middleware('auth');
$router->post('/job-types/{id}/delete', [JobTypeController::class, 'destroy'])->middleware('auth');

+ 17
- 0
scripts/README.md Ver arquivo

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

+ 102
- 0
scripts/migrate.php Ver arquivo

@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

loadEnv(__DIR__ . '/../.env');

$command = $argv[1] ?? 'help';
$options = array_slice($argv, 2);
$manager = migration_manager();

try {
switch ($command) {
case 'up':
$ran = $manager->runPending();

if ($ran === []) {
echo "No pending migrations." . PHP_EOL;
exit(0);
}

foreach ($ran as $migration) {
echo "Migrated: {$migration}" . PHP_EOL;
}

echo 'Applied ' . count($ran) . ' migration(s).' . PHP_EOL;
exit(0);

case 'down':
$steps = isset($argv[2]) ? max(1, (int) $argv[2]) : 1;
$rolledBack = $manager->rollback($steps);

if ($rolledBack === []) {
echo "No applied migrations to roll back." . PHP_EOL;
exit(0);
}

foreach ($rolledBack as $migration) {
echo "Rolled back: {$migration}" . PHP_EOL;
}

echo 'Rolled back ' . count($rolledBack) . ' migration(s).' . PHP_EOL;
exit(0);

case 'status':
$status = $manager->status();

if ($status === []) {
echo "No migration files found." . PHP_EOL;
exit(0);
}

foreach ($status as $row) {
$state = $row['ran'] ? 'up' : 'pending';
$ranAt = $row['ran_at'] ?? '-';
echo str_pad($state, 10) . ' ' . $row['migration'] . ' ' . $ranAt . PHP_EOL;
}

exit(0);

case 'make':
case 'create':
$name = $argv[2] ?? '';

if ($name === '') {
throw new InvalidArgumentException('Provide a migration name. Example: php scripts/migrate.php make create_projects_table');
}

$path = $manager->make($name);
echo "Created migration: {$path}" . PHP_EOL;
exit(0);

case 'fresh':
$result = $manager->fresh();

foreach ($result['rolled_back'] as $migration) {
echo "Rolled back: {$migration}" . PHP_EOL;
}

foreach ($result['migrated'] as $migration) {
echo "Migrated: {$migration}" . PHP_EOL;
}

echo "Fresh migration run complete." . PHP_EOL;
exit(0);

case 'help':
default:
echo "Migration CLI" . PHP_EOL;
echo "Usage:" . PHP_EOL;
echo " php scripts/migrate.php up" . PHP_EOL;
echo " php scripts/migrate.php down [steps]" . PHP_EOL;
echo " php scripts/migrate.php status" . PHP_EOL;
echo " php scripts/migrate.php make <name>" . PHP_EOL;
echo " php scripts/migrate.php fresh [--seed]" . PHP_EOL;
exit(0);
}
} catch (Throwable $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}

+ 113
- 0
tests/run.php Ver arquivo

@@ -0,0 +1,113 @@
<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use Core\App;
use Core\Database;
use Core\Dispatcher;
use Core\MigrationManager;
use Core\Request;
use Core\Router;

$tempMigrationPath = sys_get_temp_dir() . '/mvc_migrations_' . uniqid('', true);
mkdir($tempMigrationPath, 0777, true);

$migrationFile = $tempMigrationPath . '/20260509_120000_create_projects_table.php';
file_put_contents($migrationFile, <<<'PHP'
<?php

declare(strict_types=1);

use Core\Database;
use Core\Migration;

return new class extends Migration
{
public function up(Database $database): void
{
$database->execute('CREATE TABLE projects (id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(100) NOT NULL)');
}

public function down(Database $database): void
{
$database->execute('DROP TABLE IF EXISTS projects');
}
};
PHP
);

$memoryDatabase = new Database([
'dsn' => 'sqlite::memory:',
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
],
]);

$migrationManager = new MigrationManager($memoryDatabase, $tempMigrationPath);
$ran = $migrationManager->runPending();

if ($ran !== ['20260509_120000_create_projects_table.php']) {
echo "FAIL: migration manager did not apply the expected migration\n";
exit(1);
}

$projectTable = $memoryDatabase->first("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'projects'");

if ($projectTable === null) {
echo "FAIL: migration up() did not create the projects table\n";
exit(1);
}

$rolledBack = $migrationManager->rollback();

if ($rolledBack !== ['20260509_120000_create_projects_table.php']) {
echo "FAIL: migration manager did not roll back the expected migration\n";
exit(1);
}

$projectTableAfterRollback = $memoryDatabase->first("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'projects'");

if ($projectTableAfterRollback !== null) {
echo "FAIL: migration down() did not remove the projects table\n";
exit(1);
}

$createdMigrationPath = $migrationManager->make('create_tasks_table');

if (!file_exists($createdMigrationPath)) {
echo "FAIL: migration manager did not create a migration file\n";
exit(1);
}

$router = new Router();
$app = new App();

(new MigrationManager(database(), __DIR__ . '/../database/migrations'))->runPending();

require_once __DIR__ . '/../routes/web.php';

$router->get('/hello/{name}', function (string $name) {
return 'Hello, ' . $name;
});

$request = new Request([], [], [
'REQUEST_METHOD' => 'GET',
'REQUEST_URI' => '/hello/Daniel',
]);

$response = (new Dispatcher($router, $app))->dispatch($request);

if ($response->status() !== 200) {
echo "FAIL: expected status 200\n";
exit(1);
}

if ($response->content() !== 'Hello, Daniel') {
echo "FAIL: unexpected response content\n";
exit(1);
}

echo "PASS: migration manager and route dispatch work\n";

Carregando…
Cancelar
Salvar

Powered by TurnKey Linux.