Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6fd2dba
chore: add AGENTS.md (phase 1.6)
Apr 6, 2026
8eaa3af
docs: clarify boost gate (host-cloned, needs real tty)
Apr 6, 2026
05c7386
feat: add reusable HasComments trait for polymorphic comment threads
TLemmAI Apr 13, 2026
5019e3b
feat(BUILD-08): add document queue with safety-first ingestion pipeline
TLemmAI Apr 13, 2026
909bb3e
feat(multi-tenant): add parent_company_uuid, company_type, is_client,…
TLemmAI Apr 14, 2026
9deca08
feat(test): add Orchestra Testbench bootstrap for Pest feature tests
TLemmAI Apr 14, 2026
db85f7a
feat(multi-tenant): add access_level and is_default to company_users …
TLemmAI Apr 14, 2026
c8f2638
feat(multi-tenant): seed existing companies as organizations and back…
TLemmAI Apr 14, 2026
763b7ca
feat(multi-tenant): add hierarchy relations, scopes, and accessor hel…
TLemmAI Apr 14, 2026
6f448ea
feat(multi-tenant): add access_level and is_default fillable+casts to…
TLemmAI Apr 14, 2026
2cee8d0
test(multi-tenant): hoist responsecache and mysql connection shims in…
TLemmAI Apr 14, 2026
6e532e0
feat(multi-tenant): add defaultCompany, accessibleCompanyUuids, canAc…
TLemmAI Apr 14, 2026
24182ba
feat(multi-tenant): add stateless CompanyContextResolver middleware w…
TLemmAI Apr 14, 2026
2b100ce
feat(multi-tenant): add ScopedToCompanyContext trait with strict fail…
TLemmAI Apr 14, 2026
6f71a44
feat(multi-tenant): register CompanyContextResolver alias and verify …
TLemmAI Apr 14, 2026
7e28119
feat(multi-tenant): add ClientCompanyController with org-scoped CRUD
TLemmAI Apr 14, 2026
1f6f379
feat(multi-tenant): add stateless CompanyContextController with curre…
TLemmAI Apr 14, 2026
424caf7
feat(settings): add CompanySettingsResolver with parent-client inheri…
TLemmAI Apr 15, 2026
84e947d
feat(settings): add CompanySettingsController with GET/PUT/PATCH /cur…
TLemmAI Apr 15, 2026
67adb26
feat(settings): add CompanyContextSelfResolver middleware for role-ag…
TLemmAI Apr 15, 2026
caf63dd
Merge remote-tracking branch 'origin/main' into feat/multi-tenant-hie…
TLemmAI Apr 15, 2026
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
40 changes: 40 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# AGENTS.md — core-api

## Repo purpose
The foundational Laravel package (`fleetbase/core-api`) providing models, services, abstractions, helpers, and the extension contract used by every Fleetbase backend extension. Imported by `fleetbase/api` via composer (either from Packagist or as a local path).

## What this repo owns
- `src/Models/` — base models (Organization, User, etc.)
- `src/Http/Controllers/` — internal controllers used by the console
- `src/Support/` — `Utils`, `EnvironmentMapper`, `Str` expansions, etc.
- `src/Expansions/` — Laravel macro registrations
- `src/Notifications/`, `src/Mail/`, `src/Events/`, `src/Listeners/`
- The extension service provider contract

## What this repo must not modify
- Anything that breaks public method signatures of widely-used helpers (`Utils`, `Str` expansions). These are called by every extension.
- The extension contract — adding required methods is a breaking change for every downstream extension.

## Framework conventions
- Laravel 10+, PHP 8.0+
- PSR-4 autoload under `Fleetbase\\`
- Notifications via Laravel's notification system
- Eloquent + activity log via `spatie/laravel-activitylog`

## Test / build commands
- This package is consumed by `fleetbase/api`. To test changes: edit here, then in the application container run `composer update fleetbase/core-api` (requires path repository in `api/composer.json`).
- `vendor/bin/phpunit`

## Known sharp edges
- **`Str::domain($url)` at `src/Expansions/Str.php:53`** crashes on hosts with no `.` (e.g. `localhost`). Workaround in `fleetbase/api/.env`: set `MAIL_FROM_ADDRESS`. **If you fix this here, also remove the workaround.**
- `EnvironmentMapper.php` has dozens of nested AWS/SQS/SES key mappings. Don't refactor without a clear need.
- `Utils::getDefaultMailFromAddress()` is the caller of the buggy `Str::domain` — start here when fixing the upstream bug.

## Read first
- `~/fleetbase-project/docs/project-description.md`
- `~/fleetbase-project/docs/repo-map.md`
- `~/fleetbase-project/docs/ai-rules-laravel.md`
- `~/fleetbase-project/docs/ai-rules-workspace.md`

## Boost gate
This repo IS host-cloned (unlike `fleetbase/api`), so Boost outputs would land in a place future agents can read. Before first edit: `composer require laravel/boost --dev && php artisan boost:install` from a **real terminal** (the installer is interactive and crashes on `docker compose exec -T`). Then commit.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,10 @@
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.34.1",
"nunomaduro/collision": "^7.0",
"orchestra/testbench": "^8.0",
"pestphp/pest": "^2.33.2",
"phpstan/phpstan": "^1.10.38",
"symfony/var-dumper": "^5.4.29"
"symfony/var-dumper": "^5.4.29|^6.2"
},
"autoload": {
"psr-4": {
Expand Down
48 changes: 48 additions & 0 deletions migrations/2026_04_13_000001_create_document_queue_items_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

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

return new class extends Migration
{
public function up()
{
Schema::create('document_queue_items', function (Blueprint $table) {
$table->increments('id');
$table->string('uuid', 191)->nullable()->unique();
$table->string('public_id', 191)->nullable()->unique();
$table->uuid('company_uuid')->index();
$table->char('file_uuid', 36)->nullable();

$table->string('source', 20)->default('manual'); // manual, email, edi, api
$table->string('document_type', 30)->default('unknown'); // carrier_invoice, bol, pod, rate_confirmation, insurance_cert, customs, other, unknown
$table->string('status', 20)->default('received');
// received, processing, parsed, matched, needs_review, failed

$table->longText('raw_content')->nullable();
$table->json('parsed_data')->nullable();

$table->char('matched_order_uuid', 36)->nullable()->index();
$table->char('matched_shipment_uuid', 36)->nullable()->index();
$table->char('matched_carrier_invoice_uuid', 36)->nullable();
$table->decimal('match_confidence', 4, 2)->nullable(); // 0.00 to 1.00
$table->string('match_method', 30)->nullable(); // pro_number, bol_number, carrier_date, manual

$table->text('error_message')->nullable();
$table->timestamp('processed_at')->nullable();

$table->json('meta')->nullable();
$table->softDeletes();
$table->timestamp('created_at')->nullable()->index();
$table->timestamp('updated_at')->nullable();

$table->foreign('company_uuid')->references('uuid')->on('companies');
});
}

public function down()
{
Schema::dropIfExists('document_queue_items');
}
};
41 changes: 41 additions & 0 deletions migrations/2026_04_13_100000_add_hierarchy_to_companies_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

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

return new class extends Migration {
public function up(): void
{
Schema::table('companies', function (Blueprint $table) {
$table->char('parent_company_uuid', 36)->nullable()->index()->after('uuid');
$table->enum('company_type', ['platform', 'organization', 'client'])
->default('organization')
->after('parent_company_uuid');
$table->boolean('is_client')->default(false)->index()->after('company_type');
$table->string('client_code', 50)->nullable()->after('is_client');
$table->json('client_settings')->nullable()->after('client_code');

$table->foreign('parent_company_uuid')
->references('uuid')
->on('companies')
->nullOnDelete();
});
}

public function down(): void
{
Schema::table('companies', function (Blueprint $table) {
$table->dropForeign(['parent_company_uuid']);
$table->dropIndex(['parent_company_uuid']);
$table->dropIndex(['is_client']);
$table->dropColumn([
'parent_company_uuid',
'company_type',
'is_client',
'client_code',
'client_settings',
]);
});
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

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

return new class extends Migration {
public function up(): void
{
Schema::table('company_users', function (Blueprint $table) {
$table->enum('access_level', ['full', 'read_only', 'financial', 'operations'])
->default('full')
->after('external');
$table->boolean('is_default')->default(false)->index()->after('access_level');
});
}

public function down(): void
{
Schema::table('company_users', function (Blueprint $table) {
$table->dropIndex(['is_default']);
$table->dropColumn(['access_level', 'is_default']);
});
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

return new class extends Migration {
public function up(): void
{
// 1. Every no-parent company becomes an organization.
// Idempotent: uses query builder, preserves explicit overrides
// (e.g. someone already set company_type='client' stays untouched).
DB::table('companies')
->whereNull('parent_company_uuid')
->where(function ($q) {
$q->where('company_type', '!=', 'organization')
->orWhereNull('company_type');
})
->update([
'company_type' => 'organization',
'is_client' => false,
]);

// 2. Backfill is_default on company_users pivot using the tie-breaker:
// the pivot row matching users.company_uuid wins.
$users = DB::table('users')
->whereNotNull('company_uuid')
->select('uuid', 'company_uuid')
->cursor();

foreach ($users as $user) {
$pivot = DB::table('company_users')
->where('user_uuid', $user->uuid)
->where('company_uuid', $user->company_uuid)
->first();

if ($pivot) {
// Only update when not already correct — avoids churn on re-run.
DB::table('company_users')
->where('id', $pivot->id)
->where('is_default', false)
->update(['is_default' => true]);
} else {
// No pivot row yet for user+company — insert one, marked default.
DB::table('company_users')->insert([
'uuid' => (string) Str::uuid(),
'user_uuid' => $user->uuid,
'company_uuid' => $user->company_uuid,
'status' => 'active',
'is_default' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}

// Demote any OTHER pivot rows for this user that are still marked default.
DB::table('company_users')
->where('user_uuid', $user->uuid)
->where('company_uuid', '!=', $user->company_uuid)
->where('is_default', true)
->update(['is_default' => false]);
}
}

public function down(): void
{
// No-op: this is a data seed, not a schema change. Reversing it
// would require restoring the pre-seed state which we don't retain.
}
};
11 changes: 11 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,15 @@
<directory suffix=".php">./src</directory>
</whitelist>
</filter>
<php>
<env name="APP_ENV" value="testing"/>
<env name="CI" value="true"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="MAIL_MAILER" value="array"/>
<env name="BCRYPT_ROUNDS" value="4"/>
</php>
</phpunit>
106 changes: 106 additions & 0 deletions src/Http/Controllers/Api/v1/DocumentQueueController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

namespace Fleetbase\Http\Controllers\Api\v1;

use Fleetbase\Http\Controllers\FleetbaseController;
use Fleetbase\Models\DocumentQueueItem;
use Fleetbase\Models\File;
use Fleetbase\Services\DocumentIngestionService;
use Illuminate\Http\Request;

/**
* Thin controller for document queue operations.
* All ingestion/parsing/matching logic lives in DocumentIngestionService.
*/
class DocumentQueueController extends FleetbaseController
{
public $resource = DocumentQueueItem::class;

/**
* POST /document-queue/upload
* Manual upload — the primary first-class entry point.
* Body: multipart/form-data with 'file' field, optional 'document_type'.
*/
public function upload(Request $request)
{
$validated = $request->validate([
'file' => 'required|file',
'document_type' => 'nullable|string',
]);

$uploadedFile = $request->file('file');

// Persist the file via the existing File model
$path = "document-queue/" . session('company') . "/" . uniqid('doc_') . '_' . $uploadedFile->getClientOriginalName();
$file = File::createFromUpload($uploadedFile, $path);

if (!$file) {
return response()->apiError('Failed to store uploaded file.');
}

$item = DocumentQueueItem::create([
'company_uuid' => session('company'),
'file_uuid' => $file->uuid,
'source' => DocumentQueueItem::SOURCE_MANUAL,
'document_type' => $validated['document_type'] ?? DocumentQueueItem::TYPE_UNKNOWN,
'status' => DocumentQueueItem::STATUS_RECEIVED,
]);

return response()->json(['data' => $item->load('file')]);
}

/**
* POST /document-queue/{id}/process
* Synchronously process an item through the full ingestion pipeline.
*/
public function process(string $id)
{
$item = DocumentQueueItem::findRecordOrFail($id);
$processed = app(DocumentIngestionService::class)->process($item);

return response()->json(['data' => $processed->load('file')]);
}

/**
* POST /document-queue/{id}/reprocess
* Re-run the pipeline. Useful after fixing AI key, PDF tooling, or fixing match.
*/
public function reprocess(string $id)
{
$item = DocumentQueueItem::findRecordOrFail($id);

// Reset to received state (preserve raw_content if present)
$item->update([
'status' => DocumentQueueItem::STATUS_RECEIVED,
'error_message' => null,
]);

$processed = app(DocumentIngestionService::class)->process($item);

return response()->json(['data' => $processed->load('file')]);
}

/**
* POST /document-queue/{id}/manual-match
* Manually associate a queue item with an order or shipment.
*/
public function manualMatch(string $id, Request $request)
{
$item = DocumentQueueItem::findRecordOrFail($id);

$validated = $request->validate([
'order_uuid' => 'nullable|string',
'shipment_uuid' => 'nullable|string',
]);

$item->update([
'matched_order_uuid' => $validated['order_uuid'] ?? null,
'matched_shipment_uuid' => $validated['shipment_uuid'] ?? null,
'match_confidence' => 1.00,
'match_method' => 'manual',
'status' => DocumentQueueItem::STATUS_MATCHED,
]);

return response()->json(['data' => $item->fresh()]);
}
}
Loading