Compare commits

..

3 Commits

Author SHA1 Message Date
Nyan Lin Paing e555413295 Fix EV company logo not rendering in Filament table
PHP Tests / php-tests (push) Waiting to run
ImageColumn::make('logo') had no explicit disk, so it defaulted to
Filament's local disk while uploads write to the public disk,
causing the file-existence check to fail and the src to render
empty. Align the column's disk with the upload field's disk.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015HaT8a5ZWh45JkjTLPWeaP
2026-09-03 00:45:19 +07:00
Nyan Lin Paing 3768b82932 chg sync endpoint
PHP Tests / php-tests (push) Waiting to run
2026-09-03 00:33:10 +07:00
Nyan Lin Paing d1ce73e7ff Fix EV company logo upload to use public disk
Logos were being written to the default filesystem disk, which
resolves to storage/app/private and isn't web-servable, so the
frontend couldn't load the image. Store logos on the public disk
(storage/app/public, symlinked to public/storage) instead, and
resolve logoUrl() against that disk explicitly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015HaT8a5ZWh45JkjTLPWeaP
2026-09-03 00:30:50 +07:00
9 changed files with 38 additions and 22 deletions
@@ -328,7 +328,7 @@ class ManageSuggestions extends Page implements HasTable
/** /**
* Polls a sync job's status (wire:poll.2s, see the view). On "finished", * Polls a sync job's status (wire:poll.2s, see the view). On "finished",
* chains a reload-index call same two-step flow shweai_backend's admin * chains a reload-index call same two-step flow shweai_backend's admin
* JS does (POST sync-chroma poll POST reload-index) and refreshes * JS does (POST sync poll POST reload-index) and refreshes
* the table. On "failed", notifies and stops polling. * the table. On "failed", notifies and stops polling.
*/ */
public function pollSyncStatus(): void public function pollSyncStatus(): void
@@ -122,8 +122,8 @@ test('deleteSelected bulk action calls batchDeleteSuggestions with just the sele
test('sync sets job state and polling chains a reload-index call on finished', function () { test('sync sets job state and polling chains a reload-index call on finished', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) { Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) {
str_contains($request->url(), '/sync-chroma/job-1') => Http::response(['status' => 'finished', 'result' => ['synced' => 3]]), str_contains($request->url(), '/sync/job-1') => Http::response(['status' => 'finished', 'result' => ['synced' => 3]]),
str_contains($request->url(), '/sync-chroma') => Http::response(['job_id' => 'job-1'], 202), str_contains($request->url(), '/sync') => Http::response(['job_id' => 'job-1'], 202),
str_contains($request->url(), '/reload-index') => Http::response(['trie_rebuilt' => true]), str_contains($request->url(), '/reload-index') => Http::response(['trie_rebuilt' => true]),
default => Http::response([]), default => Http::response([]),
}]); }]);
@@ -144,8 +144,8 @@ test('sync sets job state and polling chains a reload-index call on finished', f
test('a failed sync job notifies danger and stops polling', function () { test('a failed sync job notifies danger and stops polling', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) { Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) {
str_contains($request->url(), '/sync-chroma/job-1') => Http::response(['status' => 'failed', 'result' => null]), str_contains($request->url(), '/sync/job-1') => Http::response(['status' => 'failed', 'result' => null]),
str_contains($request->url(), '/sync-chroma') => Http::response(['job_id' => 'job-1'], 202), str_contains($request->url(), '/sync') => Http::response(['job_id' => 'job-1'], 202),
default => Http::response([]), default => Http::response([]),
}]); }]);
@@ -182,6 +182,6 @@ test('syncEmbedding and deleteEmbedding row actions call the right per-id endpoi
$test->callTableAction('syncEmbedding', 1)->assertNotified('Embedding synced'); $test->callTableAction('syncEmbedding', 1)->assertNotified('Embedding synced');
$test->callTableAction('deleteEmbedding', 1)->assertNotified('Embedding deleted'); $test->callTableAction('deleteEmbedding', 1)->assertNotified('Embedding deleted');
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions/1/sync-chroma') && $request->method() === 'POST'); Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions/1/sync') && $request->method() === 'POST');
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions/1/chroma') && $request->method() === 'DELETE'); Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions/1/embedding') && $request->method() === 'DELETE');
}); });
@@ -32,7 +32,9 @@ class EvCompanyForm
->required() ->required()
->maxLength(255), ->maxLength(255),
FileUpload::make('logo') FileUpload::make('logo')
->image(), ->image()
->disk('public')
->directory('logos'),
Toggle::make('is_active') Toggle::make('is_active')
->required() ->required()
->default(true), ->default(true),
@@ -18,6 +18,7 @@ class EvCompaniesTable
return $table return $table
->columns([ ->columns([
ImageColumn::make('logo') ImageColumn::make('logo')
->disk('public')
->circular(), ->circular(),
TextColumn::make('name') TextColumn::make('name')
->searchable() ->searchable()
+5 -4
View File
@@ -78,9 +78,10 @@ class EvCompany extends Model
/** /**
* `logo` is stored as the disk-relative path Filament's FileUpload * `logo` is stored as the disk-relative path Filament's FileUpload
* writes (e.g. "logos/xxx.png"), not a URL API consumers need a full * writes (e.g. "logos/xxx.png"), not a URL API consumers need a full
* absolute URL to render it directly. Guards against the disk itself * absolute URL to render it directly. Logos are always written to the
* already returning an absolute URL (e.g. an s3 disk), so this stays * "public" disk (storage/app/public, symlinked to public/storage) so
* correct if the storage disk ever changes from local. * the frontend can actually reach the image, regardless of what the
* app's default filesystem disk is configured to.
*/ */
public function logoUrl(): Attribute public function logoUrl(): Attribute
{ {
@@ -90,7 +91,7 @@ class EvCompany extends Model
return null; return null;
} }
$url = Storage::disk(config('filesystems.default'))->url($this->logo); $url = Storage::disk('public')->url($this->logo);
return str($url)->startsWith(['http://', 'https://']) ? $url : url($url); return str($url)->startsWith(['http://', 'https://']) ? $url : url($url);
}, },
@@ -26,7 +26,7 @@ test('returns the company logo as a full absolute url', function () {
$this->withHeader('Authorization', "Bearer {$this->token}") $this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/companies') ->getJson('/api/v1/companies')
->assertSuccessful() ->assertSuccessful()
->assertJsonFragment(['logo' => url(Storage::disk(config('filesystems.default'))->url($company->logo))]); ->assertJsonFragment(['logo' => url(Storage::disk('public')->url($company->logo))]);
}); });
test('returns a null logo when the company has none', function () { test('returns a null logo when the company has none', function () {
@@ -1,6 +1,7 @@
<?php <?php
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Livewire\Livewire; use Livewire\Livewire;
use Modules\Catalog\Filament\Resources\EvCompanies\Pages\CreateEvCompany; use Modules\Catalog\Filament\Resources\EvCompanies\Pages\CreateEvCompany;
@@ -26,6 +27,17 @@ test('can list ev companies', function () {
->assertCanSeeTableRecords($companies); ->assertCanSeeTableRecords($companies);
}); });
test('renders the logo from the public disk in the table', function () {
Storage::fake('public');
Storage::disk('public')->put('logos/example.png', 'fake-image-contents');
$company = EvCompany::factory()->create(['logo' => 'logos/example.png']);
Livewire::test(ListEvCompanies::class)
->assertOk()
->assertSee(Storage::disk('public')->url($company->logo), escape: false);
});
test('can create an ev company', function () { test('can create an ev company', function () {
$company = EvCompany::factory()->make(); $company = EvCompany::factory()->make();
@@ -282,7 +282,7 @@ class BnfexpressAdminClient
*/ */
public function syncSuggestions(): array public function syncSuggestions(): array
{ {
return $this->request('POST', '/admin/suggestions/sync-chroma'); return $this->request('POST', '/admin/suggestions/sync');
} }
/** /**
@@ -290,7 +290,7 @@ class BnfexpressAdminClient
*/ */
public function getSuggestionSyncStatus(string $jobId): array public function getSuggestionSyncStatus(string $jobId): array
{ {
return $this->request('GET', "/admin/suggestions/sync-chroma/{$jobId}"); return $this->request('GET', "/admin/suggestions/sync/{$jobId}");
} }
/** /**
@@ -298,7 +298,7 @@ class BnfexpressAdminClient
*/ */
public function syncOneSuggestion(int|string $id): array public function syncOneSuggestion(int|string $id): array
{ {
return $this->request('POST', "/admin/suggestions/{$id}/sync-chroma"); return $this->request('POST', "/admin/suggestions/{$id}/sync");
} }
/** /**
@@ -314,7 +314,7 @@ class BnfexpressAdminClient
*/ */
public function deleteSuggestionEmbedding(int|string $id): array public function deleteSuggestionEmbedding(int|string $id): array
{ {
return $this->request('DELETE', "/admin/suggestions/{$id}/chroma"); return $this->request('DELETE', "/admin/suggestions/{$id}/embedding");
} }
// --- Request plumbing ------------------------------------------------ // --- Request plumbing ------------------------------------------------
@@ -175,13 +175,13 @@ test('promoteSuggestionMisses posts miss_ids with an optional lang/intent overri
&& $request->body() === json_encode(['miss_ids' => [1, 2], 'lang' => 'my'])); && $request->body() === json_encode(['miss_ids' => [1, 2], 'lang' => 'my']));
}); });
test('syncSuggestions triggers a sync-chroma job and returns the job id', function () use ($config) { test('syncSuggestions triggers a sync job and returns the job id', function () use ($config) {
Http::fake(['bnfexpress.test/*' => Http::response(['job_id' => 'abc-123'], 202)]); Http::fake(['bnfexpress.test/*' => Http::response(['job_id' => 'abc-123'], 202)]);
$result = (new BnfexpressAdminClient($config))->syncSuggestions(); $result = (new BnfexpressAdminClient($config))->syncSuggestions();
expect($result)->toBe(['job_id' => 'abc-123']); expect($result)->toBe(['job_id' => 'abc-123']);
Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/suggestions/sync-chroma' Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/suggestions/sync'
&& $request->method() === 'POST'); && $request->method() === 'POST');
}); });
@@ -191,7 +191,7 @@ test('getSuggestionSyncStatus polls the job status endpoint', function () use ($
$result = (new BnfexpressAdminClient($config))->getSuggestionSyncStatus('abc-123'); $result = (new BnfexpressAdminClient($config))->getSuggestionSyncStatus('abc-123');
expect($result)->toBe(['status' => 'finished', 'result' => ['synced' => 3]]); expect($result)->toBe(['status' => 'finished', 'result' => ['synced' => 3]]);
Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/suggestions/sync-chroma/abc-123' Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/suggestions/sync/abc-123'
&& $request->method() === 'GET'); && $request->method() === 'GET');
}); });
@@ -201,8 +201,8 @@ test('deleteSuggestionEmbedding signs a bodyless DELETE request', function () us
(new BnfexpressAdminClient($config))->deleteSuggestionEmbedding(5); (new BnfexpressAdminClient($config))->deleteSuggestionEmbedding(5);
Http::assertSent(function ($request) use ($config) { Http::assertSent(function ($request) use ($config) {
return $request->url() === 'https://bnfexpress.test/admin/suggestions/5/chroma' return $request->url() === 'https://bnfexpress.test/admin/suggestions/5/embedding'
&& $request->method() === 'DELETE' && $request->method() === 'DELETE'
&& assertSignedCorrectly($request, 'DELETE', '/admin/suggestions/5/chroma', '', $config['client_secret']); && assertSignedCorrectly($request, 'DELETE', '/admin/suggestions/5/embedding', '', $config['client_secret']);
}); });
}); });