From 982e80027d5fcf5b8bb24517b1c0e1e3a2381e62 Mon Sep 17 00:00:00 2001 From: F4ilji Date: Sun, 5 Jul 2026 02:31:27 +0500 Subject: [PATCH] docs: add partial updates implementation plan --- .../plans/2026-07-05-vikon-partial-updates.md | 1100 +++++++++++++++++ 1 file changed, 1100 insertions(+) create mode 100644 docs/compose/plans/2026-07-05-vikon-partial-updates.md diff --git a/docs/compose/plans/2026-07-05-vikon-partial-updates.md b/docs/compose/plans/2026-07-05-vikon-partial-updates.md new file mode 100644 index 0000000..814ce0b --- /dev/null +++ b/docs/compose/plans/2026-07-05-vikon-partial-updates.md @@ -0,0 +1,1100 @@ +# VikonPartialUpdates Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement partial module updates (per-part: common, struct, document...) in VikonIntegration with full backend support and Vue UI. + +**Architecture:** Backend-driven polling — frontend sends one request, backend executes the 4-step flow (request → poll → check → download), returns result. Atomic swap with `_new`/`_old` suffixes for crash safety. Separate Action for orchestration, Task for polling loop. + +**Tech Stack:** Laravel 10, PHP 8.1+, Vue 3 Composition API, Inertia.js, PHPUnit + +## Global Constraints + +- Porto Architecture: Route → Controller → Action → Task +- All PHP code in `app/Containers/VikonIntegration/` +- Tests in `app/Containers/VikonIntegration/Tests/Unit/` and `Tests/Feature/` +- Docker constraint: PHP/composer commands via `docker exec ntspi-php` +- Code in English, comments in English +- No Options API in Vue — Composition API only + +--- + +### Task 1: Add `atomicSwap()` to FilesystemTask + +**Covers:** [S3], [S7] + +**Files:** +- Modify: `app/Containers/VikonIntegration/Tasks/FilesystemTask.php` +- Test: `app/Containers/VikonIntegration/Tests/Unit/FilesystemTaskTest.php` + +**Interfaces:** +- Consumes: existing `isPathSafe()` method +- Produces: `atomicSwap(string $newEntryPath, string $currentEntryPath, string $baseDir, int $moduleId): bool` + +- [ ] **Step 1: Write the failing test** + +```php +tempDir = sys_get_temp_dir() . '/vikon_test_' . uniqid(); + File::makeDirectory($this->tempDir, 0755, true, true); + $this->fs = new FilesystemTask(); + } + + protected function tearDown(): void + { + File::deleteDirectory($this->tempDir); + parent::tearDown(); + } + + public function test_atomic_swap_replaces_existing_entry(): void + { + $base = $this->tempDir . '/module'; + File::makeDirectory($base, 0755, true, true); + + // Current entry exists + file_put_contents($base . '/common/index.php', 'tempDir . '/extracted/common'; + File::makeDirectory($newDir, 0755, true, true); + file_put_contents($newDir . '/index.php', 'fs->atomicSwap( + $newDir, + $base . '/common', + $base, + 1 + ); + + $this->assertTrue($result); + $this->assertFileExists($base . '/common/index.php'); + $this->assertStringContainsString('new', file_get_contents($base . '/common/index.php')); + $this->assertDirectoryExists($base . '/common_old'); + $this->assertDirectoryDoesNotExist($base . '/common_new'); + } + + public function test_atomic_swap_creates_new_entry_when_not_exists(): void + { + $base = $this->tempDir . '/module'; + File::makeDirectory($base, 0755, true, true); + + $newDir = $this->tempDir . '/extracted/assets'; + File::makeDirectory($newDir, 0755, true, true); + file_put_contents($newDir . '/style.css', 'body {}'); + + $result = $this->fs->atomicSwap( + $newDir, + $base . '/assets', + $base, + 1 + ); + + $this->assertTrue($result); + $this->assertFileExists($base . '/assets/style.css'); + $this->assertDirectoryDoesNotExist($base . '/assets_old'); + } + + public function test_atomic_swap_returns_false_on_path_traversal(): void + { + $result = $this->fs->atomicSwap( + '/etc/passwd', + $this->tempDir . '/module/etc', + $this->tempDir . '/module', + 1 + ); + + $this->assertFalse($result); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker exec ntspi-php php artisan test --filter=FilesystemTaskTest` +Expected: FAIL with "Call to undefined method atomicSwap" + +- [ ] **Step 3: Write minimal implementation** + +Add to `app/Containers/VikonIntegration/Tasks/FilesystemTask.php`: + +```php +public function atomicSwap( + string $newEntryPath, + string $currentEntryPath, + string $baseDir, + int $moduleId +): bool { + if (!$this->isPathSafe($newEntryPath, $baseDir) || !$this->isPathSafe($currentEntryPath, $baseDir)) { + return false; + } + + if (!File::exists($newEntryPath)) { + return false; + } + + $isFile = is_file($newEntryPath); + $newPostfix = $currentEntryPath . '_new'; + $oldPostfix = $currentEntryPath . '_old'; + + if (File::exists($currentEntryPath)) { + // Step 1: Remove stale _new if exists + if (File::exists($newPostfix)) { + $isFile ? File::delete($newPostfix) : File::deleteDirectory($newPostfix); + } + + // Step 2: Move new → _new + if (!$this->moveEntry($newEntryPath, $newPostfix, $isFile)) { + return false; + } + + // Step 3: Remove stale _old if exists + if (File::exists($oldPostfix)) { + $isFile ? File::delete($oldPostfix) : File::deleteDirectory($oldPostfix); + } + + // Step 4: Move current → _old + if (!$this->moveEntry($currentEntryPath, $oldPostfix, $isFile)) { + return false; + } + + // Step 5: Move _new → current + return $this->moveEntry($newPostfix, $currentEntryPath, $isFile); + } + + // Entry doesn't exist — move directly + return $this->moveEntry($newEntryPath, $currentEntryPath, $isFile); +} + +private function moveEntry(string $source, string $dest, bool $isFile): bool +{ + if ($isFile) { + $parent = dirname($dest); + if (!File::isDirectory($parent)) { + File::makeDirectory($parent, 0755, true, true); + } + return rename($source, $dest); + } + return rename($source, $dest); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `docker exec ntspi-php php artisan test --filter=FilesystemTaskTest` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/Containers/VikonIntegration/Tasks/FilesystemTask.php app/Containers/VikonIntegration/Tests/Unit/FilesystemTaskTest.php +git commit -m "feat(vikon): add atomicSwap to FilesystemTask for crash-safe updates" +``` + +--- + +### Task 2: Create PollPartStatusTask + +**Covers:** [S3], [S8] + +**Files:** +- Create: `app/Containers/VikonIntegration/Tasks/PollPartStatusTask.php` +- Test: `app/Containers/VikonIntegration/Tests/Unit/PollPartStatusTaskTest.php` + +**Interfaces:** +- Consumes: `HttpTask::getWithToken()` +- Produces: `run(string $operationIdentity, string $accessToken): array` returning `['status' => 'completed'|'failed'|'timeout']` + +- [ ] **Step 1: Write the failing test** + +```php +shouldReceive('json')->once()->andReturn(['status' => 'completed']); + + $http->shouldReceive('getWithToken') + ->once() + ->with( + Mockery::on(fn($endpoint) => str_contains($endpoint, 'getStatusPartGeneration')), + 'test-token', + 'api' + ) + ->andReturn($response); + + $task = new PollPartStatusTask($http, 3, 50); + $result = $task->run('op-123', 'test-token'); + + $this->assertEquals('completed', $result['status']); + } + + public function test_returns_failed_when_status_is_failed(): void + { + $http = Mockery::mock(HttpTask::class); + $response = Mockery::mock(); + $response->shouldReceive('json')->once()->andReturn([ + 'status' => 'failed', + 'message' => 'Generation error', + ]); + + $http->shouldReceive('getWithToken') + ->once() + ->andReturn($response); + + $task = new PollPartStatusTask($http, 3, 50); + $result = $task->run('op-123', 'test-token'); + + $this->assertEquals('failed', $result['status']); + $this->assertEquals('Generation error', $result['error'] ?? null); + } + + public function test_returns_timeout_after_max_attempts(): void + { + $http = Mockery::mock(HttpTask::class); + $response = Mockery::mock(); + $response->shouldReceive('json')->andReturn(['status' => 'pending']); + + $http->shouldReceive('getWithToken') + ->times(3) + ->andReturn($response); + + $task = new PollPartStatusTask($http, 0, 3); // interval=0, max=3 + $result = $task->run('op-123', 'test-token'); + + $this->assertEquals('timeout', $result['status']); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker exec ntspi-php php artisan test --filter=PollPartStatusTaskTest` +Expected: FAIL with "Class not found" + +- [ ] **Step 3: Write minimal implementation** + +Create `app/Containers/VikonIntegration/Tasks/PollPartStatusTask.php`: + +```php +maxAttempts; $attempt++) { + $response = $this->http->getWithToken( + "pull_updates/getStatusPartGenerationByNewCoreJson?operation_identity={$operationIdentity}", + $accessToken + ); + + $body = $response->json(); + $status = $body['status'] ?? 'unknown'; + + Log::info('Vikon poll part status', [ + 'operation' => $operationIdentity, + 'status' => $status, + 'attempt' => $attempt + 1, + ]); + + if ($status === 'completed') { + return ['status' => 'completed']; + } + + if ($status === 'failed') { + return [ + 'status' => 'failed', + 'error' => $body['message'] ?? 'Unknown error', + ]; + } + + if ($attempt < $this->maxAttempts - 1) { + sleep($this->interval); + } + } + + return ['status' => 'timeout']; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `docker exec ntspi-php php artisan test --filter=PollPartStatusTaskTest` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/Containers/VikonIntegration/Tasks/PollPartStatusTask.php app/Containers/VikonIntegration/Tests/Unit/PollPartStatusTaskTest.php +git commit -m "feat(vikon): add PollPartStatusTask for part update polling" +``` + +--- + +### Task 3: Add parts config to vikon.php + +**Covers:** [S8] + +**Files:** +- Modify: `config/vikon.php` +- Modify: `app/Containers/VikonIntegration/Providers/VikonServiceProvider.php` (if needed for new config) + +**Interfaces:** +- Consumes: existing config structure +- Produces: `config('vikon.parts')`, `config('vikon.poll_interval')`, `config('vikon.poll_max_attempts')` + +- [ ] **Step 1: Read current config** + +Read `config/vikon.php` to see current structure. + +- [ ] **Step 2: Add parts configuration** + +Add to `config/vikon.php` before the closing `];`: + +```php + 'parts' => [ + 1 => ['common', 'struct', 'document', 'education', 'managers', 'employees', 'objects', 'paid_edu', 'budget', 'vacant', 'grants', 'inter', 'catering', 'eduStandarts', 'corruption', 'antiterrorism'], + 2 => ['abitur'], + 6 => ['general', 'structure', 'faq', 'procedures', 'results-and-reports', 'plans', 'survey'], + ], + 'poll_interval' => (int) env('VIKON_POLL_INTERVAL', 3), + 'poll_max_attempts' => (int) env('VIKON_POLL_MAX_ATTEMPTS', 50), +``` + +- [ ] **Step 3: Verify config loads** + +Run: `docker exec ntspi-php php artisan tinker --execute="dump(config('vikon.parts'));"` +Expected: Array with module IDs as keys + +- [ ] **Step 4: Commit** + +```bash +git add config/vikon.php +git commit -m "feat(vikon): add parts config for partial updates" +``` + +--- + +### Task 4: Create UpdatePartAction + +**Covers:** [S3], [S5], [S6], [S7] + +**Files:** +- Create: `app/Containers/VikonIntegration/Actions/UpdatePartAction.php` +- Test: `app/Containers/VikonIntegration/Tests/Unit/UpdatePartActionTest.php` + +**Interfaces:** +- Consumes: `HttpTask`, `FilesystemTask::atomicSwap()`, `PollPartStatusTask` +- Produces: `run(int $moduleId, string $part, string $accessToken): array` + +- [ ] **Step 1: Write the failing test** + +```php +tempDir = sys_get_temp_dir() . '/vikon_update_test_' . uniqid(); + File::makeDirectory($this->tempDir, 0755, true, true); + } + + protected function tearDown(): void + { + File::deleteDirectory($this->tempDir); + Mockery::close(); + parent::tearDown(); + } + + public function test_rejects_invalid_module_id(): void + { + $http = Mockery::mock(HttpTask::class); + $fs = Mockery::mock(FilesystemTask::class); + $poll = Mockery::mock(PollPartStatusTask::class); + + $action = new UpdatePartAction($http, $fs, $poll, $this->tempDir, $this->tempDir, [ + 1 => ['path' => 'sveden', 'allowed_folders' => ['common']], + ]); + + $this->expectException(\RuntimeException::class); + $action->run(999, 'common', 'token'); + } + + public function test_rejects_invalid_part(): void + { + $http = Mockery::mock(HttpTask::class); + $fs = Mockery::mock(FilesystemTask::class); + $poll = Mockery::mock(PollPartStatusTask::class); + + $action = new UpdatePartAction($http, $fs, $poll, $this->tempDir, $this->tempDir, [ + 1 => ['path' => 'sveden', 'allowed_folders' => ['common']], + ]); + + $this->expectException(\RuntimeException::class); + $action->run(1, 'nonexistent', 'token'); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker exec ntspi-php php artisan test --filter=UpdatePartActionTest` +Expected: FAIL with "Class not found" + +- [ ] **Step 3: Write minimal implementation** + +Create `app/Containers/VikonIntegration/Actions/UpdatePartAction.php`: + +```php +modulesConfig[$moduleId] ?? throw new \RuntimeException("Unknown module: {$moduleId}"); + + if (!in_array($part, config('vikon.parts', [])[$moduleId] ?? [], true)) { + throw new \RuntimeException("Invalid part '{$part}' for module {$moduleId}"); + } + + Log::info('Vikon: starting part update', ['module' => $moduleId, 'part' => $part]); + + // Step 1: Request generation + $genResponse = $this->http->postWithToken( + 'pull_updates/requestGeneratePartByNewCoreJson', + $accessToken, + ['part' => $part] + ); + $genBody = $genResponse->json(); + + if (empty($genBody['operation_identity'])) { + throw new \RuntimeException('Failed to request part generation: ' . ($genBody['message'] ?? 'Unknown')); + } + + $operationIdentity = $genBody['operation_identity']; + $ttl = $genBody['ttl'] ?? 120; + + Log::info('Vikon: part generation requested', ['operation' => $operationIdentity, 'ttl' => $ttl]); + + // Step 2: Poll status + $pollResult = $this->pollStatus->run($operationIdentity, $accessToken); + + if ($pollResult['status'] !== 'completed') { + $error = $pollResult['error'] ?? $pollResult['status']; + throw new \RuntimeException("Part generation failed: {$error}"); + } + + // Step 3: Check result + $checkResponse = $this->http->postWithToken( + 'pull_updates/checkPartGenerationByNewCoreResultJson', + $accessToken, + ['operation_identity' => $operationIdentity, 'part' => $part] + ); + $checkBody = $checkResponse->json(); + + if (empty($checkBody['success'])) { + throw new \RuntimeException('Part not ready: ' . ($checkBody['message'] ?? 'Unknown')); + } + + // Step 4: Download and apply + $zipContent = $this->http->downloadWithToken( + "pull_updates/downloadPartByNewCoreResult?operation_identity={$operationIdentity}&part={$part}", + $accessToken + ); + + $tempPath = $this->storagePath . '/temp/' . $config['path'] . '_part'; + File::makeDirectory($tempPath, 0755, true, true); + + $zipFile = $tempPath . '/part.zip'; + file_put_contents($zipFile, $zipContent); + + $zip = new ZipArchive(); + if ($zip->open($zipFile) !== true) { + throw new \RuntimeException('Failed to open part ZIP'); + } + $zip->extractTo($tempPath); + $zip->close(); + File::delete($zipFile); + + $modulePath = $this->basePath . '/' . $config['path']; + $moduleConfig = $this->modulesConfig[$moduleId]; + + // Determine which module owns this part + $ownerModuleId = $moduleId; + if ($part !== 'abitur') { + // For non-ABITUR parts, apply to the module that owns them + $ownerModuleId = $moduleId; + } + + $syncedCount = $this->applyPart($part, $tempPath, $modulePath, $ownerModuleId, $moduleConfig); + + // Clean temp + File::deleteDirectory($tempPath); + + Log::info('Vikon: part update complete', ['module' => $moduleId, 'part' => $part, 'synced' => $syncedCount]); + + return [ + 'success' => true, + "message" => "Part '{$part}' updated successfully.", + 'synced_count' => $syncedCount, + ]; + } + + private function applyPart( + string $part, + string $tempPath, + string $modulePath, + int $moduleId, + array $moduleConfig + ): int { + if ($part === 'abitur') { + return $this->applyAbiturPart($tempPath, $modulePath, $moduleId, $moduleConfig); + } + + return $this->applyRegularPart($part, $tempPath, $modulePath, $moduleId, $moduleConfig); + } + + private function applyRegularPart( + string $part, + string $tempPath, + string $modulePath, + int $moduleId, + array $moduleConfig + ): int { + $partSource = $tempPath . '/' . $part; + $partTarget = $modulePath . '/' . $part; + + if (!File::exists($partSource)) { + // ZIP might have the part nested under a directory + $extractedDirs = File::directories($tempPath); + if (!empty($extractedDirs)) { + $partSource = $extractedDirs[0] . '/' . $part; + } + } + + if (!File::exists($partSource)) { + throw new \RuntimeException("Part directory not found in ZIP: {$part}"); + } + + $result = $this->fs->atomicSwap($partSource, $partTarget, $modulePath, $moduleId); + if (!$result) { + $this->fs->restoreAfterFail($modulePath, [$part], $moduleId); + throw new \RuntimeException("Failed to apply part: {$part}"); + } + + return 1; + } + + private function applyAbiturPart( + string $tempPath, + string $modulePath, + int $moduleId, + array $moduleConfig + ): int { + $abiturSource = $tempPath . '/abitur'; + if (!File::exists($abiturSource)) { + $extractedDirs = File::directories($tempPath); + if (!empty($extractedDirs)) { + $abiturSource = $extractedDirs[0] . '/abitur'; + } + } + + if (!File::exists($abiturSource)) { + throw new \RuntimeException('ABITUR directory not found in ZIP'); + } + + $entries = File::allFiles($abiturSource); + $synced = 0; + + foreach ($entries as $file) { + $relative = ltrim(str_replace($abiturSource, '', $file->getPathname()), '/'); + $targetPath = $modulePath . '/' . $relative; + $targetDir = dirname($targetPath); + + if (!File::isDirectory($targetDir)) { + File::makeDirectory($targetDir, 0755, true, true); + } + + $result = $this->fs->atomicSwap( + $file->getPathname(), + $targetPath, + $modulePath, + $moduleId + ); + + if (!$result) { + $this->fs->restoreAfterFail($modulePath, ['abitur'], $moduleId); + throw new \RuntimeException("Failed to sync ABITUR file: {$relative}"); + } + + $synced++; + } + + return $synced; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `docker exec ntspi-php php artisan test --filter=UpdatePartActionTest` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/Containers/VikonIntegration/Actions/UpdatePartAction.php app/Containers/VikonIntegration/Tests/Unit/UpdatePartActionTest.php +git commit -m "feat(vikon): add UpdatePartAction for partial module updates" +``` + +--- + +### Task 5: Add `restoreAfterFail()` to FilesystemTask + +**Covers:** [S7] + +**Files:** +- Modify: `app/Containers/VikonIntegration/Tasks/FilesystemTask.php` +- Test: `app/Containers/VikonIntegration/Tests/Unit/FilesystemTaskTest.php` (add test) + +**Interfaces:** +- Consumes: existing `isPathSafe()`, `atomicSwap()` +- Produces: `restoreAfterFail(string $modulePath, array $allowedEntries, int $moduleId): bool` + +- [ ] **Step 1: Add failing test** + +Append to `app/Containers/VikonIntegration/Tests/Unit/FilesystemTaskTest.php`: + +```php +public function test_restore_after_fail_removes_new_and_restores_old(): void +{ + $base = $this->tempDir . '/module'; + File::makeDirectory($base, 0755, true, true); + + // Simulate partial swap state: original exists, _old has backup + file_put_contents($base . '/common/index.php', 'fs->restoreAfterFail($base, ['common'], 1); + + $this->assertTrue($result); + $this->assertFileExists($base . '/common/index.php'); + $this->assertStringContainsString('original', file_get_contents($base . '/common/index.php')); + $this->assertDirectoryDoesNotExist($base . '/common_old'); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker exec ntspi-php php artisan test --filter=FilesystemTaskTest::test_restore_after_fail` +Expected: FAIL with "Call to undefined method restoreAfterFail" + +- [ ] **Step 3: Write implementation** + +Add to `app/Containers/VikonIntegration/Tasks/FilesystemTask.php`: + +```php +public function restoreAfterFail(string $modulePath, array $allowedEntries, int $moduleId): bool +{ + $entries = File::directories($modulePath); + $entries = array_merge($entries, File::files($modulePath)); + + foreach ($entries as $entryPath) { + $name = basename($entryPath); + + // Remove _new entries (they were never applied) + if (str_ends_with($name, '_new')) { + $baseName = substr($name, 0, -4); + if (in_array($baseName, $allowedEntries, true)) { + if (is_dir($entryPath)) { + File::deleteDirectory($entryPath); + } else { + File::delete($entryPath); + } + } + } + + // Restore _old entries back to original name + if (str_ends_with($name, '_old')) { + $baseName = substr($name, 0, -4); + if (in_array($baseName, $allowedEntries, true)) { + $originalPath = $modulePath . '/' . $baseName; + if (File::exists($originalPath)) { + if (is_dir($originalPath)) { + File::deleteDirectory($originalPath); + } else { + File::delete($originalPath); + } + } + rename($entryPath, $originalPath); + } + } + } + + return true; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `docker exec ntspi-php php artisan test --filter=FilesystemTaskTest` +Expected: PASS (all 4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/Containers/VikonIntegration/Tasks/FilesystemTask.php app/Containers/VikonIntegration/Tests/Unit/FilesystemTaskTest.php +git commit -m "feat(vikon): add restoreAfterFail for crash recovery in FilesystemTask" +``` + +--- + +### Task 6: Create UpdatePartRequest and Controller route + +**Covers:** [S5] + +**Files:** +- Create: `app/Containers/VikonIntegration/UI/WEB/Requests/UpdatePartRequest.php` +- Modify: `app/Containers/VikonIntegration/UI/WEB/Controllers/VikonController.php` +- Modify: `app/Containers/VikonIntegration/UI/WEB/Routes/web.php` + +**Interfaces:** +- Consumes: `UpdatePartAction::run()` +- Produces: `POST /dashboard/vikon-updates/update-part` endpoint + +- [ ] **Step 1: Create UpdatePartRequest** + +Create `app/Containers/VikonIntegration/UI/WEB/Requests/UpdatePartRequest.php`: + +```php + ['required', 'integer', 'in:1,2,6'], + 'part' => ['required', 'string', 'max:50'], + ]; + } +} +``` + +- [ ] **Step 2: Add updatePart method to VikonController** + +Add to `app/Containers/VikonIntegration/UI/WEB/Controllers/VikonController.php`: + +```php +use App\Containers\VikonIntegration\Actions\UpdatePartAction; +use App\Containers\VikonIntegration\UI\WEB\Requests\UpdatePartRequest; +``` + +Add property and constructor injection: + +```php +private readonly UpdatePartAction $updatePart, +``` + +Add method: + +```php +public function updatePart(UpdatePartRequest $request): JsonResponse +{ + $token = Session::get('vikon_access_token'); + if (!$token) { + return response()->json(['success' => false, 'requires_auth' => true], 401); + } + + try { + $result = $this->updatePart->run( + $request->validated('module_id'), + $request->validated('part'), + $token + ); + return response()->json($result); + } catch (\Throwable $e) { + Log::error('Vikon part update failed', ['error' => $e->getMessage()]); + return response()->json(['success' => false, 'message' => $e->getMessage()], 500); + } +} +``` + +- [ ] **Step 3: Add route** + +Add to `app/Containers/VikonIntegration/UI/WEB/Routes/web.php`: + +```php +Route::post('/update-part', [VikonController::class, 'updatePart'])->name('update-part'); +``` + +- [ ] **Step 4: Verify route exists** + +Run: `docker exec ntspi-php php artisan route:list --name=dashboard.vikon-updates` +Expected: Shows `update-part` route + +- [ ] **Step 5: Commit** + +```bash +git add app/Containers/VikonIntegration/UI/WEB/Requests/UpdatePartRequest.php app/Containers/VikonIntegration/UI/WEB/Controllers/VikonController.php app/Containers/VikonIntegration/UI/WEB/Routes/web.php +git commit -m "feat(vikon): add updatePart endpoint with request validation" +``` + +--- + +### Task 7: Update Vue frontend with part selection UI + +**Covers:** [S4] + +**Files:** +- Modify: `resources/js/Pages/Dashboard/VikonUpdates/Index.vue` +- Modify: `app/Containers/VikonIntegration/UI/WEB/Controllers/VikonController.php` (add parts to props) + +**Interfaces:** +- Consumes: `POST /dashboard/vikon-updates/update-part` endpoint +- Produces: UI with module/part selection and update button + +- [ ] **Step 1: Add parts to controller props** + +In `VikonController::index()` and `oauthCallback()`, add to the inertia render: + +```php +'parts' => config('vikon.parts'), +``` + +- [ ] **Step 2: Update Index.vue** + +Read current `resources/js/Pages/Dashboard/VikonUpdates/Index.vue` and add: + +1. New prop: `parts` +2. New reactive state: `selectedPart`, `updatingPart`, `partResult` +3. New method: `updatePart(moduleId, part)` +4. UI section: dropdown for part selection per module, update button, progress indicator + +Key additions to the `