From 7fb48edcf1096364d4d6a02fc72088e754848ef8 Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Thu, 21 May 2026 13:27:00 -0400 Subject: [PATCH] init --- .ai/SKILLS.md | 333 +++++++ .ai/skills/database/SKILL.md | 197 +++++ .ai/skills/mvc/SKILL.md | 321 +++++++ .ai/skills/php/SKILL.md | 331 +++++++ .ai/skills/security/SKILL.md | 297 +++++++ .ai/skills/testing/SKILL.md | 162 ++++ .ai/skills/workflow/SKILL.md | 126 +++ .claude/settings.local.json | 29 + .dockerignore | 4 + .gitignore | 19 + AGENTS.md | 242 ++++++ CLAUDE.md | 1 + Dockerfile | 33 + app/Controllers/AuthController.php | 80 ++ app/Controllers/BoardsController.php | 204 +++++ app/Controllers/CardsController.php | 132 +++ app/Controllers/ColumnsController.php | 120 +++ app/Controllers/HomeController.php | 15 + app/Controllers/SwimLanesController.php | 120 +++ app/Models/Board.php | 34 + app/Models/BoardColumn.php | 32 + app/Models/Card.php | 65 ++ app/Models/SwimLane.php | 32 + app/Repositories/BoardColumnRepository.php | 77 ++ app/Repositories/BoardRepository.php | 105 +++ app/Repositories/CardRepository.php | 131 +++ app/Repositories/SwimLaneRepository.php | 77 ++ app/Services/AuthService.php | 121 +++ app/Views/auth/callback-error.php | 5 + app/Views/boards/create.php | 39 + app/Views/boards/edit.php | 47 + app/Views/boards/index.php | 33 + app/Views/boards/show.php | 99 +++ app/Views/home/index.php | 39 + app/Views/layouts/app.php | 14 + app/Views/partials/card-modal.php | 54 ++ app/Views/partials/footer.php | 9 + app/Views/partials/header.php | 43 + app/Views/partials/settings-panel.php | 79 ++ composer.json | 24 + composer.lock | 812 ++++++++++++++++++ config/auth.php | 14 + config/database.php | 11 + config/view.php | 6 + core/App.php | 154 ++++ core/Controller.php | 37 + core/Database.php | 69 ++ core/Dispatcher.php | 61 ++ core/Migration.php | 12 + core/MigrationManager.php | 233 +++++ core/Repository.php | 38 + core/Request.php | 80 ++ core/Response.php | 64 ++ core/Route.php | 52 ++ core/Router.php | 54 ++ core/Validator.php | 114 +++ core/View.php | 79 ++ core/helpers.php | 134 +++ ...20260509_000001_create_employees_table.php | 30 + .../20260522_000001_create_boards_table.php | 33 + ...0522_000002_create_board_columns_table.php | 33 + ...0260522_000003_create_swim_lanes_table.php | 33 + .../20260522_000004_create_cards_table.php | 45 + database/seed_employees.php | 107 +++ docker-compose.yml | 8 + docker/entrypoint.sh | 17 + docker/vhost.conf | 13 + docs/README.md | 78 ++ docs/REQUEST_FLOW.md | 80 ++ public/.htaccess | 5 + public/css/kanban.css | 507 +++++++++++ public/css/site.css | 791 +++++++++++++++++ public/index.php | 45 + public/js/app.js | 65 ++ public/js/kanban-board.js | 442 ++++++++++ public/js/kanban-modal.js | 200 +++++ public/js/kanban-settings.js | 235 +++++ routes/web.php | 45 + scripts/README.md | 18 + scripts/migrate.php | 105 +++ scripts/seed_employees.php | 8 + tests/run.php | 145 ++++ 82 files changed, 9037 insertions(+) create mode 100644 .ai/SKILLS.md create mode 100644 .ai/skills/database/SKILL.md create mode 100644 .ai/skills/mvc/SKILL.md create mode 100644 .ai/skills/php/SKILL.md create mode 100644 .ai/skills/security/SKILL.md create mode 100644 .ai/skills/testing/SKILL.md create mode 100644 .ai/skills/workflow/SKILL.md create mode 100644 .claude/settings.local.json create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 Dockerfile create mode 100644 app/Controllers/AuthController.php create mode 100644 app/Controllers/BoardsController.php create mode 100644 app/Controllers/CardsController.php create mode 100644 app/Controllers/ColumnsController.php create mode 100644 app/Controllers/HomeController.php create mode 100644 app/Controllers/SwimLanesController.php create mode 100644 app/Models/Board.php create mode 100644 app/Models/BoardColumn.php create mode 100644 app/Models/Card.php create mode 100644 app/Models/SwimLane.php create mode 100644 app/Repositories/BoardColumnRepository.php create mode 100644 app/Repositories/BoardRepository.php create mode 100644 app/Repositories/CardRepository.php create mode 100644 app/Repositories/SwimLaneRepository.php create mode 100644 app/Services/AuthService.php create mode 100644 app/Views/auth/callback-error.php create mode 100644 app/Views/boards/create.php create mode 100644 app/Views/boards/edit.php create mode 100644 app/Views/boards/index.php create mode 100644 app/Views/boards/show.php create mode 100644 app/Views/home/index.php create mode 100644 app/Views/layouts/app.php create mode 100644 app/Views/partials/card-modal.php create mode 100644 app/Views/partials/footer.php create mode 100644 app/Views/partials/header.php create mode 100644 app/Views/partials/settings-panel.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/auth.php create mode 100644 config/database.php create mode 100644 config/view.php create mode 100644 core/App.php create mode 100644 core/Controller.php create mode 100644 core/Database.php create mode 100644 core/Dispatcher.php create mode 100644 core/Migration.php create mode 100644 core/MigrationManager.php create mode 100644 core/Repository.php create mode 100644 core/Request.php create mode 100644 core/Response.php create mode 100644 core/Route.php create mode 100644 core/Router.php create mode 100644 core/Validator.php create mode 100644 core/View.php create mode 100644 core/helpers.php create mode 100644 database/migrations/20260509_000001_create_employees_table.php create mode 100644 database/migrations/20260522_000001_create_boards_table.php create mode 100644 database/migrations/20260522_000002_create_board_columns_table.php create mode 100644 database/migrations/20260522_000003_create_swim_lanes_table.php create mode 100644 database/migrations/20260522_000004_create_cards_table.php create mode 100644 database/seed_employees.php create mode 100644 docker-compose.yml create mode 100644 docker/entrypoint.sh create mode 100644 docker/vhost.conf create mode 100644 docs/README.md create mode 100644 docs/REQUEST_FLOW.md create mode 100644 public/.htaccess create mode 100644 public/css/kanban.css create mode 100644 public/css/site.css create mode 100644 public/index.php create mode 100644 public/js/app.js create mode 100644 public/js/kanban-board.js create mode 100644 public/js/kanban-modal.js create mode 100644 public/js/kanban-settings.js create mode 100644 routes/web.php create mode 100644 scripts/README.md create mode 100644 scripts/migrate.php create mode 100644 scripts/seed_employees.php create mode 100644 tests/run.php diff --git a/.ai/SKILLS.md b/.ai/SKILLS.md new file mode 100644 index 0000000..ca3f84d --- /dev/null +++ b/.ai/SKILLS.md @@ -0,0 +1,333 @@ +# SKILLS.md + +## Purpose + +This is the main skill index for AI coding agents working on **MindVisionCode PHP**. + +Read this file after `AGENTS.md`, then load only the focused skill files needed for the current task. + +--- + +## Project Overview + +MindVisionCode PHP is a small PHP MVC framework inspired by a Classic ASP MVC framework style. + +The project favors: + +- 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, CakePHP, or another large framework. + +--- + +## Configuration Files + +| File | Purpose | +|------|---------| +| `config/database.php` | PDO DSN, credentials, and PDO options | +| `config/view.php` | Views directory path and layout file path | + +Add new config files to `config/` — never hardcode environment-specific paths in `core/`. + +--- + +## Tech Stack + +- PHP 8.2+ +- Composer +- PSR-4 autoloading +- PDO +- PHP views +- Optional SQLite/MySQL/SQL Server through PDO +- Minimal dependencies + +--- + +## Preferred Project Structure + +```text +project-root/ + AGENTS.md + .ai/ + SKILLS.md + skills/ + php/ + SKILL.md + mvc/ + SKILL.md + database/ + SKILL.md + security/ + SKILL.md + testing/ + SKILL.md + workflow/ + SKILL.md + public/ + index.php + src/ + Controller/ + Service/ + Repository/ + Entity/ + ValueObject/ + ViewModel/ + Http/ + Routing/ + Validation/ + Database/ + Migration/ + templates/ + config/ + tests/ + var/ + cache/ + logs/ + vendor/ + composer.json +``` + +Rules: + +- `public/` is the web root. +- Do not expose `src/`, `config/`, `tests/`, `vendor/`, `.ai/`, 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. + +--- + +## Setup 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 +``` + +Run database migrations: + +```bash +php scripts/migrate.php up +``` + +Roll back the last migration: + +```bash +php scripts/migrate.php down +``` + +Check migration status: + +```bash +php scripts/migrate.php status +``` + +Create a new migration file: + +```bash +php scripts/migrate.php make +``` + +Reset and re-run all migrations: + +```bash +php scripts/migrate.php fresh +php scripts/migrate.php fresh --seed +``` + +--- + +## Request Flow + +```text +Browser + → public/index.php + → Request + → Dispatcher + → Router + → Route + → Controller + → ViewModel/Repository/Service + → View + → Response +``` + +--- + +## Skill Routes + +### PHP Language, Style, Composer, OOP + +Read: + +```text +./.ai/skills/php/SKILL.md +``` + +Use for: + +- PHP version decisions +- PSR standards +- Composer dependencies +- Namespaces and autoloading +- OOP design +- Dependency injection +- Documentation and PHPDoc +- Performance and caching + +--- + +### MVC Framework Architecture + +Read: + +```text +./.ai/skills/mvc/SKILL.md +``` + +Use for: + +- Dispatcher changes +- Router changes +- Controllers and actions +- ViewModels +- PHP templates/views +- HTTP request/response flow +- Framework structure + +--- + +### Database and Persistence + +Read: + +```text +./.ai/skills/database/SKILL.md +``` + +Use for: + +- PDO +- SQL +- Repositories +- Migrations +- Transactions +- Database configuration +- SQLite/MySQL/SQL Server support + +--- + +### Security + +Read: + +```text +./.ai/skills/security/SKILL.md +``` + +Use for: + +- Input validation +- Output escaping +- Passwords +- Authentication +- Authorization +- Sessions +- CSRF +- Secrets +- Error disclosure +- Dangerous functions + +--- + +### Testing and Quality + +Read: + +```text +./.ai/skills/testing/SKILL.md +``` + +Use for: + +- Tests +- Test runner changes +- Static analysis +- Composer quality scripts +- Code style tools +- Verification steps + +--- + +### Agent Workflow + +Read: + +```text +./.ai/skills/workflow/SKILL.md +``` + +Use for: + +- Multi-file changes +- Pull-request style review +- Legacy PHP changes +- Non-negotiable rules +- Response format +- Skill feedback updates + +--- + +## Default 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. + +--- + +## Default Security Rules + +- Validate input. +- Escape output. +- Use prepared statements for SQL. +- Do not expose sensitive errors. +- Check authorization separately from authentication. + +--- + +## Default Testing Rules + +- Add or update tests for meaningful behavior changes. +- Explain how to verify changes. +- If tests are not added, explain why. diff --git a/.ai/skills/database/SKILL.md b/.ai/skills/database/SKILL.md new file mode 100644 index 0000000..aab7d35 --- /dev/null +++ b/.ai/skills/database/SKILL.md @@ -0,0 +1,197 @@ +# Database Skill + +## Purpose + +Use this skill for PDO, repositories, SQL, migrations, transactions, database configuration, and persistence rules. + +--- + +## Database Stack + +Preferred database access: + +- PDO +- Repositories or data access classes +- Optional SQLite, MySQL, or SQL Server through PDO +- Prepared statements for all untrusted values + +--- + +## Database Class API + +`Core\Database` wraps PDO and exposes these methods: + +| Method | Returns | Description | +|--------|---------|-------------| +| `query(string $sql, array $params = [])` | `array` | Runs a SELECT and returns all rows as associative arrays | +| `first(string $sql, array $params = [])` | `?array` | Runs a SELECT and returns the first row, or `null` | +| `execute(string $sql, array $params = [])` | `bool` | Runs INSERT / UPDATE / DELETE | +| `lastInsertId()` | `string` | Returns the last auto-increment ID as a string — cast to `int` for integer PKs | +| `transaction(callable $fn)` | `mixed` | Runs `$fn($db)` inside a transaction; commits on success, rolls back and rethrows on failure | +| `pdo()` | `PDO` | Returns the raw PDO instance for advanced use | + +`lastInsertId()` is only meaningful immediately after an `execute()` INSERT on the same connection. Calling it at any other point returns `"0"`. + +Typical INSERT + ID retrieval in a repository: + +```php +public function create(Employee $employee): int +{ + $this->database->execute( + 'INSERT INTO employees (first_name, email) VALUES (:first_name, :email)', + ['first_name' => $employee->firstName, 'email' => $employee->email] + ); + + return (int) $this->database->lastInsertId(); +} +``` + +--- + +## Database Access Rules + +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. + +--- + +## Transactions + +Use `Database::transaction()` when multiple writes must succeed or fail together. It begins a transaction, runs the callback, commits on success, and rolls back and rethrows on any `Throwable`. + +```php +$database->transaction(function (Database $db) use ($order): void { + $orders->create($order); + $auditLog->record('order.created', $order->id()); +}); +``` + +Do not call `$database->pdo()->beginTransaction()` directly for transaction management — use `transaction()` instead. Reserve `pdo()` for driver-specific features that have no `Database` API equivalent. + +--- + +## Repository Rules + +- Put persistence logic in repositories or data access classes. +- Keep repositories focused around a table, aggregate, or use case. +- Do not let repositories render HTML. +- Do not let repositories read directly from `$_GET`, `$_POST`, or other superglobals. +- Return domain objects, entities, DTOs, arrays, or ViewModels according to existing project convention. +- Prefer explicit methods such as `findById`, `findAllActive`, and `save` over generic magic calls. + +--- + +## Migration System + +The migration system is made up of three files: + +- `core/Migration.php` — abstract base class all migration files extend +- `core/MigrationManager.php` — runs, rolls back, and tracks applied migrations +- `core/helpers.php` — provides the `migration_manager()` helper that wires a `MigrationManager` to the app database and the `database/migrations/` path + +The CLI entry point is `scripts/migrate.php`. Run it from the project root: + +| Command | Description | +|---------|-------------| +| `php scripts/migrate.php up` | Run all pending migrations | +| `php scripts/migrate.php down [steps]` | Roll back the last N migrations (default: 1) | +| `php scripts/migrate.php status` | Show which migrations have run and when | +| `php scripts/migrate.php make ` | Scaffold a new timestamped migration file | +| `php scripts/migrate.php fresh` | Roll back everything and re-run from scratch | +| `php scripts/migrate.php fresh --seed` | Same as fresh, then run the employee seed | + +Migration files live in `database/migrations/` and are named `YYYYMMDD_HHMMSS_.php`. + +Each file must return a `Migration` instance: + +```php +execute('CREATE TABLE ...'); + } + + public function down(Database $database): void + { + $database->execute('DROP TABLE IF EXISTS ...'); + } +}; +``` + +--- + +## Migration Rules + +- Keep migrations small and reversible when practical. +- Document destructive migrations clearly. +- Do not mix schema changes with unrelated feature logic. +- Use project migration conventions before inventing new ones. +- Support SQLite/MySQL/SQL Server differences explicitly when the project targets multiple engines. +- Do not use database-specific SQL in framework or migration code. `INSERT OR IGNORE` (SQLite) and `INSERT IGNORE` (MySQL) are not portable — use a check-then-insert pattern instead. +- Migration records in the `migrations` table are permanent. A row means "this migration ran against this database." Deleting a migration file does not remove the record and does not allow the migration to be re-run. To intentionally re-run a migration, delete its row from the `migrations` table manually — this makes the action explicit. +- To reset completely, use `php scripts/migrate.php fresh`, which rolls back all migrations in reverse order and re-runs them from scratch. + +--- + +## SQL Safety Checklist + +Before completing database work, verify: + +- [ ] SQL uses prepared statements or a safe query builder. +- [ ] Untrusted values are never concatenated into SQL. +- [ ] Writes validate input server-side. +- [ ] Multi-step writes use transactions where needed. +- [ ] Database errors are logged safely and not displayed raw to users. +- [ ] Schema changes are documented. +- [ ] Tests or verification steps cover the changed behavior. + +--- + +## Database Configuration + +- Keep database credentials out of source control. +- Prefer environment variables or ignored local config files for secrets. +- Provide safe examples such as `.env.example`. +- Do not commit production DSNs, passwords, tokens, or private keys. + +Example `.env.example`: + +```text +APP_ENV=local +APP_DEBUG=true +DATABASE_URL=mysql://user:password@localhost:3306/app +``` diff --git a/.ai/skills/mvc/SKILL.md b/.ai/skills/mvc/SKILL.md new file mode 100644 index 0000000..da7750e --- /dev/null +++ b/.ai/skills/mvc/SKILL.md @@ -0,0 +1,321 @@ +# MVC Framework Skill + +## Purpose + +Use this skill for MindVisionCode PHP framework architecture, routing, dispatching, controllers, actions, ViewModels, templates, and HTTP request/response flow. + +--- + +## Project Identity + +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, CakePHP, or another large framework. + +--- + +## Request Flow + +```text +Browser + → public/index.php + → Request + → Dispatcher + → Router + → Route + → Controller + → ViewModel/Repository/Service + → View + → Response +``` + +--- + +## Project Structure + +Preferred structure: + +```text +project-root/ + public/ + index.php + src/ + Controller/ + Service/ + Repository/ + Entity/ + ValueObject/ + ViewModel/ + Http/ + Routing/ + Validation/ + Database/ + Migration/ + templates/ + config/ + tests/ + var/ + cache/ + logs/ + vendor/ + composer.json +``` + +Rules: + +- `public/` is the web root. +- Do not expose `src/`, `config/`, `tests/`, `vendor/`, `.ai/`, 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. + +--- + +## 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 +``` + +--- + +## Framework 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. + +--- + +## Service and Request Injection + +`Core\App::resolveArgs()` injects constructor and action parameters by type. Any service registered via `$app->bind(SomeClass::class, $instance)` in `public/index.php` is automatically injected when an action declares a typed parameter of that class. + +`Core\Request` is registered as a binding in `public/index.php` before dispatch, so controller actions can declare it as a parameter without calling `Request::capture()` manually: + +```php +public function index(Request $request): Response +{ + $search = $request->input('search', ''); + // ... +} +``` + +Route segment parameters (e.g. `{id}`) are still resolved by name before the binding lookup. + +To make a service injectable, register it once at bootstrap: + +```php +// public/index.php +$app->bind(Database::class, database()); +``` + +Then declare it as a typed parameter in any action: + +```php +public function index(Database $db): Response +{ + // $db is injected automatically +} +``` + +**Binding concrete instances:** Use `$app->instance($name, $obj)` to bind a specific object by key. Instances take precedence over bindings when resolving. + +**Auto-wiring:** Use `$app->make(SomeClass::class)` to resolve a class with its constructor dependencies injected automatically: + +```php +$repo = $app->make(EmployeeRepository::class); +// $app checks bindings first, then instantiates the class and resolves its constructor +``` + +**Test isolation:** Call `$app->clear()` to reset all bindings and instances between test runs. + +Do not call `Request::capture()` inside action bodies. Declare the parameter instead. + +--- + +## Controller Rules + +- Keep controllers thin. +- Validate request method and request shape at the boundary. +- Do not put database query details directly in controllers when a repository or service is more appropriate. +- Do not put template rendering logic inside business services. +- Return or produce a response through the framework’s response mechanism. +- Use `requirePost($request)` to guard POST-only actions. It returns `?Response` (null when the method is POST, a 405 Response otherwise). Always return it immediately if non-null: + +```php +if ($guard = $this->requirePost($request)) { + return $guard; +} +``` + +- Verify CSRF **before** field validation on any state-changing action. CSRF failure is a security gate, not a form validation error. See the Security skill for the helper pattern. +- When a controller uses a repository across multiple methods, store it as a nullable property and lazy-initialize once — do not call `new Repository(database())` on every method call: + +```php +private ?EmployeeRepository $employees = null; + +private function employees(): EmployeeRepository +{ + if ($this->employees === null) { + $this->employees = new EmployeeRepository(database()); + } + + return $this->employees; +} +``` + +--- + +## ViewModel Rules + +- Use ViewModels to shape data for views. +- Keep ViewModels simple and explicit. +- Avoid passing raw database rows directly into complex templates when a ViewModel would make the template clearer. +- Do not put database access inside ViewModels unless the existing project convention explicitly does that. + +--- + +## View Configuration + +View paths are set in `config/view.php`: + +```php +return [ + 'views_path' => __DIR__ . '/../app/Views', + 'layout_path' => __DIR__ . '/../app/Views/layouts/app.php', +]; +``` + +`core/View.php` reads this file lazily on first use and caches the result. To change where views or the layout live, edit `config/view.php` — do not edit `core/View.php`. This follows the same pattern as `config/database.php`. + +--- + +## 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 only if it fits the project and does not make the framework unnecessarily large. + +Plain PHP template example: + +```php +

+ +
    + +
  • name()) ?>
  • + +
+``` + +--- + +## Router Methods + +`Core\Router` exposes one method per HTTP verb: + +| Method | HTTP verb | +|--------|-----------| +| `$router->get($path, $handler)` | GET | +| `$router->post($path, $handler)` | POST | +| `$router->put($path, $handler)` | PUT | +| `$router->patch($path, $handler)` | PATCH | +| `$router->delete($path, $handler)` | DELETE | +| `$router->add($method, $path, $handler)` | Any verb | + +--- + +## Method Override for HTML Forms + +HTML forms only support GET and POST. To route a form submission to a PUT, PATCH, or DELETE handler, add a hidden `_method` field: + +```html +
+ + + +
+``` + +`Core\Request::method()` checks for this field (and the `X-HTTP-Method-Override` header from JavaScript clients) when the base method is POST, and returns the overridden verb. Only `PUT`, `PATCH`, and `DELETE` are accepted as override values — all others are ignored. + +--- + +## 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 using the framework helper: + +```php +public function store(Request $request): Response +{ + if ($guard = $this->requirePost($request)) { + return $guard; + } + + // POST-only logic here +} +``` + +--- + +## Architecture Guardrail + +When adding features, preserve the small-framework character: + +- Prefer explicit code over hidden convention. +- Prefer simple routing over complex annotation systems. +- Prefer plain PHP views unless a project decision says otherwise. +- Prefer focused services and repositories over large framework abstractions. +- Do not introduce a large package just to solve a small problem. diff --git a/.ai/skills/php/SKILL.md b/.ai/skills/php/SKILL.md new file mode 100644 index 0000000..98c73b8 --- /dev/null +++ b/.ai/skills/php/SKILL.md @@ -0,0 +1,331 @@ +# PHP Skill + +## Purpose + +Use this skill for PHP language rules, coding style, Composer, namespacing, autoloading, object-oriented design, dependency injection, documentation, and performance. + +Source reference: + +```text +https://phptherightway.com/ +``` + +--- + +## 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. + +PHP does not have only one canonical “right way,” so prefer widely accepted standards, documented project conventions, and clear tradeoffs over personal style. + +--- + +## PHP Version Standard + +Use the current stable PHP version supported by the project. + +Default expectation: + +```text +PHP 8.2+ +``` + +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" + } +} +``` + +--- + +## 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. + +Good: + +```php +class InvoiceRepository +{ + public function findByCustomerId(int $customerId): array + { + // ... + } +} +``` + +Avoid unclear abbreviations: + +```php +class InvRepo +{ + public function fbcid($cid) + { + // ... + } +} +``` + +--- + +## Formatting Rules + +- Use `value; + } +} +``` + +Guidelines: + +- Keep controllers thin. +- Put business rules in services or domain objects. +- Put persistence logic in repositories or data access classes. +- Use interfaces when multiple implementations are expected or when it improves testing. +- Avoid huge “utility” classes. +- Avoid magic methods unless they provide clear framework integration or a documented benefit. + +--- + +## 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. + +--- + +## Documentation Rules + +Use PHPDoc where it adds clarity, especially for arrays, complex return values, and public APIs. + +Good: + +```php +/** + * @return list + */ +public function findActiveCustomers(): array +{ + // ... +} +``` + +Avoid noisy comments that repeat the code: + +```php +// Increment i by one. +$i++; +``` + +Rules: + +- Explain why, not just what. +- Document non-obvious tradeoffs. +- Keep README setup instructions current. +- Update examples when behavior changes. + +--- + +## 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. diff --git a/.ai/skills/security/SKILL.md b/.ai/skills/security/SKILL.md new file mode 100644 index 0000000..055183b --- /dev/null +++ b/.ai/skills/security/SKILL.md @@ -0,0 +1,297 @@ +# Security Skill + +## Purpose + +Use this skill for input validation, output escaping, passwords, authentication, authorization, sessions, CSRF, secrets, error disclosure, dangerous functions, serialization, and file/path safety. + +--- + +## Security Baseline + +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 + +--- + +## Input Validation + +Validate on input. Use `Core\Validator` for field validation — it is a fluent chain that collects all errors before returning. + +### Validator API + +| Method | Description | +|--------|-------------| +| `required(field, value, message?)` | Fails if value is null or blank | +| `maxLength(field, value, max, message?)` | Fails if string length exceeds max | +| `minLength(field, value, min, message?)` | Fails if string length is below min | +| `numeric(field, value, message?)` | Fails if value is not numeric | +| `min(field, value, min, message?)` | Fails if numeric value is below min | +| `max(field, value, max, message?)` | Fails if numeric value exceeds max | +| `email(field, value, message?)` | Fails if non-empty value is not a valid email | +| `date(field, value, format?, message?)` | Fails if non-empty value does not match the date format (default `Y-m-d`) | +| `in(field, value, allowed[], message?)` | Fails if value is not in the allowed list (strict comparison) | +| `passes()` | Returns `true` when no errors were collected | +| `fails()` | Returns `true` when any errors were collected | +| `errors()` | Returns `array>` of field errors | + +`email()`, `date()`, `min()`, and `max()` skip empty or non-numeric values respectively — pair them with `required()` or `numeric()` when the field is mandatory. + +Example: + +```php +$validator = new Validator(); + +$validator + ->required('email', $form['email'], 'Email is required.') + ->maxLength('email', $form['email'], 255) + ->email('email', $form['email'], 'Enter a valid email address.') + ->required('start_date', $form['start_date'], 'Start date is required.') + ->date('start_date', $form['start_date'], 'Y-m-d', 'Enter a valid start date.'); + +if ($validator->fails()) { + // $validator->errors() returns field => [messages] map +} +``` + +Rules: + +- Validate type, range, length, format, and allowed values. +- Validate server-side even when client-side validation exists. +- Reject unexpected fields when appropriate. +- Normalize data intentionally, not accidentally. +- Do not reimplement email or date validation inline in controllers — use the Validator methods. + +--- + +## Output Escaping + +Escape on output based on context. + +For HTML output: + +```php +function e(string $value): string +{ + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} +``` + +Usage: + +```php +

name()) ?>

+``` + +Rules: + +- Escape based on context: HTML, attribute, JavaScript, CSS, URL, SQL, shell. +- Do not use the same escaping function for every context. +- Prefer template engines with automatic escaping only when appropriate for the project. +- 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 when user-provided paths are not allowed. + +--- + +## 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. + +--- + +## Authorization + +- Check authorization separately from authentication. +- Do not assume logged-in means allowed. +- Enforce permissions server-side. +- Avoid hiding buttons as the only authorization control. +- Prefer explicit permission checks near protected actions or service boundaries. + +--- + +## CSRF + +Use CSRF protection for state-changing forms and unsafe HTTP methods. + +State-changing actions include: + +- Create +- Update +- Delete +- Login/logout state changes +- Password changes +- Email changes +- Permission changes + +When using `_method` override to tunnel PUT, PATCH, or DELETE through a POST form, always include a CSRF token. The override is only honoured for POST requests, and only for the values `PUT`, `PATCH`, and `DELETE` — all other values are rejected by the framework. + +In MindVisionCode PHP, use the built-in helpers from `core/helpers.php`: + +| Helper | Purpose | +|--------|---------| +| `csrf_token()` | Generates and persists the token in the session | +| `csrf_field()` | Outputs a hidden `` carrying the token — use in every state-changing form | +| `verify_csrf_token(string $token)` | Returns `bool` — call before any business logic in POST actions | + +**Always verify CSRF before field validation and business logic.** A token failure is a security event, not a form validation error. Use a dedicated private method that returns `?Response` and short-circuits the action: + +```php +private function verifyCsrf(Request $request): ?Response +{ + if (!verify_csrf_token((string) $request->input('_token', ''))) { + return new Response('Your session has expired. Please go back and try again.', 419); + } + + return null; +} +``` + +Call it as the first thing in the action: + +```php +public function store(): Response +{ + $request = Request::capture(); + + if ($guard = $this->verifyCsrf($request)) { + return $guard; + } + + // field validation and business logic follow +} +``` + +--- + +## 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. + +--- + +## 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/ +``` + +--- + +## Error Handling and Logging + +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.'; +} +``` + +--- + +## 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`. diff --git a/.ai/skills/testing/SKILL.md b/.ai/skills/testing/SKILL.md new file mode 100644 index 0000000..707070d --- /dev/null +++ b/.ai/skills/testing/SKILL.md @@ -0,0 +1,162 @@ +# Testing and Quality Skill + +## Purpose + +Use this skill for tests, static analysis, quality gates, Composer scripts, verification steps, and code style checks. + +--- + +## Testing Standard + +Automated tests are expected for new behavior. + +Preferred tools: + +- The project’s existing `tests/run.php` runner +- PHPUnit, if the project adds or already uses it +- Pest, if the project already uses it + +Rules: + +- Add or update tests with every meaningful 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. + +--- + +## Basic Test Command + +Run basic tests: + +```bash +php tests/run.php +``` + +If PHPUnit is installed: + +```bash +vendor/bin/phpunit +``` + +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); + } +} +``` + +--- + +## 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. + +--- + +## Recommended Style 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 +``` + +--- + +## 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 +``` + +--- + +## Verification Rules + +When completing work: + +- Run relevant checks when possible. +- Explain any checks that could not be run. +- If tests are not added, explain why. +- If a test fails, do not hide the failure. +- Do not change unrelated tests just to make failures disappear. + +--- + +## Quality Checklist + +Before considering work complete: + +- [ ] New behavior is tested. +- [ ] Existing tests pass, or failures are explained. +- [ ] Code follows PSR-12 or project-specific style. +- [ ] Composer files are valid. +- [ ] No unrelated dependency updates were introduced. +- [ ] Static analysis passes if configured. +- [ ] Manual verification steps are documented when automated testing is not practical. diff --git a/.ai/skills/workflow/SKILL.md b/.ai/skills/workflow/SKILL.md new file mode 100644 index 0000000..9d40542 --- /dev/null +++ b/.ai/skills/workflow/SKILL.md @@ -0,0 +1,126 @@ +# Agent Workflow Skill + +## Purpose + +Use this skill for AI agent behavior, multi-file work, pull-request checks, legacy PHP changes, non-negotiable rules, response format, and skill feedback. + +--- + +## 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 and the loaded skill files. +10. Leave the repository better organized than it was found. + +--- + +## 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. + +--- + +## 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 + +--- + +## 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. + +--- + +## Response Format + +For non-trivial tasks, respond using this structure: + +```text +Goal: +Route: +Assumptions: +Plan: +Implementation: +Tests: +Risks: +``` + +For simple questions, answer directly. + +--- + +## Skill Feedback Rule + +If project guidance is missing or unclear, suggest an update. + +```text +Suggested SKILLS.md update: +- Add/update: ... +- Reason: ... +``` + +--- + +## 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 diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..e8bad42 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,29 @@ +{ + "permissions": { + "allow": [ + "PowerShell(\\(Get-Item \"d:\\\\Development\\\\PHP\\\\Mind-Vision-Code\\\\CLAUDE.md\"\\).Target)", + "Bash(Get-ChildItem -Recurse \"C:\\\\Users\\\\danielc.NTP\\\\AppData\\\\Local\\\\Temp\\\\KCI-KANBAN-inspect\")", + "Bash(Select-Object FullName)", + "Bash(Sort-Object FullName)", + "Bash(find /mnt/c/Users/danielc.NTP/AppData/Local/Temp/KCI-KANBAN-inspect -type f | sort 2>&1)", + "Read(//mnt/c/Users/danielc.NTP/AppData/Local/Temp/**)", + "PowerShell(git clone https://onefortheroadgit.sytes.net/dcovington/KCI-KANBAN \"$env:TEMP\\\\KCI-KANBAN-inspect\" 2>&1; Get-ChildItem -Recurse \"$env:TEMP\\\\KCI-KANBAN-inspect\" | Select-Object FullName | Sort-Object FullName)", + "PowerShell(Get-ChildItem -Recurse \"$env:TEMP\\\\KCI-KANBAN-inspect\" | Select-Object -ExpandProperty FullName | Sort-Object)", + "Bash(composer require *)", + "Bash(php -r \"echo PHP_VERSION;\")", + "Read(//usr/local/**)", + "Bash(find /usr /opt /home -name \"composer\" -o -name \"composer.phar\")", + "Bash(php /d/Development/PHP/PHP-TERRITORY/composer.phar require stevenmaguire/oauth2-keycloak)", + "Bash(php -m)", + "Bash(find /c/Users/danielc.NTP/AppData/Local/JetBrains -name \"composer.phar\" 2>/dev/null; find /d/Development -name \"composer.phar\" 2>/dev/null)", + "Read(//d/Development/**)", + "Bash(/c/Users/danielc.NTP/AppData/Local/Microsoft/WinGet/Packages/PHP.PHP.8.5_Microsoft.Winget.Source_8wekyb3d8bbwe/php.exe -m)", + "Bash(/c/Users/danielc.NTP/AppData/Local/Microsoft/WinGet/Packages/PHP.PHP.8.5_Microsoft.Winget.Source_8wekyb3d8bbwe/php.exe -d extension_dir=/c/Users/danielc.NTP/AppData/Local/Microsoft/WinGet/Packages/PHP.PHP.8.5_Microsoft.Winget.Source_8wekyb3d8bbwe/ext -d extension=php_openssl.dll /d/Development/PHP/PHP-TERRITORY/composer.phar require stevenmaguire/oauth2-keycloak)", + "Bash(cp \"/c/Users/danielc.NTP/AppData/Local/Temp/KCI-KANBAN-inspect/public/css/kanban.css\" \"d:/Development/PHP/KCI-PHP-KANBAN/public/css/kanban.css\")", + "Bash(cp \"/c/Users/danielc.NTP/AppData/Local/Temp/KCI-KANBAN-inspect/public/js/kanban-board.js\" \"d:/Development/PHP/KCI-PHP-KANBAN/public/js/kanban-board.js\")", + "Bash(cp \"/c/Users/danielc.NTP/AppData/Local/Temp/KCI-KANBAN-inspect/public/js/kanban-modal.js\" \"d:/Development/PHP/KCI-PHP-KANBAN/public/js/kanban-modal.js\")", + "Bash(cp \"/c/Users/danielc.NTP/AppData/Local/Temp/KCI-KANBAN-inspect/public/js/kanban-settings.js\" \"d:/Development/PHP/KCI-PHP-KANBAN/public/js/kanban-settings.js\")", + "Bash(/c/Users/danielc.NTP/AppData/Local/Microsoft/WinGet/Packages/PHP.PHP.8.5_Microsoft.Winget.Source_8wekyb3d8bbwe/php.exe -d extension_dir=/c/Users/danielc.NTP/AppData/Local/Microsoft/WinGet/Packages/PHP.PHP.8.5_Microsoft.Winget.Source_8wekyb3d8bbwe/ext -d extension=php_pdo.dll -d extension=php_pdo_sqlite.dll scripts/migrate.php fresh)" + ] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f4a0755 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +.ai +vendor +database/app.sqlite diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d68168 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +/vendor/ +.env +.env.local +.env.* +*.log +.DS_Store +.idea/ +.vscode/ +/public/uploads/ +/storage/cache/ +/storage/logs/ +/database/app.sqlite +/database/*.sqlite +/database/*.sqlite-shm +/database/*.sqlite-wal +/database/*.sqlite-journal +.phpunit.result.cache +Thumbs.db + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4453a76 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,242 @@ +# AGENTS.md + +## Purpose + +Describe how AI coding agents should work in this repository. + +This file is intentionally small. It acts as the repository-level router and startup guide. Detailed rules live in focused files under `./.ai/skills/` and should be loaded only when relevant to the current task. + +--- + +## Startup Instructions + +Before working on any task: + +1. Read this `AGENTS.md`. +2. Read the main skill index: + + ```text + ./.ai/SKILLS.md + ``` + +3. Load only the relevant skill files for the current task. +4. Follow project-specific instructions before general best practices. +5. Inspect existing code before introducing new patterns. +6. State important assumptions before making major changes. +7. Prefer small, focused, reviewable changes. + +--- + +## Instruction Priority + +When instructions conflict, follow this order: + +1. User's current request +2. Safety, security, privacy, and data-loss prevention +3. This `AGENTS.md` +4. `./.ai/SKILLS.md` +5. Referenced files in `./.ai/skills/` +6. Existing project conventions +7. General best practices + +--- + +## Project Summary + +This project is the **KCI Kanban Board** — a print/mail job tracking kanban application built on the **MindVisionCode PHP** framework. + +It was migrated from an ASP Classic implementation (source: `https://onefortheroadgit.sytes.net/dcovington/KCI-KANBAN`). + +### What this app does + +- Manage multiple kanban **boards** (each board has a slug-based URL) +- Each board has configurable **columns** (job stages) and **swim lanes** (job categories/priority rows) +- **Cards** represent print jobs: job_number, job_name, customer_name, delivery_date, quantity, notes, full_note (PrintStream raw data) +- Cards drag-and-drop between cells (column × lane intersection) via SortableJS +- Board settings panel: add/rename/delete/reorder columns and swim lanes +- **PrintStream integration flag** on boards (`import_from_printstream`, `printstream_job_name`) — the actual import script is a separate process not yet ported to PHP +- Authentication via **Keycloak SSO** (OIDC authorization-code flow) + +### Domain tables + +| Table | Purpose | +|-------|---------| +| `boards` | One row per kanban board | +| `board_columns` | Columns (stages) belonging to a board | +| `swim_lanes` | Swim lanes (rows) belonging to a board | +| `cards` | Job cards placed in a column × lane cell | + +### Auth pattern + +- `app/Services/AuthService.php` — static helpers: `requireLogin()`, `isLoggedIn()`, `getCurrentUsername()` +- Page controllers call `if ($guard = AuthService::requireLogin()) return $guard;` +- JSON API controllers call `if (!AuthService::isLoggedIn()) return $this->json([...], 401);` +- Keycloak config lives in `config/auth.php`, reads from env vars — see `.env.example` + +### Repository instantiation pattern + +Repositories are instantiated directly inside controllers using the `database()` helper (not DI container): + +```php +private function boards(): BoardRepository +{ + return new BoardRepository(database()); +} +``` + +### Route ordering rule + +`/columns/reorder` and `/swimlanes/reorder` **must be registered before** `/columns/{id}` and `/swimlanes/{id}`. If the literal route comes after the param route, "reorder" is treated as an id. See `routes/web.php` comments. + +### JSON body endpoints + +`ColumnsController::reorder()` and `SwimLanesController::reorder()` receive a JSON array body (not form data). They read it with `json_decode(file_get_contents('php://input'), true)`. Do not try to use `$request->input()` for these. + +### Board show view + +`BoardsController::show()` uses `$this->fragment()` (not `$this->view()`) because the kanban board is a fully self-contained HTML page with its own `//` — it does not use the shared `app.php` layout. + +### Static assets + +- `public/css/kanban.css` — kanban grid layout and card styles (copied from ASP repo) +- `public/js/kanban-board.js` — grid rendering, drag-drop, search +- `public/js/kanban-modal.js` — card create/edit modal +- `public/js/kanban-settings.js` — settings panel (add/rename/delete/reorder columns and lanes) + +The JS posts to `/cards/*`, `/columns/*`, `/swimlanes/*` — the PHP routes must match exactly. + +### Composer + +Run with the PHP binary found at: +``` +C:\Users\danielc.NTP\AppData\Local\Microsoft\WinGet\Packages\PHP.PHP.8.5_Microsoft.Winget.Source_8wekyb3d8bbwe\php.exe +``` +Composer.phar is at `D:\Development\PHP\PHP-TERRITORY\composer.phar`. Requires `-d extension=php_openssl.dll`. + +### Docker build notes + +The `vendor/` directory is excluded by `.dockerignore`, so `composer install` runs inside the container at build time. The Dockerfile must install `libzip-dev unzip` (apt) and `zip` (PHP ext) **before** the `composer install` step — without them, Composer cannot extract downloaded package archives and exits with code 1. This is already in the Dockerfile. Do not remove those packages if updating the Dockerfile. + +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 project into Laravel, Symfony, Slim, CakePHP, or another large framework. + +The goal is to keep the framework understandable, practical, and easy to extend. + +--- + +## Main Route + +Always start here: + +```text +./.ai/SKILLS.md +``` + +Then load only the skill files needed for the task. + +--- + +## Common Skill Routes + +```text +PHP language, style, Composer, OOP: +./.ai/skills/php/SKILL.md + +MVC framework architecture, routing, controllers, views, ViewModels: +./.ai/skills/mvc/SKILL.md + +PDO, repositories, migrations, SQL, database safety: +./.ai/skills/database/SKILL.md + +Input validation, escaping, passwords, sessions, secrets, web security: +./.ai/skills/security/SKILL.md + +Tests, static analysis, quality gates, composer scripts: +./.ai/skills/testing/SKILL.md + +Agent behavior, PR checklist, legacy policy, response format: +./.ai/skills/workflow/SKILL.md +``` + +--- + +## Response Format + +For non-trivial tasks, respond using this structure: + +```text +Goal: +Route: +Assumptions: +Plan: +Implementation: +Tests: +Risks: +``` + +For simple questions, answer directly. + +--- + +## Framework Change Policy + +The framework core may be modified to add functionality or optimize existing code, but **never silently**. Any time an agent identifies a change to framework-level code (dispatcher, routing, base controller, base repository, migration runner, validation engine, autoloader, or any file under `core/`), it must stop and present the following proposal to the user before writing a single line: + +```text +FRAMEWORK CHANGE PROPOSAL +========================== +Issue: + What problem or limitation was found, and where in the framework it lives. + +Proposed Change: + What would be added, modified, or removed. + +Why It Is Needed: + The specific reason application code cannot solve this without a framework change. + +Risks / Dangers: + - Breaking changes to existing controllers, repositories, or views + - Behavioral differences across PHP versions + - Security surface changes + - Performance regressions + - Any other relevant concerns + +Benefits: + - What improves or is unlocked by the change + +Alternatives Considered: + Any application-level workarounds that were ruled out and why. + +Ai Agent Skills Update: + - What skills need to be changed to support this framework-level change? + +Awaiting your approval before proceeding. Reply YES to apply, NO to skip, or ask questions. +``` + +**Rules:** +- Do not apply the change until the user explicitly approves. +- If the user says NO, document the limitation as a comment or note and continue with the best available application-level workaround. +- Keep framework changes small and focused — one concern per change. +- After approval, note the change in the commit message so the history is clear. +- Update the proper skill file so that the new process can be applied to all future changes in this repository. +--- + +## Skill Feedback Rule + +If project guidance is missing or unclear, suggest an update. + +```text +Suggested SKILLS.md update: +- Add/update: ... +- Reason: ... +``` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d92a5ac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM php:8.5-apache + +# Install pdo_sqlite and enable mod_rewrite +RUN apt-get update \ + && apt-get install -y libsqlite3-dev libzip-dev unzip \ + && rm -rf /var/lib/apt/lists/* \ + && docker-php-ext-install pdo_sqlite zip \ + && a2enmod rewrite + +# Install Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# Configure Apache virtual host +COPY docker/vhost.conf /etc/apache2/sites-available/000-default.conf +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +WORKDIR /var/www/html + +# Copy application files +COPY . . + +# Generate autoloader (no external dependencies — just generates vendor/autoload.php) +RUN composer install --no-dev --optimize-autoloader --no-interaction + +# Create database directory and set correct permissions +RUN mkdir -p database \ + && chown -R www-data:www-data /var/www/html \ + && chmod 775 database + +EXPOSE 80 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php new file mode 100644 index 0000000..5f01b33 --- /dev/null +++ b/app/Controllers/AuthController.php @@ -0,0 +1,80 @@ +input('returnTo', '')); + if ($returnTo !== '') { + $_SESSION['auth_return_to'] = $returnTo; + } + + $provider = AuthService::provider(); + $authUrl = $provider->getAuthorizationUrl(); + + $_SESSION['oauth2_state'] = $provider->getState(); + + return $this->redirect($authUrl); + } + + public function callback(Request $request): mixed + { + $state = (string) $request->input('state', ''); + $code = (string) $request->input('code', ''); + + if ($state === '' || $state !== ($_SESSION['oauth2_state'] ?? '')) { + unset($_SESSION['oauth2_state']); + + return $this->view('auth.callback-error', [ + 'pageTitle' => 'Authentication Error', + 'error' => 'Invalid state parameter. Please try logging in again.', + ]); + } + + unset($_SESSION['oauth2_state']); + + try { + $provider = AuthService::provider(); + $token = $provider->getAccessToken('authorization_code', ['code' => $code]); + $userInfo = AuthService::claimsFromToken($token->getToken()); + + if (empty($userInfo)) { + throw new \RuntimeException('Access token payload was empty or undecodable.'); + } + + AuthService::storeUser($userInfo); + + $redirectTo = $_SESSION['auth_return_to'] ?? '/boards'; + unset($_SESSION['auth_return_to']); + + return $this->redirect($redirectTo); + } catch (\Throwable $e) { + error_log('Keycloak callback error: ' . $e->getMessage()); + + $debug = filter_var(getenv('APP_DEBUG'), FILTER_VALIDATE_BOOLEAN); + $error = $debug + ? get_class($e) . ': ' . $e->getMessage() + : 'Authentication failed. Please try again.'; + + return $this->view('auth.callback-error', [ + 'pageTitle' => 'Authentication Error', + 'error' => $error, + ]); + } + } + + public function logout(): mixed + { + AuthService::clearSession(); + + return $this->redirect(AuthService::logoutUrl()); + } +} diff --git a/app/Controllers/BoardsController.php b/app/Controllers/BoardsController.php new file mode 100644 index 0000000..f21535d --- /dev/null +++ b/app/Controllers/BoardsController.php @@ -0,0 +1,204 @@ +boards()->getAll(); + + return $this->view('boards.index', [ + 'pageTitle' => 'Boards', + 'boards' => $boards, + ]); + } + + public function create(): mixed + { + if ($guard = AuthService::requireLogin()) { + return $guard; + } + + return $this->view('boards.create', ['pageTitle' => 'New Board']); + } + + public function store(Request $request): mixed + { + if ($guard = AuthService::requireLogin()) { + return $guard; + } + + $name = trim((string) $request->input('name', '')); + + if ($name === '') { + return $this->view('boards.create', [ + 'pageTitle' => 'New Board', + 'error' => 'Board name is required.', + 'old' => $request->all(), + ]); + } + + $username = AuthService::getCurrentUsername(); + $now = date('Y-m-d H:i:s'); + + $board = new Board(); + $board->name = $name; + $board->slug = $this->boards()->uniqueSlug($this->generateSlug($name)); + $board->importFromPrintstream = $request->input('import_from_printstream') === 'on'; + $board->printstreamJobName = trim((string) $request->input('printstream_job_name', '')); + $board->createdAt = $now; + $board->createdBy = $username; + $board->updatedAt = $now; + $board->updatedBy = $username; + + $this->boards()->insert($board); + + return $this->redirect('/board/' . $board->slug); + } + + public function show(string $slug): mixed + { + if ($guard = AuthService::requireLogin()) { + return $guard; + } + + $board = $this->boards()->findBySlug($slug); + + if ($board === null) { + return \Core\Response::notFound('Board not found.'); + } + + $columns = $this->columns()->findByBoardId($board->id); + $lanes = $this->lanes()->findByBoardId($board->id); + $allCards = $this->cards()->findByBoardId($board->id); + + $cardsJson = json_encode( + array_map(fn($c) => $c->toJsonArray(), $allCards), + JSON_THROW_ON_ERROR + ); + + return $this->fragment('boards.show', [ + 'board' => $board, + 'columns' => $columns, + 'lanes' => $lanes, + 'cardsJson' => $cardsJson, + ]); + } + + public function edit(string $slug): mixed + { + if ($guard = AuthService::requireLogin()) { + return $guard; + } + + $board = $this->boards()->findBySlug($slug); + + if ($board === null) { + return \Core\Response::notFound('Board not found.'); + } + + return $this->view('boards.edit', [ + 'pageTitle' => 'Edit Board', + 'board' => $board, + ]); + } + + public function update(Request $request, string $slug): mixed + { + if ($guard = AuthService::requireLogin()) { + return $guard; + } + + $board = $this->boards()->findBySlug($slug); + + if ($board === null) { + return \Core\Response::notFound('Board not found.'); + } + + $newName = trim((string) $request->input('name', '')); + + if ($newName === '') { + return $this->view('boards.edit', [ + 'pageTitle' => 'Edit Board', + 'board' => $board, + 'error' => 'Board name is required.', + ]); + } + + $board->name = $newName; + $board->slug = $this->boards()->uniqueSlug($this->generateSlug($newName), $board->id); + $board->importFromPrintstream = $request->input('import_from_printstream') === 'on'; + $board->printstreamJobName = trim((string) $request->input('printstream_job_name', '')); + $board->updatedAt = date('Y-m-d H:i:s'); + $board->updatedBy = AuthService::getCurrentUsername(); + + $this->boards()->update($board); + + return $this->redirect('/board/' . $board->slug); + } + + public function destroy(string $slug): mixed + { + if ($guard = AuthService::requireLogin()) { + return $guard; + } + + $board = $this->boards()->findBySlug($slug); + + if ($board === null) { + return $this->redirect('/boards'); + } + + $this->cards()->deleteByBoardId($board->id); + $this->columns()->deleteByBoardId($board->id); + $this->lanes()->deleteByBoardId($board->id); + $this->boards()->delete($board->id); + + return $this->redirect('/boards'); + } + + private function generateSlug(string $text): string + { + $slug = strtolower($text); + $slug = preg_replace('/[^a-z0-9\s-]/', '', $slug) ?? $slug; + $slug = preg_replace('/[\s-]+/', '-', trim($slug)) ?? $slug; + + return trim($slug, '-'); + } +} diff --git a/app/Controllers/CardsController.php b/app/Controllers/CardsController.php new file mode 100644 index 0000000..5816866 --- /dev/null +++ b/app/Controllers/CardsController.php @@ -0,0 +1,132 @@ +json(['ok' => false, 'error' => 'Unauthorized'], 401); + } + + $boardId = (int) $request->input('board_id', 0); + $columnId = (int) $request->input('column_id', 0); + $swimLaneId = (int) $request->input('swim_lane_id', 0); + + if ($boardId === 0 || $columnId === 0 || $swimLaneId === 0) { + return $this->json(['ok' => false, 'error' => 'board_id, column_id, and swim_lane_id are required']); + } + + $now = date('Y-m-d H:i:s'); + $username = AuthService::getCurrentUsername(); + $nextPos = $this->cards()->maxPosition($columnId, $swimLaneId) + 1; + + $card = new Card(); + $card->boardId = $boardId; + $card->columnId = $columnId; + $card->swimLaneId = $swimLaneId; + $card->jobNumber = trim((string) $request->input('job_number', '')); + $card->jobName = trim((string) $request->input('job_name', '')); + $card->customerName = trim((string) $request->input('customer_name', '')); + $card->deliveryDate = trim((string) $request->input('delivery_date', '')) ?: null; + $card->quantity = trim((string) $request->input('quantity', '')); + $card->notes = trim((string) $request->input('notes', '')); + $card->fullNote = (string) $request->input('full_note', ''); + $card->position = $nextPos; + $card->createdAt = $now; + $card->createdBy = $username; + $card->updatedAt = $now; + $card->updatedBy = $username; + + try { + $this->cards()->insert($card); + } catch (\Throwable $e) { + return $this->json(['ok' => false, 'error' => $e->getMessage()]); + } + + return $this->json(['ok' => true] + $card->toJsonArray()); + } + + public function update(Request $request, int $id): mixed + { + if (!AuthService::isLoggedIn()) { + return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); + } + + $card = $this->cards()->findById($id); + + if ($card === null) { + return $this->json(['ok' => false, 'error' => 'Not found'], 404); + } + + $card->jobNumber = trim((string) $request->input('job_number', '')); + $card->jobName = trim((string) $request->input('job_name', '')); + $card->customerName = trim((string) $request->input('customer_name', '')); + $card->deliveryDate = trim((string) $request->input('delivery_date', '')) ?: null; + $card->quantity = trim((string) $request->input('quantity', '')); + $card->notes = trim((string) $request->input('notes', '')); + $card->fullNote = (string) $request->input('full_note', ''); + $card->updatedAt = date('Y-m-d H:i:s'); + $card->updatedBy = AuthService::getCurrentUsername(); + + try { + $this->cards()->update($card); + } catch (\Throwable $e) { + return $this->json(['ok' => false, 'error' => $e->getMessage()]); + } + + return $this->json(['ok' => true] + $card->toJsonArray()); + } + + public function move(Request $request, int $id): mixed + { + if (!AuthService::isLoggedIn()) { + return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); + } + + $columnId = (int) $request->input('column_id', 0); + $swimLaneId = (int) $request->input('swim_lane_id', 0); + $position = (int) $request->input('position', 0); + $now = date('Y-m-d H:i:s'); + $username = AuthService::getCurrentUsername(); + + $this->cards()->move($id, $columnId, $swimLaneId, $position, $now, $username); + + $siblings = trim((string) $request->input('sibling_ids', '')); + if ($siblings !== '') { + foreach (explode(',', $siblings) as $idx => $sibId) { + $sibId = (int) trim($sibId); + if ($sibId > 0) { + $this->cards()->updatePosition($sibId, $idx, $now, $username); + } + } + } + + return $this->json(['ok' => true]); + } + + public function destroy(int $id): mixed + { + if (!AuthService::isLoggedIn()) { + return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); + } + + $this->cards()->delete($id); + + return $this->json(['ok' => true]); + } +} diff --git a/app/Controllers/ColumnsController.php b/app/Controllers/ColumnsController.php new file mode 100644 index 0000000..f807d94 --- /dev/null +++ b/app/Controllers/ColumnsController.php @@ -0,0 +1,120 @@ +json(['ok' => false, 'error' => 'Unauthorized'], 401); + } + + $boardId = (int) $request->input('board_id', 0); + $name = trim((string) $request->input('name', '')); + + if ($boardId === 0 || $name === '') { + return $this->json(['ok' => false, 'error' => 'board_id and name are required']); + } + + $now = date('Y-m-d H:i:s'); + $username = AuthService::getCurrentUsername(); + + $col = new BoardColumn(); + $col->boardId = $boardId; + $col->name = $name; + $col->position = $this->columns()->maxPosition($boardId) + 1; + $col->createdAt = $now; + $col->createdBy = $username; + $col->updatedAt = $now; + $col->updatedBy = $username; + + $this->columns()->insert($col); + + return $this->json(['ok' => true, 'id' => $col->id, 'name' => $col->name, 'position' => $col->position]); + } + + public function update(Request $request, int $id): mixed + { + if (!AuthService::isLoggedIn()) { + return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); + } + + $name = trim((string) $request->input('name', '')); + if ($name === '') { + return $this->json(['ok' => false, 'error' => 'name is required']); + } + + $row = $this->columns()->find($id); + if ($row === null) { + return $this->json(['ok' => false, 'error' => 'Not found'], 404); + } + + $col = \App\Models\BoardColumn::fromRow($row); + $col->name = $name; + $col->updatedAt = date('Y-m-d H:i:s'); + $col->updatedBy = AuthService::getCurrentUsername(); + + $this->columns()->update($col); + + return $this->json(['ok' => true]); + } + + public function destroy(int $id): mixed + { + if (!AuthService::isLoggedIn()) { + return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); + } + + $this->cards()->deleteByColumnId($id); + $this->columns()->delete($id); + + return $this->json(['ok' => true]); + } + + public function reorder(): mixed + { + if (!AuthService::isLoggedIn()) { + return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); + } + + $raw = file_get_contents('php://input'); + $items = json_decode((string) $raw, true); + + if (!is_array($items)) { + return $this->json(['ok' => false, 'error' => 'Invalid JSON payload']); + } + + $now = date('Y-m-d H:i:s'); + $username = AuthService::getCurrentUsername(); + + foreach ($items as $item) { + $colId = (int) ($item['id'] ?? 0); + $position = (int) ($item['position'] ?? 0); + if ($colId > 0) { + $this->columns()->updatePosition($colId, $position, $now, $username); + } + } + + return $this->json(['ok' => true]); + } +} diff --git a/app/Controllers/HomeController.php b/app/Controllers/HomeController.php new file mode 100644 index 0000000..e767d1d --- /dev/null +++ b/app/Controllers/HomeController.php @@ -0,0 +1,15 @@ +redirect('/boards'); + } +} diff --git a/app/Controllers/SwimLanesController.php b/app/Controllers/SwimLanesController.php new file mode 100644 index 0000000..4ebc01b --- /dev/null +++ b/app/Controllers/SwimLanesController.php @@ -0,0 +1,120 @@ +json(['ok' => false, 'error' => 'Unauthorized'], 401); + } + + $boardId = (int) $request->input('board_id', 0); + $name = trim((string) $request->input('name', '')); + + if ($boardId === 0 || $name === '') { + return $this->json(['ok' => false, 'error' => 'board_id and name are required']); + } + + $now = date('Y-m-d H:i:s'); + $username = AuthService::getCurrentUsername(); + + $lane = new SwimLane(); + $lane->boardId = $boardId; + $lane->name = $name; + $lane->position = $this->lanes()->maxPosition($boardId) + 1; + $lane->createdAt = $now; + $lane->createdBy = $username; + $lane->updatedAt = $now; + $lane->updatedBy = $username; + + $this->lanes()->insert($lane); + + return $this->json(['ok' => true, 'id' => $lane->id, 'name' => $lane->name, 'position' => $lane->position]); + } + + public function update(Request $request, int $id): mixed + { + if (!AuthService::isLoggedIn()) { + return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); + } + + $name = trim((string) $request->input('name', '')); + if ($name === '') { + return $this->json(['ok' => false, 'error' => 'name is required']); + } + + $row = $this->lanes()->find($id); + if ($row === null) { + return $this->json(['ok' => false, 'error' => 'Not found'], 404); + } + + $lane = SwimLane::fromRow($row); + $lane->name = $name; + $lane->updatedAt = date('Y-m-d H:i:s'); + $lane->updatedBy = AuthService::getCurrentUsername(); + + $this->lanes()->update($lane); + + return $this->json(['ok' => true]); + } + + public function destroy(int $id): mixed + { + if (!AuthService::isLoggedIn()) { + return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); + } + + $this->cards()->deleteBySwimLaneId($id); + $this->lanes()->delete($id); + + return $this->json(['ok' => true]); + } + + public function reorder(): mixed + { + if (!AuthService::isLoggedIn()) { + return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); + } + + $raw = file_get_contents('php://input'); + $items = json_decode((string) $raw, true); + + if (!is_array($items)) { + return $this->json(['ok' => false, 'error' => 'Invalid JSON payload']); + } + + $now = date('Y-m-d H:i:s'); + $username = AuthService::getCurrentUsername(); + + foreach ($items as $item) { + $laneId = (int) ($item['id'] ?? 0); + $position = (int) ($item['position'] ?? 0); + if ($laneId > 0) { + $this->lanes()->updatePosition($laneId, $position, $now, $username); + } + } + + return $this->json(['ok' => true]); + } +} diff --git a/app/Models/Board.php b/app/Models/Board.php new file mode 100644 index 0000000..16b8125 --- /dev/null +++ b/app/Models/Board.php @@ -0,0 +1,34 @@ +id = (int) ($row['id'] ?? 0); + $model->name = (string) ($row['name'] ?? ''); + $model->slug = (string) ($row['slug'] ?? ''); + $model->importFromPrintstream = (bool) ($row['import_from_printstream'] ?? false); + $model->printstreamJobName = (string) ($row['printstream_job_name'] ?? ''); + $model->createdAt = $row['created_at'] ?? null; + $model->createdBy = (string) ($row['created_by'] ?? ''); + $model->updatedAt = $row['updated_at'] ?? null; + $model->updatedBy = (string) ($row['updated_by'] ?? ''); + + return $model; + } +} diff --git a/app/Models/BoardColumn.php b/app/Models/BoardColumn.php new file mode 100644 index 0000000..8604de7 --- /dev/null +++ b/app/Models/BoardColumn.php @@ -0,0 +1,32 @@ +id = (int) ($row['id'] ?? 0); + $model->boardId = (int) ($row['board_id'] ?? 0); + $model->name = (string) ($row['name'] ?? ''); + $model->position = (int) ($row['position'] ?? 0); + $model->createdAt = $row['created_at'] ?? null; + $model->createdBy = (string) ($row['created_by'] ?? ''); + $model->updatedAt = $row['updated_at'] ?? null; + $model->updatedBy = (string) ($row['updated_by'] ?? ''); + + return $model; + } +} diff --git a/app/Models/Card.php b/app/Models/Card.php new file mode 100644 index 0000000..e067676 --- /dev/null +++ b/app/Models/Card.php @@ -0,0 +1,65 @@ +id = (int) ($row['id'] ?? 0); + $model->boardId = (int) ($row['board_id'] ?? 0); + $model->columnId = (int) ($row['column_id'] ?? 0); + $model->swimLaneId = (int) ($row['swim_lane_id'] ?? 0); + $model->jobNumber = (string) ($row['job_number'] ?? ''); + $model->jobName = (string) ($row['job_name'] ?? ''); + $model->customerName = (string) ($row['customer_name'] ?? ''); + $model->deliveryDate = $row['delivery_date'] ?: null; + $model->quantity = (string) ($row['quantity'] ?? ''); + $model->notes = (string) ($row['notes'] ?? ''); + $model->fullNote = (string) ($row['full_note'] ?? ''); + $model->position = (int) ($row['position'] ?? 0); + $model->createdAt = $row['created_at'] ?? null; + $model->createdBy = (string) ($row['created_by'] ?? ''); + $model->updatedAt = $row['updated_at'] ?? null; + $model->updatedBy = (string) ($row['updated_by'] ?? ''); + + return $model; + } + + public function toJsonArray(): array + { + return [ + 'id' => $this->id, + 'column_id' => $this->columnId, + 'swim_lane_id' => $this->swimLaneId, + 'job_number' => $this->jobNumber, + 'job_name' => $this->jobName, + 'customer_name' => $this->customerName, + 'delivery_date' => $this->deliveryDate, + 'quantity' => $this->quantity, + 'notes' => $this->notes, + 'full_note' => $this->fullNote, + 'position' => $this->position, + ]; + } +} diff --git a/app/Models/SwimLane.php b/app/Models/SwimLane.php new file mode 100644 index 0000000..4978294 --- /dev/null +++ b/app/Models/SwimLane.php @@ -0,0 +1,32 @@ +id = (int) ($row['id'] ?? 0); + $model->boardId = (int) ($row['board_id'] ?? 0); + $model->name = (string) ($row['name'] ?? ''); + $model->position = (int) ($row['position'] ?? 0); + $model->createdAt = $row['created_at'] ?? null; + $model->createdBy = (string) ($row['created_by'] ?? ''); + $model->updatedAt = $row['updated_at'] ?? null; + $model->updatedBy = (string) ($row['updated_by'] ?? ''); + + return $model; + } +} diff --git a/app/Repositories/BoardColumnRepository.php b/app/Repositories/BoardColumnRepository.php new file mode 100644 index 0000000..fe5404e --- /dev/null +++ b/app/Repositories/BoardColumnRepository.php @@ -0,0 +1,77 @@ +database->query( + 'SELECT * FROM board_columns WHERE board_id = :board_id ORDER BY position ASC', + ['board_id' => $boardId] + ); + + return array_map(fn(array $r) => BoardColumn::fromRow($r), $rows); + } + + public function maxPosition(int $boardId): int + { + $row = $this->database->first( + 'SELECT MAX(position) AS max_pos FROM board_columns WHERE board_id = :board_id', + ['board_id' => $boardId] + ); + + return (int) ($row['max_pos'] ?? -1); + } + + public function insert(BoardColumn $col): BoardColumn + { + $this->database->execute( + 'INSERT INTO board_columns (board_id, name, position, created_at, created_by, updated_at, updated_by) + VALUES (:board_id, :name, :position, :created_at, :created_by, :updated_at, :updated_by)', + [ + 'board_id' => $col->boardId, + 'name' => $col->name, + 'position' => $col->position, + 'created_at' => $col->createdAt, + 'created_by' => $col->createdBy, + 'updated_at' => $col->updatedAt, + 'updated_by' => $col->updatedBy, + ] + ); + + $row = $this->database->first('SELECT last_insert_rowid() AS id'); + $col->id = (int) ($row['id'] ?? 0); + + return $col; + } + + public function update(BoardColumn $col): void + { + $this->database->execute( + 'UPDATE board_columns SET name = :name, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', + ['name' => $col->name, 'updated_at' => $col->updatedAt, 'updated_by' => $col->updatedBy, 'id' => $col->id] + ); + } + + public function updatePosition(int $id, int $position, string $updatedAt, string $updatedBy): void + { + $this->database->execute( + 'UPDATE board_columns SET position = :position, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', + ['position' => $position, 'updated_at' => $updatedAt, 'updated_by' => $updatedBy, 'id' => $id] + ); + } + + public function deleteByBoardId(int $boardId): void + { + $this->database->execute('DELETE FROM board_columns WHERE board_id = :board_id', ['board_id' => $boardId]); + } +} diff --git a/app/Repositories/BoardRepository.php b/app/Repositories/BoardRepository.php new file mode 100644 index 0000000..f565341 --- /dev/null +++ b/app/Repositories/BoardRepository.php @@ -0,0 +1,105 @@ +database->first( + 'SELECT * FROM boards WHERE slug = :slug', + ['slug' => $slug] + ); + + return $row ? Board::fromRow($row) : null; + } + + /** @return Board[] */ + public function getAll(): array + { + $rows = $this->database->query('SELECT * FROM boards ORDER BY name ASC'); + + return array_map(fn(array $r) => Board::fromRow($r), $rows); + } + + public function slugExists(string $slug, int $excludeId = 0): bool + { + if ($excludeId > 0) { + $row = $this->database->first( + 'SELECT COUNT(*) AS cnt FROM boards WHERE slug = :slug AND id != :id', + ['slug' => $slug, 'id' => $excludeId] + ); + } else { + $row = $this->database->first( + 'SELECT COUNT(*) AS cnt FROM boards WHERE slug = :slug', + ['slug' => $slug] + ); + } + + return (int) ($row['cnt'] ?? 0) > 0; + } + + public function uniqueSlug(string $base, int $excludeId = 0): string + { + $candidate = $base; + $suffix = 2; + + while ($this->slugExists($candidate, $excludeId)) { + $candidate = $base . '-' . $suffix; + $suffix++; + } + + return $candidate; + } + + public function insert(Board $board): Board + { + $this->database->execute( + 'INSERT INTO boards + (name, slug, import_from_printstream, printstream_job_name, created_at, created_by, updated_at, updated_by) + VALUES + (:name, :slug, :import_from_printstream, :printstream_job_name, :created_at, :created_by, :updated_at, :updated_by)', + [ + 'name' => $board->name, + 'slug' => $board->slug, + 'import_from_printstream' => $board->importFromPrintstream ? 1 : 0, + 'printstream_job_name' => $board->printstreamJobName, + 'created_at' => $board->createdAt, + 'created_by' => $board->createdBy, + 'updated_at' => $board->updatedAt, + 'updated_by' => $board->updatedBy, + ] + ); + + $row = $this->database->first('SELECT last_insert_rowid() AS id'); + $board->id = (int) ($row['id'] ?? 0); + + return $board; + } + + public function update(Board $board): void + { + $this->database->execute( + 'UPDATE boards + SET name = :name, slug = :slug, import_from_printstream = :import_from_printstream, + printstream_job_name = :printstream_job_name, updated_at = :updated_at, updated_by = :updated_by + WHERE id = :id', + [ + 'name' => $board->name, + 'slug' => $board->slug, + 'import_from_printstream' => $board->importFromPrintstream ? 1 : 0, + 'printstream_job_name' => $board->printstreamJobName, + 'updated_at' => $board->updatedAt, + 'updated_by' => $board->updatedBy, + 'id' => $board->id, + ] + ); + } +} diff --git a/app/Repositories/CardRepository.php b/app/Repositories/CardRepository.php new file mode 100644 index 0000000..625c68e --- /dev/null +++ b/app/Repositories/CardRepository.php @@ -0,0 +1,131 @@ +database->first('SELECT * FROM cards WHERE id = :id', ['id' => $id]); + + return $row ? Card::fromRow($row) : null; + } + + /** @return Card[] */ + public function findByBoardId(int $boardId): array + { + $rows = $this->database->query( + 'SELECT * FROM cards WHERE board_id = :board_id ORDER BY swim_lane_id ASC, column_id ASC, position ASC', + ['board_id' => $boardId] + ); + + return array_map(fn(array $r) => Card::fromRow($r), $rows); + } + + public function maxPosition(int $columnId, int $swimLaneId): int + { + $row = $this->database->first( + 'SELECT MAX(position) AS max_pos FROM cards WHERE column_id = :col AND swim_lane_id = :lane', + ['col' => $columnId, 'lane' => $swimLaneId] + ); + + return (int) ($row['max_pos'] ?? -1); + } + + public function insert(Card $card): Card + { + $this->database->execute( + 'INSERT INTO cards + (board_id, column_id, swim_lane_id, job_number, job_name, customer_name, delivery_date, + quantity, notes, full_note, position, created_at, created_by, updated_at, updated_by) + VALUES + (:board_id, :column_id, :swim_lane_id, :job_number, :job_name, :customer_name, :delivery_date, + :quantity, :notes, :full_note, :position, :created_at, :created_by, :updated_at, :updated_by)', + [ + 'board_id' => $card->boardId, + 'column_id' => $card->columnId, + 'swim_lane_id' => $card->swimLaneId, + 'job_number' => $card->jobNumber, + 'job_name' => $card->jobName, + 'customer_name' => $card->customerName, + 'delivery_date' => $card->deliveryDate ?: null, + 'quantity' => $card->quantity !== '' ? $card->quantity : null, + 'notes' => $card->notes, + 'full_note' => $card->fullNote, + 'position' => $card->position, + 'created_at' => $card->createdAt, + 'created_by' => $card->createdBy, + 'updated_at' => $card->updatedAt, + 'updated_by' => $card->updatedBy, + ] + ); + + $row = $this->database->first('SELECT last_insert_rowid() AS id'); + $card->id = (int) ($row['id'] ?? 0); + + return $card; + } + + public function update(Card $card): void + { + $this->database->execute( + 'UPDATE cards + SET job_number = :job_number, job_name = :job_name, customer_name = :customer_name, + delivery_date = :delivery_date, quantity = :quantity, notes = :notes, full_note = :full_note, + updated_at = :updated_at, updated_by = :updated_by + WHERE id = :id', + [ + 'job_number' => $card->jobNumber, + 'job_name' => $card->jobName, + 'customer_name' => $card->customerName, + 'delivery_date' => $card->deliveryDate ?: null, + 'quantity' => $card->quantity !== '' ? $card->quantity : null, + 'notes' => $card->notes, + 'full_note' => $card->fullNote, + 'updated_at' => $card->updatedAt, + 'updated_by' => $card->updatedBy, + 'id' => $card->id, + ] + ); + } + + public function move(int $id, int $columnId, int $swimLaneId, int $position, string $updatedAt, string $updatedBy): void + { + $this->database->execute( + 'UPDATE cards SET column_id = :column_id, swim_lane_id = :swim_lane_id, position = :position, + updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', + ['column_id' => $columnId, 'swim_lane_id' => $swimLaneId, 'position' => $position, + 'updated_at' => $updatedAt, 'updated_by' => $updatedBy, 'id' => $id] + ); + } + + public function updatePosition(int $id, int $position, string $updatedAt, string $updatedBy): void + { + $this->database->execute( + 'UPDATE cards SET position = :position, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', + ['position' => $position, 'updated_at' => $updatedAt, 'updated_by' => $updatedBy, 'id' => $id] + ); + } + + public function deleteByBoardId(int $boardId): void + { + $this->database->execute('DELETE FROM cards WHERE board_id = :board_id', ['board_id' => $boardId]); + } + + public function deleteByColumnId(int $columnId): void + { + $this->database->execute('DELETE FROM cards WHERE column_id = :column_id', ['column_id' => $columnId]); + } + + public function deleteBySwimLaneId(int $swimLaneId): void + { + $this->database->execute('DELETE FROM cards WHERE swim_lane_id = :swim_lane_id', ['swim_lane_id' => $swimLaneId]); + } +} diff --git a/app/Repositories/SwimLaneRepository.php b/app/Repositories/SwimLaneRepository.php new file mode 100644 index 0000000..5bec01f --- /dev/null +++ b/app/Repositories/SwimLaneRepository.php @@ -0,0 +1,77 @@ +database->query( + 'SELECT * FROM swim_lanes WHERE board_id = :board_id ORDER BY position ASC', + ['board_id' => $boardId] + ); + + return array_map(fn(array $r) => SwimLane::fromRow($r), $rows); + } + + public function maxPosition(int $boardId): int + { + $row = $this->database->first( + 'SELECT MAX(position) AS max_pos FROM swim_lanes WHERE board_id = :board_id', + ['board_id' => $boardId] + ); + + return (int) ($row['max_pos'] ?? -1); + } + + public function insert(SwimLane $lane): SwimLane + { + $this->database->execute( + 'INSERT INTO swim_lanes (board_id, name, position, created_at, created_by, updated_at, updated_by) + VALUES (:board_id, :name, :position, :created_at, :created_by, :updated_at, :updated_by)', + [ + 'board_id' => $lane->boardId, + 'name' => $lane->name, + 'position' => $lane->position, + 'created_at' => $lane->createdAt, + 'created_by' => $lane->createdBy, + 'updated_at' => $lane->updatedAt, + 'updated_by' => $lane->updatedBy, + ] + ); + + $row = $this->database->first('SELECT last_insert_rowid() AS id'); + $lane->id = (int) ($row['id'] ?? 0); + + return $lane; + } + + public function update(SwimLane $lane): void + { + $this->database->execute( + 'UPDATE swim_lanes SET name = :name, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', + ['name' => $lane->name, 'updated_at' => $lane->updatedAt, 'updated_by' => $lane->updatedBy, 'id' => $lane->id] + ); + } + + public function updatePosition(int $id, int $position, string $updatedAt, string $updatedBy): void + { + $this->database->execute( + 'UPDATE swim_lanes SET position = :position, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', + ['position' => $position, 'updated_at' => $updatedAt, 'updated_by' => $updatedBy, 'id' => $id] + ); + } + + public function deleteByBoardId(int $boardId): void + { + $this->database->execute('DELETE FROM swim_lanes WHERE board_id = :board_id', ['board_id' => $boardId]); + } +} diff --git a/app/Services/AuthService.php b/app/Services/AuthService.php new file mode 100644 index 0000000..330b962 --- /dev/null +++ b/app/Services/AuthService.php @@ -0,0 +1,121 @@ + rtrim($cfg['base_url'], '/'), + 'realm' => $cfg['realm'], + 'clientId' => $cfg['client_id'], + 'clientSecret' => $cfg['client_secret'], + 'redirectUri' => $cfg['redirect_uri'], + ]); + } + + /** + * Decode user claims from the access token JWT payload. + * Avoids calling the userinfo endpoint, which Keycloak may return as a + * signed JWT (application/jwt) rather than JSON — causing decryption errors. + * + * @return array + */ + public static function claimsFromToken(string $jwt): array + { + $parts = explode('.', $jwt); + if (count($parts) < 2) { + return []; + } + + $payload = base64_decode(strtr($parts[1], '-_', '+/'), true); + if ($payload === false) { + return []; + } + + $data = json_decode($payload, true); + + return is_array($data) ? $data : []; + } + + public static function requireLogin(): ?Response + { + if (!self::isLoggedIn()) { + $_SESSION['auth_return_to'] = $_SERVER['REQUEST_URI'] ?? '/'; + return Response::redirect('/auth/login'); + } + + return null; + } + + public static function isLoggedIn(): bool + { + return !empty($_SESSION['auth_user']); + } + + public static function getCurrentUser(): array + { + return $_SESSION['auth_user'] ?? []; + } + + public static function getCurrentUsername(): string + { + $user = self::getCurrentUser(); + + return $user['preferred_username'] ?? $user['email'] ?? ''; + } + + public static function storeUser(array $userInfo): void + { + $_SESSION['auth_user'] = $userInfo; + } + + public static function clearSession(): 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(); + } + + public static function logoutUrl(): string + { + $cfg = self::config(); + $base = rtrim($cfg['base_url'], '/'); + $realm = $cfg['realm']; + $postLogout = urlencode($cfg['post_logout_redirect_uri']); + + return "{$base}/realms/{$realm}/protocol/openid-connect/logout?redirect_uri={$postLogout}"; + } +} diff --git a/app/Views/auth/callback-error.php b/app/Views/auth/callback-error.php new file mode 100644 index 0000000..6f61d13 --- /dev/null +++ b/app/Views/auth/callback-error.php @@ -0,0 +1,5 @@ +
+

Authentication Error

+

+ Try Again +
diff --git a/app/Views/boards/create.php b/app/Views/boards/create.php new file mode 100644 index 0000000..5b892f3 --- /dev/null +++ b/app/Views/boards/create.php @@ -0,0 +1,39 @@ +
+
+

New Board

+ + +
+ + +
+ + +
+ + +
+ +
+ /> + +
+ +
+ + +
+ +
+ + Cancel +
+
+
+
diff --git a/app/Views/boards/edit.php b/app/Views/boards/edit.php new file mode 100644 index 0000000..83471be --- /dev/null +++ b/app/Views/boards/edit.php @@ -0,0 +1,47 @@ +
+
+

Edit Board

+ + +
+ + +
+ + +
+ + +
+ +
+ importFromPrintstream ? 'checked' : '' ?> /> + +
+ +
+ + +
+ +
+ + Cancel +
+
+ +
+ +
+ + +
+
+
diff --git a/app/Views/boards/index.php b/app/Views/boards/index.php new file mode 100644 index 0000000..819615d --- /dev/null +++ b/app/Views/boards/index.php @@ -0,0 +1,33 @@ +
+

Boards

+ + New Board + +
+ + +
+ +

No boards yet.

+ Create your first board +
+ +
+ +
+
+
+
name) ?>
+

slug) ?>

+
+ Open + + + +
+
+
+
+ +
+ diff --git a/app/Views/boards/show.php b/app/Views/boards/show.php new file mode 100644 index 0000000..74c1f1a --- /dev/null +++ b/app/Views/boards/show.php @@ -0,0 +1,99 @@ + + + + + <?= e($board->name) ?> — Kanban + + + + + + + + + + + + + + + +
+
+ +
+ + +
+ name) ?> +
+ + + +
+ + name) ?> +
+ +
+
+ + + +
+
+ + + + + + + + + + + + + diff --git a/app/Views/home/index.php b/app/Views/home/index.php new file mode 100644 index 0000000..9cc963f --- /dev/null +++ b/app/Views/home/index.php @@ -0,0 +1,39 @@ +
+
+ eyebrow) ?> +

title) ?>

+

message) ?>

+ + +
+ + +
+ +
+
+

Readable by design

+

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

+
+ +
+

Classic MVC feel

+

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

+
+ +
+

SQLite ready

+

Typed PHP 8.2 code, Composer autoloading, PDO access, and auto-run migrations make the project feel current without becoming heavyweight.

+
+
diff --git a/app/Views/layouts/app.php b/app/Views/layouts/app.php new file mode 100644 index 0000000..5796585 --- /dev/null +++ b/app/Views/layouts/app.php @@ -0,0 +1,14 @@ + + +
+
+ +
+
+ + diff --git a/app/Views/partials/card-modal.php b/app/Views/partials/card-modal.php new file mode 100644 index 0000000..609bbc2 --- /dev/null +++ b/app/Views/partials/card-modal.php @@ -0,0 +1,54 @@ + + diff --git a/app/Views/partials/footer.php b/app/Views/partials/footer.php new file mode 100644 index 0000000..d2a38dd --- /dev/null +++ b/app/Views/partials/footer.php @@ -0,0 +1,9 @@ +
+ +
+ + + diff --git a/app/Views/partials/header.php b/app/Views/partials/header.php new file mode 100644 index 0000000..4f7feb2 --- /dev/null +++ b/app/Views/partials/header.php @@ -0,0 +1,43 @@ + 'Boards', 'href' => '/boards'], +]; + +$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); +$currentPath = is_string($currentPath) && $currentPath !== '' ? $currentPath : '/'; +?> + + + + + + <?= e($pageTitle ?? 'KCI Kanban') ?> + + + + + +
+ diff --git a/app/Views/partials/settings-panel.php b/app/Views/partials/settings-panel.php new file mode 100644 index 0000000..7925bd5 --- /dev/null +++ b/app/Views/partials/settings-panel.php @@ -0,0 +1,79 @@ + +
+ +
+
+
Board Settings
+ +
+ +
+ + +
+
+ Columns + +
+
+
+ + + +
+
+
    + +
  • + + name) ?> + + +
  • + +
+
+ + +
+
+ Swim Lanes + +
+
+
+ + + +
+
+
    + +
  • + + name) ?> + + +
  • + +
+
+ +
+
diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..20d5e1e --- /dev/null +++ b/composer.json @@ -0,0 +1,24 @@ +{ + "name": "kci/mindvisioncode", + "description": "A small PHP MVC framework inspired by a Classic ASP MVC framework.", + "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": { + "stevenmaguire/oauth2-keycloak": "^6.1" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..2536cea --- /dev/null +++ b/composer.lock @@ -0,0 +1,812 @@ +{ + "_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": "60e95733c2ba68d85ecc8ec7828e8d39", + "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.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/47ba23c7a55247e2e1b7407aca90e9bbed0d9d86", + "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86", + "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", + "guzzlehttp/test-server": "^0.3.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "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.3" + }, + "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": "2026-05-20T22:59:19+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2", + "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "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.4.1" + }, + "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": "2026-05-20T22:57:30+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.10.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "73ab136360b5dfd858006eae9795e8fe43c80361" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/73ab136360b5dfd858006eae9795e8fe43c80361", + "reference": "73ab136360b5dfd858006eae9795e8fe43c80361", + "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": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "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.10.1" + }, + "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-05-20T09:27:36+00:00" + }, + { + "name": "league/oauth2-client", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-client.git", + "reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/26e8c5da4f3d78cede7021e09b1330a0fc093d5e", + "reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", + "php": "^7.1 || >=8.0.0 <8.6.0" + }, + "require-dev": { + "mockery/mockery": "^1.3.5", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Woody Gilk", + "homepage": "https://github.com/shadowhand", + "role": "Contributor" + } + ], + "description": "OAuth 2.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "identity", + "idp", + "oauth", + "oauth2", + "single sign on" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-client/issues", + "source": "https://github.com/thephpleague/oauth2-client/tree/2.9.0" + }, + "time": "2025-11-25T22:17:17+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "stevenmaguire/oauth2-keycloak", + "version": "6.1.1", + "source": { + "type": "git", + "url": "https://github.com/stevenmaguire/oauth2-keycloak.git", + "reference": "31bb3b1fa15b444212ed43facc898fafc7c2707a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stevenmaguire/oauth2-keycloak/zipball/31bb3b1fa15b444212ed43facc898fafc7c2707a", + "reference": "31bb3b1fa15b444212ed43facc898fafc7c2707a", + "shasum": "" + }, + "require": { + "firebase/php-jwt": "^7.0", + "league/oauth2-client": "^2.8", + "php": "^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "~9.6.4", + "squizlabs/php_codesniffer": "~3.7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Stevenmaguire\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Steven Maguire", + "email": "stevenmaguire@gmail.com", + "homepage": "https://github.com/stevenmaguire" + } + ], + "description": "Keycloak OAuth 2.0 Client Provider for The PHP League OAuth2-Client", + "keywords": [ + "authorisation", + "authorization", + "client", + "keycloak", + "oauth", + "oauth2" + ], + "support": { + "issues": "https://github.com/stevenmaguire/oauth2-keycloak/issues", + "source": "https://github.com/stevenmaguire/oauth2-keycloak/tree/6.1.1" + }, + "time": "2026-03-30T07:32:03+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T15:52:40+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..178c6c0 --- /dev/null +++ b/config/auth.php @@ -0,0 +1,14 @@ + [ + 'base_url' => getenv('KEYCLOAK_BASE_URL') ?: '', + 'realm' => getenv('KEYCLOAK_REALM') ?: '', + 'client_id' => getenv('KEYCLOAK_CLIENT_ID') ?: '', + 'client_secret' => getenv('KEYCLOAK_CLIENT_SECRET') ?: '', + 'redirect_uri' => getenv('KEYCLOAK_REDIRECT_URI') ?: '', + 'post_logout_redirect_uri' => getenv('APP_URL') ?: 'http://localhost:8000', + ], +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..a7bea57 --- /dev/null +++ b/config/database.php @@ -0,0 +1,11 @@ + 'sqlite:' . __DIR__ . '/../database/app.sqlite', + 'username' => null, + 'password' => null, + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], +]; diff --git a/config/view.php b/config/view.php new file mode 100644 index 0000000..0db4705 --- /dev/null +++ b/config/view.php @@ -0,0 +1,6 @@ + __DIR__ . '/../app/Views', + 'layout_path' => __DIR__ . '/../app/Views/layouts/app.php', +]; diff --git a/core/App.php b/core/App.php new file mode 100644 index 0000000..67e85e3 --- /dev/null +++ b/core/App.php @@ -0,0 +1,154 @@ +bindings[$name] = $value; + } + + public function instance(string $name, mixed $value): void + { + $this->instances[$name] = $value; + } + + public function make(string $className): object + { + if (isset($this->bindings[$className])) { + $binding = $this->bindings[$className]; + + if (is_string($binding) && is_a($binding, $className, true)) { + return $this->instantiate($binding); + } + + return $binding; + } + + if (isset($this->instances[$className])) { + return $this->instances[$className]; + } + + return $this->instantiate($className); + } + + public function clear(): void + { + $this->bindings = []; + $this->instances = []; + } + + public function get(string $name): mixed + { + return $this->instances[$name] ?? $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($this->resolveArgs($reflection, $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, $this->resolveArgs($reflection, $parameters)); + } + + protected function resolveArgs(\ReflectionFunctionAbstract $reflection, array $parameters): array + { + $args = []; + + foreach ($reflection->getParameters() as $param) { + $name = $param->getName(); + + if (array_key_exists($name, $parameters)) { + $args[] = $parameters[$name]; + continue; + } + + $type = $param->getType(); + + if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { + $typeName = $type->getName(); + + if (array_key_exists($typeName, $this->instances)) { + $args[] = $this->instances[$typeName]; + continue; + } + + if (array_key_exists($typeName, $this->bindings)) { + $binding = $this->bindings[$typeName]; + + if (is_string($binding) && is_a($binding, $typeName, true)) { + $args[] = $this->instantiate($binding); + continue; + } + + $args[] = $binding; + continue; + } + } + + $args[] = $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null; + } + + return $args; + } + + /** + * @param class-string $className + */ + protected function instantiate(string $className): object + { + $reflection = new ReflectionClass($className); + + if (!$reflection->isInstantiable()) { + throw new Exception("Cannot instantiate {$className}: class is not instantiable."); + } + + $constructor = $reflection->getConstructor(); + + if ($constructor === null) { + return new $className(); + } + + $args = $this->resolveArgs($constructor, []); + + return $reflection->newInstanceArgs($args); + } +} diff --git a/core/Controller.php b/core/Controller.php new file mode 100644 index 0000000..bcc8f2f --- /dev/null +++ b/core/Controller.php @@ -0,0 +1,37 @@ +method() !== 'POST') { + return new Response('Method Not Allowed.', 405); + } + + return null; + } +} diff --git a/core/Database.php b/core/Database.php new file mode 100644 index 0000000..6ccd2b0 --- /dev/null +++ b/core/Database.php @@ -0,0 +1,69 @@ +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 $statement->fetchAll(PDO::FETCH_ASSOC); + } + + 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); + } + + public function lastInsertId(): string + { + return $this->pdo->lastInsertId(); + } + + public function transaction(callable $fn): mixed + { + $this->pdo->beginTransaction(); + + try { + $result = $fn($this); + $this->pdo->commit(); + + return $result; + } catch (\Throwable $e) { + $this->pdo->rollBack(); + throw $e; + } + } +} diff --git a/core/Dispatcher.php b/core/Dispatcher.php new file mode 100644 index 0000000..fe344a9 --- /dev/null +++ b/core/Dispatcher.php @@ -0,0 +1,61 @@ +router = $router; + $this->app = $app; + $this->debug = $debug; + } + + 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.'); + } + + $result = $route->dispatch($this->app); + + return $this->normalizeResponse($result); + } catch (Throwable $e) { + if (!$this->debug) { + error_log($e->getMessage()); + } + + $message = $this->debug ? $e->getMessage() : 'An unexpected error occurred.'; + + return Response::serverError($message); + } finally { + Request::clearCurrent(); + } + } + + protected function normalizeResponse(mixed $result): Response + { + if ($result instanceof Response) { + return $result; + } + + if (is_array($result)) { + return Response::json($result); + } + + return new Response((string) $result); + } +} diff --git a/core/Migration.php b/core/Migration.php new file mode 100644 index 0000000..4bc46ee --- /dev/null +++ b/core/Migration.php @@ -0,0 +1,12 @@ +database = $database; + $this->path = rtrim($path, '/'); + } + + public function ensureTable(): void + { + $this->database->execute( + 'CREATE TABLE IF NOT EXISTS migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, migration VARCHAR(255) NOT NULL, ran_at DATETIME DEFAULT CURRENT_TIMESTAMP)' + ); + + $this->database->execute( + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_migrations_migration_unique ON migrations (migration)' + ); + } + + 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->transaction(function (Database $db) use ($migration, $name, &$ranMigrations): void { + $migration->up($db); + + if ($db->first('SELECT id FROM migrations WHERE migration = :migration', ['migration' => $name]) === null) { + $db->execute('INSERT INTO migrations (migration) VALUES (:migration)', ['migration' => $name]); + } + + $ranMigrations[] = $name; + }); + } + + return $ranMigrations; + } + + public function rollback(int $steps = 1): array + { + $this->ensureTable(); + + $steps = max(1, $steps); + $applied = $this->database->query( + 'SELECT id, migration FROM migrations ORDER BY id DESC LIMIT :steps', + ['steps' => $steps] + ); + $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->transaction(function (Database $db) use ($migration, $row, &$rolledBack): void { + $migration->down($db); + $db->execute('DELETE FROM migrations WHERE id = :id', ['id' => $row['id']]); + $rolledBack[] = $row['migration']; + }); + } + + 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->transaction(function (Database $db) use ($migration, $name, &$rolledBack): void { + $migration->down($db); + $rolledBack[] = $name; + }); + } + + $this->database->execute('DELETE FROM migrations'); + $ran = $this->runPending(); + + return [ + 'rolled_back' => $rolledBack, + 'migrated' => $ran, + ]; + } + + public function make(string $name): string + { + $slug = trim(strtolower(preg_replace('/[^a-zA-Z0-9]+/', '_', $name) ?? ''), '_'); + + if ($slug === '') { + throw new \InvalidArgumentException('Migration name must contain letters or numbers.'); + } + + if (!is_dir($this->path)) { + mkdir($this->path, 0777, true); + } + + $timestamp = date('Ymd_His'); + $filename = $timestamp . '_' . $slug . '.php'; + $path = $this->path . '/' . $filename; + + if (file_exists($path)) { + throw new \RuntimeException("Migration already exists: {$filename}"); + } + + $template = <<path . '/*.php') ?: []; + sort($files); + + return $files; + } + + private function loadMigration(string $file): Migration + { + $migration = require $file; + + if ($migration instanceof Migration) { + return $migration; + } + + if (is_callable($migration)) { + return new class ($migration, basename($file)) extends Migration + { + private $callback; + private string $name; + + public function __construct(callable $callback, string $name) + { + $this->callback = $callback; + $this->name = $name; + } + + public function up(Database $database): void + { + ($this->callback)($database); + } + + public function down(Database $database): void + { + throw new \RuntimeException("Migration {$this->name} cannot be rolled back because it has no down() method."); + } + }; + } + + throw new \RuntimeException('Migration files must return a Migration instance.'); + } +} diff --git a/core/Repository.php b/core/Repository.php new file mode 100644 index 0000000..4c01c77 --- /dev/null +++ b/core/Repository.php @@ -0,0 +1,38 @@ +database = $database; + } + + public function find(int|string $id): ?array + { + return $this->database->first( + "SELECT * FROM {$this->table} WHERE {$this->primaryKey} = :id", + ['id' => $id] + ); + } + + public function all(): array + { + return $this->database->query("SELECT * FROM {$this->table}"); + } + + public function delete(int|string $id): bool + { + return $this->database->execute( + "DELETE FROM {$this->table} WHERE {$this->primaryKey} = :id", + ['id' => $id] + ); + } +} diff --git a/core/Request.php b/core/Request.php new file mode 100644 index 0000000..4cd22eb --- /dev/null +++ b/core/Request.php @@ -0,0 +1,80 @@ +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 + { + $method = strtoupper($this->method); + + if ($method === 'POST') { + $override = strtoupper((string) ($this->post['_method'] ?? $this->server['HTTP_X_HTTP_METHOD_OVERRIDE'] ?? '')); + + if (in_array($override, ['PUT', 'PATCH', 'DELETE'], true)) { + return $override; + } + } + + return $method; + } + + public function path(): string + { + $path = parse_url($this->uri, PHP_URL_PATH); + + return $path ?: '/'; + } + + public function input(string $key, mixed $default = null): mixed + { + return $this->post[$key] ?? $this->get[$key] ?? $default; + } + + public function server(string $key, mixed $default = null): mixed + { + return $this->server[$key] ?? $default; + } + + public function all(): array + { + return array_merge($this->get, $this->post); + } +} diff --git a/core/Response.php b/core/Response.php new file mode 100644 index 0000000..da74f72 --- /dev/null +++ b/core/Response.php @@ -0,0 +1,64 @@ +content = $content; + $this->status = $status; + $this->headers = $headers; + } + + public static function json(array $data, int $status = 200): self + { + return new self( + json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), + $status, + ['Content-Type' => 'application/json'] + ); + } + + public static function redirect(string $url): self + { + return new self('', 302, ['Location' => $url]); + } + + public static function notFound(string $message = 'Not found'): self + { + return new self($message, 404); + } + + public static function serverError(string $message = 'Server error'): self + { + return new self($message, 500); + } + + public function send(): void + { + http_response_code($this->status); + + foreach ($this->headers as $name => $value) { + header($name . ': ' . $value); + } + + echo $this->content; + } + + public function content(): string + { + return $this->content; + } + + public function status(): int + { + return $this->status; + } +} diff --git a/core/Route.php b/core/Route.php new file mode 100644 index 0000000..a91a919 --- /dev/null +++ b/core/Route.php @@ -0,0 +1,52 @@ +method = strtoupper($method); + $this->path = $path; + $this->handler = $handler; + + preg_match_all('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', $path, $names); + $this->parameterNames = $names[1]; + $compiled = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '([^/]+)', $path); + $this->compiledPattern = '#^' . $compiled . '$#'; + } + + public function matches(string $method, string $path): bool + { + if (strtoupper($method) !== $this->method) { + return false; + } + + if (!preg_match($this->compiledPattern, $path, $matches)) { + return false; + } + + array_shift($matches); + $this->parameters = []; + + foreach ($this->parameterNames as $index => $name) { + $this->parameters[$name] = $matches[$index] ?? null; + } + + return true; + } + + public function dispatch(App $app): mixed + { + return $app->call($this->handler, $this->parameters); + } +} diff --git a/core/Router.php b/core/Router.php new file mode 100644 index 0000000..24b51b0 --- /dev/null +++ b/core/Router.php @@ -0,0 +1,54 @@ +add('GET', $path, $handler); + } + + public function post(string $path, callable|array|string $handler): Route + { + return $this->add('POST', $path, $handler); + } + + public function put(string $path, callable|array|string $handler): Route + { + return $this->add('PUT', $path, $handler); + } + + public function patch(string $path, callable|array|string $handler): Route + { + return $this->add('PATCH', $path, $handler); + } + + public function delete(string $path, callable|array|string $handler): Route + { + return $this->add('DELETE', $path, $handler); + } + + public function add(string $method, string $path, callable|array|string $handler): Route + { + $route = new Route($method, $path, $handler); + $this->routes[] = $route; + + return $route; + } + + public function match(string $method, string $path): ?Route + { + foreach ($this->routes as $route) { + if ($route->matches($method, $path)) { + return $route; + } + } + + return null; + } +} diff --git a/core/Validator.php b/core/Validator.php new file mode 100644 index 0000000..e6c34d7 --- /dev/null +++ b/core/Validator.php @@ -0,0 +1,114 @@ +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 email(string $field, mixed $value, string $message = ''): self + { + if ((string) $value !== '' && filter_var($value, FILTER_VALIDATE_EMAIL) === false) { + $this->errors[$field][] = $message ?: "{$field} must be a valid email address."; + } + + return $this; + } + + public function date(string $field, mixed $value, string $format = 'Y-m-d', string $message = ''): self + { + $str = (string) $value; + + if ($str === '') { + return $this; + } + + $parsed = \DateTimeImmutable::createFromFormat($format, $str); + + if ($parsed === false || $parsed->format($format) !== $str) { + $this->errors[$field][] = $message ?: "{$field} must be a valid date ({$format})."; + } + + return $this; + } + + public function minLength(string $field, mixed $value, int $min, string $message = ''): self + { + if (strlen((string) $value) < $min) { + $this->errors[$field][] = $message ?: "{$field} must be at least {$min} characters."; + } + + return $this; + } + + public function min(string $field, mixed $value, int|float $min, string $message = ''): self + { + if (!is_numeric($value) || (float) $value < $min) { + $this->errors[$field][] = $message ?: "{$field} must be at least {$min}."; + } + + return $this; + } + + public function max(string $field, mixed $value, int|float $max, string $message = ''): self + { + if (!is_numeric($value) || (float) $value > $max) { + $this->errors[$field][] = $message ?: "{$field} must be no more than {$max}."; + } + + return $this; + } + + public function in(string $field, mixed $value, array $allowed, string $message = ''): self + { + if (!in_array($value, $allowed, true)) { + $this->errors[$field][] = $message ?: "{$field} must be one of: " . implode(', ', $allowed) . '.'; + } + + return $this; + } + + public function passes(): bool + { + return empty($this->errors); + } + + public function fails(): bool + { + return !$this->passes(); + } + + public function errors(): array + { + return $this->errors; + } +} diff --git a/core/View.php b/core/View.php new file mode 100644 index 0000000..96a9d17 --- /dev/null +++ b/core/View.php @@ -0,0 +1,79 @@ +title) && + trim($data['model']->title) !== '' + ) { + return $data['model']->title; + } + + return 'MindVisionCode PHP'; + } +} diff --git a/core/helpers.php b/core/helpers.php new file mode 100644 index 0000000..fffc7cb --- /dev/null +++ b/core/helpers.php @@ -0,0 +1,134 @@ + $config */ + $config = require __DIR__ . '/../config/database.php'; + + prepareSqliteDatabase($config['dsn'] ?? ''); + + $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; +} + +function ensureSessionStarted(): void +{ + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } +} + +function prepareSqliteDatabase(string $dsn): void +{ + if (!str_starts_with($dsn, 'sqlite:')) { + return; + } + + $path = substr($dsn, 7); + + if ($path === false || $path === '') { + return; + } + + $directory = dirname($path); + + if (!is_dir($directory)) { + mkdir($directory, 0777, true); + } + + if (!is_writable($directory)) { + @chmod($directory, 0777); + } + + if (!file_exists($path)) { + touch($path); + } + + if (!is_writable($path)) { + @chmod($path, 0666); + } +} + +function e(?string $value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +function asset(string $path): string +{ + return '/' . ltrim($path, '/'); +} + +function csrf_token(): string +{ + ensureSessionStarted(); + + if (!isset($_SESSION['_csrf_token']) || !is_string($_SESSION['_csrf_token'])) { + $_SESSION['_csrf_token'] = bin2hex(random_bytes(32)); + } + + return $_SESSION['_csrf_token']; +} + +function csrf_field(): string +{ + return ''; +} + +function verify_csrf_token(?string $token): bool +{ + ensureSessionStarted(); + + if (!is_string($token) || $token === '') { + return false; + } + + $sessionToken = $_SESSION['_csrf_token'] ?? null; + + return is_string($sessionToken) && hash_equals($sessionToken, $token); +} diff --git a/database/migrations/20260509_000001_create_employees_table.php b/database/migrations/20260509_000001_create_employees_table.php new file mode 100644 index 0000000..2ac3812 --- /dev/null +++ b/database/migrations/20260509_000001_create_employees_table.php @@ -0,0 +1,30 @@ +execute( + 'CREATE TABLE IF NOT EXISTS employees ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + department VARCHAR(100) NOT NULL, + job_title VARCHAR(150) NOT NULL, + start_date DATE NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )' + ); + } + + public function down(Database $database): void + { + $database->execute('DROP TABLE IF EXISTS employees'); + } +}; diff --git a/database/migrations/20260522_000001_create_boards_table.php b/database/migrations/20260522_000001_create_boards_table.php new file mode 100644 index 0000000..e13028b --- /dev/null +++ b/database/migrations/20260522_000001_create_boards_table.php @@ -0,0 +1,33 @@ +execute( + 'CREATE TABLE IF NOT EXISTS boards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, + import_from_printstream INTEGER NOT NULL DEFAULT 0, + printstream_job_name TEXT, + created_at DATETIME, + created_by VARCHAR(255), + updated_at DATETIME, + updated_by VARCHAR(255) + )' + ); + + $database->execute('CREATE INDEX IF NOT EXISTS idx_boards_slug ON boards (slug)'); + } + + public function down(Database $database): void + { + $database->execute('DROP TABLE IF EXISTS boards'); + } +}; diff --git a/database/migrations/20260522_000002_create_board_columns_table.php b/database/migrations/20260522_000002_create_board_columns_table.php new file mode 100644 index 0000000..d24dcbc --- /dev/null +++ b/database/migrations/20260522_000002_create_board_columns_table.php @@ -0,0 +1,33 @@ +execute( + 'CREATE TABLE IF NOT EXISTS board_columns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + board_id INTEGER NOT NULL, + name VARCHAR(255) NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + created_at DATETIME, + created_by VARCHAR(255), + updated_at DATETIME, + updated_by VARCHAR(255), + FOREIGN KEY (board_id) REFERENCES boards(id) + )' + ); + + $database->execute('CREATE INDEX IF NOT EXISTS idx_board_columns_board_id ON board_columns (board_id)'); + } + + public function down(Database $database): void + { + $database->execute('DROP TABLE IF EXISTS board_columns'); + } +}; diff --git a/database/migrations/20260522_000003_create_swim_lanes_table.php b/database/migrations/20260522_000003_create_swim_lanes_table.php new file mode 100644 index 0000000..60caa94 --- /dev/null +++ b/database/migrations/20260522_000003_create_swim_lanes_table.php @@ -0,0 +1,33 @@ +execute( + 'CREATE TABLE IF NOT EXISTS swim_lanes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + board_id INTEGER NOT NULL, + name VARCHAR(255) NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + created_at DATETIME, + created_by VARCHAR(255), + updated_at DATETIME, + updated_by VARCHAR(255), + FOREIGN KEY (board_id) REFERENCES boards(id) + )' + ); + + $database->execute('CREATE INDEX IF NOT EXISTS idx_swim_lanes_board_id ON swim_lanes (board_id)'); + } + + public function down(Database $database): void + { + $database->execute('DROP TABLE IF EXISTS swim_lanes'); + } +}; diff --git a/database/migrations/20260522_000004_create_cards_table.php b/database/migrations/20260522_000004_create_cards_table.php new file mode 100644 index 0000000..5c4433c --- /dev/null +++ b/database/migrations/20260522_000004_create_cards_table.php @@ -0,0 +1,45 @@ +execute( + 'CREATE TABLE IF NOT EXISTS cards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + board_id INTEGER NOT NULL, + column_id INTEGER NOT NULL, + swim_lane_id INTEGER NOT NULL, + job_number VARCHAR(255), + job_name VARCHAR(255), + customer_name VARCHAR(255), + delivery_date DATE, + quantity VARCHAR(50), + notes TEXT, + full_note TEXT, + position INTEGER NOT NULL DEFAULT 0, + created_at DATETIME, + created_by VARCHAR(255), + updated_at DATETIME, + updated_by VARCHAR(255), + FOREIGN KEY (board_id) REFERENCES boards(id), + FOREIGN KEY (column_id) REFERENCES board_columns(id), + FOREIGN KEY (swim_lane_id) REFERENCES swim_lanes(id) + )' + ); + + $database->execute('CREATE INDEX IF NOT EXISTS idx_cards_board_id ON cards (board_id)'); + $database->execute('CREATE INDEX IF NOT EXISTS idx_cards_column_id ON cards (column_id)'); + $database->execute('CREATE INDEX IF NOT EXISTS idx_cards_swim_lane_id ON cards (swim_lane_id)'); + } + + public function down(Database $database): void + { + $database->execute('DROP TABLE IF EXISTS cards'); + } +}; diff --git a/database/seed_employees.php b/database/seed_employees.php new file mode 100644 index 0000000..82317e3 --- /dev/null +++ b/database/seed_employees.php @@ -0,0 +1,107 @@ +runPending(); + $database = database(); + + if ($resetExisting) { + $database->execute('DELETE FROM employees'); + } + + $currentTotal = (int) (database()->first('SELECT COUNT(*) AS total FROM employees')['total'] ?? 0); + + if ($currentTotal >= $targetTotal) { + echo "Employee table already has {$currentTotal} records." . PHP_EOL; + return; + } + +$firstNames = [ + 'Ava', 'Liam', 'Noah', 'Emma', 'Olivia', 'Mason', 'Sophia', 'Ethan', 'Isabella', 'Lucas', + 'Mia', 'Amelia', 'James', 'Harper', 'Benjamin', 'Ella', 'Henry', 'Evelyn', 'Jack', 'Abigail', + 'Alexander', 'Emily', 'Michael', 'Charlotte', 'Daniel', 'Grace', 'Elijah', 'Scarlett', 'William', 'Chloe', + 'Matthew', 'Victoria', 'Samuel', 'Lily', 'David', 'Aria', 'Joseph', 'Zoey', 'Carter', 'Hannah', + 'Owen', 'Addison', 'Wyatt', 'Natalie', 'John', 'Aubrey', 'Luke', 'Brooklyn', 'Gabriel', 'Layla', + 'Anthony', 'Zoe', 'Isaac', 'Penelope', 'Dylan', 'Riley', 'Grayson', 'Nora', 'Levi', 'Lillian', + 'Julian', 'Eleanor', 'Christopher', 'Stella', 'Joshua', 'Savannah', 'Andrew', 'Audrey', 'Nathan', 'Claire', + 'Thomas', 'Skylar', 'Caleb', 'Lucy', 'Ryan', 'Paisley', 'Christian', 'Everly', 'Hunter', 'Anna', + 'Jonathan', 'Caroline', 'Aaron', 'Nova', 'Charles', 'Genesis', 'Connor', 'Kennedy', 'Eli', 'Samantha', + 'Landon', 'Maya', 'Adrian', 'Willow', 'Nicholas', 'Kinsley', 'Jeremiah', 'Naomi', 'Easton', 'Ariana', +]; + +$lastNames = [ + 'Carter', 'Brooks', 'Hayes', 'Parker', 'Turner', 'Sullivan', 'Reed', 'Ward', 'Price', 'Foster', + 'Powell', 'Bennett', 'Coleman', 'Russell', 'Long', 'Perry', 'Morgan', 'Peterson', 'Cooper', 'Bailey', + 'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez', + 'Hernandez', 'Lopez', 'Gonzalez', 'Wilson', 'Anderson', 'Thomas', 'Taylor', 'Moore', 'Jackson', 'Martin', + 'Lee', 'Perez', 'Thompson', 'White', 'Harris', 'Sanchez', 'Clark', 'Ramirez', 'Lewis', 'Robinson', + 'Walker', 'Young', 'Allen', 'King', 'Wright', 'Scott', 'Torres', 'Nguyen', 'Hill', 'Flores', + 'Green', 'Adams', 'Nelson', 'Baker', 'Hall', 'Rivera', 'Campbell', 'Mitchell', 'Roberts', 'Gomez', + 'Phillips', 'Evans', 'Edwards', 'Collins', 'Stewart', 'Morris', 'Rogers', 'Murphy', 'Cook', 'Ramos', + 'Richardson', 'Cox', 'Howard', 'Bell', 'Ortiz', 'Gutierrez', 'Chavez', 'Wood', 'James', 'Bennett', + 'Gray', 'Mendoza', 'Ruiz', 'Hughes', 'Grant', 'Stone', 'Spencer', 'Warren', 'Porter', 'Bryant', +]; + + $departments = [ + 'Engineering', 'Finance', 'Operations', 'Sales', 'Marketing', 'People', 'Support', 'Legal', + ]; + + $jobTitles = [ + 'Coordinator', 'Analyst', 'Manager', 'Specialist', 'Administrator', 'Engineer', 'Consultant', 'Lead', + ]; + + $statement = $database->pdo()->prepare( + 'INSERT INTO employees (first_name, last_name, email, department, job_title, start_date) + VALUES (:first_name, :last_name, :email, :department, :job_title, :start_date)' + ); + + $database->pdo()->beginTransaction(); + + try { + for ($i = $currentTotal + 1; $i <= $targetTotal; $i++) { + $firstName = $firstNames[$i % count($firstNames)]; + $lastName = $lastNames[$i % count($lastNames)]; + $department = $departments[$i % count($departments)]; + $jobTitle = $jobTitles[$i % count($jobTitles)]; + $email = sprintf( + '%s.%s.%04d@example.test', + strtolower($firstName), + strtolower($lastName), + $i + ); + + $month = (($i - 1) % 12) + 1; + $day = (($i - 1) % 28) + 1; + $year = 2019 + (($i - 1) % 8); + $startDate = sprintf('%04d-%02d-%02d', $year, $month, $day); + + $statement->execute([ + 'first_name' => $firstName, + 'last_name' => $lastName, + 'email' => $email, + 'department' => $department, + 'job_title' => $jobTitle, + 'start_date' => $startDate, + ]); + } + + $database->pdo()->commit(); + } catch (Throwable $exception) { + $database->pdo()->rollBack(); + throw $exception; + } + + $inserted = $targetTotal - $currentTotal; + echo "Inserted {$inserted} sample employees. Total is now {$targetTotal}." . PHP_EOL; +} + +if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) { + $targetTotal = isset($argv[1]) ? max(1, (int) $argv[1]) : 1000; + seed_employees($targetTotal); +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d4965e9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + app: + build: . + ports: + - "8080:80" + volumes: + - .:/var/www/html + env_file: .env diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..4b6dfc3 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -e + +mkdir -p database +chmod 777 database + +composer install --no-interaction --quiet + +# Runs as root — migrations may create database/app.sqlite owned by root. +php scripts/migrate.php up + +# Fix ownership after migrations so www-data (Apache) can write. +# chmod works on Windows volume mounts even when chown is ignored by p9fs. +chmod 777 database +chmod 666 database/app.sqlite + +exec apache2-foreground diff --git a/docker/vhost.conf b/docker/vhost.conf new file mode 100644 index 0000000..829dd31 --- /dev/null +++ b/docker/vhost.conf @@ -0,0 +1,13 @@ + + ServerName localhost + DocumentRoot /var/www/html/public + + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..55488aa --- /dev/null +++ b/docs/README.md @@ -0,0 +1,78 @@ +# MindVisionCode PHP + +A small PHP MVC framework inspired by a Classic ASP MVC framework. + +## Run + +```bash +composer install +php scripts/migrate.php up +php -S localhost:8000 -t public +``` + +Open: + +```text +http://localhost:8000 +``` + +Try: + +```text +http://localhost:8000/users/123 +``` + +Employee form: + +```text +http://localhost:8000/employees +``` + +## 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 + +## SQLite + +The default database is SQLite and points to: + +```text +database/app.sqlite +``` + +The database file is created automatically when the app first needs it. + +Run migrations from the PHP CLI: + +```bash +php scripts/migrate.php up +php scripts/migrate.php down +php scripts/migrate.php status +php scripts/migrate.php make create_projects_table +php scripts/migrate.php fresh +php scripts/migrate.php fresh --seed +php scripts/seed_employees.php 1000 +``` + +## Frontend Libraries + +The employee directory page uses: + +- `htmx` for fragment-based form and summary updates +- `Alpine.js` for lightweight page state +- `Tabulator` for the interactive employee table + +## Flow chart + +See [`REQUEST_FLOW.md`](./REQUEST_FLOW.md) for a chart of how requests and responses move through the framework. diff --git a/docs/REQUEST_FLOW.md b/docs/REQUEST_FLOW.md new file mode 100644 index 0000000..31ed9d1 --- /dev/null +++ b/docs/REQUEST_FLOW.md @@ -0,0 +1,80 @@ +# Request / Response Flow + +This chart shows how a browser request moves through the MindVisionCode framework and how a response is built and returned. + +```text +Browser Request + | + v +public/index.php + |-- loads autoload.php / vendor autoload + |-- starts the session + |-- creates App + Router + |-- loads routes/web.php + | + v +Request::capture() + | + v +Dispatcher::dispatch() + | + +--> no route matched ----> Response::notFound() + | + +--> route matched -------> Route::dispatch() + | + v + App::call() + | + +--> controller method + | | + | v + | Controller action + | | + | +--> repository / service / view model + | +--> Database::query() / execute() + | +--> view() / json() / redirect() + | + +--> closure route + | + v + direct response data + +Dispatcher::normalizeResponse() + | + +--> Response object --------> Response::send() + +--> array ------------------> Response::json() --> Response::send() + +--> string -----------------> Response::send() + +Final result: +Browser receives HTML, JSON, or a redirect +``` + +## Response building paths + +### View response + +```text +Controller -> view() -> View::render() -> template -> layout -> Response +``` + +### JSON response + +```text +Controller -> json() -> Response::json() -> Response::send() +``` + +### Redirect response + +```text +Controller -> redirect() -> Response::redirect() -> Response::send() +``` + +## Key classes + +- `public/index.php` bootstraps the app +- `Core\Dispatcher` matches routes and handles errors +- `Core\Route` extracts route parameters +- `Core\App` invokes controller methods or closures +- `Core\Controller` gives actions helper methods +- `Core\View` renders templates into a layout +- `Core\Response` sends the final output diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..8661356 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,5 @@ +RewriteEngine On + +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.php [QSA,L] diff --git a/public/css/kanban.css b/public/css/kanban.css new file mode 100644 index 0000000..1603cc5 --- /dev/null +++ b/public/css/kanban.css @@ -0,0 +1,507 @@ +/* Kanban board theme aligned with public/css/site.css */ + +html, +body.kanban-page { + height: 100%; +} + +body.kanban-page { + margin: 0; + overflow: hidden; + background: + radial-gradient(75rem 35rem at -12% -18%, #dae8ff 0%, transparent 44%), + radial-gradient(68rem 32rem at 115% -16%, #d8f3ff 0%, transparent 40%), + linear-gradient(180deg, #eef4ff 0%, #f4f8ff 58%, #f3f7ff 100%); +} + +/* Top bar */ +body.kanban-page .navbar { + position: sticky; + top: 0; + z-index: 1000; + border-bottom: 1px solid rgba(255, 255, 255, 0.18); + backdrop-filter: blur(9px); + background: linear-gradient(120deg, #102241 0%, #173a72 56%, #1c4c90 100%); + box-shadow: 0 8px 24px rgba(8, 20, 48, 0.26); +} + +body.kanban-page .navbar-brand { + color: #f4f8ff !important; + letter-spacing: -0.01em; +} + +.board-header-main { + min-width: 0; +} + +.board-header-actions { + flex-shrink: 0; +} + +.board-header-search { + width: min(440px, 36vw); + margin: 0 0.75rem; +} + +.board-header-search .input-group-text { + border-color: rgba(214, 229, 250, 0.7); + background: rgba(255, 255, 255, 0.15); + color: #eff6ff; +} + +.board-header-search .form-control { + border-color: rgba(214, 229, 250, 0.7); + background: rgba(255, 255, 255, 0.17); + color: #f5f9ff; +} + +.board-header-search .form-control::placeholder { + color: rgba(235, 243, 255, 0.72); +} + +.board-header-search .form-control:focus { + border-color: rgba(235, 245, 255, 0.95); + box-shadow: 0 0 0 0.2rem rgba(157, 194, 245, 0.25); + background: rgba(255, 255, 255, 0.23); +} + +.kanban-board-title { + display: block; + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: clamp(1rem, 0.6vw + 0.95rem, 1.28rem); +} + +body.kanban-page .navbar .btn-outline-light, +body.kanban-page .navbar .btn-outline-secondary { + border-color: rgba(213, 230, 255, 0.55) !important; + color: #eef5ff !important; + background: rgba(255, 255, 255, 0.06); +} + +body.kanban-page .navbar .btn-outline-light:hover, +body.kanban-page .navbar .btn-outline-secondary:hover { + background: rgba(255, 255, 255, 0.16); + border-color: rgba(234, 243, 255, 0.85) !important; +} + +/* Board wrapper */ +.kanban-wrapper { + width: 100%; + margin: 0 auto; + height: calc(100vh - 65px); + overflow-x: auto; + overflow-y: auto; + padding: 0.9rem 1rem 1.1rem; + -webkit-overflow-scrolling: touch; + scroll-behavior: smooth; + touch-action: pan-x pan-y; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: #8fb0e0 #dce8f8; + scrollbar-gutter: stable; +} + +.kanban-grid { + display: grid; + width: max-content; + min-width: 100%; + border: 1px solid var(--line, #d9e3f5); + border-radius: 14px; + background: rgba(255, 255, 255, 0.72); + box-shadow: 0 12px 34px rgba(22, 48, 92, 0.12); + overflow: clip; +} + +/* Sticky corner and headers */ +.kanban-corner { + position: sticky; + top: 0; + left: 0; + z-index: 40; + min-width: 240px; + min-height: 56px; + background: linear-gradient(135deg, #173864 0%, #1e4d8f 100%); + border-right: 1px solid rgba(255, 255, 255, 0.15); + border-bottom: 1px solid rgba(255, 255, 255, 0.15); +} + +.kanban-col-header { + position: sticky; + top: 0; + z-index: 30; + min-width: 230px; + padding: 0.75rem 0.88rem; + color: #eff5ff; + font-size: clamp(0.68rem, 0.16vw + 0.65rem, 0.78rem); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + white-space: normal; + line-height: 1.22; + word-break: break-word; + overflow-wrap: anywhere; + background: linear-gradient(120deg, #173864 0%, #1e4d8f 100%); + border-left: 1px solid rgba(255, 255, 255, 0.16); +} + +.kanban-lane-header { + position: sticky; + left: 0; + z-index: 20; + min-width: 240px; + display: flex; + align-items: center; + gap: 0.42rem; + padding: 0.85rem 0.82rem; + font-size: clamp(0.7rem, 0.14vw + 0.66rem, 0.78rem); + font-weight: 700; + color: #2a3a58; + background: linear-gradient(180deg, #f6faff 0%, #edf3ff 100%); + border-top: 1px solid #d7e1f5; + border-right: 1px solid #d4deef; +} + +.kanban-lane-header .lane-label { + display: inline-block; + max-width: 100%; + white-space: normal; + line-height: 1.2; + word-break: break-word; + overflow-wrap: anywhere; +} + +.kanban-lane-header .lane-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.34rem; + height: 1.34rem; + padding: 0; + border: 0; + border-radius: 999px; + background: rgba(26, 74, 145, 0.12); + color: #1f4f96; + cursor: pointer; + flex: 0 0 auto; +} + +.kanban-lane-header .lane-toggle:hover { + background: rgba(26, 74, 145, 0.22); +} + +.kanban-lane-header .lane-toggle i { + transition: transform 140ms ease; +} + +.kanban-lane-header.lane-collapsed .lane-toggle i { + transform: rotate(-90deg); +} + +/* Cells */ +.kanban-cell { + min-width: 230px; + min-height: 132px; + padding: 0.56rem; + border-top: 1px solid #dce5f4; + border-left: 1px solid #dce5f4; + display: flex; + flex-direction: column; + gap: 0.45rem; + vertical-align: top; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.88) 0%, rgba(244, 248, 255, 0.93) 100%); +} + +.kanban-cell.lane-collapsed { + min-height: 0; + max-height: 0; + padding-top: 0; + padding-bottom: 0; + border-top-color: transparent; + overflow: hidden; + pointer-events: none; +} + +.kanban-cell.lane-collapsed .kanban-card { + display: none !important; +} + +.kanban-cell.drag-over { + background: linear-gradient(180deg, #e9f2ff 0%, #deecff 100%); + box-shadow: inset 0 0 0 2px rgba(19, 99, 223, 0.24); +} + +/* Cards */ +.kanban-card { + border: 1px solid #d5e1f6; + border-radius: 12px; + background: #ffffff; + padding: 0.56rem 0.62rem; + cursor: pointer; + user-select: none; + box-shadow: 0 4px 12px rgba(16, 44, 90, 0.08); + transition: transform 120ms ease, box-shadow 140ms ease, border-color 120ms ease; + touch-action: manipulation; +} + +.kanban-card:hover { + transform: translateY(-1px); + border-color: #8eb0ea; + box-shadow: 0 10px 22px rgba(16, 44, 90, 0.14); +} + +.kanban-card.sortable-ghost { + opacity: 0.38; +} + +.kanban-card.sortable-chosen { + border-color: #4d87e2; + box-shadow: 0 14px 30px rgba(17, 46, 94, 0.22); +} + +.kanban-card-hidden { + display: none !important; +} + +.card-headline { + display: flex; + align-items: center; + gap: 0.38rem; + min-width: 0; +} + +.card-job-number { + display: inline-block; + padding: 0.08rem 0.42rem; + border-radius: 999px; + font-size: 0.66rem; + font-weight: 800; + letter-spacing: 0.07em; + text-transform: uppercase; + color: #0e4fae; + background: #e7f0ff; + flex: 0 0 auto; +} + +.card-customer { + font-size: 0.78rem; + font-weight: 600; + color: #2b4a80; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.card-meta-label { + font-size: 0.63rem; + font-weight: 700; + color: #7a90b2; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.card-delivery, +.card-qty { + font-size: 0.73rem; + color: #3a5080; +} + +.card-notes { + margin-top: 0.22rem; + font-size: 0.71rem; + color: #647899; + line-height: 1.3; +} + +/* Settings panel */ +.kanban-settings-overlay { + position: fixed; + inset: 0; + z-index: 1040; + background: rgba(13, 25, 48, 0.42); + backdrop-filter: blur(2px); +} + +.kanban-settings-panel { + position: fixed; + top: 0; + right: 0; + width: min(420px, 94vw); + max-width: 100vw; + height: 100%; + z-index: 1050; + background: linear-gradient(180deg, #f7fbff 0%, #f1f6ff 100%); + border-left: 1px solid #d8e2f2; + box-shadow: -8px 0 28px rgba(19, 40, 81, 0.2); + display: flex; + flex-direction: column; + overflow: hidden; + transform: translateX(104%); + transition: transform 180ms cubic-bezier(0.2, 0.7, 0.2, 1); + will-change: transform; +} + +.kanban-settings-panel.open { + transform: translateX(0); +} + +.settings-header { + background: rgba(255, 255, 255, 0.7); + border-bottom-color: #dce6f5 !important; +} + +.settings-header h6 { + letter-spacing: -0.01em; +} + +.settings-body { + flex: 1; + overflow-y: auto; +} + +.settings-body .list-group-item { + border-color: #d7e2f4; + border-radius: 10px !important; + margin-bottom: 0.45rem; + background: #ffffff; + box-shadow: 0 3px 9px rgba(25, 45, 84, 0.05); +} + +.settings-sortable .drag-handle { + color: #8fa1bf !important; +} + +.settings-sortable .drag-handle:hover { + color: var(--brand, #1363df) !important; +} + +.inline-rename { + height: auto; + font-size: 0.84rem; + padding: 0.23rem 0.45rem; +} + +/* Modal polish */ +#cardModal .modal-content { + border: 1px solid #d7e2f5; + border-radius: 14px; + box-shadow: 0 22px 48px rgba(18, 43, 83, 0.22); +} + +#cardModal .modal-header { + border-bottom-color: #dfe8f7; + background: #f7fbff; +} + +#cardModal .modal-footer { + border-top-color: #dfe8f7; + background: #fbfdff; +} + +/* Scrollbars */ +.kanban-wrapper::-webkit-scrollbar, +.settings-body::-webkit-scrollbar { + width: 12px; + height: 12px; +} + +.kanban-wrapper::-webkit-scrollbar-track, +.settings-body::-webkit-scrollbar-track { + background: #dce8f8; + border-radius: 999px; +} + +.kanban-wrapper::-webkit-scrollbar-thumb, +.settings-body::-webkit-scrollbar-thumb { + background: #8fb0e0; + border-radius: 999px; + border: 2px solid #dce8f8; +} + +.kanban-wrapper::-webkit-scrollbar-thumb:hover, +.settings-body::-webkit-scrollbar-thumb:hover { + background: #5e8ecb; +} + +.kanban-wrapper::-webkit-scrollbar-corner { + background: #dce8f8; +} + +/* Small screens */ +@media (max-width: 900px) { + .kanban-wrapper { + padding: 0.7rem; + height: calc(100vh - 62px); + } + + .kanban-corner, + .kanban-lane-header { + min-width: 190px; + } + + .kanban-col-header, + .kanban-cell { + min-width: 206px; + } + + .kanban-board-title { + font-size: clamp(0.92rem, 2.8vw, 1.1rem); + } + + .board-header-actions { + gap: 0.35rem !important; + } + + .board-header-search { + width: min(270px, 44vw); + margin: 0 0.35rem; + } + + .board-header-actions .btn { + padding-left: 0.48rem; + padding-right: 0.48rem; + } +} + +@media (max-width: 640px) { + .board-header-search { + width: 100%; + margin: 0.5rem 0 0; + order: 3; + } + + body.kanban-page .navbar { + flex-wrap: wrap; + align-items: flex-start !important; + } + + .board-header-main { + flex: 1 1 auto; + } + + .board-header-actions { + flex: 0 0 auto; + } + + .kanban-settings-panel { + width: 100vw; + border-left: 0; + box-shadow: none; + } + + .settings-body { + padding-bottom: 1.25rem; + } +} diff --git a/public/css/site.css b/public/css/site.css new file mode 100644 index 0000000..f0e1fa1 --- /dev/null +++ b/public/css/site.css @@ -0,0 +1,791 @@ +: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; +} + +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-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, +.employee-card, +.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; +} + +.employee-layout { + display: grid; + grid-template-columns: minmax(320px, 0.9fr) minmax(0, 1.5fr); + gap: 1.5rem; + align-items: start; +} + +.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 { + margin-bottom: 1.5rem; +} + +.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; +} + +.employee-form { + display: grid; + gap: 1.25rem; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.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; +} + +.employee-cards { + display: grid; + gap: 1rem; +} + +.employee-card { + padding: 1.15rem; + border-radius: 1.3rem; +} + +.employee-card-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.8rem; +} + +.employee-card-top h3 { + margin: 0; + font-size: 1.05rem; +} + +.employee-card-top span { + padding: 0.4rem 0.7rem; + border-radius: 999px; + background: rgba(29, 122, 109, 0.09); + color: var(--accent-strong); + font-size: 0.78rem; + font-weight: 700; +} + +.employee-card p { + margin: 0 0 1rem; + color: var(--text-secondary); +} + +.employee-meta { + display: grid; + gap: 0.75rem; + margin: 0; +} + +.employee-meta div { + display: grid; + gap: 0.2rem; +} + +.employee-meta dt { + color: var(--text-secondary); + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.employee-meta dd { + margin: 0; + font-weight: 600; +} + +.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, + .employee-layout { + 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; + } + + .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; + } +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..3bd9b13 --- /dev/null +++ b/public/index.php @@ -0,0 +1,45 @@ +bind(Request::class, $request); +$response = $dispatcher->dispatch($request); + +$response->send(); diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..62af4cb --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,65 @@ +window.employeeDirectory = function () { + return { + search: '', + table: null, + + init() { + this.search = this.$root.querySelector('#employee-search')?.value ?? ''; + this.initTable(); + + document.body.addEventListener('employees-changed', () => { + this.reloadTable(); + }); + }, + + initTable() { + const tableElement = document.getElementById('employee-table'); + + if (!tableElement || typeof Tabulator === 'undefined') { + return; + } + + this.table = new Tabulator(tableElement, { + ajaxURL: '/employees/data', + ajaxParams: { + search: this.search, + }, + layout: 'fitColumns', + responsiveLayout: 'collapse', + pagination: true, + paginationMode: 'local', + paginationSize: 8, + movableColumns: true, + placeholder: 'No employees found.', + columns: [ + { title: 'Name', field: 'full_name', minWidth: 180 }, + { title: 'Email', field: 'email', minWidth: 220 }, + { title: 'Department', field: 'department', minWidth: 140 }, + { title: 'Job Title', field: 'job_title', minWidth: 180 }, + { title: 'Start Date', field: 'start_date', hozAlign: 'left', minWidth: 130 }, + ], + }); + }, + + applySearch() { + if (!this.table) { + return; + } + + this.table.setData('/employees/data', { + search: this.search, + }); + }, + + reloadTable() { + if (!this.table) { + this.initTable(); + return; + } + + this.table.setData('/employees/data', { + search: this.search, + }); + }, + }; +}; diff --git a/public/js/kanban-board.js b/public/js/kanban-board.js new file mode 100644 index 0000000..64f4a67 --- /dev/null +++ b/public/js/kanban-board.js @@ -0,0 +1,442 @@ +/* kanban-board.js - grid rendering and drag-drop between cells */ +(function () { + 'use strict'; + + var boardId = KANBAN.boardId; + var laneCollapseStorageKey = 'kanban_lane_collapsed_' + String(boardId); + var collapsedLaneIds = loadCollapsedLaneIds(); + var searchState = { + query: '' + }; + var dragState = { + active: false, + x: 0, + y: 0, + rafId: 0 + }; + + function post(url, data, cb) { + var params = new URLSearchParams(); + Object.keys(data).forEach(function (k) { params.append(k, data[k]); }); + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString() + }) + .then(function (r) { return r.json(); }) + .then(cb) + .catch(function (e) { console.error(url, e); }); + } + + function esc(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function applyGridTemplate() { + var grid = document.getElementById('kanban-grid'); + var colHs = grid.querySelectorAll('.kanban-col-header'); + var cols = '240px'; + colHs.forEach(function () { cols += ' 230px'; }); + grid.style.gridTemplateColumns = cols; + } + + function loadCollapsedLaneIds() { + var laneMap = {}; + try { + var raw = window.localStorage.getItem(laneCollapseStorageKey); + if (!raw) return laneMap; + var arr = JSON.parse(raw); + if (!Array.isArray(arr)) return laneMap; + arr.forEach(function (laneId) { + laneMap[String(laneId)] = true; + }); + } catch (e) { + console.warn('Failed to load lane collapse state', e); + } + return laneMap; + } + + function saveCollapsedLaneIds() { + try { + window.localStorage.setItem(laneCollapseStorageKey, JSON.stringify(Object.keys(collapsedLaneIds))); + } catch (e) { + console.warn('Failed to save lane collapse state', e); + } + } + + function setLaneCollapsed(laneId, isCollapsed) { + var laneKey = String(laneId); + var header = document.querySelector('.kanban-lane-header[data-lane-id="' + laneKey + '"]'); + if (!header) return; + + var laneCells = document.querySelectorAll('.kanban-cell[data-lane-id="' + laneKey + '"]'); + header.classList.toggle('lane-collapsed', isCollapsed); + laneCells.forEach(function (cell) { + cell.classList.toggle('lane-collapsed', isCollapsed); + }); + + var toggleBtn = header.querySelector('.lane-toggle'); + if (toggleBtn) { + toggleBtn.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true'); + toggleBtn.title = isCollapsed ? 'Expand swim lane' : 'Collapse swim lane'; + toggleBtn.setAttribute('aria-label', toggleBtn.title); + } + + if (isCollapsed) { + collapsedLaneIds[laneKey] = true; + } else { + delete collapsedLaneIds[laneKey]; + } + saveCollapsedLaneIds(); + } + + function toggleLaneCollapsed(laneId) { + var laneKey = String(laneId); + setLaneCollapsed(laneKey, !collapsedLaneIds[laneKey]); + } + + function bindLaneHeaderToggle(headerEl) { + if (!headerEl) return; + var toggleBtn = headerEl.querySelector('.lane-toggle'); + if (!toggleBtn) return; + + if (!toggleBtn.dataset.boundToggle) { + toggleBtn.addEventListener('click', function (evt) { + evt.preventDefault(); + evt.stopPropagation(); + toggleLaneCollapsed(headerEl.dataset.laneId); + }); + toggleBtn.dataset.boundToggle = '1'; + } + } + + function initLaneHeaderToggles() { + document.querySelectorAll('.kanban-lane-header').forEach(function (headerEl) { + bindLaneHeaderToggle(headerEl); + if (collapsedLaneIds[String(headerEl.dataset.laneId)]) { + setLaneCollapsed(headerEl.dataset.laneId, true); + } + }); + } + + function cardBodyHtml(card) { + var html = '
' + + '' + esc(card.job_number || '') + ''; + + if (card.customer_name) { + html += '' + esc(card.customer_name) + ''; + } + + html += '
'; + + return html; + } + + function buildCardSearchText(card) { + return [ + card.job_number || '', + card.job_name || '', + card.customer_name || '', + card.notes || '' + ].join(' ').toLowerCase(); + } + + function buildCardEl(card) { + var div = document.createElement('div'); + div.className = 'kanban-card'; + div.dataset.id = card.id; + div.dataset.columnId = card.column_id; + div.dataset.laneId = card.swim_lane_id; + div.dataset.searchText = buildCardSearchText(card); + div.innerHTML = cardBodyHtml(card); + div.addEventListener('click', function () { + var c = KANBAN.cards.find(function (x) { return String(x.id) === String(div.dataset.id); }); + if (!c) return; + window.KanbanModal.openEdit(c.id, c.column_id, c.swim_lane_id, c.job_number, c.job_name, c.customer_name, c.delivery_date, c.quantity, c.notes, c.full_note); + }); + return div; + } + + function renderCards() { + KANBAN.cards.forEach(function (card) { + var cell = document.querySelector( + '.kanban-cell[data-col-id="' + card.column_id + '"][data-lane-id="' + card.swim_lane_id + '"]' + ); + if (cell) { + cell.appendChild(buildCardEl(card)); + } + }); + applyCardFilter(); + } + + function applyCardFilter() { + var activeQuery = searchState.query; + document.querySelectorAll('.kanban-card').forEach(function (el) { + var searchableText = (el.dataset.searchText || '').toLowerCase(); + var isMatch = activeQuery === '' || searchableText.indexOf(activeQuery) > -1; + el.classList.toggle('kanban-card-hidden', !isMatch); + }); + } + + function initJobSearch() { + var searchInput = document.getElementById('job-search-input'); + if (!searchInput) return; + + searchInput.addEventListener('input', function () { + searchState.query = String(searchInput.value || '').toLowerCase().trim(); + applyCardFilter(); + }); + } + + function handleDragEnd(evt) { + var cardId = evt.item.dataset.id; + var newColId = evt.to.dataset.colId; + var newLaneId = evt.to.dataset.laneId; + var newPos = evt.newIndex; + + var siblings = []; + evt.to.querySelectorAll('.kanban-card').forEach(function (el) { + siblings.push(el.dataset.id); + }); + + var card = KANBAN.cards.find(function (c) { return String(c.id) === String(cardId); }); + if (card) { + card.column_id = parseInt(newColId, 10); + card.swim_lane_id = parseInt(newLaneId, 10); + card.position = newPos; + } + + evt.item.dataset.columnId = newColId; + evt.item.dataset.laneId = newLaneId; + + post('/cards/' + cardId + '/move', { + column_id: newColId, + swim_lane_id: newLaneId, + position: newPos, + sibling_ids: siblings.join(',') + }, function (res) { + if (!res.ok) console.error('Move failed', res); + }); + } + + function createCellSortable(cell) { + Sortable.create(cell, { + group: 'cards', + animation: 150, + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + handle: '.kanban-card', + delayOnTouchOnly: true, + delay: 120, + touchStartThreshold: 3, + fallbackTolerance: 4, + scroll: true, + bubbleScroll: true, + scrollSensitivity: 140, + scrollSpeed: 32, + onStart: function () { startEdgeAutoScroll(); }, + onEnd: function (evt) { + stopEdgeAutoScroll(); + handleDragEnd(evt); + } + }); + } + + function updatePointerFromEvent(evt) { + if (!evt) return; + if (evt.touches && evt.touches.length > 0) { + dragState.x = evt.touches[0].clientX; + dragState.y = evt.touches[0].clientY; + return; + } + if (evt.clientX !== undefined && evt.clientY !== undefined) { + dragState.x = evt.clientX; + dragState.y = evt.clientY; + } + } + + function edgeScrollStep() { + if (!dragState.active) return; + + var wrapper = document.querySelector('.kanban-wrapper'); + if (wrapper) { + var rect = wrapper.getBoundingClientRect(); + var edge = 110; + var maxStep = 40; + var dx = 0; + var dy = 0; + + if (dragState.x > 0 && dragState.x < rect.left + edge) { + dx = -Math.min(maxStep, Math.ceil((rect.left + edge - dragState.x) / 3)); + } else if (dragState.x > rect.right - edge && dragState.x < rect.right + edge) { + dx = Math.min(maxStep, Math.ceil((dragState.x - (rect.right - edge)) / 3)); + } + + if (dragState.y > 0 && dragState.y < rect.top + edge) { + dy = -Math.min(maxStep, Math.ceil((rect.top + edge - dragState.y) / 3)); + } else if (dragState.y > rect.bottom - edge && dragState.y < rect.bottom + edge) { + dy = Math.min(maxStep, Math.ceil((dragState.y - (rect.bottom - edge)) / 3)); + } + + if (dx !== 0) wrapper.scrollLeft += dx; + if (dy !== 0) wrapper.scrollTop += dy; + } + + dragState.rafId = window.requestAnimationFrame(edgeScrollStep); + } + + function startEdgeAutoScroll() { + if (dragState.active) return; + dragState.active = true; + document.addEventListener('pointermove', updatePointerFromEvent, { passive: true }); + document.addEventListener('touchmove', updatePointerFromEvent, { passive: true }); + document.addEventListener('dragover', updatePointerFromEvent, { passive: true }); + dragState.rafId = window.requestAnimationFrame(edgeScrollStep); + } + + function stopEdgeAutoScroll() { + if (!dragState.active) return; + dragState.active = false; + if (dragState.rafId) { + window.cancelAnimationFrame(dragState.rafId); + dragState.rafId = 0; + } + document.removeEventListener('pointermove', updatePointerFromEvent); + document.removeEventListener('touchmove', updatePointerFromEvent); + document.removeEventListener('dragover', updatePointerFromEvent); + } + + function initSortables() { + document.querySelectorAll('.kanban-cell').forEach(createCellSortable); + } + + document.getElementById('btn-add-card').addEventListener('click', function () { + window.KanbanModal.openCreate(boardId, null, null); + }); + + window.KanbanBoard = { + onCardCreated: function (card) { + KANBAN.cards.push(card); + var cell = document.querySelector( + '.kanban-cell[data-col-id="' + card.column_id + '"][data-lane-id="' + card.swim_lane_id + '"]' + ); + if (cell) { + cell.appendChild(buildCardEl(card)); + } + applyCardFilter(); + }, + onCardUpdated: function (id, data) { + var card = KANBAN.cards.find(function (c) { return String(c.id) === String(id); }); + if (card) { + card.job_number = data.job_number || ''; + card.job_name = data.job_name || ''; + card.customer_name = data.customer_name || ''; + card.delivery_date = data.delivery_date || null; + card.quantity = data.quantity || ''; + card.notes = data.notes || ''; + card.full_note = data.full_note !== undefined ? data.full_note : (card.full_note || ''); + } + var el = document.querySelector('.kanban-card[data-id="' + id + '"]'); + if (el && card) { + el.innerHTML = cardBodyHtml(card); + el.dataset.searchText = buildCardSearchText(card); + } + applyCardFilter(); + }, + onCardDeleted: function (id) { + KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.id) !== String(id); }); + var el = document.querySelector('.kanban-card[data-id="' + id + '"]'); + if (el) el.remove(); + applyCardFilter(); + }, + addColumn: function (col) { + var grid = document.getElementById('kanban-grid'); + + var headers = grid.querySelectorAll('.kanban-col-header'); + var refNode = headers.length ? headers[headers.length - 1].nextSibling : null; + + var hdr = document.createElement('div'); + hdr.className = 'kanban-col-header'; + hdr.dataset.colId = col.id; + hdr.innerHTML = '' + esc(col.name) + ''; + grid.insertBefore(hdr, refNode); + + var laneHeaders = grid.querySelectorAll('.kanban-lane-header'); + laneHeaders.forEach(function (lh) { + var laneId = lh.dataset.laneId; + var cell = document.createElement('div'); + cell.className = 'kanban-cell'; + cell.dataset.colId = col.id; + cell.dataset.laneId = laneId; + var row = lh.parentNode; + row.appendChild(cell); + createCellSortable(cell); + }); + + applyGridTemplate(); + }, + removeColumn: function (colId) { + document.querySelector('.kanban-col-header[data-col-id="' + colId + '"]').remove(); + document.querySelectorAll('.kanban-cell[data-col-id="' + colId + '"]').forEach(function (el) { el.remove(); }); + KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.column_id) !== String(colId); }); + applyGridTemplate(); + }, + addLane: function (lane) { + var grid = document.getElementById('kanban-grid'); + var colHeaders = grid.querySelectorAll('.kanban-col-header'); + + var lh = document.createElement('div'); + lh.className = 'kanban-lane-header'; + lh.dataset.laneId = lane.id; + lh.innerHTML = + '' + + '' + esc(lane.name) + ''; + grid.appendChild(lh); + bindLaneHeaderToggle(lh); + + colHeaders.forEach(function (ch) { + var cell = document.createElement('div'); + cell.className = 'kanban-cell'; + cell.dataset.colId = ch.dataset.colId; + cell.dataset.laneId = lane.id; + grid.appendChild(cell); + createCellSortable(cell); + }); + + if (collapsedLaneIds[String(lane.id)]) { + setLaneCollapsed(lane.id, true); + } + + applyGridTemplate(); + }, + removeLane: function (laneId) { + document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"]').remove(); + document.querySelectorAll('.kanban-cell[data-lane-id="' + laneId + '"]').forEach(function (el) { el.remove(); }); + KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.swim_lane_id) !== String(laneId); }); + if (collapsedLaneIds[String(laneId)]) { + delete collapsedLaneIds[String(laneId)]; + saveCollapsedLaneIds(); + } + }, + renameColumn: function (colId, name) { + var hdr = document.querySelector('.kanban-col-header[data-col-id="' + colId + '"] .col-label'); + if (hdr) hdr.textContent = name; + }, + renameLane: function (laneId, name) { + var hdr = document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"] .lane-label'); + if (hdr) hdr.textContent = name; + } + }; + + applyGridTemplate(); + renderCards(); + initSortables(); + initJobSearch(); + initLaneHeaderToggles(); +})(); diff --git a/public/js/kanban-modal.js b/public/js/kanban-modal.js new file mode 100644 index 0000000..07300e7 --- /dev/null +++ b/public/js/kanban-modal.js @@ -0,0 +1,200 @@ +/* kanban-modal.js — card create/edit modal */ +(function () { + 'use strict'; + + var modal = document.getElementById('cardModal'); + var bsModal = new bootstrap.Modal(modal); + var titleEl = document.getElementById('cardModalLabel'); + var cardIdEl = document.getElementById('card-id'); + var colIdEl = document.getElementById('card-column-id'); + var laneIdEl = document.getElementById('card-lane-id'); + var jobNumEl = document.getElementById('card-job-number'); + var jobNameEl = document.getElementById('card-job-name'); + var errEl = document.getElementById('card-modal-error'); + var btnSave = document.getElementById('btn-save-card'); + var btnDelete = document.getElementById('btn-delete-card'); + + var custNameEl = document.getElementById('card-customer-name'); + var delivDateEl = document.getElementById('card-delivery-date'); + var qtyEl = document.getElementById('card-quantity'); + var notesEl = document.getElementById('card-notes'); + var fullNoteEl = document.getElementById('card-full-note'); + + var boardId = KANBAN.boardId; + + /* ── Helpers ─────────────────────────────────────────────── */ + function post(url, data, cb) { + var params = new URLSearchParams(); + Object.keys(data).forEach(function (k) { params.append(k, data[k]); }); + fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() }) + .then(function (r) { return r.json(); }) + .then(cb) + .catch(function (e) { showError('Network error: ' + e); }); + } + + function showError(msg) { + errEl.textContent = msg; + errEl.classList.remove('d-none'); + } + + function clearError() { + errEl.textContent = ''; + errEl.classList.add('d-none'); + } + + /* ── Open for create ──────────────────────────────────────── */ + function openCreate(bId, colId, laneId) { + titleEl.textContent = 'Add Card'; + cardIdEl.value = ''; + colIdEl.value = colId || ''; + laneIdEl.value = laneId || ''; + jobNumEl.value = ''; + jobNameEl.value = ''; + custNameEl.value = ''; + delivDateEl.value = ''; + qtyEl.value = ''; + notesEl.value = ''; + fullNoteEl.value = ''; + btnDelete.classList.add('d-none'); + clearError(); + bsModal.show(); + jobNumEl.focus(); + } + + /* ── Open for edit ───────────────────────────────────────── */ + function openEdit(id, colId, laneId, jobNum, jobName, custName, delivDate, qty, notes, fullNote) { + titleEl.textContent = 'Edit Card'; + cardIdEl.value = id; + colIdEl.value = colId; + laneIdEl.value = laneId; + jobNumEl.value = jobNum || ''; + jobNameEl.value = jobName || ''; + custNameEl.value = custName || ''; + delivDateEl.value = delivDate || ''; + qtyEl.value = qty || ''; + notesEl.value = notes || ''; + fullNoteEl.value = fullNote || ''; + btnDelete.classList.remove('d-none'); + clearError(); + bsModal.show(); + jobNumEl.focus(); + } + + /* ── Save ─────────────────────────────────────────────────── */ + btnSave.addEventListener('click', function () { + clearError(); + var id = cardIdEl.value; + var colId = colIdEl.value; + var laneId = laneIdEl.value; + var jNum = jobNumEl.value.trim(); + var jName = jobNameEl.value.trim(); + var cust = custNameEl.value.trim(); + var dDate = delivDateEl.value; + var qty = qtyEl.value.trim(); + var notes = notesEl.value.trim(); + var fullNote = fullNoteEl.value; + + if (!jNum && !jName) { + showError('Enter at least a job number or job name.'); + return; + } + + if (id) { + // Update existing + post('/cards/' + id, { job_number: jNum, job_name: jName, customer_name: cust, delivery_date: dDate, quantity: qty, notes: notes, full_note: fullNote }, function (res) { + if (res.ok) { + bsModal.hide(); + window.KanbanBoard.onCardUpdated(id, res); + } else { + showError(res.error || 'Save failed.'); + } + }); + } else { + // Create new — if no col/lane selected show column/lane picker + if (!colId || !laneId) { + showError('Please choose a column and swim lane first.'); + return; + } + post('/cards', { + board_id: boardId, + column_id: colId, + swim_lane_id: laneId, + job_number: jNum, + job_name: jName, + customer_name: cust, + delivery_date: dDate, + quantity: qty, + notes: notes, + full_note: fullNote + }, function (res) { + if (res.ok) { + bsModal.hide(); + window.KanbanBoard.onCardCreated(res); + } else { + showError(res.error || 'Save failed.'); + } + }); + } + }); + + /* ── Delete ──────────────────────────────────────────────── */ + btnDelete.addEventListener('click', function () { + if (!confirm('Delete this card?')) return; + var id = cardIdEl.value; + post('/cards/' + id + '/delete', {}, function (res) { + if (res.ok) { + bsModal.hide(); + window.KanbanBoard.onCardDeleted(id); + } else { + showError(res.error || 'Delete failed.'); + } + }); + }); + + /* ── Column/Lane picker when Add Card clicked with no cell ── */ + // Populated lazily from board data + modal.addEventListener('shown.bs.modal', function () { + if (!cardIdEl.value && (!colIdEl.value || !laneIdEl.value)) { + injectPicker(); + } + }); + + function injectPicker() { + if (document.getElementById('card-picker')) return; + + var picker = document.createElement('div'); + picker.id = 'card-picker'; + picker.className = 'row g-2 mb-3'; + + var colSel = ''; + + document.querySelectorAll('.kanban-col-header').forEach(function (el) { + colSel += ''; + }); + document.querySelectorAll('.kanban-lane-header').forEach(function (el) { + laneSel += ''; + }); + + colSel += ''; + laneSel += ''; + + picker.innerHTML = + '
' + colSel + '
' + + '
' + laneSel + '
'; + + var first = document.getElementById('card-job-number').closest('.mb-3'); + modal.querySelector('.modal-body').insertBefore(picker, first); + + document.getElementById('pick-col').addEventListener('change', function () { + colIdEl.value = this.value; + }); + document.getElementById('pick-lane').addEventListener('change', function () { + laneIdEl.value = this.value; + }); + } + + /* ── Public API ──────────────────────────────────────────── */ + window.KanbanModal = { openCreate: openCreate, openEdit: openEdit }; + +})(); diff --git a/public/js/kanban-settings.js b/public/js/kanban-settings.js new file mode 100644 index 0000000..bbd2f6c --- /dev/null +++ b/public/js/kanban-settings.js @@ -0,0 +1,235 @@ +/* kanban-settings.js — settings panel: add/rename/delete/reorder columns and lanes */ +(function () { + 'use strict'; + + var boardId = KANBAN.boardId; + var panel = document.getElementById('settings-panel'); + var overlay = document.getElementById('settings-overlay'); + + /* ── Panel open/close ────────────────────────────────────── */ + document.getElementById('btn-settings').addEventListener('click', openPanel); + document.getElementById('btn-close-settings').addEventListener('click', closePanel); + overlay.addEventListener('click', closePanel); + + function openPanel() { + panel.classList.add('open'); + overlay.classList.remove('d-none'); + } + function closePanel() { + panel.classList.remove('open'); + overlay.classList.add('d-none'); + } + + /* ── Helpers ─────────────────────────────────────────────── */ + function post(url, data, cb) { + var params = new URLSearchParams(); + Object.keys(data).forEach(function (k) { params.append(k, data[k]); }); + fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() }) + .then(function (r) { return r.json(); }) + .then(cb) + .catch(function (e) { console.error(url, e); }); + } + + function postJson(url, payload, cb) { + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }).then(function (r) { return r.json(); }).then(cb) + .catch(function (e) { console.error(url, e); }); + } + + function collectOrder(listId) { + return Array.from(document.querySelectorAll('#' + listId + ' li')).map(function (li, idx) { + return { id: parseInt(li.dataset.id), position: idx }; + }); + } + + function buildListItem(id, name, editClass, deleteClass, labelClass) { + var li = document.createElement('li'); + li.className = 'list-group-item d-flex align-items-center gap-2 py-2'; + li.dataset.id = id; + li.innerHTML = + '' + + '' + esc(name) + '' + + '' + + ''; + return li; + } + + function esc(s) { + return String(s) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + /* ── Sortable reorder ─────────────────────────────────────── */ + function initSortable(listId, reorderUrl) { + var el = document.getElementById(listId); + Sortable.create(el, { + handle: '.drag-handle', + animation: 150, + onEnd: function () { + postJson(reorderUrl, collectOrder(listId), function (res) { + if (!res.ok) console.error('Reorder failed', res); + }); + } + }); + } + + initSortable('col-list', '/columns/reorder'); + initSortable('lane-list', '/swimlanes/reorder'); + + /* ═══════════════════════════════════════════════════════════ + COLUMNS + ═══════════════════════════════════════════════════════════ */ + + /* ── Add column ───────────────────────────────────────────── */ + document.getElementById('btn-add-column').addEventListener('click', function () { + document.getElementById('col-add-form').classList.remove('d-none'); + document.getElementById('col-add-input').focus(); + }); + document.getElementById('btn-col-add-cancel').addEventListener('click', function () { + document.getElementById('col-add-form').classList.add('d-none'); + document.getElementById('col-add-input').value = ''; + }); + document.getElementById('btn-col-add-save').addEventListener('click', function () { + var name = document.getElementById('col-add-input').value.trim(); + if (!name) return; + post('/columns', { board_id: boardId, name: name }, function (res) { + if (!res.ok) { alert(res.error || 'Failed'); return; } + document.getElementById('col-add-form').classList.add('d-none'); + document.getElementById('col-add-input').value = ''; + var li = buildListItem(res.id, res.name, 'btn-edit-col', 'btn-delete-col', 'col-label-text'); + document.getElementById('col-list').appendChild(li); + bindColItem(li); + window.KanbanBoard.addColumn(res); + }); + }); + + /* ── Bind edit/delete on existing column items ─────────────── */ + function bindColItem(li) { + li.querySelector('.btn-edit-col').addEventListener('click', function () { + startRename(li, '.col-label-text', function (newName, done) { + post('/columns/' + li.dataset.id, { name: newName }, function (res) { + if (res.ok) { + done(true); + window.KanbanBoard.renameColumn(li.dataset.id, newName); + } else { + done(false); + alert(res.error || 'Rename failed'); + } + }); + }); + }); + li.querySelector('.btn-delete-col').addEventListener('click', function () { + if (!confirm('Delete this column and all its cards?')) return; + post('/columns/' + li.dataset.id + '/delete', {}, function (res) { + if (res.ok) { + window.KanbanBoard.removeColumn(li.dataset.id); + li.remove(); + } else { + alert(res.error || 'Delete failed'); + } + }); + }); + } + + document.querySelectorAll('#col-list li').forEach(bindColItem); + + /* ═══════════════════════════════════════════════════════════ + SWIM LANES + ═══════════════════════════════════════════════════════════ */ + + /* ── Add lane ─────────────────────────────────────────────── */ + document.getElementById('btn-add-lane').addEventListener('click', function () { + document.getElementById('lane-add-form').classList.remove('d-none'); + document.getElementById('lane-add-input').focus(); + }); + document.getElementById('btn-lane-add-cancel').addEventListener('click', function () { + document.getElementById('lane-add-form').classList.add('d-none'); + document.getElementById('lane-add-input').value = ''; + }); + document.getElementById('btn-lane-add-save').addEventListener('click', function () { + var name = document.getElementById('lane-add-input').value.trim(); + if (!name) return; + post('/swimlanes', { board_id: boardId, name: name }, function (res) { + if (!res.ok) { alert(res.error || 'Failed'); return; } + document.getElementById('lane-add-form').classList.add('d-none'); + document.getElementById('lane-add-input').value = ''; + var li = buildListItem(res.id, res.name, 'btn-edit-lane', 'btn-delete-lane', 'lane-label-text'); + document.getElementById('lane-list').appendChild(li); + bindLaneItem(li); + window.KanbanBoard.addLane(res); + }); + }); + + /* ── Bind edit/delete on existing lane items ──────────────── */ + function bindLaneItem(li) { + li.querySelector('.btn-edit-lane').addEventListener('click', function () { + startRename(li, '.lane-label-text', function (newName, done) { + post('/swimlanes/' + li.dataset.id, { name: newName }, function (res) { + if (res.ok) { + done(true); + window.KanbanBoard.renameLane(li.dataset.id, newName); + } else { + done(false); + alert(res.error || 'Rename failed'); + } + }); + }); + }); + li.querySelector('.btn-delete-lane').addEventListener('click', function () { + if (!confirm('Delete this swim lane and all its cards?')) return; + post('/swimlanes/' + li.dataset.id + '/delete', {}, function (res) { + if (res.ok) { + window.KanbanBoard.removeLane(li.dataset.id); + li.remove(); + } else { + alert(res.error || 'Delete failed'); + } + }); + }); + } + + document.querySelectorAll('#lane-list li').forEach(bindLaneItem); + + /* ── Inline rename helper ─────────────────────────────────── */ + function startRename(li, labelSel, saveCb) { + var span = li.querySelector(labelSel); + var oldName = span.textContent.trim(); + var input = document.createElement('input'); + input.type = 'text'; + input.className = 'form-control form-control-sm inline-rename flex-grow-1'; + input.value = oldName; + span.replaceWith(input); + input.focus(); + input.select(); + + function commit() { + var newName = input.value.trim(); + if (!newName || newName === oldName) { + abort(); + return; + } + saveCb(newName, function (ok) { + var replacement = document.createElement('span'); + replacement.className = labelSel.replace('.', '') + ' flex-grow-1'; + replacement.textContent = ok ? newName : oldName; + input.replaceWith(replacement); + }); + } + function abort() { + var replacement = document.createElement('span'); + replacement.className = labelSel.replace('.', '') + ' flex-grow-1'; + replacement.textContent = oldName; + input.replaceWith(replacement); + } + input.addEventListener('blur', commit); + input.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { e.preventDefault(); commit(); } + if (e.key === 'Escape') { e.preventDefault(); abort(); } + }); + } + +})(); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..cb39e23 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,45 @@ +get('/', [HomeController::class, 'index']); + +// Boards +$router->get('/boards', [BoardsController::class, 'index']); +$router->get('/boards/create', [BoardsController::class, 'create']); +$router->post('/boards', [BoardsController::class, 'store']); +$router->get('/board/{slug}', [BoardsController::class, 'show']); +$router->get('/board/{slug}/edit', [BoardsController::class, 'edit']); +$router->post('/board/{slug}/update', [BoardsController::class, 'update']); +$router->post('/board/{slug}/delete', [BoardsController::class, 'destroy']); + +// Cards (JSON API) +$router->post('/cards', [CardsController::class, 'store']); +$router->post('/cards/{id}/move', [CardsController::class, 'move']); +$router->post('/cards/{id}/delete', [CardsController::class, 'destroy']); +$router->post('/cards/{id}', [CardsController::class, 'update']); + +// Columns (JSON API) — /columns/reorder MUST be before /columns/{id} +$router->post('/columns/reorder', [ColumnsController::class, 'reorder']); +$router->post('/columns/{id}/delete', [ColumnsController::class, 'destroy']); +$router->post('/columns/{id}', [ColumnsController::class, 'update']); +$router->post('/columns', [ColumnsController::class, 'store']); + +// Swim lanes (JSON API) — /swimlanes/reorder MUST be before /swimlanes/{id} +$router->post('/swimlanes/reorder', [SwimLanesController::class, 'reorder']); +$router->post('/swimlanes/{id}/delete', [SwimLanesController::class, 'destroy']); +$router->post('/swimlanes/{id}', [SwimLanesController::class, 'update']); +$router->post('/swimlanes', [SwimLanesController::class, 'store']); + +// Auth (Keycloak SSO) +$router->get('/auth/login', [AuthController::class, 'login']); +$router->get('/auth/callback', [AuthController::class, 'callback']); +$router->get('/auth/logout', [AuthController::class, 'logout']); diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..ff39152 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,18 @@ +# 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 --seed +php scripts/seed_employees.php 1000 +``` + +Guidelines: + +- Put CLI-only PHP entrypoints here. +- Keep reusable logic in `core/`, `app/`, or `database/`. +- Let scripts stay thin and call into application classes or helper functions. diff --git a/scripts/migrate.php b/scripts/migrate.php new file mode 100644 index 0000000..394781b --- /dev/null +++ b/scripts/migrate.php @@ -0,0 +1,105 @@ +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; + } + + if (in_array('--seed', $options, true)) { + require __DIR__ . '/../database/seed_employees.php'; + seed_employees(1000, true); + } + + echo "Fresh migration run complete." . PHP_EOL; + exit(0); + + case 'help': + default: + echo "Migration CLI" . PHP_EOL; + echo "Usage:" . PHP_EOL; + echo " php scripts/migrate.php up" . PHP_EOL; + echo " php scripts/migrate.php down [steps]" . PHP_EOL; + echo " php scripts/migrate.php status" . PHP_EOL; + echo " php scripts/migrate.php make " . PHP_EOL; + echo " php scripts/migrate.php fresh [--seed]" . PHP_EOL; + exit(0); + } +} catch (Throwable $exception) { + fwrite(STDERR, $exception->getMessage() . PHP_EOL); + exit(1); +} diff --git a/scripts/seed_employees.php b/scripts/seed_employees.php new file mode 100644 index 0000000..2d68499 --- /dev/null +++ b/scripts/seed_employees.php @@ -0,0 +1,8 @@ +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); +} + +$employeePage = (new Dispatcher($router, $app))->dispatch(new Request([], [], [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => '/employees', +])); + +if ($employeePage->status() !== 200) { + echo "FAIL: expected employee page status 200\n"; + exit(1); +} + +if (strpos($employeePage->content(), 'Add Employee') === false) { + echo "FAIL: employee page did not render form content\n"; + exit(1); +} + +$employeeData = (new Dispatcher($router, $app))->dispatch(new Request([ + 'search' => '', +], [], [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => '/employees/data', +])); + +if ($employeeData->status() !== 200) { + echo "FAIL: expected employee data status 200\n"; + exit(1); +} + +if (strpos($employeeData->content(), '[') === false) { + echo "FAIL: employee data endpoint did not return JSON array content\n"; + exit(1); +} + +echo "PASS: migration manager and route dispatch work\n";