From 4298544381c6dea6900bcac2ddb5fc357e2d1a11 Mon Sep 17 00:00:00 2001 From: sean Date: Tue, 25 Aug 2026 18:33:56 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20phase=202=20=E2=80=94=20master=20data?= =?UTF-8?q?=20(products,=20customers,=20drivers)=20via=20InsForge-backed?= =?UTF-8?q?=20Filament=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Filament/Pages/Masters/Customers.php | 112 +++++++++++ app/Filament/Pages/Masters/Drivers.php | 122 ++++++++++++ app/Filament/Pages/Masters/Products.php | 124 ++++++++++++ app/Services/CustomerService.php | 36 ++++ app/Services/DriverService.php | 36 ++++ app/Services/InsForge/InsForgeClient.php | 14 +- .../InsForge/InsForgeTableService.php | 145 +++++++++++++++ app/Services/ProductService.php | 36 ++++ .../views/filament/pages/blank.blade.php | 3 + tests/Feature/Auth/AuthenticationTest.php | 8 +- tests/Feature/MasterData/MasterDataTest.php | 176 ++++++++++++++++++ tests/Support/InsForgeFake.php | 96 +++++++--- 12 files changed, 870 insertions(+), 38 deletions(-) create mode 100644 app/Filament/Pages/Masters/Customers.php create mode 100644 app/Filament/Pages/Masters/Drivers.php create mode 100644 app/Filament/Pages/Masters/Products.php create mode 100644 app/Services/CustomerService.php create mode 100644 app/Services/DriverService.php create mode 100644 app/Services/InsForge/InsForgeTableService.php create mode 100644 app/Services/ProductService.php create mode 100644 resources/views/filament/pages/blank.blade.php create mode 100644 tests/Feature/MasterData/MasterDataTest.php diff --git a/app/Filament/Pages/Masters/Customers.php b/app/Filament/Pages/Masters/Customers.php new file mode 100644 index 0000000..6ef9b3f --- /dev/null +++ b/app/Filament/Pages/Masters/Customers.php @@ -0,0 +1,112 @@ +records(fn (?string $search, ?string $sortColumn, ?string $sortDirection, int|string $page, int|string $recordsPerPage) => $service->paginate( + search: $search, + sortColumn: $sortColumn, + sortDirection: $sortDirection, + page: (int) $page, + perPage: (int) $recordsPerPage, + )) + ->columns([ + TextColumn::make('customer_code')->label('Kode')->sortable(), + TextColumn::make('name')->label('Nama')->searchable()->sortable(), + TextColumn::make('address')->label('Alamat')->limit(40), + TextColumn::make('phone')->label('Telepon'), + TextColumn::make('notes')->label('Catatan')->limit(30), + ]) + ->headerActions([ + CreateAction::make() + ->schema(fn (Schema $schema): Schema => $schema->components(self::customerFormSchema())) + ->using(function (array $data): void { + app(CustomerService::class)->create($data); + }), + ]) + ->recordActions([ + EditAction::make() + ->schema(fn (Schema $schema): Schema => $schema->components(self::customerFormSchema())) + ->fillForm(fn (array $record): array => $record) + ->action(function (array $data, array $record): void { + app(CustomerService::class)->update($record['id'], $data); + }), + Action::make('hapus') + ->label('Hapus') + ->color('danger') + ->icon('heroicon-o-trash') + ->requiresConfirmation() + ->modalDescription('Yakin menghapus pelanggan ini?') + ->action(function (array $record): void { + app(CustomerService::class)->delete($record['id']); + }), + ]) + ->paginated([10, 25, 50]); + } + + /** + * @return list + */ + private static function customerFormSchema(): array + { + return [ + TextInput::make('customer_code')->label('Kode')->default(fn () => app(CustomerService::class)->nextCode())->required()->maxLength(20), + TextInput::make('name')->label('Nama Pelanggan')->required()->maxLength(150), + TextInput::make('address')->label('Alamat')->maxLength(255), + TextInput::make('phone')->label('Telepon')->tel()->maxLength(30), + Textarea::make('notes')->label('Catatan')->columnSpanFull(), + ]; + } + + public function content(Schema $schema): Schema + { + return $schema + ->components([ + EmbeddedTable::make(), + ]); + } +} diff --git a/app/Filament/Pages/Masters/Drivers.php b/app/Filament/Pages/Masters/Drivers.php new file mode 100644 index 0000000..91fdc2e --- /dev/null +++ b/app/Filament/Pages/Masters/Drivers.php @@ -0,0 +1,122 @@ +records(fn (?string $search, ?string $sortColumn, ?string $sortDirection, int|string $page, int|string $recordsPerPage) => $service->paginate( + search: $search, + sortColumn: $sortColumn, + sortDirection: $sortDirection, + page: (int) $page, + perPage: (int) $recordsPerPage, + )) + ->columns([ + TextColumn::make('driver_code')->label('Kode')->sortable(), + TextColumn::make('name')->label('Nama')->searchable()->sortable(), + TextColumn::make('phone')->label('Telepon'), + IconColumn::make('is_active')->label('Aktif')->boolean(), + TextColumn::make('notes')->label('Catatan')->limit(30), + ]) + ->headerActions([ + CreateAction::make() + ->schema(fn (Schema $schema): Schema => $schema->components(self::driverFormSchema())) + ->using(function (array $data): void { + $data['is_active'] = true; + app(DriverService::class)->create($data); + }), + ]) + ->recordActions([ + EditAction::make() + ->schema(fn (Schema $schema): Schema => $schema->components(self::driverFormSchema(withStatus: true))) + ->fillForm(fn (array $record): array => $record) + ->action(function (array $data, array $record): void { + $data['is_active'] = (bool) ($data['is_active'] ?? true); + app(DriverService::class)->update($record['id'], $data); + }), + Action::make('hapus') + ->label('Hapus') + ->color('danger') + ->icon('heroicon-o-trash') + ->requiresConfirmation() + ->modalDescription('Yakin menghapus pengemudi ini?') + ->action(function (array $record): void { + app(DriverService::class)->delete($record['id']); + }), + ]) + ->paginated([10, 25, 50]); + } + + /** + * @return list + */ + private static function driverFormSchema(bool $withStatus = false): array + { + return [ + TextInput::make('driver_code')->label('Kode')->default(fn () => app(DriverService::class)->nextCode())->required()->maxLength(20), + TextInput::make('name')->label('Nama Pengemudi')->required()->maxLength(150), + TextInput::make('phone')->label('Telepon')->tel()->maxLength(30), + Textarea::make('notes')->label('Catatan')->columnSpanFull(), + ...($withStatus ? [Select::make('is_active')->label('Status')->options([1 => 'Aktif', 0 => 'Nonaktif'])->default(1)->required()] : []), + ]; + } + + public function content(Schema $schema): Schema + { + return $schema + ->components([ + EmbeddedTable::make(), + ]); + } +} diff --git a/app/Filament/Pages/Masters/Products.php b/app/Filament/Pages/Masters/Products.php new file mode 100644 index 0000000..add5c40 --- /dev/null +++ b/app/Filament/Pages/Masters/Products.php @@ -0,0 +1,124 @@ +records(fn (?string $search, ?string $sortColumn, ?string $sortDirection, int|string $page, int|string $recordsPerPage) => $service->paginate( + search: $search, + sortColumn: $sortColumn, + sortDirection: $sortDirection, + page: (int) $page, + perPage: (int) $recordsPerPage, + )) + ->columns([ + TextColumn::make('code')->label('Kode')->sortable(), + TextColumn::make('name')->label('Nama')->searchable()->sortable(), + TextColumn::make('unit')->label('Satuan'), + TextColumn::make('weight')->label('Berat'), + TextColumn::make('stock_minimum')->label('Stok Minimum')->numeric()->sortable(), + TextColumn::make('cost_price')->label('Harga Beli')->numeric()->prefix('Rp ')->sortable(), + IconColumn::make('is_active')->label('Aktif')->boolean(), + ]) + ->headerActions([ + CreateAction::make() + ->schema(fn (Schema $schema): Schema => $schema->components(self::productFormSchema())) + ->using(function (array $data): void { + $data['is_active'] = true; + app(ProductService::class)->create($data); + }), + ]) + ->recordActions([ + EditAction::make() + ->schema(fn (Schema $schema): Schema => $schema->components(self::productFormSchema(withStatus: true))) + ->fillForm(fn (array $record): array => $record) + ->action(function (array $data, array $record): void { + app(ProductService::class)->update($record['id'], $data); + }), + Action::make('hapus') + ->label('Hapus') + ->color('danger') + ->icon('heroicon-o-trash') + ->requiresConfirmation() + ->modalDescription('Yakin menghapus produk ini?') + ->action(function (array $record): void { + app(ProductService::class)->delete($record['id']); + }), + ]) + ->paginated([10, 25, 50]); + } + + /** + * @return list + */ + private static function productFormSchema(bool $withStatus = false): array + { + return [ + TextInput::make('code')->label('Kode')->default(fn () => app(ProductService::class)->nextCode())->required()->maxLength(50), + TextInput::make('name')->label('Nama Produk')->required()->maxLength(150), + Select::make('unit')->label('Satuan')->options(['kg' => 'Kilogram', 'sak' => 'Sak', 'ton' => 'Ton'])->default('kg')->required(), + TextInput::make('weight')->label('Berat')->numeric()->default(0), + TextInput::make('stock_minimum')->label('Stok Minimum')->numeric()->default(0), + TextInput::make('cost_price')->label('Harga Beli')->numeric()->default(0), + ...($withStatus ? [Select::make('is_active')->label('Status')->options([1 => 'Aktif', 0 => 'Nonaktif'])->default(1)->required()] : []), + ]; + } + + public function content(Schema $schema): Schema + { + return $schema + ->components([ + EmbeddedTable::make(), + ]); + } +} diff --git a/app/Services/CustomerService.php b/app/Services/CustomerService.php new file mode 100644 index 0000000..3759f8e --- /dev/null +++ b/app/Services/CustomerService.php @@ -0,0 +1,36 @@ +client->listRecords($this->table(), [ + 'order' => 'customer_code.desc.nullslast', + 'limit' => 1, + 'select' => 'customer_code', + ])[0]['customer_code'] ?? null; + + preg_match('/(\d+)$/', (string) $last, $matches); + + return sprintf('CUST-%04d', ((int) ($matches[1] ?? 0)) + 1); + } +} diff --git a/app/Services/DriverService.php b/app/Services/DriverService.php new file mode 100644 index 0000000..ad48da8 --- /dev/null +++ b/app/Services/DriverService.php @@ -0,0 +1,36 @@ +client->listRecords($this->table(), [ + 'order' => 'driver_code.desc.nullslast', + 'limit' => 1, + 'select' => 'driver_code', + ])[0]['driver_code'] ?? null; + + preg_match('/(\d+)$/', (string) $last, $matches); + + return sprintf('DRV-%04d', ((int) ($matches[1] ?? 0)) + 1); + } +} diff --git a/app/Services/InsForge/InsForgeClient.php b/app/Services/InsForge/InsForgeClient.php index 4eb57f5..ae93f8b 100644 --- a/app/Services/InsForge/InsForgeClient.php +++ b/app/Services/InsForge/InsForgeClient.php @@ -40,17 +40,17 @@ class InsForgeClient return $this->request('GET', '/api/auth/profiles/current')->json(); } - public function listRecords(string $table, array $params = []): array + public function listRecords(string $table, array $params = [], bool $asUser = false): array { - return $this->request('GET', "/api/database/records/{$table}", query: $params)->json() ?? []; + return $this->request('GET', "/api/database/records/{$table}", query: $params, asUser: $asUser)->json() ?? []; } - public function getRecord(string $table, string $id, array $params = []): ?array + public function getRecord(string $table, string $id, array $params = [], bool $asUser = false): ?array { $params['id'] = 'eq.'.$id; $params['limit'] = 1; - return $this->listRecords($table, $params)[0] ?? null; + return $this->listRecords($table, $params, $asUser)[0] ?? null; } public function insertRecord(string $table, array $data, bool $asUser = true): array @@ -118,9 +118,11 @@ class InsForgeClient $verb = strtolower($method); + $uri = $query !== [] ? $path.'?'.http_build_query($query) : $path; + $response = in_array($verb, ['get', 'head']) - ? $http->{$verb}($path, $query) - : $http->{$verb}($path, $body); + ? $http->{$verb}($uri) + : $http->{$verb}($uri, $body); throw_unless($response->successful(), fn () => InsForgeException::fromResponse( $response->status(), diff --git a/app/Services/InsForge/InsForgeTableService.php b/app/Services/InsForge/InsForgeTableService.php new file mode 100644 index 0000000..2e6836f --- /dev/null +++ b/app/Services/InsForge/InsForgeTableService.php @@ -0,0 +1,145 @@ + */ + abstract protected function searchableColumns(): array; + + abstract protected function sortableColumns(): array; + + protected function defaultSortColumn(): string + { + return 'created_at'; + } + + protected function defaultSortDirection(): string + { + return 'desc'; + } + + /** @param array $filters column => value (eq) */ + public function paginate( + ?string $search = null, + ?string $sortColumn = null, + ?string $sortDirection = null, + int $page = 1, + int $perPage = 25, + array $filters = [], + ): LengthAwarePaginator { + $query = [ + 'select' => '*', + 'limit' => max(1, min($perPage, 100)), + 'offset' => ($page - 1) * $perPage, + 'order' => sprintf( + '%s.%s.nullslast', + in_array($sortColumn, $this->sortableColumns(), true) ? $sortColumn : $this->defaultSortColumn(), + strtolower($sortDirection ?? '') === 'asc' ? 'asc' : $this->defaultSortDirection(), + ), + ]; + + foreach ($filters as $column => $value) { + if ($value !== null && $value !== '') { + $query[$column] = 'eq.'.$value; + } + } + + if (filled($search) && $this->searchableColumns() !== []) { + $terms = str_replace(['(', ')', ','], ' ', $search); + $clauses = array_map( + fn (string $column): string => sprintf('%s.ilike.*%s*', $column, trim($terms)), + $this->searchableColumns(), + ); + $query['or'] = '('.implode(',', $clauses).')'; + } + + [$client, $asUser] = $this->scoped(); + + $response = $client->send('GET', '/api/database/records/'.$this->table(), query: $query, asUser: $asUser, headers: ['Prefer' => 'count=exact']); + + $rows = $response->json() ?? []; + $total = $this->parseTotal($response->header('Content-Range')) ?? count($rows); + + return new LengthAwarePaginator( + items: $rows, + total: $total, + perPage: $perPage, + currentPage: $page, + ); + } + + public function find(string $id): ?array + { + [$client, $asUser] = $this->scoped(); + + return $client->getRecord($this->table(), $id, asUser: $asUser); + } + + /** @param array $filters */ + public function findBy(array $filters): ?array + { + [$client, $asUser] = $this->scoped(); + + $params = ['limit' => 1]; + + foreach ($filters as $column => $value) { + $params[$column] = 'eq.'.$value; + } + + return $client->listRecords($this->table(), $params, asUser: $asUser)[0] ?? null; + } + + public function create(array $data): void + { + [$client, $asUser] = $this->scoped(); + + $client->insertRecord($this->table(), [$data], asUser: $asUser); + } + + public function update(string $id, array $data): void + { + [$client, $asUser] = $this->scoped(); + + $client->updateRecords($this->table(), ['id' => $id], $data, asUser: $asUser); + } + + public function delete(string $id): void + { + [$client, $asUser] = $this->scoped(); + + $client->deleteRecords($this->table(), ['id' => $id], asUser: $asUser); + } + + /** + * Pakai JWT user saat ada sesi (RLS aktif); jatuh ke admin key untuk konteks tanpa sesi. + * + * @return array{0: InsForgeClient, 1: bool} + */ + private function scoped(): array + { + $token = session(InsForgeAuthService::SESSION_ACCESS_TOKEN); + + return [ + $token ? $this->client->withToken($token) : $this->client, + filled($token), + ]; + } + + private function parseTotal(?string $contentRange): ?int + { + if (! filled($contentRange)) { + return null; + } + + $total = explode('/', $contentRange)[1] ?? ''; + + return ctype_digit($total) ? (int) $total : null; + } +} diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php new file mode 100644 index 0000000..e971da0 --- /dev/null +++ b/app/Services/ProductService.php @@ -0,0 +1,36 @@ +client->listRecords($this->table(), [ + 'order' => 'code.desc.nullslast', + 'limit' => 1, + 'select' => 'code', + ])[0]['code'] ?? null; + + preg_match('/(\d+)$/', (string) $last, $matches); + + return sprintf('BERAS-%03d', ((int) ($matches[1] ?? 0)) + 1); + } +} diff --git a/resources/views/filament/pages/blank.blade.php b/resources/views/filament/pages/blank.blade.php new file mode 100644 index 0000000..a741733 --- /dev/null +++ b/resources/views/filament/pages/blank.blade.php @@ -0,0 +1,3 @@ + + {{ $this->content }} + diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index c9340a5..39796ff 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -59,14 +59,16 @@ class AuthenticationTest extends TestCase public function test_mirror_profile_is_created_when_missing(): void { InsForgeFake::fakeLoginSuccess($this->adminProfile()); - unset(InsForgeFake::$profiles['admin@beraspro.test']); + InsForgeFake::reset(); Livewire::test(Login::class) ->fillForm(['email' => 'admin@beraspro.test', 'password' => 'secret']) ->call('authenticate'); - $this->assertArrayHasKey('admin@beraspro.test', InsForgeFake::$profiles); - $this->assertSame('operator', InsForgeFake::$profiles['admin@beraspro.test']['role']); + $profiles = array_filter(InsForgeFake::$store['users'] ?? [], fn (array $row): bool => $row['email'] === 'admin@beraspro.test'); + + $this->assertCount(1, $profiles); + $this->assertSame('operator', reset($profiles)['role']); } public function test_invalid_credentials_do_not_authenticate(): void diff --git a/tests/Feature/MasterData/MasterDataTest.php b/tests/Feature/MasterData/MasterDataTest.php new file mode 100644 index 0000000..e451ac9 --- /dev/null +++ b/tests/Feature/MasterData/MasterDataTest.php @@ -0,0 +1,176 @@ + '11111111-1111-1111-1111-111111111111', + 'name' => 'Admin BerasPro', + 'email' => 'admin@beraspro.test', + 'password' => '$2y$04$hash', + 'role' => 'admin', + 'is_active' => true, + ]); + + Livewire::test(Login::class) + ->fillForm(['email' => 'admin@beraspro.test', 'password' => 'secret']) + ->call('authenticate') + ->assertHasNoErrors(); + + $this->assertAuthenticated(); + } + + private function actingAsRole(string $role): User + { + $user = new User; + + $user->forceFill([ + 'id' => "33333333-3333-3333-3333-{$role}0000000", + 'name' => ucfirst($role), + 'email' => "{$role}@beraspro.test", + 'role' => $role, + 'is_active' => true, + ]); + + InsForgeFake::reset(); + InsForgeFake::fakeRest(); + + $this->actingAs($user); + + return $user; + } + + public function test_products_page_lists_records_from_insforge(): void + { + $this->loginAsAdmin(); + + $this->assertTrue(auth()->check(), 'admin should be authenticated after login'); + $this->assertTrue(auth()->user()->isAdmin(), 'authenticated user should have admin role'); + + InsForgeFake::seed('products', [ + 'id' => '00000000-0000-0000-0000-000000000101', + 'code' => 'BERAS-001', + 'name' => 'Beras Premium 5kg', + 'unit' => 'sak', + 'weight' => 5, + 'stock_minimum' => 100, + 'cost_price' => 13000, + 'is_active' => true, + ]); + + InsForgeFake::fakeRest(); + + Livewire::test(Products::class) + ->assertSuccessful() + ->assertSee('Beras Premium 5kg'); + } + + public function test_products_page_is_denied_for_operator(): void + { + $this->actingAsRole('operator'); + + $this->get('/admin/'.(new Products)->getSlug())->assertForbidden(); + } + + public function test_customers_page_is_accessible_for_operator(): void + { + $this->actingAsRole('operator'); + + Livewire::test(Customers::class)->assertSuccessful(); + } + + public function test_drivers_page_is_denied_for_operator(): void + { + $this->actingAsRole('operator'); + + $this->get('/admin/'.(new Drivers)->getSlug())->assertForbidden(); + } + + public function test_drivers_page_is_accessible_for_admin(): void + { + $this->loginAsAdmin(); + InsForgeFake::fakeRest(); + + Livewire::test(Drivers::class)->assertSuccessful(); + } + + public function test_product_service_creates_record_via_post(): void + { + InsForgeFake::reset(); + InsForgeFake::fakeRest(); + + app(ProductService::class)->create([ + 'code' => 'BERAS-002', + 'name' => 'Beras Merah', + 'unit' => 'kg', + 'weight' => 25, + 'stock_minimum' => 50, + 'cost_price' => 12000, + 'is_active' => true, + ]); + + $stored = array_values(InsForgeFake::$store['products'])[0]; + + $this->assertSame('BERAS-002', $stored['code']); + $this->assertSame(12000, $stored['cost_price']); + } + + public function test_customer_service_updates_and_deletes(): void + { + InsForgeFake::reset(); + InsForgeFake::fakeRest(); + + InsForgeFake::seed('customers', [ + 'id' => 'aaaa1111-0000-0000-0000-000000000001', + 'customer_code' => 'CUST-0001', + 'name' => 'Andi', + 'phone' => '0812', + ]); + + $service = app(CustomerService::class); + + $found = $service->findBy(['customer_code' => 'CUST-0001']); + $this->assertNotNull($found); + + $service->update($found['id'], ['name' => 'Andi Updated']); + $this->assertSame('Andi Updated', $service->find($found['id'])['name']); + + $service->delete($found['id']); + $this->assertNull($service->find($found['id'])); + } + + public function test_driver_service_paginates_with_total(): void + { + InsForgeFake::reset(); + InsForgeFake::fakeRest(); + + for ($i = 1; $i <= 3; $i++) { + InsForgeFake::seed('drivers', [ + 'id' => sprintf('bbbb2222-0000-0000-0000-00000000000%d', $i), + 'driver_code' => sprintf('DRV-%04d', $i), + 'name' => "Driver {$i}", + 'is_active' => true, + ]); + } + + $paginator = app(DriverService::class)->paginate(page: 1, perPage: 2); + + $this->assertCount(2, $paginator->items()); + $this->assertSame(3, $paginator->total()); + } +} diff --git a/tests/Support/InsForgeFake.php b/tests/Support/InsForgeFake.php index 10eaf5f..4accd0c 100644 --- a/tests/Support/InsForgeFake.php +++ b/tests/Support/InsForgeFake.php @@ -7,13 +7,19 @@ use Illuminate\Support\Facades\Http; class InsForgeFake { - public static array $profiles = []; + /** @var array>> table => id => row */ + public static array $store = []; - public static string $accessToken = 'fake.header.'; + public static string $accessToken = ''; public static function reset(): void { - self::$profiles = []; + self::$store = []; + } + + public static function seed(string $table, array $row): void + { + self::$store[$table][$row['id']] = $row; } public static function fakeLoginSuccess(array $profile): void @@ -25,7 +31,7 @@ class InsForgeFake 'exp' => now()->addHour()->getTimestamp(), ])).'.sig'; - self::$profiles[$profile['email']] = $profile; + self::seed('users', $profile); Http::fake([ '*/api/auth/sessions' => Http::response([ @@ -39,12 +45,8 @@ class InsForgeFake ], 200, [ 'Set-Cookie' => 'insforge_refresh_token=fake-refresh-cookie; Path=/; HttpOnly', ]), - '*/api/auth/refresh' => Http::response([ - 'accessToken' => self::$accessToken, - ], 200), - '*/api/database/records/users*' => function (Request $request) { - return self::handleUsersRequest($request); - }, + '*/api/auth/refresh' => Http::response(['accessToken' => self::$accessToken], 200), + '*/api/database/records/*' => fn (Request $request) => self::handleRecordsRequest($request), ]); } @@ -61,25 +63,54 @@ class InsForgeFake ]); } - public static function fakeProfile(array $profile): void + public static function fakeRest(): void { - self::$profiles[$profile['email']] = $profile; + Http::fake([ + '*/api/auth/refresh' => Http::response(['accessToken' => self::$accessToken], 200), + '*/api/database/records/*' => fn (Request $request) => self::handleRecordsRequest($request), + ]); } - private static function handleUsersRequest(Request $request) + public static function handleRecordsRequest(Request $request) { + $path = parse_url($request->url(), PHP_URL_PATH) ?? ''; + $table = str_replace('/api/database/records/', '', $path); parse_str((string) parse_url($request->url(), PHP_URL_QUERY) ?? '', $query); $method = strtoupper($request->method()); + $rows = array_values(self::$store[$table] ?? []); + if ($method === 'GET') { - foreach (self::$profiles as $profile) { - if (($query['email'] ?? null) === 'eq.'.$profile['email'] - || ($query['id'] ?? null) === 'eq.'.$profile['id']) { - return Http::response([$profile], 200); + foreach ($query as $key => $value) { + if (in_array($key, ['select', 'limit', 'offset', 'order', 'or'], true)) { + continue; + } + + if (str_starts_with((string) $value, 'eq.')) { + $column = $key; + $expected = substr((string) $value, 3); + $rows = array_values(array_filter($rows, fn (array $row): bool => (string) ($row[$column] ?? '') === $expected)); } } - return Http::response([], 200); + $total = count($rows); + + $rows = array_slice( + $rows, + (int) ($query['offset'] ?? 0), + (int) ($query['limit'] ?? 25), + ); + + return Http::response( + array_map( + fn (array $row): array => isset($query['select']) && $query['select'] !== '*' + ? array_intersect_key($row, array_flip(explode(',', (string) $query['select']))) ?: $row + : $row, + $rows, + ), + 200, + ['Content-Range' => sprintf('0-%d/%d', max(count($rows) - 1, 0), $total)], + ); } if ($method === 'POST') { @@ -89,25 +120,32 @@ class InsForgeFake return Http::response([], 201); } - self::$profiles[$payload['email']] = array_merge([ - 'role' => 'operator', - 'is_active' => true, - ], $payload); + $payload = array_merge($payload, ['id' => $payload['id'] ?? fake()->uuid()]); + + self::$store[$table][$payload['id']] = $payload; return Http::response([], 201); } - if ($method === 'PATCH') { - $targetId = str_replace('eq.', '', $query['id'] ?? ''); + $targetId = str_replace('eq.', '', $query['id'] ?? ''); - foreach (self::$profiles as &$profile) { - if ($profile['id'] === $targetId) { - $profile = array_merge($profile, $request->data()); + if ($method === 'PATCH') { + $payload = $request->data(); + + foreach (self::$store[$table] as &$row) { + if (($row['id'] ?? null) === $targetId) { + $row = array_merge($row, $payload); } } - unset($profile); + unset($row); - return Http::response([], 200); + return Http::response([], 204); + } + + if ($method === 'DELETE') { + unset(self::$store[$table][$targetId]); + + return Http::response([], 204); } return Http::response([], 200);