Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,15 @@ actual-real-resolved-issues.md
current-issues.md
merge-order.md
"saving some issues.md"
issues_full.json
refine-issues.json
/plans/
feature-parity.md
issues_index.json
parity-results.md
report-2026-07-18.md
results-transcript.md
summary-feature-parity.md
plan-2026-07-19.md
storage/dompdf_log
untouched.json
10 changes: 9 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@

## What this is

Laravel 11 + Filament v4 + Livewire v3 invoicing app. Modular architecture via `nwidart/laravel-modules`. PHP 8.1+ enums, Spatie roles/permissions, multi-tenancy via Filament's built-in tenant system scoped to `Company`.
Laravel 13 + Filament v5 + Livewire v4 invoicing app. Modular architecture via `nwidart/laravel-modules` v13. PHP 8.3+ (dev box: 8.4.23), Spatie roles/permissions, multi-tenancy via Filament's built-in tenant system scoped to `Company`.

**Resolved versions** (from `composer.lock`):
- `laravel/framework` 13.15.0 (PHP ^8.3)
- `filament/filament` 5.6.7 (+ 9 sub-packages @ 5.6.7)
- `livewire/livewire` 4.3.1
- `nwidart/laravel-modules` 13.0.0
- `spatie/laravel-permission` 8.0.0
- `danharrin/livewire-rate-limiting` 2.2.0

---

Expand Down
2 changes: 1 addition & 1 deletion Modules/Core/Database/Factories/CompanyUserFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

namespace Modules\Core\Database\Factories;

use Modules\Core\Models\CompanyUser;
use Modules\Core\Models\Company;
use Modules\Core\Models\CompanyUser;
use Modules\Core\Models\User;

class CompanyUserFactory extends AbstractFactory
Expand Down
4 changes: 2 additions & 2 deletions Modules/Core/Database/Factories/CustomFieldValueFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ class CustomFieldValueFactory extends AbstractFactory

public function definition(): array
{
$company = $this->resolveCompany() ?? Company::factory()->create();
$company = $this->resolveCompany() ?? Company::factory()->create();
$customField = CustomField::query()->where('company_id', $company->id)->inRandomOrder()->first()
?? CustomField::factory()->for($company)->create();
$fieldable = Relation::factory()->for($company)->create();
$fieldable = Relation::factory()->for($company)->create();

return [
'company_id' => $company->id,
Expand Down
4 changes: 2 additions & 2 deletions Modules/Core/Database/Factories/NoteFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ class NoteFactory extends AbstractFactory

public function definition(): array
{
$company = $this->resolveCompany() ?? Company::factory()->create();
$notable = Relation::factory()->for($company)->create();
$company = $this->resolveCompany() ?? Company::factory()->create();
$notable = Relation::factory()->for($company)->create();

return [
'company_id' => $company->id,
Expand Down
17 changes: 16 additions & 1 deletion Modules/Core/Database/Factories/SettingFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Modules\Core\Database\Factories;

use Modules\Core\Models\Company;
use Modules\Core\Models\Setting;

class SettingFactory extends AbstractFactory
Expand All @@ -11,8 +12,22 @@ class SettingFactory extends AbstractFactory
public function definition(): array
{
return [
'setting_key' => fake()->word,
'setting_key' => fake()->unique()->word,
'setting_value' => fake()->word,
'company_id' => null,
];
}

/**
* State: create a setting scoped to a specific company.
*/
public function forCompany(Company|int $company): self
{
$companyId = $company instanceof Company ? $company->id : $company;

return $this->state(fn (): array => [
'company_id' => $companyId,
'setting_key' => fake()->unique()->word,
]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class () extends Migration {
/**
* Add `company_id` to the global settings table so that company-panel
* settings can be scoped per-company. Rows with a NULL `company_id`
* are global (the historical behavior of InvoicePlane v1).
*
* The unique index is a *partial* index (Postgres / MariaDB / SQLite all
* support `WHERE`): the `(company_id, setting_key)` pair is unique only
* when `company_id IS NOT NULL`. Two companies may each have their own
* `currency_code`; a global `currency_code` and a company-scoped one
* may coexist.
Comment on lines +14 to +18

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Docblock describes a partial UNIQUE index that the migration never creates.

Lines 46–56 create a plain, non-unique composite index (->index([...])) and the inline comment states uniqueness is enforced only at the application layer. This docblock claims a WHERE company_id IS NOT NULL partial unique index, which contradicts the actual schema and could mislead maintainers into assuming DB-level uniqueness. Please align the docblock with the soft-constraint reality.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Modules/Core/Database/Migrations/2026_07_19_000001_add_company_id_to_settings_table.php`
around lines 14 - 18, Update the migration docblock describing the `(company_id,
setting_key)` index to state that it is a regular non-unique composite index and
uniqueness is enforced by the application layer. Remove claims about partial
indexing, database-enforced uniqueness, and `WHERE company_id IS NOT NULL`,
keeping the actual schema behavior represented by the index definition.

*/
public function up(): void
{
$driver = DB::connection()->getDriverName();

if ( ! $this->columnExists('settings', 'company_id')) {
Schema::table('settings', function (Blueprint $table): void {
$table->unsignedBigInteger('company_id')->nullable()->after('id');
$table->index('company_id');

$table->foreign('company_id')
->references('id')->on('companies')
->cascadeOnDelete();
});
}

// Drop the old single-column index on setting_key — the (company_id,
// setting_key) composite below replaces it for scoped rows, and
// for global (NULL company_id) rows we don't need an index because
// global key lookups are rare (legacy v1 callers only).
// (The original 2023 migration named it `settings_setting_key_index`.)
if ($this->indexExists('settings', 'settings_setting_key_index')) {
Schema::table('settings', function (Blueprint $table): void {
$table->dropIndex('settings_setting_key_index');
});
}

// MariaDB / MySQL do not support partial unique indexes. We rely on
// application-level enforcement in Setting::saveForCompany() and
// Setting::saveByKey() to keep the (company_id, setting_key) pair
// unique within a single tenant. The unique constraint is
// therefore a soft constraint: callers MUST use the save* helpers
// instead of inserting directly.
if ( ! $this->indexExists('settings', 'settings_company_id_setting_key_index')) {
Schema::table('settings', function (Blueprint $table): void {
$table->index(['company_id', 'setting_key'], 'settings_company_id_setting_key_index');
});
}
}

public function down(): void
{
if ($this->indexExists('settings', 'settings_company_id_setting_key_index')) {
Schema::table('settings', function (Blueprint $table): void {
$table->dropIndex('settings_company_id_setting_key_index');
});
}

Schema::table('settings', function (Blueprint $table): void {
$table->dropForeign(['company_id']);
$table->dropIndex(['company_id']);
$table->dropColumn('company_id');
$table->index('setting_key');
});
}

private function columnExists(string $table, string $column): bool
{
$driver = DB::connection()->getDriverName();

if ($driver === 'sqlite') {
$rows = DB::select("PRAGMA table_info('{$table}')");

foreach ($rows as $row) {
if (($row->name ?? null) === $column) {
return true;
}
}

return false;
}

$database = DB::connection()->getDatabaseName();

$rows = DB::select(
'SELECT COLUMN_NAME AS name FROM information_schema.columns '
."WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?",
[$database, $table, $column]
);

return count($rows) > 0;
}

private function indexExists(string $table, string $index): bool
{
$driver = DB::connection()->getDriverName();

if ($driver === 'sqlite') {
$rows = DB::select("PRAGMA index_list('{$table}')");

foreach ($rows as $row) {
if (($row->name ?? null) === $index) {
return true;
}
}

return false;
}

// MySQL / MariaDB
$database = DB::connection()->getDatabaseName();
$rows = DB::select(
'SELECT INDEX_NAME AS name FROM information_schema.statistics '
."WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME = ?",
[$database, $table, $index]
);

return count($rows) > 0;
}
};
Loading
Loading