33 KiB
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/andTests/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
namespace Tests\Unit;
use App\Containers\VikonIntegration\Tasks\FilesystemTask;
use Tests\TestCase;
use Illuminate\Support\Facades\File;
class FilesystemTaskTest extends TestCase
{
private string $tempDir;
private FilesystemTask $fs;
protected function setUp(): void
{
parent::setUp();
$this->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', '<?php echo "old";');
// New entry from extracted ZIP
$newDir = $this->tempDir . '/extracted/common';
File::makeDirectory($newDir, 0755, true, true);
file_put_contents($newDir . '/index.php', '<?php echo "new";');
$result = $this->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:
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
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): arrayreturning['status' => 'completed'|'failed'|'timeout'] -
Step 1: Write the failing test
<?php
namespace Tests\Unit;
use App\Containers\VikonIntegration\Tasks\PollPartStatusTask;
use App\Containers\VikonIntegration\Tasks\HttpTask;
use Tests\TestCase;
use Mockery;
class PollPartStatusTaskTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_returns_completed_when_status_is_completed(): void
{
$http = Mockery::mock(HttpTask::class);
$response = Mockery::mock();
$response->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
namespace App\Containers\VikonIntegration\Tasks;
use Illuminate\Support\Facades\Log;
class PollPartStatusTask
{
public function __construct(
private readonly HttpTask $http,
private readonly int $interval,
private readonly int $maxAttempts,
) {}
public function run(string $operationIdentity, string $accessToken): array
{
for ($attempt = 0; $attempt < $this->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
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 ];:
'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
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
namespace Tests\Unit;
use App\Containers\VikonIntegration\Actions\UpdatePartAction;
use App\Containers\VikonIntegration\Tasks\HttpTask;
use App\Containers\VikonIntegration\Tasks\FilesystemTask;
use App\Containers\VikonIntegration\Tasks\PollPartStatusTask;
use Tests\TestCase;
use Mockery;
use Illuminate\Support\Facades\File;
class UpdatePartActionTest extends TestCase
{
private string $tempDir;
protected function setUp(): void
{
parent::setUp();
$this->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
namespace App\Containers\VikonIntegration\Actions;
use App\Containers\VikonIntegration\Tasks\HttpTask;
use App\Containers\VikonIntegration\Tasks\FilesystemTask;
use App\Containers\VikonIntegration\Tasks\PollPartStatusTask;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use ZipArchive;
class UpdatePartAction
{
public function __construct(
private readonly HttpTask $http,
private readonly FilesystemTask $fs,
private readonly PollPartStatusTask $pollStatus,
private readonly string $storagePath,
private readonly string $basePath,
private readonly array $modulesConfig,
) {}
public function run(int $moduleId, string $part, string $accessToken): array
{
$config = $this->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
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:
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', '<?php echo "current";');
file_put_contents($base . '/common_old/index.php', '<?php echo "original";');
$result = $this->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:
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
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-partendpoint -
Step 1: Create UpdatePartRequest
Create app/Containers/VikonIntegration/UI/WEB/Requests/UpdatePartRequest.php:
<?php
namespace App\Containers\VikonIntegration\UI\WEB\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdatePartRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'module_id' => ['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:
use App\Containers\VikonIntegration\Actions\UpdatePartAction;
use App\Containers\VikonIntegration\UI\WEB\Requests\UpdatePartRequest;
Add property and constructor injection:
private readonly UpdatePartAction $updatePart,
Add method:
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:
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
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-partendpoint -
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:
'parts' => config('vikon.parts'),
- Step 2: Update Index.vue
Read current resources/js/Pages/Dashboard/VikonUpdates/Index.vue and add:
- New prop:
parts - New reactive state:
selectedPart,updatingPart,partResult - New method:
updatePart(moduleId, part) - UI section: dropdown for part selection per module, update button, progress indicator
Key additions to the <script setup>:
const props = defineProps({
// ... existing props
parts: Object,
});
const selectedPart = ref({});
const updatingPart = ref(null);
const partResult = ref(null);
async function updatePart(moduleId, part) {
updatingPart.value = `${moduleId}-${part}`;
partResult.value = null;
try {
const res = await fetch('/dashboard/vikon-updates/update-part', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content,
'Accept': 'application/json',
},
body: JSON.stringify({ module_id: moduleId, part }),
});
const data = await res.json();
partResult.value = data;
} catch (e) {
partResult.value = { success: false, message: e.message };
} finally {
updatingPart.value = null;
}
}
Template additions (inside the module list):
<div v-if="parts && parts[moduleId]" class="mt-2">
<select v-model="selectedPart[moduleId]" class="text-sm border rounded px-2 py-1">
<option value="">Select part...</option>
<option v-for="p in parts[moduleId]" :key="p" :value="p">{{ p }}</option>
</select>
<button
@click="updatePart(moduleId, selectedPart[moduleId])"
:disabled="!selectedPart[moduleId] || updatingPart"
class="ml-2 text-sm bg-blue-500 text-white px-3 py-1 rounded disabled:opacity-50"
>
<span v-if="updatingPart === `${moduleId}-${selectedPart[moduleId]}`">Updating...</span>
<span v-else>Update Part</span>
</button>
</div>
- Step 3: Verify Vue compiles
Run: npm run build
Expected: No errors
- Step 4: Commit
git add resources/js/Pages/Dashboard/VikonUpdates/Index.vue app/Containers/VikonIntegration/UI/WEB/Controllers/VikonController.php
git commit -m "feat(vikon): add part selection UI to Vue dashboard"
Task 8: Integration test for full flow
Covers: [S3], [S5], [S7]
Files:
- Create:
app/Containers/VikonIntegration/Tests/Feature/UpdatePartTest.php
Interfaces:
-
Consumes: all previous tasks
-
Produces: Feature test hitting the HTTP endpoint
-
Step 1: Create feature test
Create app/Containers/VikonIntegration/Tests/Feature/UpdatePartTest.php:
<?php
namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Support\Facades\Session;
class UpdatePartTest extends TestCase
{
public function test_update_part_requires_authentication(): void
{
$response = $this->postJson('/dashboard/vikon-updates/update-part', [
'module_id' => 1,
'part' => 'common',
]);
$response->assertStatus(401);
}
public function test_update_part_validates_module_id(): void
{
Session::put('vikon_access_token', 'test-token');
$response = $this->postJson('/dashboard/vikon-updates/update-part', [
'module_id' => 999,
'part' => 'common',
]);
$response->assertStatus(422);
}
public function test_update_part_validates_part(): void
{
Session::put('vikon_access_token', 'test-token');
$response = $this->postJson('/dashboard/vikon-updates/update-part', [
'module_id' => 1,
'part' => '',
]);
$response->assertStatus(422);
}
}
- Step 2: Run test
Run: docker exec ntspi-php php artisan test --filter=UpdatePartTest
Expected: PASS
- Step 3: Commit
git add app/Containers/VikonIntegration/Tests/Feature/UpdatePartTest.php
git commit -m "test(vikon): add feature tests for part update endpoint"