From a38f09cf7bb7643d097f6b11d8bfbbb16feda283 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski <5760249+gdarko@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:48:08 +0200 Subject: [PATCH] Installer reliability improvements (#593) * docs: add CLAUDE.md for Claude Code guidance * fix: handle missing settings table in installation middlewares RedirectIfInstalled crashed with "no such table: settings" when the database_created marker file existed but the database was empty. Changed to use isDbCreated() which verifies actual tables, and added try-catch around Setting queries in both middlewares. * feat: pre-select database driver from env in installation wizard The database step now reads DB_CONNECTION from the environment and pre-selects the matching driver on load, including correct defaults for hostname and port. * feat: pre-select mail driver and config from env in installation wizard The email step now fetches the current mail configuration on load instead of hardcoding the driver to 'mail'. SMTP fields fall back to Laravel config values from the environment. * refactor: remove file-based DB marker in favor of direct DB checks The database_created marker file was a second source of truth that could drift out of sync with the actual database. InstallUtils now checks the database directly via Schema::hasTable which is cached per-request and handles all error cases gracefully. --- CLAUDE.md | 80 +++++++++++++++++++ app/Console/Commands/ResetApp.php | 2 +- .../Settings/MailConfigurationController.php | 10 +-- .../DatabaseConfigurationController.php | 3 +- .../V1/Installation/FinishController.php | 5 -- .../OnboardingWizardController.php | 2 +- .../Middleware/InstallationMiddleware.php | 6 +- app/Http/Middleware/RedirectIfInstalled.php | 10 ++- app/Space/InstallUtils.php | 52 +----------- database/seeders/DemoSeeder.php | 4 - .../installation/Step3DatabaseConfig.vue | 19 ++++- .../views/installation/Step5EmailConfig.vue | 3 +- 12 files changed, 118 insertions(+), 78 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..6ba2a11e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,80 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +InvoiceShelf is an open-source invoicing and expense tracking application built with Laravel 13 (PHP 8.4) and Vue 3. It supports multi-company tenancy, customer portals, recurring invoices, and PDF generation. + +## Common Commands + +### Development +```bash +composer run dev # Starts PHP server, queue listener, log tail, and Vite dev server concurrently +npm run dev # Vite dev server only +npm run build # Production frontend build +``` + +### Testing +```bash +php artisan test --compact # Run all tests +php artisan test --compact --filter=testName # Run specific test +./vendor/bin/pest --stop-on-failure # Run via Pest directly +make test # Makefile shortcut +``` + +Tests use SQLite in-memory DB, configured in `phpunit.xml`. Tests seed via `DatabaseSeeder` + `DemoSeeder` in `beforeEach`. Authenticate with `Sanctum::actingAs()` and set the `company` header. + +### Code Style +```bash +vendor/bin/pint --dirty --format agent # Fix style on modified PHP files +vendor/bin/pint --test # Check style without fixing (CI uses this) +``` + +### Artisan Generators +Always use `php artisan make:*` with `--no-interaction` to create new files (models, controllers, migrations, tests, etc.). + +## Architecture + +### Multi-Tenancy +Every major model has a `company_id` foreign key. The `CompanyMiddleware` sets the active company from the `company` request header. Bouncer authorization is scoped to the company level via `DefaultScope` (`app/Bouncer/Scopes/DefaultScope.php`). + +### Authentication +Three guards: `web` (session), `api` (Sanctum tokens for `/api/v1/`), `customer` (session for customer portal). API routes use `auth:sanctum` middleware; customer portal uses `auth:customer`. + +### Routing +- **API**: All endpoints under `/api/v1/` in `routes/api.php`, grouped with `auth:sanctum`, `company`, and `bouncer` middleware +- **Web**: `routes/web.php` serves PDF endpoints, auth pages, and catch-all SPA routes (`/admin/{vue?}`, `/{company:slug}/customer/{vue?}`) + +### Frontend +- Entry point: `resources/scripts/main.js` +- Vue Router: `resources/scripts/admin/admin-router.js` (admin), `resources/scripts/customer/customer-router.js` (customer portal) +- State: Pinia stores in `resources/scripts/admin/stores/` +- Path aliases: `@` = `resources/`, `$fonts`, `$images` for static assets +- Vite dev server expects `invoiceshelf.test` hostname + +### Backend Patterns +- **Authorization**: Silber/Bouncer with policies in `app/Policies/`. Controllers use `$this->authorize()`. +- **Validation**: Form Request classes, never inline validation +- **API responses**: Eloquent API Resources in `app/Http/Resources/` +- **PDF generation**: DomPDF (`GeneratesPdfTrait`) or Gotenberg +- **Email**: Mailable classes with `EmailLog` tracking +- **File storage**: Spatie MediaLibrary, supports local/S3/Dropbox +- **Serial numbers**: `SerialNumberFormatter` service +- **Company settings**: `CompanySetting` model (key-value per company) + +### Database +Supports MySQL, PostgreSQL, and SQLite. Prefer Eloquent over raw queries. Use `Model::query()` instead of `DB::`. Use eager loading to prevent N+1 queries. + +## Code Conventions + +- PHP: snake_case, constructor property promotion, explicit return types, PHPDoc blocks over inline comments +- JS: camelCase +- Always check sibling files for patterns before creating new ones +- Use `config()` helper, never `env()` outside config files +- Every change must have tests (feature tests preferred over unit tests) +- Run `vendor/bin/pint --dirty --format agent` after modifying PHP files + +## CI Pipeline + +GitHub Actions (`check.yaml`): runs Pint style check, then builds frontend and runs Pest tests on PHP 8.4. diff --git a/app/Console/Commands/ResetApp.php b/app/Console/Commands/ResetApp.php index 6724286f..2e9036ba 100644 --- a/app/Console/Commands/ResetApp.php +++ b/app/Console/Commands/ResetApp.php @@ -24,7 +24,7 @@ class ResetApp extends Command * * @var string */ - protected $description = 'Clean database, database_created and public/storage folder'; + protected $description = 'Clean database and public/storage folder'; /** * Create a new command instance. diff --git a/app/Http/Controllers/V1/Admin/Settings/MailConfigurationController.php b/app/Http/Controllers/V1/Admin/Settings/MailConfigurationController.php index ee13d402..0be5349e 100755 --- a/app/Http/Controllers/V1/Admin/Settings/MailConfigurationController.php +++ b/app/Http/Controllers/V1/Admin/Settings/MailConfigurationController.php @@ -165,11 +165,11 @@ class MailConfigurationController extends Controller switch ($driver) { case 'smtp': $MailData = array_merge($MailData, [ - 'mail_host' => $mailSettings['mail_host'] ?? '', - 'mail_port' => $mailSettings['mail_port'] ?? '', - 'mail_username' => $mailSettings['mail_username'] ?? '', - 'mail_password' => $mailSettings['mail_password'] ?? '', - 'mail_encryption' => $mailSettings['mail_encryption'] ?? 'none', + 'mail_host' => $mailSettings['mail_host'] ?? config('mail.mailers.smtp.host', ''), + 'mail_port' => $mailSettings['mail_port'] ?? config('mail.mailers.smtp.port', ''), + 'mail_username' => $mailSettings['mail_username'] ?? config('mail.mailers.smtp.username', ''), + 'mail_password' => $mailSettings['mail_password'] ?? config('mail.mailers.smtp.password', ''), + 'mail_encryption' => $mailSettings['mail_encryption'] ?? config('mail.mailers.smtp.encryption', 'none'), 'mail_scheme' => $mailSettings['mail_scheme'] ?? '', 'mail_url' => $mailSettings['mail_url'] ?? '', 'mail_timeout' => $mailSettings['mail_timeout'] ?? '', diff --git a/app/Http/Controllers/V1/Installation/DatabaseConfigurationController.php b/app/Http/Controllers/V1/Installation/DatabaseConfigurationController.php index a977055e..446a1e31 100644 --- a/app/Http/Controllers/V1/Installation/DatabaseConfigurationController.php +++ b/app/Http/Controllers/V1/Installation/DatabaseConfigurationController.php @@ -48,8 +48,9 @@ class DatabaseConfigurationController extends Controller public function getDatabaseEnvironment(Request $request) { $databaseData = []; + $connection = $request->connection ?? config('database.default'); - switch ($request->connection) { + switch ($connection) { case 'sqlite': $databaseData = [ 'database_connection' => 'sqlite', diff --git a/app/Http/Controllers/V1/Installation/FinishController.php b/app/Http/Controllers/V1/Installation/FinishController.php index a7405bd2..8e7f4e1c 100644 --- a/app/Http/Controllers/V1/Installation/FinishController.php +++ b/app/Http/Controllers/V1/Installation/FinishController.php @@ -3,7 +3,6 @@ namespace App\Http\Controllers\V1\Installation; use App\Http\Controllers\Controller; -use App\Space\InstallUtils; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -16,10 +15,6 @@ class FinishController extends Controller */ public function __invoke(Request $request) { - if (! InstallUtils::createDbMarker()) { - \Log::error('Install: Unable to create db marker.'); - } - return response()->json(['success' => true]); } } diff --git a/app/Http/Controllers/V1/Installation/OnboardingWizardController.php b/app/Http/Controllers/V1/Installation/OnboardingWizardController.php index 954d86f4..7630420c 100644 --- a/app/Http/Controllers/V1/Installation/OnboardingWizardController.php +++ b/app/Http/Controllers/V1/Installation/OnboardingWizardController.php @@ -17,7 +17,7 @@ class OnboardingWizardController extends Controller */ public function getStep(Request $request) { - if (! InstallUtils::dbMarkerExists()) { + if (! InstallUtils::isDbCreated()) { return response()->json([ 'profile_complete' => 0, 'profile_language' => 'en', diff --git a/app/Http/Middleware/InstallationMiddleware.php b/app/Http/Middleware/InstallationMiddleware.php index 0386a453..d8900199 100644 --- a/app/Http/Middleware/InstallationMiddleware.php +++ b/app/Http/Middleware/InstallationMiddleware.php @@ -17,7 +17,11 @@ class InstallationMiddleware */ public function handle(Request $request, Closure $next): Response { - if (! InstallUtils::isDbCreated() || Setting::getSetting('profile_complete') !== 'COMPLETED') { + try { + if (! InstallUtils::isDbCreated() || Setting::getSetting('profile_complete') !== 'COMPLETED') { + return redirect('/installation'); + } + } catch (\Exception $e) { return redirect('/installation'); } diff --git a/app/Http/Middleware/RedirectIfInstalled.php b/app/Http/Middleware/RedirectIfInstalled.php index 0a788da4..23cad525 100644 --- a/app/Http/Middleware/RedirectIfInstalled.php +++ b/app/Http/Middleware/RedirectIfInstalled.php @@ -17,9 +17,13 @@ class RedirectIfInstalled */ public function handle(Request $request, Closure $next): Response { - if (InstallUtils::dbMarkerExists()) { - if (Setting::getSetting('profile_complete') === 'COMPLETED') { - return redirect('login'); + if (InstallUtils::isDbCreated()) { + try { + if (Setting::getSetting('profile_complete') === 'COMPLETED') { + return redirect('login'); + } + } catch (\Exception $e) { + // Settings table may not exist yet during installation } } diff --git a/app/Space/InstallUtils.php b/app/Space/InstallUtils.php index 7b2944e1..c618983a 100644 --- a/app/Space/InstallUtils.php +++ b/app/Space/InstallUtils.php @@ -5,8 +5,6 @@ namespace App\Space; use App\Models\Setting; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\File; -use Illuminate\Support\Facades\Log; -use League\Flysystem\FilesystemException; class InstallUtils { @@ -17,7 +15,7 @@ class InstallUtils */ public static function isDbCreated() { - return self::dbMarkerExists() && self::tableExists('users'); + return self::tableExists('users'); } /** @@ -44,54 +42,6 @@ class InstallUtils return $cache[$table]; } - /** - * Check if database created marker exists - * - * @return bool - */ - public static function dbMarkerExists() - { - try { - return \Storage::disk('local')->has('database_created'); - } catch (FilesystemException $e) { - Log::error('Unable to verify db marker: '.$e->getMessage()); - } - - return false; - } - - /** - * Creates the database marker - * - * @return bool - */ - public static function createDbMarker() - { - try { - return \Storage::disk('local')->put('database_created', time()); - } catch (\Exception $e) { - Log::error('Unable to create db marker: '.$e->getMessage()); - } - - return false; - } - - /** - * Deletes the database marker - * - * @return bool - */ - public static function deleteDbMarker() - { - try { - return \Storage::disk('local')->delete('database_created'); - } catch (\Exception $e) { - Log::error('Unable to delete db marker: '.$e->getMessage()); - } - - return false; - } - /** * Set the app version * diff --git a/database/seeders/DemoSeeder.php b/database/seeders/DemoSeeder.php index 6b334147..199c8446 100644 --- a/database/seeders/DemoSeeder.php +++ b/database/seeders/DemoSeeder.php @@ -8,7 +8,6 @@ use App\Models\CompanySetting; use App\Models\Customer; use App\Models\Setting; use App\Models\User; -use App\Space\InstallUtils; use Illuminate\Database\Seeder; use Silber\Bouncer\BouncerFacade; @@ -71,8 +70,5 @@ class DemoSeeder extends Seeder // Mark profile setup as complete Setting::setSetting('profile_complete', 'COMPLETED'); - - // Create installation marker - InstallUtils::createDbMarker(); } } diff --git a/resources/scripts/admin/views/installation/Step3DatabaseConfig.vue b/resources/scripts/admin/views/installation/Step3DatabaseConfig.vue index b2a86821..d9832570 100644 --- a/resources/scripts/admin/views/installation/Step3DatabaseConfig.vue +++ b/resources/scripts/admin/views/installation/Step3DatabaseConfig.vue @@ -15,7 +15,7 @@