20 Commits

Author SHA1 Message Date
72fb80b673 Add tests for town sync job and search endpoint
All checks were successful
linter / quality (pull_request) Successful in 1m37s
security / Dependency Audit (pull_request) Successful in 1m36s
security / Static Analysis (pull_request) Successful in 1m25s
tests / ci (8.4) (pull_request) Successful in 1m57s
tests / ci (8.5) (pull_request) Successful in 1m31s
- SyncTownsTest: covers API syncing, pagination, upsert, retired towns, error logging, and success logging
- TownSearchTest: covers auth requirement, min query length, prefix matching, state filtering, retired town exclusion, result limit, response shape, and ordering
- Update TownFactory with retired() and inState() states
2026-03-06 04:22:49 +00:00
a37ada96e2 Add nightly town sync from ArcGIS API with autocomplete
All checks were successful
linter / quality (pull_request) Successful in 1m21s
security / Dependency Audit (pull_request) Successful in 1m25s
security / Static Analysis (pull_request) Successful in 1m49s
tests / ci (8.4) (pull_request) Successful in 1m23s
tests / ci (8.5) (pull_request) Successful in 1m27s
- Add towns table with town_pid, town_name, state, population, town_class, date_retired
- Add AustralianState enum with label and abbreviation helpers
- Add Town model with active() and search() scopes
- Add SyncTowns job that paginates ArcGIS API and upserts all 1977 towns
- Schedule SyncTowns to run nightly at 02:00
- Add /towns/search endpoint returning JSON suggestions filtered by name and state
- Add Alpine-powered autocomplete on origin/destination fields in create form
- Add state filter dropdown in journeys card header to narrow autocomplete results
2026-03-06 04:16:55 +00:00
a4a31c97e3 Merge pull request 'Update project config and remove tailwindcss skill' (#10) from feature/ldap-email-bootstrap into master
Some checks failed
linter / quality (push) Has been cancelled
security / Dependency Audit (push) Has been cancelled
security / Static Analysis (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Reviewed-on: #10
2026-03-06 12:02:20 +08:00
2ef5014919 Merge pull request 'Default Bootstrap theme to dark mode' (#9) from feature/dark-mode-default into master
Some checks failed
linter / quality (push) Has been cancelled
security / Dependency Audit (push) Has been cancelled
security / Static Analysis (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Reviewed-on: #9
2026-03-06 12:01:57 +08:00
de6e7e6632 Update project config and remove tailwindcss skill
All checks were successful
linter / quality (pull_request) Successful in 1m39s
security / Dependency Audit (pull_request) Successful in 2m6s
security / Static Analysis (pull_request) Successful in 1m25s
tests / ci (8.4) (pull_request) Successful in 2m5s
tests / ci (8.5) (pull_request) Successful in 1m34s
2026-03-06 03:58:59 +00:00
e07984fa97 Default Bootstrap theme to dark mode
All checks were successful
linter / quality (pull_request) Successful in 1m51s
security / Dependency Audit (pull_request) Successful in 1m24s
security / Static Analysis (pull_request) Successful in 1m53s
tests / ci (8.4) (pull_request) Successful in 1m28s
tests / ci (8.5) (pull_request) Successful in 1m54s
- Change default theme from light to dark for first-time visitors
- Fix Alpine reactive theme toggle so icons and data-bs-theme update correctly
- Remove hardcoded bg-light classes that prevented dark mode from applying
- Fix broken duplicate Bootstrap bundle import in app.js
2026-03-06 03:54:34 +00:00
dd3c623bfc Merge pull request 'Seed OpenLDAP with bootstrap users including email addresses' (#8) from feature/ldap-email-bootstrap into master
All checks were successful
linter / quality (push) Successful in 1m34s
security / Dependency Audit (push) Successful in 1m24s
security / Static Analysis (push) Successful in 2m42s
tests / ci (8.4) (push) Successful in 1m23s
tests / ci (8.5) (push) Successful in 1m51s
Reviewed-on: #8
2026-03-06 10:48:31 +08:00
9f47e6e2c8 Seed OpenLDAP with bootstrap users including email addresses
All checks were successful
linter / quality (pull_request) Successful in 1m20s
security / Dependency Audit (pull_request) Successful in 2m22s
security / Static Analysis (pull_request) Successful in 1m42s
tests / ci (8.4) (pull_request) Successful in 1m25s
tests / ci (8.5) (pull_request) Successful in 1m24s
Add docker/openldap/bootstrap.ldif which creates an ou=people OU and
three test users (admin, approver, staff) each with a mail attribute,
matching the database seeder. Mount the LDIF into the openldap container
and add --copy-service so it is loaded on first boot.
2026-03-06 02:47:54 +00:00
fd53a28f03 Merge pull request 'Fix LDAP authentication failures' (#7) from worktree-ldap-issues into master
Some checks failed
linter / quality (push) Successful in 1m23s
security / Dependency Audit (push) Successful in 1m29s
security / Static Analysis (push) Successful in 1m42s
tests / ci (8.5) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
Reviewed-on: #7
2026-03-06 10:35:45 +08:00
bbcffe64b7 Fix LDAP authentication failures
All checks were successful
linter / quality (pull_request) Successful in 1m35s
security / Dependency Audit (pull_request) Successful in 1m19s
security / Static Analysis (pull_request) Successful in 1m32s
tests / ci (8.4) (pull_request) Successful in 2m36s
tests / ci (8.5) (pull_request) Successful in 1m37s
- Add missing LDAP env vars to .env.example (host, base DN, bind credentials)
- Use 'uid' instead of 'username' as the LDAP lookup attribute in Auth::attempt
- Override getLdapGuidColumn/getLdapDomainColumn in User model to match
  migration column names (ldap_guid/ldap_domain vs default guid/domain)
2026-03-06 02:34:46 +00:00
98abc637c8 Merge pull request 'Fix Bootstrap light/dark theming' (#6) from feature/bootstrap-theming into master
All checks were successful
linter / quality (push) Successful in 1m15s
security / Dependency Audit (push) Successful in 1m29s
security / Static Analysis (push) Successful in 1m25s
tests / ci (8.4) (push) Successful in 1m58s
tests / ci (8.5) (push) Successful in 1m28s
Reviewed-on: #6
2026-03-06 10:17:48 +08:00
236ba9558c Fix Bootstrap light/dark theming across all layouts
All checks were successful
linter / quality (pull_request) Successful in 1m52s
security / Dependency Audit (pull_request) Successful in 1m27s
security / Static Analysis (pull_request) Successful in 1m44s
tests / ci (8.4) (pull_request) Successful in 1m40s
tests / ci (8.5) (pull_request) Successful in 1m49s
- Make dark mode toggle reactive using Alpine x-data theme property
- Apply saved theme on page load via data-bs-theme attribute binding
- Remove hardcoded bg-light classes that broke dark mode styling
- Fix duplicate/broken Bootstrap bundle import in app.js
2026-03-06 02:16:45 +00:00
5f0b4218ae Merge pull request 'Add phpLDAPadmin for LDAP user management' (#5) from feature/phpldapadmin into master
All checks were successful
linter / quality (push) Successful in 1m20s
security / Dependency Audit (push) Successful in 1m30s
security / Static Analysis (push) Successful in 1m37s
tests / ci (8.4) (push) Successful in 1m27s
tests / ci (8.5) (push) Successful in 1m29s
Reviewed-on: #5
2026-03-06 09:43:41 +08:00
38b1dd0f4d Add phpLDAPadmin service for LDAP user management
All checks were successful
linter / quality (pull_request) Successful in 1m23s
security / Dependency Audit (pull_request) Successful in 1m20s
security / Static Analysis (pull_request) Successful in 1m26s
tests / ci (8.4) (pull_request) Successful in 1m37s
tests / ci (8.5) (pull_request) Successful in 1m30s
Accessible at localhost:8085 (configurable via FORWARD_PHPLDAPADMIN_PORT).
Login with cn=admin,dc=travel,dc=local and the LDAP admin password.
2026-03-06 01:42:03 +00:00
2b9b2fd32d Merge pull request 'Fix migration order for approval tables' (#4) from fix/migration-order into master
Some checks failed
linter / quality (push) Successful in 1m23s
security / Static Analysis (push) Has been cancelled
security / Dependency Audit (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Reviewed-on: #4
2026-03-06 09:38:00 +08:00
35f3af9efe Fix migration order so approval_workflows runs before approval_steps
All checks were successful
linter / quality (pull_request) Successful in 1m21s
tests / ci (8.4) (pull_request) Successful in 1m25s
tests / ci (8.5) (pull_request) Successful in 1m28s
The approval_steps migration references approval_workflows via a
foreign key, but both had the same timestamp (020959), causing
steps to run first alphabetically and fail. Moved workflows to
020958 so it runs first.
2026-03-06 01:37:11 +00:00
95d953cf13 Merge pull request 'Consolidate Docker Compose into single compose.yaml' (#3) from fix/consolidate-docker-compose into master
All checks were successful
linter / quality (push) Successful in 1m32s
security / Dependency Audit (push) Successful in 1m22s
security / Static Analysis (push) Successful in 1m20s
tests / ci (8.4) (push) Successful in 1m31s
tests / ci (8.5) (push) Successful in 1m26s
Reviewed-on: #3
2026-03-06 09:13:12 +08:00
bbf8acef3c Consolidate Docker Compose into single compose.yaml
All checks were successful
linter / quality (pull_request) Successful in 1m31s
security / Dependency Audit (pull_request) Successful in 1m21s
security / Static Analysis (pull_request) Successful in 1m23s
tests / ci (8.4) (pull_request) Successful in 1m24s
tests / ci (8.5) (pull_request) Successful in 1m32s
Merge openldap service from docker-compose.yml into compose.yaml
and remove the duplicate file to fix the Docker Compose warning
about multiple config files.
2026-03-06 01:11:27 +00:00
d2f7812432 Merge pull request 'Add code security checks workflow' (#2) from feature/security-checks into master
All checks were successful
linter / quality (push) Successful in 1m30s
security / Dependency Audit (push) Successful in 1m26s
security / Static Analysis (push) Successful in 1m33s
tests / ci (8.4) (push) Successful in 1m30s
tests / ci (8.5) (push) Successful in 1m29s
Reviewed-on: #2
2026-03-05 13:54:25 +08:00
39fcb55904 Add security workflow with composer audit, npm audit, and PHPStan
All checks were successful
linter / quality (pull_request) Successful in 1m23s
security / Dependency Audit (pull_request) Successful in 1m21s
security / Static Analysis (pull_request) Successful in 1m32s
tests / ci (8.4) (pull_request) Successful in 1m42s
tests / ci (8.5) (pull_request) Successful in 1m31s
Adds a new GitHub Actions workflow that runs dependency vulnerability
checks for both PHP and Node packages, plus PHPStan static analysis
with Larastan. Includes a baseline for existing errors.
2026-03-05 05:51:21 +00:00
32 changed files with 1099 additions and 651 deletions

View File

@@ -1,129 +0,0 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode

View File

@@ -1,129 +0,0 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode

View File

@@ -63,3 +63,10 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
LDAP_HOST=openldap
LDAP_USERNAME="cn=admin,dc=travel,dc=local"
LDAP_PASSWORD=adminpassword
LDAP_PORT=389
LDAP_BASE_DN="dc=travel,dc=local"
LDAP_LOGGING=true

67
.github/workflows/security.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: security
on:
push:
branches:
- develop
- main
- master
pull_request:
branches:
- develop
- main
- master
jobs:
audit:
name: Dependency Audit
runs-on: ubuntu-latest
environment: Testing
steps:
- uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Add Flux Credentials Loaded From ENV
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
- name: Install PHP Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Install Node Dependencies
run: npm install
- name: Composer Audit
run: composer audit
- name: NPM Audit
run: npm audit --omit=dev
phpstan:
name: Static Analysis
runs-on: ubuntu-latest
environment: Testing
steps:
- uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- name: Add Flux Credentials Loaded From ENV
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
- name: Install Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Run PHPStan
run: vendor/bin/phpstan analyse --no-progress

View File

@@ -9,25 +9,24 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.5.3 - php - 8.4.1
- filament/filament (FILAMENT) - v5 - filament/filament (FILAMENT) - v5
- laravel/framework (LARAVEL) - v12 - laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0
- livewire/livewire (LIVEWIRE) - v4 - livewire/livewire (LIVEWIRE) - v4
- larastan/larastan (LARASTAN) - v3
- laravel/boost (BOOST) - v2 - laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0 - laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1 - laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1 - laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1 - laravel/sail (SAIL) - v1
- phpunit/phpunit (PHPUNIT) - v11 - phpunit/phpunit (PHPUNIT) - v11
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation ## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `livewire-development` — Develops reactive Livewire 4 components. Activates when creating, updating, or modifying Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives; adding real-time updates, loading states, or reactivity; debugging component behavior; writing Livewire tests; or when the user mentions Livewire, component, counter, or reactive UI. - `livewire-development` — Develops reactive Livewire 4 components. Activates when creating, updating, or modifying Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives; adding real-time updates, loading states, or reactivity; debugging component behavior; writing Livewire tests; or when the user mentions Livewire, component, counter, or reactive UI.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions ## Conventions
@@ -133,6 +132,13 @@ protected function isAccessible(User $user, ?string $path = null): bool
- Add useful array shape type definitions when appropriate. - Add useful array shape type definitions when appropriate.
=== tests rules ===
# Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
=== laravel/core rules === === laravel/core rules ===
# Do Things the Laravel Way # Do Things the Laravel Way
@@ -247,12 +253,4 @@ protected function isAccessible(User $user, ?string $path = null): bool
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`. - To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file). - To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
=== tailwindcss/core rules ===
# Tailwind CSS
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
</laravel-boost-guidelines> </laravel-boost-guidelines>

View File

@@ -9,25 +9,24 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.5.3 - php - 8.4.1
- filament/filament (FILAMENT) - v5 - filament/filament (FILAMENT) - v5
- laravel/framework (LARAVEL) - v12 - laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0
- livewire/livewire (LIVEWIRE) - v4 - livewire/livewire (LIVEWIRE) - v4
- larastan/larastan (LARASTAN) - v3
- laravel/boost (BOOST) - v2 - laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0 - laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1 - laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1 - laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1 - laravel/sail (SAIL) - v1
- phpunit/phpunit (PHPUNIT) - v11 - phpunit/phpunit (PHPUNIT) - v11
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation ## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `livewire-development` — Develops reactive Livewire 4 components. Activates when creating, updating, or modifying Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives; adding real-time updates, loading states, or reactivity; debugging component behavior; writing Livewire tests; or when the user mentions Livewire, component, counter, or reactive UI. - `livewire-development` — Develops reactive Livewire 4 components. Activates when creating, updating, or modifying Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives; adding real-time updates, loading states, or reactivity; debugging component behavior; writing Livewire tests; or when the user mentions Livewire, component, counter, or reactive UI.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions ## Conventions
@@ -133,6 +132,13 @@ protected function isAccessible(User $user, ?string $path = null): bool
- Add useful array shape type definitions when appropriate. - Add useful array shape type definitions when appropriate.
=== tests rules ===
# Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
=== laravel/core rules === === laravel/core rules ===
# Do Things the Laravel Way # Do Things the Laravel Way
@@ -247,12 +253,4 @@ protected function isAccessible(User $user, ?string $path = null): bool
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`. - To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file). - To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
=== tailwindcss/core rules ===
# Tailwind CSS
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
</laravel-boost-guidelines> </laravel-boost-guidelines>

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Enums;
enum AustralianState: int
{
case NSW = 1;
case VIC = 2;
case QLD = 3;
case SA = 4;
case WA = 5;
case TAS = 6;
case NT = 7;
case ACT = 8;
public function label(): string
{
return match ($this) {
self::NSW => 'New South Wales',
self::VIC => 'Victoria',
self::QLD => 'Queensland',
self::SA => 'South Australia',
self::WA => 'Western Australia',
self::TAS => 'Tasmania',
self::NT => 'Northern Territory',
self::ACT => 'Australian Capital Territory',
};
}
public function abbreviation(): string
{
return match ($this) {
self::NSW => 'NSW',
self::VIC => 'VIC',
self::QLD => 'QLD',
self::SA => 'SA',
self::WA => 'WA',
self::TAS => 'TAS',
self::NT => 'NT',
self::ACT => 'ACT',
};
}
}

76
app/Jobs/SyncTowns.php Normal file
View File

@@ -0,0 +1,76 @@
<?php
namespace App\Jobs;
use App\Models\Town;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class SyncTowns implements ShouldQueue
{
use Queueable;
private const API_URL = 'https://services-ap1.arcgis.com/ypkPEy1AmwPKGNNv/arcgis/rest/services/Town_Point/FeatureServer/0/query';
private const PAGE_SIZE = 1000;
public int $timeout = 300;
public function handle(): void
{
$offset = 0;
$synced = 0;
do {
$response = Http::timeout(30)->get(self::API_URL, [
'outFields' => 'town_pid,town_name,state,population,town_class,date_retired',
'where' => '1=1',
'f' => 'json',
'resultOffset' => $offset,
'resultRecordCount' => self::PAGE_SIZE,
'orderByFields' => 'objectid ASC',
]);
if ($response->failed()) {
Log::error('SyncTowns: API request failed', ['offset' => $offset, 'status' => $response->status()]);
break;
}
$features = $response->json('features', []);
if (empty($features)) {
break;
}
$records = collect($features)->map(fn (array $feature) => [
'town_pid' => $feature['attributes']['town_pid'],
'town_name' => $feature['attributes']['town_name'],
'state' => $feature['attributes']['state'],
'population' => $feature['attributes']['population'],
'town_class' => $feature['attributes']['town_class'],
'date_retired' => isset($feature['attributes']['date_retired'])
? Carbon::createFromTimestampMs($feature['attributes']['date_retired'])
: null,
'updated_at' => now(),
'created_at' => now(),
])->toArray();
Town::upsert($records, uniqueBy: ['town_pid'], update: [
'town_name',
'state',
'population',
'town_class',
'date_retired',
'updated_at',
]);
$synced += count($features);
$offset += self::PAGE_SIZE;
} while (count($features) === self::PAGE_SIZE);
Log::info("SyncTowns: synced {$synced} towns.");
}
}

41
app/Models/Town.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use App\Enums\AustralianState;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Town extends Model
{
/** @use HasFactory<\Database\Factories\TownFactory> */
use HasFactory;
protected $fillable = [
'town_pid',
'town_name',
'state',
'population',
'town_class',
'date_retired',
];
protected function casts(): array
{
return [
'state' => AustralianState::class,
'date_retired' => 'datetime',
];
}
public function scopeActive(Builder $query): void
{
$query->whereNull('date_retired');
}
public function scopeSearch(Builder $query, string $term): void
{
$query->where('town_name', 'like', $term.'%');
}
}

View File

@@ -64,6 +64,16 @@ class User extends Authenticatable implements FilamentUser, LdapAuthenticatable
return $this->hasRole('administrator'); return $this->hasRole('administrator');
} }
public function getLdapGuidColumn(): string
{
return 'ldap_guid';
}
public function getLdapDomainColumn(): string
{
return 'ldap_domain';
}
public function emergencyContacts(): HasMany public function emergencyContacts(): HasMany
{ {
return $this->hasMany(EmergencyContact::class); return $this->hasMany(EmergencyContact::class);

View File

@@ -9,7 +9,6 @@
"nightwatch_mcp": false, "nightwatch_mcp": false,
"sail": false, "sail": false,
"skills": [ "skills": [
"livewire-development", "livewire-development"
"tailwindcss-development"
] ]
} }

View File

@@ -26,6 +26,7 @@ services:
- redis - redis
- selenium - selenium
- mailpit - mailpit
- openldap
mysql: mysql:
image: 'mysql:8.4' image: 'mysql:8.4'
ports: ports:
@@ -81,6 +82,52 @@ services:
- '${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025' - '${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025'
networks: networks:
- sail - sail
openldap:
image: 'osixia/openldap:1.5.0'
command: '--copy-service'
ports:
- '${FORWARD_LDAP_PORT:-389}:389'
- '${FORWARD_LDAPS_PORT:-636}:636'
environment:
LDAP_ORGANISATION: '${LDAP_ORGANISATION:-Travel App}'
LDAP_DOMAIN: '${LDAP_DOMAIN:-travel.local}'
LDAP_ADMIN_PASSWORD: '${LDAP_ADMIN_PASSWORD:-adminpassword}'
LDAP_CONFIG_PASSWORD: '${LDAP_CONFIG_PASSWORD:-configpassword}'
LDAP_READONLY_USER: 'true'
LDAP_READONLY_USER_USERNAME: '${LDAP_READONLY_USERNAME:-readonly}'
LDAP_READONLY_USER_PASSWORD: '${LDAP_READONLY_PASSWORD:-readonly}'
volumes:
- 'sail-ldap-data:/var/lib/ldap'
- 'sail-ldap-config:/etc/ldap/slapd.d'
- './docker/openldap/bootstrap.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/bootstrap.ldif'
networks:
- sail
healthcheck:
test:
- CMD
- ldapsearch
- '-x'
- '-H'
- 'ldap://localhost'
- '-b'
- 'dc=travel,dc=local'
- '-D'
- 'cn=admin,dc=travel,dc=local'
- '-w'
- '${LDAP_ADMIN_PASSWORD:-adminpassword}'
retries: 3
timeout: 5s
phpldapadmin:
image: 'osixia/phpldapadmin:latest'
ports:
- '${FORWARD_PHPLDAPADMIN_PORT:-8085}:80'
environment:
PHPLDAPADMIN_LDAP_HOSTS: openldap
PHPLDAPADMIN_HTTPS: 'false'
networks:
- sail
depends_on:
- openldap
networks: networks:
sail: sail:
driver: bridge driver: bridge
@@ -89,3 +136,7 @@ volumes:
driver: local driver: local
sail-redis: sail-redis:
driver: local driver: local
sail-ldap-data:
driver: local
sail-ldap-config:
driver: local

View File

@@ -17,12 +17,14 @@
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
"larastan/larastan": "^3.9",
"laravel/boost": "^2.0", "laravel/boost": "^2.0",
"laravel/pail": "^1.2.2", "laravel/pail": "^1.2.2",
"laravel/pint": "^1.24", "laravel/pint": "^1.24",
"laravel/sail": "^1.41", "laravel/sail": "^1.41",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6", "nunomaduro/collision": "^8.6",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^11.5.3" "phpunit/phpunit": "^11.5.3"
}, },
"autoload": { "autoload": {

188
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "dee637c924fba6db2c11a4a299a491b6", "content-hash": "5a44c97e5f49f06d06ce0246f320edf2",
"packages": [ "packages": [
{ {
"name": "anourvalar/eloquent-serialize", "name": "anourvalar/eloquent-serialize",
@@ -8592,6 +8592,137 @@
}, },
"time": "2025-04-30T06:54:44+00:00" "time": "2025-04-30T06:54:44+00:00"
}, },
{
"name": "iamcal/sql-parser",
"version": "v0.7",
"source": {
"type": "git",
"url": "https://github.com/iamcal/SQLParser.git",
"reference": "610392f38de49a44dab08dc1659960a29874c4b8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8",
"reference": "610392f38de49a44dab08dc1659960a29874c4b8",
"shasum": ""
},
"require-dev": {
"php-coveralls/php-coveralls": "^1.0",
"phpunit/phpunit": "^5|^6|^7|^8|^9"
},
"type": "library",
"autoload": {
"psr-4": {
"iamcal\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Cal Henderson",
"email": "cal@iamcal.com"
}
],
"description": "MySQL schema parser",
"support": {
"issues": "https://github.com/iamcal/SQLParser/issues",
"source": "https://github.com/iamcal/SQLParser/tree/v0.7"
},
"time": "2026-01-28T22:20:33+00:00"
},
{
"name": "larastan/larastan",
"version": "v3.9.3",
"source": {
"type": "git",
"url": "https://github.com/larastan/larastan.git",
"reference": "64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/larastan/larastan/zipball/64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65",
"reference": "64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65",
"shasum": ""
},
"require": {
"ext-json": "*",
"iamcal/sql-parser": "^0.7.0",
"illuminate/console": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/container": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/database": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/http": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/support": "^11.44.2 || ^12.4.1 || ^13",
"php": "^8.2",
"phpstan/phpstan": "^2.1.32"
},
"require-dev": {
"doctrine/coding-standard": "^13",
"laravel/framework": "^11.44.2 || ^12.7.2 || ^13",
"mockery/mockery": "^1.6.12",
"nikic/php-parser": "^5.4",
"orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11",
"orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11",
"phpstan/phpstan-deprecation-rules": "^2.0.1",
"phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8"
},
"suggest": {
"orchestra/testbench": "Using Larastan for analysing a package needs Testbench",
"phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically"
},
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"Larastan\\Larastan\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Can Vural",
"email": "can9119@gmail.com"
}
],
"description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel",
"keywords": [
"PHPStan",
"code analyse",
"code analysis",
"larastan",
"laravel",
"package",
"php",
"static analysis"
],
"support": {
"issues": "https://github.com/larastan/larastan/issues",
"source": "https://github.com/larastan/larastan/tree/v3.9.3"
},
"funding": [
{
"url": "https://github.com/canvural",
"type": "github"
}
],
"time": "2026-02-20T12:07:12+00:00"
},
{ {
"name": "laravel/boost", "name": "laravel/boost",
"version": "v2.2.2", "version": "v2.2.2",
@@ -9359,6 +9490,59 @@
}, },
"time": "2022-02-21T01:04:05+00:00" "time": "2022-02-21T01:04:05+00:00"
}, },
{
"name": "phpstan/phpstan",
"version": "2.1.40",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b",
"reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b",
"shasum": ""
},
"require": {
"php": "^7.4|^8.0"
},
"conflict": {
"phpstan/phpstan-shim": "*"
},
"bin": [
"phpstan",
"phpstan.phar"
],
"type": "library",
"autoload": {
"files": [
"bootstrap.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPStan - PHP Static Analysis Tool",
"keywords": [
"dev",
"static analysis"
],
"support": {
"docs": "https://phpstan.org/user-guide/getting-started",
"forum": "https://github.com/phpstan/phpstan/discussions",
"issues": "https://github.com/phpstan/phpstan/issues",
"security": "https://github.com/phpstan/phpstan/security/policy",
"source": "https://github.com/phpstan/phpstan-src"
},
"funding": [
{
"url": "https://github.com/ondrejmirtes",
"type": "github"
},
{
"url": "https://github.com/phpstan",
"type": "github"
}
],
"time": "2026-02-23T15:04:35+00:00"
},
{ {
"name": "phpunit/php-code-coverage", "name": "phpunit/php-code-coverage",
"version": "11.0.12", "version": "11.0.12",
@@ -10989,5 +11173,5 @@
"php": "^8.2" "php": "^8.2"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.9.0" "plugin-api-version": "2.6.0"
} }

View File

@@ -0,0 +1,42 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Town>
*/
class TownFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'town_pid' => 'TWN'.$this->faker->unique()->numerify('####'),
'town_name' => $this->faker->city(),
'state' => $this->faker->numberBetween(1, 8),
'population' => $this->faker->numberBetween(100, 500000),
'town_class' => $this->faker->numberBetween(0, 5),
'date_retired' => null,
];
}
public function retired(): static
{
return $this->state(fn (array $attributes) => [
'date_retired' => now()->subDays(30),
]);
}
public function inState(\App\Enums\AustralianState $state): static
{
return $this->state(fn (array $attributes) => [
'state' => $state->value,
]);
}
}

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('towns', function (Blueprint $table) {
$table->id();
$table->string('town_pid', 15)->unique();
$table->string('town_name', 50);
$table->unsignedTinyInteger('state');
$table->unsignedInteger('population')->nullable();
$table->unsignedTinyInteger('town_class')->nullable();
$table->timestamp('date_retired')->nullable();
$table->timestamps();
$table->index(['town_name', 'state']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('towns');
}
};

View File

@@ -1,125 +0,0 @@
services:
laravel.test:
build:
context: ./vendor/laravel/sail/runtimes/8.4
dockerfile: Dockerfile
args:
WWWGROUP: '${WWWGROUP}'
image: sail-8.4/app
extra_hosts:
- 'host.docker.internal:host-gateway'
ports:
- '${APP_PORT:-80}:80'
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
environment:
WWWUSER: '${WWWUSER}'
LARAVEL_SAIL: 1
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
IGNITION_LOCAL_SITES_PATH: '${PWD}'
volumes:
- '.:/var/www/html'
networks:
- sail
depends_on:
- mysql
- redis
- mailpit
- openldap
mysql:
image: 'mysql/mysql-server:8.0'
ports:
- '${FORWARD_DB_PORT:-3306}:3306'
environment:
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
MYSQL_ROOT_HOST: '%'
MYSQL_DATABASE: '${DB_DATABASE}'
MYSQL_USER: '${DB_USERNAME}'
MYSQL_PASSWORD: '${DB_PASSWORD}'
MYSQL_ALLOW_EMPTY_PASSWORD: 1
volumes:
- 'sail-mysql:/var/lib/mysql'
- './vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh'
networks:
- sail
healthcheck:
test:
- CMD
- mysqladmin
- ping
- '-p${DB_PASSWORD}'
retries: 3
timeout: 5s
redis:
image: 'redis:alpine'
ports:
- '${FORWARD_REDIS_PORT:-6379}:6379'
volumes:
- 'sail-redis:/data'
networks:
- sail
healthcheck:
test:
- CMD
- redis-cli
- ping
retries: 3
timeout: 5s
mailpit:
image: 'axllent/mailpit:latest'
ports:
- '${FORWARD_MAILPIT_PORT:-1025}:1025'
- '${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025'
networks:
- sail
openldap:
image: 'osixia/openldap:1.5.0'
ports:
- '${FORWARD_LDAP_PORT:-389}:389'
- '${FORWARD_LDAPS_PORT:-636}:636'
environment:
LDAP_ORGANISATION: '${LDAP_ORGANISATION:-Travel App}'
LDAP_DOMAIN: '${LDAP_DOMAIN:-travel.local}'
LDAP_ADMIN_PASSWORD: '${LDAP_ADMIN_PASSWORD:-adminpassword}'
LDAP_CONFIG_PASSWORD: '${LDAP_CONFIG_PASSWORD:-configpassword}'
LDAP_READONLY_USER: 'true'
LDAP_READONLY_USER_USERNAME: '${LDAP_READONLY_USERNAME:-readonly}'
LDAP_READONLY_USER_PASSWORD: '${LDAP_READONLY_PASSWORD:-readonly}'
volumes:
- 'sail-ldap-data:/var/lib/ldap'
- 'sail-ldap-config:/etc/ldap/slapd.d'
networks:
- sail
healthcheck:
test:
- CMD
- ldapsearch
- '-x'
- '-H'
- 'ldap://localhost'
- '-b'
- 'dc=travel,dc=local'
- '-D'
- 'cn=admin,dc=travel,dc=local'
- '-w'
- '${LDAP_ADMIN_PASSWORD:-adminpassword}'
retries: 3
timeout: 5s
networks:
sail:
driver: bridge
volumes:
sail-mysql:
driver: local
sail-redis:
driver: local
sail-ldap-data:
driver: local
sail-ldap-config:
driver: local

View File

@@ -0,0 +1,46 @@
# People OU
dn: ou=people,dc=travel,dc=local
objectClass: organizationalUnit
ou: people
# Administrator
dn: uid=admin,ou=people,dc=travel,dc=local
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
cn: Administrator
sn: Administrator
uid: admin
mail: admin@travel.local
uidNumber: 1000
gidNumber: 1000
homeDirectory: /home/admin
userPassword: password
# Travel Approver
dn: uid=approver,ou=people,dc=travel,dc=local
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
cn: Travel Approver
sn: Approver
uid: approver
mail: approver@travel.local
uidNumber: 1001
gidNumber: 1000
homeDirectory: /home/approver
userPassword: password
# Staff Member
dn: uid=staff,ou=people,dc=travel,dc=local
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
cn: Staff Member
sn: Member
uid: staff
mail: staff@travel.local
uidNumber: 1002
gidNumber: 1000
homeDirectory: /home/staff
userPassword: password

216
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{ {
"name": "travel", "name": "html",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
@@ -439,7 +439,6 @@
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/popperjs" "url": "https://opencollective.com/popperjs"
@@ -970,16 +969,6 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1305,146 +1294,6 @@
"vite": "^7.0.0" "vite": "^7.0.0"
} }
}, },
"node_modules/lightningcss-android-arm64": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz",
"integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz",
"integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz",
"integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz",
"integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz",
"integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==",
"cpu": [
"arm"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz",
"integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz",
"integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": { "node_modules/lightningcss-linux-x64-gnu": {
"version": "1.31.1", "version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
@@ -1465,66 +1314,6 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
"integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz",
"integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz",
"integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1584,7 +1373,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -1611,7 +1399,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -1800,7 +1587,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",

67
phpstan-baseline.neon Normal file
View File

@@ -0,0 +1,67 @@
parameters:
ignoreErrors:
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$email\.$#'
identifier: property.notFound
count: 1
path: app/Jobs/SendApprovalDecisionEmail.php
-
message: '#^Cannot access property \$value on string\.$#'
identifier: property.nonObject
count: 1
path: app/Mail/ApprovalDecisionMail.php
-
message: '#^Result of && is always false\.$#'
identifier: booleanAnd.alwaysFalse
count: 2
path: app/Policies/TravelRequestPolicy.php
-
message: '#^Strict comparison using \=\=\= between string and App\\Enums\\TravelStatus\:\:Draft will always evaluate to false\.$#'
identifier: identical.alwaysFalse
count: 1
path: app/Policies/TravelRequestPolicy.php
-
message: '#^Strict comparison using \=\=\= between string and App\\Enums\\TravelStatus\:\:Pending will always evaluate to false\.$#'
identifier: identical.alwaysFalse
count: 1
path: app/Policies/TravelRequestPolicy.php
-
message: '#^Parameter \#1 \$action of method Filament\\Panel\:\:login\(\) expects array\<class\-string, string\>\|Closure\|string\|null, false given\.$#'
identifier: argument.type
count: 1
path: app/Providers/Filament/AdminPanelProvider.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#'
identifier: property.notFound
count: 1
path: app/Services/ApprovalService.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$role\.$#'
identifier: property.notFound
count: 1
path: app/Services/ApprovalService.php
-
message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:steps\(\)\.$#'
identifier: method.notFound
count: 1
path: app/Services/ApprovalService.php
-
message: '#^Parameter \#1 \$travelRequest of job class App\\Jobs\\SendApprovalDecisionEmail constructor expects App\\Models\\TravelRequest in App\\Jobs\\SendApprovalDecisionEmail\:\:dispatch\(\), Illuminate\\Database\\Eloquent\\Model\|null given\.$#'
identifier: argument.type
count: 2
path: app/Services/ApprovalService.php
-
message: '#^Parameter \#1 \$travelRequest of job class App\\Jobs\\SendApprovalRequestEmail constructor expects App\\Models\\TravelRequest in App\\Jobs\\SendApprovalRequestEmail\:\:dispatch\(\), Illuminate\\Database\\Eloquent\\Model\|null given\.$#'
identifier: argument.type
count: 1
path: app/Services/ApprovalService.php

9
phpstan.neon Normal file
View File

@@ -0,0 +1,9 @@
includes:
- vendor/larastan/larastan/extension.neon
- phpstan-baseline.neon
parameters:
paths:
- app/
level: 5

View File

@@ -1,4 +1,3 @@
import * as bootstrap from 'bootstrap'; import * as bootstrap from 'bootstrap';
import './../../node_modules/bootstrap/dist/js/bootstrap.bundle.min.js';
window.bootstrap = bootstrap; window.bootstrap = bootstrap;

View File

@@ -1,8 +1,9 @@
<!doctype html> <!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" x-data x-init=" <html lang="{{ str_replace('_', '-', app()->getLocale()) }}"
const saved = localStorage.getItem('theme'); x-data="{ theme: localStorage.getItem('theme') || 'dark' }"
if (saved) { document.documentElement.setAttribute('data-bs-theme', saved); } x-init="$watch('theme', val => { document.documentElement.setAttribute('data-bs-theme', val); localStorage.setItem('theme', val); }); document.documentElement.setAttribute('data-bs-theme', theme);"
"> :data-bs-theme="theme"
>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -43,16 +44,11 @@
<li class="nav-item me-2"> <li class="nav-item me-2">
<button <button
class="btn btn-sm btn-outline-light" class="btn btn-sm btn-outline-light"
x-on:click=" x-on:click="theme = theme === 'dark' ? 'light' : 'dark'"
const current = document.documentElement.getAttribute('data-bs-theme');
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-bs-theme', next);
localStorage.setItem('theme', next);
"
title="Toggle dark/light mode" title="Toggle dark/light mode"
> >
<span x-show="document.documentElement.getAttribute('data-bs-theme') !== 'dark'">🌙</span> <span x-show="theme !== 'dark'">🌙</span>
<span x-show="document.documentElement.getAttribute('data-bs-theme') === 'dark'">☀️</span> <span x-show="theme === 'dark'">☀️</span>
</button> </button>
</li> </li>
@auth @auth

View File

@@ -1,5 +1,9 @@
<!doctype html> <!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}"
x-data="{ theme: localStorage.getItem('theme') || 'dark' }"
x-init="document.documentElement.setAttribute('data-bs-theme', theme);"
:data-bs-theme="theme"
>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -8,7 +12,7 @@
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles @livewireStyles
</head> </head>
<body class="bg-light"> <body>
{{ $slot }} {{ $slot }}
@livewireScripts @livewireScripts
</body> </body>

View File

@@ -18,7 +18,7 @@ new #[Layout('components.layouts.guest')] class extends Component {
{ {
$this->validate(); $this->validate();
if (Auth::attempt(['username' => $this->username, 'password' => $this->password], $this->rememberMe)) { if (Auth::attempt(['uid' => $this->username, 'password' => $this->password], $this->rememberMe)) {
session()->regenerate(); session()->regenerate();
$this->redirectIntended(route('dashboard'), navigate: true); $this->redirectIntended(route('dashboard'), navigate: true);
return; return;
@@ -29,7 +29,7 @@ new #[Layout('components.layouts.guest')] class extends Component {
} }
?> ?>
<div class="min-vh-100 d-flex align-items-center justify-content-center bg-light"> <div class="min-vh-100 d-flex align-items-center justify-content-center">
<div class="card shadow-sm" style="width: 100%; max-width: 420px;"> <div class="card shadow-sm" style="width: 100%; max-width: 420px;">
<div class="card-body p-4"> <div class="card-body p-4">
<div class="text-center mb-4"> <div class="text-center mb-4">

View File

@@ -309,16 +309,48 @@ new #[Layout('components.layouts.app')] class extends Component {
</div> </div>
{{-- Journeys --}} {{-- Journeys --}}
<div class="card mb-4"> <div class="card mb-4" x-data="{
stateFilter: '',
suggestions: {},
open: {},
async searchTowns(key, query) {
if (query.length < 2) {
this.suggestions[key] = [];
this.open[key] = false;
return;
}
const params = new URLSearchParams({ query });
if (this.stateFilter) params.append('state', this.stateFilter);
const res = await fetch(`/towns/search?${params}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await res.json();
this.suggestions[key] = data;
this.open[key] = data.length > 0;
},
selectTown(key, wirePath, name) {
$wire.set(wirePath, name);
this.suggestions[key] = [];
this.open[key] = false;
}
}">
<div class="card-header fw-semibold d-flex justify-content-between align-items-center"> <div class="card-header fw-semibold d-flex justify-content-between align-items-center">
Journeys <span>Journeys</span>
<div class="d-flex align-items-center gap-2">
<select x-model="stateFilter" class="form-select form-select-sm" style="width: auto;" title="Filter town autocomplete by state">
<option value="">All states</option>
@foreach(\App\Enums\AustralianState::cases() as $state)
<option value="{{ $state->value }}">{{ $state->abbreviation() }}</option>
@endforeach
</select>
<button type="button" wire:click="addJourney" class="btn btn-sm btn-outline-primary">+ Add Journey</button> <button type="button" wire:click="addJourney" class="btn btn-sm btn-outline-primary">+ Add Journey</button>
</div> </div>
</div>
<div class="card-body"> <div class="card-body">
@error('journeys') <div class="alert alert-danger">{{ $message }}</div> @enderror @error('journeys') <div class="alert alert-danger">{{ $message }}</div> @enderror
@foreach ($journeys as $i => $journey) @foreach ($journeys as $i => $journey)
<div class="border rounded p-3 mb-3 bg-light" wire:key="journey-{{ $i }}"> <div class="border rounded p-3 mb-3" wire:key="journey-{{ $i }}">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<strong class="small">Journey {{ $i + 1 }}</strong> <strong class="small">Journey {{ $i + 1 }}</strong>
@if (count($journeys) > 1) @if (count($journeys) > 1)
@@ -328,13 +360,53 @@ new #[Layout('components.layouts.app')] class extends Component {
<div class="row g-3"> <div class="row g-3">
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Origin <span class="text-danger">*</span></label> <label class="form-label">Origin <span class="text-danger">*</span></label>
<input type="text" wire:model="journeys.{{ $i }}.origin" class="form-control @error('journeys.'.$i.'.origin') is-invalid @enderror" placeholder="e.g. Perth"> <div class="position-relative">
@error('journeys.'.$i.'.origin') <div class="invalid-feedback">{{ $message }}</div> @enderror <input
type="text"
wire:model="journeys.{{ $i }}.origin"
class="form-control @error('journeys.'.$i.'.origin') is-invalid @enderror"
placeholder="e.g. Perth"
autocomplete="off"
x-on:input.debounce.300ms="searchTowns('{{ $i }}-origin', $event.target.value)"
x-on:blur="$nextTick(() => open['{{ $i }}-origin'] = false)"
>
<ul class="dropdown-menu w-100" x-show="open['{{ $i }}-origin'] ?? false" style="z-index: 1055;">
<template x-for="s in (suggestions['{{ $i }}-origin'] ?? [])">
<li>
<button type="button" class="dropdown-item small"
x-text="s.label"
x-on:mousedown.prevent="selectTown('{{ $i }}-origin', 'journeys.{{ $i }}.origin', s.name)"
></button>
</li>
</template>
</ul>
</div>
@error('journeys.'.$i.'.origin') <div class="invalid-feedback d-block">{{ $message }}</div> @enderror
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Destination <span class="text-danger">*</span></label> <label class="form-label">Destination <span class="text-danger">*</span></label>
<input type="text" wire:model="journeys.{{ $i }}.destination" class="form-control @error('journeys.'.$i.'.destination') is-invalid @enderror" placeholder="e.g. Sydney"> <div class="position-relative">
@error('journeys.'.$i.'.destination') <div class="invalid-feedback">{{ $message }}</div> @enderror <input
type="text"
wire:model="journeys.{{ $i }}.destination"
class="form-control @error('journeys.'.$i.'.destination') is-invalid @enderror"
placeholder="e.g. Sydney"
autocomplete="off"
x-on:input.debounce.300ms="searchTowns('{{ $i }}-destination', $event.target.value)"
x-on:blur="$nextTick(() => open['{{ $i }}-destination'] = false)"
>
<ul class="dropdown-menu w-100" x-show="open['{{ $i }}-destination'] ?? false" style="z-index: 1055;">
<template x-for="s in (suggestions['{{ $i }}-destination'] ?? [])">
<li>
<button type="button" class="dropdown-item small"
x-text="s.label"
x-on:mousedown.prevent="selectTown('{{ $i }}-destination', 'journeys.{{ $i }}.destination', s.name)"
></button>
</li>
</template>
</ul>
</div>
@error('journeys.'.$i.'.destination') <div class="invalid-feedback d-block">{{ $message }}</div> @enderror
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<label class="form-label">Date <span class="text-danger">*</span></label> <label class="form-label">Date <span class="text-danger">*</span></label>
@@ -413,7 +485,7 @@ new #[Layout('components.layouts.app')] class extends Component {
</div> </div>
<div class="card-body"> <div class="card-body">
@foreach ($costCodes as $i => $code) @foreach ($costCodes as $i => $code)
<div class="border rounded p-3 mb-3 bg-light" wire:key="code-{{ $i }}"> <div class="border rounded p-3 mb-3" wire:key="code-{{ $i }}">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<strong class="small">Cost Code {{ $i + 1 }}</strong> <strong class="small">Cost Code {{ $i + 1 }}</strong>
@if (count($costCodes) > 1) @if (count($costCodes) > 1)

View File

@@ -1,8 +1,12 @@
<?php <?php
use App\Jobs\SyncTowns;
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () { Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote'); })->purpose('Display an inspiring quote');
Schedule::job(new SyncTowns)->dailyAt('02:00');

View File

@@ -1,5 +1,7 @@
<?php <?php
use App\Models\Town;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -11,9 +13,34 @@ Route::post('/logout', function () {
Auth::logout(); Auth::logout();
session()->invalidate(); session()->invalidate();
session()->regenerateToken(); session()->regenerateToken();
return redirect()->route('login'); return redirect()->route('login');
})->name('logout')->middleware('auth'); })->name('logout')->middleware('auth');
// Town autocomplete search
Route::middleware(['auth'])->get('/towns/search', function (Request $request) {
$query = $request->string('query')->trim();
if ($query->length() < 2) {
return response()->json([]);
}
$towns = Town::active()
->search($query)
->when($request->filled('state'), fn ($q) => $q->where('state', $request->integer('state')))
->orderBy('town_name')
->limit(10)
->get(['town_name', 'state']);
return response()->json(
$towns->map(fn ($town) => [
'name' => $town->town_name,
'state' => $town->state->abbreviation(),
'label' => $town->town_name.', '.$town->state->abbreviation(),
])
);
})->name('towns.search');
// Authenticated // Authenticated
Route::middleware(['auth'])->group(function () { Route::middleware(['auth'])->group(function () {
Route::livewire('/dashboard', 'dashboard')->name('dashboard'); Route::livewire('/dashboard', 'dashboard')->name('dashboard');

View File

@@ -1 +0,0 @@
This is a test file for verifying the PR workflow.

View File

@@ -0,0 +1,150 @@
<?php
namespace Tests\Feature\Jobs;
use App\Jobs\SyncTowns;
use App\Models\Town;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
class SyncTownsTest extends TestCase
{
use RefreshDatabase;
private const API_URL = 'https://services-ap1.arcgis.com/ypkPEy1AmwPKGNNv/arcgis/rest/services/Town_Point/FeatureServer/0/query*';
private function makeTownFeature(array $overrides = []): array
{
return [
'attributes' => array_merge([
'town_pid' => 'TWN0001',
'town_name' => 'Perth',
'state' => 5,
'population' => 2000000,
'town_class' => 1,
'date_retired' => null,
], $overrides),
];
}
public function test_syncs_towns_from_api(): void
{
Http::fake([
self::API_URL => Http::sequence()
->push(['features' => [
$this->makeTownFeature(['town_pid' => 'TWN0001', 'town_name' => 'Perth', 'state' => 5]),
$this->makeTownFeature(['town_pid' => 'TWN0002', 'town_name' => 'Fremantle', 'state' => 5]),
]])
->push(['features' => []]),
]);
(new SyncTowns)->handle();
$this->assertDatabaseHas('towns', ['town_pid' => 'TWN0001', 'town_name' => 'Perth']);
$this->assertDatabaseHas('towns', ['town_pid' => 'TWN0002', 'town_name' => 'Fremantle']);
$this->assertDatabaseCount('towns', 2);
}
public function test_paginates_through_all_pages(): void
{
$pageOne = [];
for ($i = 1; $i <= 1000; $i++) {
$pageOne[] = $this->makeTownFeature(['town_pid' => 'TWN'.str_pad($i, 4, '0', STR_PAD_LEFT)]);
}
Http::fake([
self::API_URL => Http::sequence()
->push(['features' => $pageOne])
->push(['features' => [
$this->makeTownFeature(['town_pid' => 'TWN1001', 'town_name' => 'Albany']),
]])
->push(['features' => []]),
]);
(new SyncTowns)->handle();
$this->assertDatabaseCount('towns', 1001);
$this->assertDatabaseHas('towns', ['town_pid' => 'TWN1001', 'town_name' => 'Albany']);
}
public function test_upserts_existing_towns_with_updated_data(): void
{
Town::factory()->create([
'town_pid' => 'TWN0001',
'town_name' => 'Old Name',
'state' => 5,
]);
Http::fake([
self::API_URL => Http::sequence()
->push(['features' => [
$this->makeTownFeature(['town_pid' => 'TWN0001', 'town_name' => 'Perth', 'population' => 2100000]),
]])
->push(['features' => []]),
]);
(new SyncTowns)->handle();
$this->assertDatabaseCount('towns', 1);
$this->assertDatabaseHas('towns', ['town_pid' => 'TWN0001', 'town_name' => 'Perth', 'population' => 2100000]);
}
public function test_marks_retired_towns_with_date_retired(): void
{
$retiredAt = Carbon::parse('2024-01-15')->startOfDay();
Http::fake([
self::API_URL => Http::sequence()
->push(['features' => [
$this->makeTownFeature([
'town_pid' => 'TWN0001',
'town_name' => 'OldTown',
'date_retired' => $retiredAt->getTimestampMs(),
]),
]])
->push(['features' => []]),
]);
(new SyncTowns)->handle();
$town = Town::where('town_pid', 'TWN0001')->first();
$this->assertNotNull($town->date_retired);
}
public function test_logs_error_and_stops_when_api_fails(): void
{
Log::spy();
Http::fake([
self::API_URL => Http::response(null, 500),
]);
(new SyncTowns)->handle();
$this->assertDatabaseCount('towns', 0);
Log::shouldHaveReceived('error')
->once()
->with('SyncTowns: API request failed', \Mockery::any());
}
public function test_logs_synced_count_on_success(): void
{
Log::spy();
Http::fake([
self::API_URL => Http::sequence()
->push(['features' => [
$this->makeTownFeature(['town_pid' => 'TWN0001']),
$this->makeTownFeature(['town_pid' => 'TWN0002']),
]])
->push(['features' => []]),
]);
(new SyncTowns)->handle();
Log::shouldHaveReceived('info')->once()->with('SyncTowns: synced 2 towns.');
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace Tests\Feature\Towns;
use App\Enums\AustralianState;
use App\Models\Town;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TownSearchTest extends TestCase
{
use RefreshDatabase;
private function actingAsUser(): static
{
return $this->actingAs(User::factory()->create());
}
public function test_requires_authentication(): void
{
$this->get('/towns/search?query=Perth')->assertRedirect('/login');
}
public function test_returns_empty_array_for_query_shorter_than_two_characters(): void
{
Town::factory()->create(['town_name' => 'Perth']);
$this->actingAsUser()
->getJson('/towns/search?query=P')
->assertOk()
->assertExactJson([]);
}
public function test_returns_matching_towns_by_prefix(): void
{
Town::factory()->create(['town_name' => 'Perth', 'state' => AustralianState::WA->value]);
Town::factory()->create(['town_name' => 'Pertham', 'state' => AustralianState::WA->value]);
Town::factory()->create(['town_name' => 'Sydney', 'state' => AustralianState::NSW->value]);
$response = $this->actingAsUser()
->getJson('/towns/search?query=Per')
->assertOk();
$this->assertCount(2, $response->json());
$this->assertSame('Perth, WA', $response->json('0.label'));
$this->assertSame('Pertham, WA', $response->json('1.label'));
}
public function test_filters_results_by_state(): void
{
Town::factory()->create(['town_name' => 'Port Hedland', 'state' => AustralianState::WA->value]);
Town::factory()->create(['town_name' => 'Port Augusta', 'state' => AustralianState::SA->value]);
$response = $this->actingAsUser()
->getJson('/towns/search?query=Port&state='.AustralianState::WA->value)
->assertOk();
$this->assertCount(1, $response->json());
$this->assertSame('Port Hedland', $response->json('0.name'));
}
public function test_excludes_retired_towns(): void
{
Town::factory()->create(['town_name' => 'Perth', 'state' => AustralianState::WA->value]);
Town::factory()->retired()->create(['town_name' => 'Perthville', 'state' => AustralianState::WA->value]);
$response = $this->actingAsUser()
->getJson('/towns/search?query=Per')
->assertOk();
$this->assertCount(1, $response->json());
$this->assertSame('Perth', $response->json('0.name'));
}
public function test_returns_at_most_ten_results(): void
{
Town::factory()->count(15)->create([
'town_name' => 'Perth',
'state' => AustralianState::WA->value,
]);
$response = $this->actingAsUser()
->getJson('/towns/search?query=Per')
->assertOk();
$this->assertCount(10, $response->json());
}
public function test_response_includes_name_state_and_label_fields(): void
{
Town::factory()->create(['town_name' => 'Perth', 'state' => AustralianState::WA->value]);
$response = $this->actingAsUser()
->getJson('/towns/search?query=Per')
->assertOk();
$result = $response->json('0');
$this->assertArrayHasKey('name', $result);
$this->assertArrayHasKey('state', $result);
$this->assertArrayHasKey('label', $result);
$this->assertSame('Perth', $result['name']);
$this->assertSame('WA', $result['state']);
$this->assertSame('Perth, WA', $result['label']);
}
public function test_results_are_ordered_alphabetically(): void
{
Town::factory()->create(['town_name' => 'Pertham', 'state' => AustralianState::WA->value]);
Town::factory()->create(['town_name' => 'Perth', 'state' => AustralianState::WA->value]);
$response = $this->actingAsUser()
->getJson('/towns/search?query=Per')
->assertOk();
$this->assertSame('Perth', $response->json('0.name'));
$this->assertSame('Pertham', $response->json('1.name'));
}
}