# Variations Image Upload in Builder — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add per-option image upload, drag-reorder, and publish-time orphan cleanup to the assessment builder's variations panel so it reaches parity with the legacy form (PR #222) without losing image data on round-trips.

**Architecture:** Extract a `variation_option_card.vue` SFC; refactor `panel_variations.vue` into a list manager using the new `[{label, image}, ...]` shape. New JSON endpoint `POST /admin/api/assessment/option-images` runs `upload_image()` against the existing `assessment_option_images` folder config and records the filename in a new `assessment_draft_uploaded_images` tracking table. `DraftService::publish()` gains an after-transaction sweep that deletes any tracked or previously-published filenames not referenced by the new publish.

**Tech Stack:** Laravel 11 + Eloquent, Vue 2 + Vuex, `vuedraggable` (already used in the builder), LESS, PHPUnit 9. Reuses `upload_image()` from `core/includes/functions.php` and `$image_folders['assessment_option_images']` from `shop/includes/image_folders.php`.

**Spec:** `docs/superpowers/specs/2026-05-25-variations-image-upload-in-builder-design.md`

---

## File map

**Create**

- `database/migrations/2026_05_25_120000_create_assessment_draft_uploaded_images_table.php` — tracking table for uploads inside the current draft cycle.
- `plugins/mtc_assessments/src/Models/AssessmentDraftUploadedImage.php` — thin Eloquent model so the controller/sweep can read and write rows idiomatically.
- `plugins/mtc_assessments/admin/js/components/assessment_builder/panels/variation_option_card.vue` — single-option card SFC (props-in / events-out, no HTTP).
- `tests/Feature/AssessmentOptionImageUploadTest.php` — Feature tests for the upload endpoint.
- `tests/Feature/AssessmentPublishOrphanSweepTest.php` — Feature tests for `DraftService::publish()` orphan sweep.

**Modify**

- `plugins/mtc_assessments/src/Http/Controllers/AssessmentBuilderController.php` — add `uploadOptionImage()` method.
- `plugins/mtc_assessments/src/routes/web.php` — register the new route inside the existing `auth` middleware group.
- `plugins/mtc_assessments/src/Services/DraftService.php` — add `sweepDraftImages()` invocation after the publish transaction.
- `plugins/mtc_assessments/admin/js/components/assessment_builder/store/builder_store.js` — add `uploadOptionImage` action.
- `plugins/mtc_assessments/admin/js/components/assessment_builder/panels/panel_variations.vue` — refactor into list manager using `[{label, image}]` shape, integrate card, drag reorder.

---

## Task 1: Tracking table migration

**Files:**
- Create: `database/migrations/2026_05_25_120000_create_assessment_draft_uploaded_images_table.php`

- [ ] **Step 1: Write the migration**

```php
<?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::create('assessment_draft_uploaded_images', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->unsignedInteger('form_id')->nullable();
            $table->string('filename', 255);
            $table->timestamp('uploaded_at')->useCurrent();
            $table->index('form_id');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('assessment_draft_uploaded_images');
    }
};
```

- [ ] **Step 2: Run the migration**

```
docker exec pharmacy-starter-20026-web php artisan migrate
```

Expected: `Migrated: 2026_05_25_120000_create_assessment_draft_uploaded_images_table`

- [ ] **Step 3: Verify the table exists**

```
docker exec pharmacy-starter-20026-mariadb mariadb -upharma_admin -ppharma_password pharma_db -e "DESCRIBE assessment_draft_uploaded_images;"
```

Expected: 4 columns (`id`, `form_id`, `filename`, `uploaded_at`) with `form_id` indexed.

- [ ] **Step 4: Commit**

```
git add database/migrations/2026_05_25_120000_create_assessment_draft_uploaded_images_table.php
git commit -m "feat(assessments): add assessment_draft_uploaded_images table"
```

---

## Task 2: Eloquent model for the tracking table

**Files:**
- Create: `plugins/mtc_assessments/src/Models/AssessmentDraftUploadedImage.php`

- [ ] **Step 1: Write the model**

```php
<?php

namespace MtcPharmacy\Assessments\Models;

use Illuminate\Database\Eloquent\Model;

class AssessmentDraftUploadedImage extends Model
{
    protected $table = 'assessment_draft_uploaded_images';

    public $timestamps = false;

    protected $fillable = [
        'form_id',
        'filename',
        'uploaded_at',
    ];

    protected $casts = [
        'form_id' => 'int',
        'uploaded_at' => 'datetime',
    ];
}
```

- [ ] **Step 2: Run composer dump-autoload so PSR-4 picks it up**

```
docker exec pharmacy-starter-20026-web composer dump-autoload -o
```

Expected: "Generated optimized autoload files containing N classes" with N incremented by 1.

- [ ] **Step 3: Smoke test via tinker / one-liner**

```
docker exec pharmacy-starter-20026-web php artisan migrate:status 2>&1 | grep 'assessment_draft_uploaded_images'
```

Expected: `[Y] 2026_05_25_120000_create_assessment_draft_uploaded_images_table`

- [ ] **Step 4: Commit**

```
git add plugins/mtc_assessments/src/Models/AssessmentDraftUploadedImage.php
git commit -m "feat(assessments): add AssessmentDraftUploadedImage model"
```

---

## Task 3: Upload controller — write the failing test first

**Files:**
- Create: `tests/Feature/AssessmentOptionImageUploadTest.php`

- [ ] **Step 1: Write the test**

```php
<?php

namespace Tests\Feature;

use Illuminate\Http\UploadedFile;
use Illuminate\Http\Request;
use MtcPharmacy\Assessments\Http\Controllers\AssessmentBuilderController;
use MtcPharmacy\Assessments\Models\AssessmentDraftUploadedImage;
use MtcPharmacy\Assessments\Models\Form;

class AssessmentOptionImageUploadTest extends \PHPUnit\Framework\TestCase
{
    private ?int $testFormId = null;
    private array $uploadedFilenames = [];

    protected function tearDown(): void
    {
        if ($this->testFormId !== null) {
            AssessmentDraftUploadedImage::query()->where('form_id', $this->testFormId)->delete();
            Form::query()->where('id', $this->testFormId)->delete();
            $this->testFormId = null;
        }
        foreach ($this->uploadedFilenames as $filename) {
            foreach ((array) ($GLOBALS['image_folders']['assessment_option_images'] ?? []) as $folder) {
                @unlink(SITE_PATH . '/' . $folder['path'] . '/' . $filename);
            }
        }
        $this->uploadedFilenames = [];
        parent::tearDown();
    }

    private function createTestForm(): Form
    {
        $form = Form::query()->create([
            'name' => 'Image upload test form ' . uniqid(),
            'is_active' => 1,
            'live' => 0,
        ]);
        $this->testFormId = $form->id;
        return $form;
    }

    public function test_uploads_valid_png_and_returns_filename_and_thumb_url(): void
    {
        $form = $this->createTestForm();
        $image = UploadedFile::fake()->image('option.png', 200, 200);

        $request = Request::create('/admin/api/assessment/option-images', 'POST', [
            'form_id' => $form->id,
        ], [], ['image' => $image]);

        $controller = new AssessmentBuilderController();
        $response = $controller->uploadOptionImage($request);

        $data = $response->getData(true);
        $this->assertArrayHasKey('filename', $data);
        $this->assertArrayHasKey('thumb_url', $data);
        $this->assertNotEmpty($data['filename']);
        $this->assertStringContainsString($data['filename'], $data['thumb_url']);

        $this->uploadedFilenames[] = $data['filename'];
    }

    public function test_persists_row_in_assessment_draft_uploaded_images(): void
    {
        $form = $this->createTestForm();
        $image = UploadedFile::fake()->image('option.png', 200, 200);

        $request = Request::create('/admin/api/assessment/option-images', 'POST', [
            'form_id' => $form->id,
        ], [], ['image' => $image]);

        $controller = new AssessmentBuilderController();
        $response = $controller->uploadOptionImage($request);
        $filename = $response->getData(true)['filename'];
        $this->uploadedFilenames[] = $filename;

        $row = AssessmentDraftUploadedImage::query()
            ->where('form_id', $form->id)
            ->where('filename', $filename)
            ->first();
        $this->assertNotNull($row);
    }

    public function test_rejects_request_with_no_image_field_with_422(): void
    {
        $form = $this->createTestForm();
        $request = Request::create('/admin/api/assessment/option-images', 'POST', [
            'form_id' => $form->id,
        ]);

        $controller = new AssessmentBuilderController();
        $response = $controller->uploadOptionImage($request);

        $this->assertSame(422, $response->getStatusCode());
        $this->assertArrayHasKey('error', $response->getData(true));
    }

    public function test_rejects_unsupported_mime_with_422(): void
    {
        $form = $this->createTestForm();
        // upload_image() validates against the assessment_option_images
        // folder set, which whitelists raster types only.
        $svg = UploadedFile::fake()->createWithContent('option.svg', '<svg/>');

        $request = Request::create('/admin/api/assessment/option-images', 'POST', [
            'form_id' => $form->id,
        ], [], ['image' => $svg]);

        $controller = new AssessmentBuilderController();
        $response = $controller->uploadOptionImage($request);

        $this->assertSame(422, $response->getStatusCode());
    }
}
```

- [ ] **Step 2: Run the test to verify it fails**

```
docker exec pharmacy-starter-20026-web ./vendor/bin/phpunit tests/Feature/AssessmentOptionImageUploadTest.php
```

Expected: ERRORS — method `uploadOptionImage` does not exist on `AssessmentBuilderController`.

- [ ] **Step 3: Commit (red)**

```
git add tests/Feature/AssessmentOptionImageUploadTest.php
git commit -m "test(assessments): failing tests for option image upload endpoint"
```

---

## Task 4: Upload controller — minimal implementation

**Files:**
- Modify: `plugins/mtc_assessments/src/Http/Controllers/AssessmentBuilderController.php`

- [ ] **Step 1: Add use statements**

Add to the top of the file alongside the existing imports:

```php
use Illuminate\Validation\ValidationException;
use MtcPharmacy\Assessments\Models\AssessmentDraftUploadedImage;
```

- [ ] **Step 2: Add the controller method**

Append inside the `AssessmentBuilderController` class (after `publish()` is a reasonable spot):

```php
    /**
     * Upload a single per-option image for a variations question.
     *
     * Uses the legacy upload_image() helper against the
     * $image_folders['assessment_option_images'] configuration so the
     * builder produces identical files / sizes / folder layout to the
     * legacy admin form. Successful uploads are tracked in
     * assessment_draft_uploaded_images so DraftService::publish() can
     * sweep orphans at publish time.
     */
    public function uploadOptionImage(Request $request): JsonResponse
    {
        try {
            $data = $this->validate($request, [
                'image' => 'required|file',
                'form_id' => 'nullable|integer',
            ]);
        } catch (ValidationException $e) {
            return response()->json(['error' => $e->validator->errors()->first()], 422);
        }

        // The legacy upload_image() helper consumes $_FILES directly by
        // key name. Laravel's UploadedFile wraps the same superglobal
        // for the duration of the request, so this works in production
        // and under TestCase fake uploads.
        global $image_folders;
        if (empty($image_folders['assessment_option_images'])) {
            require_once SITE_PATH . '/shop/includes/image_folders.php';
        }
        $folders = $image_folders['assessment_option_images'];

        $result = upload_image('image', $folders);

        if ($result === false) {
            return response()->json([
                'error' => 'Image upload failed. Allowed types: JPG, JPEG, PNG, GIF.',
            ], 422);
        }
        if (!empty($result['error'])) {
            return response()->json(['error' => $result['error']], 422);
        }
        if (empty($result['name'])) {
            return response()->json(['error' => 'Upload failed, try again.'], 500);
        }

        AssessmentDraftUploadedImage::query()->create([
            'form_id' => $data['form_id'] ?? null,
            'filename' => $result['name'],
            'uploaded_at' => now(),
        ]);

        $thumb = $folders['thumb'] ?? null;
        $thumbUrl = $thumb
            ? '/' . trim($thumb['path'], '/') . '/' . $result['name']
            : '';

        return response()->json([
            'filename' => $result['name'],
            'thumb_url' => $thumbUrl,
        ]);
    }
```

- [ ] **Step 3: Run the tests to verify they pass**

```
docker exec pharmacy-starter-20026-web ./vendor/bin/phpunit tests/Feature/AssessmentOptionImageUploadTest.php
```

Expected: 4 tests pass.

- [ ] **Step 4: Commit**

```
git add plugins/mtc_assessments/src/Http/Controllers/AssessmentBuilderController.php
git commit -m "feat(assessments): add uploadOptionImage controller action"
```

---

## Task 5: Register the route

**Files:**
- Modify: `plugins/mtc_assessments/src/routes/web.php`

- [ ] **Step 1: Add the route inside the existing auth middleware group**

Add a new route line below `Route::put('/admin/api/assessment/forms/{id}/settings', ...);` so it sits with the other builder-API routes:

```php
    Route::post('/admin/api/assessment/option-images', [AssessmentBuilderController::class, 'uploadOptionImage']);
```

- [ ] **Step 2: Verify the route registers**

```
docker exec pharmacy-starter-20026-web php artisan route:list 2>&1 | grep 'option-images'
```

Expected: `POST  admin/api/assessment/option-images  MtcPharmacy\Assessments\Http\Controllers\AssessmentBuilderController@uploadOptionImage`

- [ ] **Step 3: Commit**

```
git add plugins/mtc_assessments/src/routes/web.php
git commit -m "feat(assessments): register option-images upload route"
```

---

## Task 6: Vuex store action `uploadOptionImage`

**Files:**
- Modify: `plugins/mtc_assessments/admin/js/components/assessment_builder/store/builder_store.js`

- [ ] **Step 1: Find the actions block and add the new action**

Locate the `attachCategory` action (around line 981) and add this new action immediately after `detachProduct` (around line 1055) so all the question/upload-style POSTs cluster together. Insert:

```javascript
        async uploadOptionImage({ state }, { file }) {
            const formData = new FormData();
            formData.append('image', file);
            if (state.form && state.form.id) {
                formData.append('form_id', state.form.id);
            }
            try {
                const response = await axios.post(
                    '/admin/api/assessment/option-images',
                    formData,
                    { headers: { 'Content-Type': 'multipart/form-data' } },
                );
                return response.data;
            } catch (err) {
                const message = err.response?.data?.error
                    || 'Upload failed. Try a different image.';
                throw new Error(message);
            }
        },
```

- [ ] **Step 2: Verify Vite picks it up cleanly**

Check `npm run dev` output (already running). No HMR errors should appear after saving.

```
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5173/
```

Expected: `200` or `302` (Vite serving).

- [ ] **Step 3: Commit**

```
git add plugins/mtc_assessments/admin/js/components/assessment_builder/store/builder_store.js
git commit -m "feat(assessments): add builder uploadOptionImage Vuex action"
```

---

## Task 7: Create `variation_option_card.vue` (props/emits only, no styling yet)

**Files:**
- Create: `plugins/mtc_assessments/admin/js/components/assessment_builder/panels/variation_option_card.vue`

- [ ] **Step 1: Write the SFC skeleton**

```vue
<template>

    <div
        class="variationOptionCard"
        :class="{
            'variationOptionCard--uploading': uploading,
            'variationOptionCard--hasImage': !!option.image,
        }"
    >

        <button
            class="variationOptionCard__remove"
            title="Remove option"
            type="button"
            @click="$emit('remove-option')"
        >
            <builder-icon icon="fa fa-times" />
        </button>

        <button
            class="variationOptionCard__thumb"
            type="button"
            :disabled="uploading"
            @click="openFilePicker"
        >

            <img
                v-if="option.image && thumbUrl"
                class="variationOptionCard__thumbImage"
                :src="thumbUrl"
                :alt="option.label || 'Option image'"
            />

            <span
                v-else
                class="variationOptionCard__placeholder"
            >
                <builder-icon icon="fa fa-image" />
                <span class="variationOptionCard__placeholderLabel">Add image</span>
            </span>

            <span
                v-if="uploading"
                class="variationOptionCard__thumbSpinner"
            >
                <builder-icon icon="fa fa-spinner fa-spin" />
            </span>

        </button>

        <input
            ref="fileInput"
            class="variationOptionCard__fileInput"
            type="file"
            accept="image/png,image/jpeg,image/gif"
            @change="onFileChange"
        />

        <input
            class="variationOptionCard__label"
            type="text"
            placeholder="Option label"
            :value="option.label"
            @input="$emit('update-label', $event.target.value)"
        />

        <div
            v-if="option.image"
            class="variationOptionCard__footer"
        >
            <button
                class="variationOptionCard__action variationOptionCard__action--replace"
                type="button"
                :disabled="uploading"
                @click="openFilePicker"
            >Replace</button>
            <button
                class="variationOptionCard__action variationOptionCard__action--delete"
                type="button"
                :disabled="uploading"
                @click="$emit('remove-image')"
            >Delete</button>
        </div>

        <div
            v-if="errorMessage"
            class="variationOptionCard__error"
        >{{ errorMessage }}</div>

    </div>

</template>

<script>
import builderIcon from '../builder_icon.vue';

export default {
    name: 'VariationOptionCard',

    components: {
        builderIcon,
    },

    props: {
        option: {
            type: Object,
            required: true,
        },
        index: {
            type: Number,
            required: true,
        },
        uploading: {
            type: Boolean,
            default: false,
        },
        errorMessage: {
            type: String,
            default: '',
        },
        thumbUrl: {
            type: String,
            default: '',
        },
    },

    methods: {
        openFilePicker() {
            if (this.uploading) return;
            this.$refs.fileInput.value = '';
            this.$refs.fileInput.click();
        },
        onFileChange(event) {
            const file = event.target.files && event.target.files[0];
            if (!file) return;
            this.$emit('pick-file', file);
        },
    },
};
</script>

<style lang="less">
.variationOptionCard {
    position: relative;
    display: flex;
    flex-direction: column;
    gap: 8px;
    padding: 10px 10px 12px;
    margin-bottom: 8px;
    background: rgba(255, 255, 255, 0.05);
    border: 1px solid rgba(255, 255, 255, 0.12);
    border-radius: var(--border_radius);

    &__remove {
        position: absolute;
        top: 6px;
        right: 6px;
        width: 22px;
        height: 22px;
        padding: 0;
        background: none;
        border: none;
        color: var(--med_color);
        cursor: pointer;
        border-radius: 50%;
        display: flex;
        align-items: center;
        justify-content: center;
        line-height: 1;

        &:hover {
            color: var(--alert_color);
            background: rgba(255, 255, 255, 0.08);
        }
    }

    &__thumb {
        position: relative;
        display: flex;
        align-items: center;
        justify-content: center;
        width: 100%;
        aspect-ratio: 4 / 3;
        padding: 0;
        background: rgba(255, 255, 255, 0.04);
        border: 1px dashed rgba(255, 255, 255, 0.25);
        border-radius: var(--border_radius);
        color: var(--dark_text_color);
        cursor: pointer;
        overflow: hidden;

        &:hover {
            border-color: rgba(255, 255, 255, 0.4);
        }

        &:disabled {
            cursor: progress;
        }
    }

    &--hasImage &__thumb {
        border-style: solid;
        border-color: rgba(255, 255, 255, 0.18);
        background: rgba(0, 0, 0, 0.2);
    }

    &__thumbImage {
        width: 100%;
        height: 100%;
        object-fit: cover;
        display: block;
    }

    &__placeholder {
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 4px;
        color: var(--dark_text_color);
        opacity: 0.7;
    }

    &__placeholderLabel {
        .rems(12);
    }

    &__thumbSpinner {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        background: rgba(0, 0, 0, 0.4);
        color: #fff;
        .rems(20);
    }

    &__fileInput {
        position: absolute;
        left: -9999px;
        width: 1px;
        height: 1px;
        opacity: 0;
    }

    &__label {
        display: block;
        width: 100%;
        padding: 8px 10px !important;
        background: rgba(255, 255, 255, 0.08);
        border: 1px solid rgba(255, 255, 255, 0.15);
        border-radius: var(--border_radius);
        color: var(--dark_text_color);
        outline: none;
        box-sizing: border-box;

        &:focus {
            border-color: rgba(255, 255, 255, 0.3);
        }
    }

    &__footer {
        display: flex;
        gap: 6px;
    }

    &__action {
        flex: 1;
        padding: 6px 8px;
        background: rgba(255, 255, 255, 0.08);
        border: 1px solid rgba(255, 255, 255, 0.15);
        border-radius: var(--border_radius);
        color: var(--dark_text_color);
        cursor: pointer;
        .rems(13);

        &:hover:not(:disabled) {
            background: rgba(255, 255, 255, 0.14);
        }

        &--delete:hover:not(:disabled) {
            color: var(--alert_color);
        }

        &:disabled {
            opacity: 0.5;
            cursor: not-allowed;
        }
    }

    &__error {
        .rems(12);
        color: var(--alert_color);
    }
}
</style>
```

> Note: `builderIcon` lives at `plugins/mtc_assessments/admin/js/components/assessment_builder/builder_icon.vue` — the same component used elsewhere in the builder panels. Confirm the relative path matches your file's depth before saving (it's one level up from `panels/`).

- [ ] **Step 2: Verify Vite compiles the new SFC**

Save the file and watch `npm run dev` output — no compile errors should appear.

- [ ] **Step 3: Commit**

```
git add plugins/mtc_assessments/admin/js/components/assessment_builder/panels/variation_option_card.vue
git commit -m "feat(assessments): add VariationOptionCard SFC"
```

---

## Task 8: Refactor `panel_variations.vue` — switch to `[{label, image}]` shape

**Files:**
- Modify: `plugins/mtc_assessments/admin/js/components/assessment_builder/panels/panel_variations.vue`

The current panel stores `localVariations` as a string array and JSON-encodes it as-is. We need to migrate to objects without breaking legacy data on read, and integrate the new card component. Drag-reorder is a separate task (Task 9) so this task focuses on the data + card swap only.

- [ ] **Step 1: Replace the file with the refactored version**

```vue
<template>

    <div
        v-if="question.question_type === 'variations'"
        class="builderPanel"
    >

        <div
            class="builderPanel__header"
            @click="open = !open"
        >

            <span>Variations</span>

            <builder-icon
                :icon="'fa ' + (open ? 'fa-chevron-up' : 'fa-chevron-down')"
            />

        </div>

        <div
            v-if="open"
            :key="question.id"
            class="builderPanel__body"
        >

            <div class="builderPanel__field">

                <label>Options</label>

                <variation-option-card
                    v-for="(option, index) in localOptions"
                    :key="optionKeys[index]"
                    :option="option"
                    :index="index"
                    :uploading="uploadingIndex === index"
                    :error-message="errorMessages[index] || ''"
                    :thumb-url="thumbUrls[index] || ''"
                    @update-label="value => onUpdateLabel(index, value)"
                    @pick-file="file => onPickFile(index, file)"
                    @remove-image="onRemoveImage(index)"
                    @remove-option="onRemoveOption(index)"
                />

                <button
                    class="variationAddBtn"
                    @click="addOption"
                >
                    <builder-icon icon="fa fa-plus" /> Add option
                </button>

            </div>

            <div class="builderPanel__field">

                <builder-toggle
                    dark
                    :checked="!!question.is_multiple"
                    @change="updateField('is_multiple', $event ? 1 : 0)"
                >
                    Allow multiple
                </builder-toggle>

            </div>

            <div
                v-if="localOptions.length"
                class="builderPanel__field"
            >

                <label>Clear-all answer</label>

                <select
                    :value="question.clearall_answer || ''"
                    @change="updateField('clearall_answer', $event.target.value)"
                >

                    <option value="">-- None --</option>

                    <option
                        v-for="opt in localOptions"
                        :key="opt.label"
                        :value="opt.label"
                    >{{ opt.label }}</option>

                </select>

            </div>

        </div>

    </div>

</template>

<script>
import variationOptionCard from './variation_option_card.vue';

const THUMB_PATH = '/uploads/images/assessment_options/thumbs';

export default {
    name: 'PanelVariations',

    components: {
        variationOptionCard,
    },

    props: {
        question: {
            type: Object,
            required: true,
        },
    },

    data() {
        return {
            open: false,
            localOptions: [],
            optionKeys: [],
            uploadingIndex: null,
            errorMessages: {},
        };
    },

    computed: {
        thumbUrls() {
            return this.localOptions.map(opt => opt.image ? `${THUMB_PATH}/${opt.image}` : '');
        },
    },

    watch: {
        'question.variations': {
            immediate: true,
            handler(raw) {
                this.localOptions = this.parseVariations(raw);
                this.optionKeys = this.localOptions.map((_, i) => `opt-${i}-${Date.now()}`);
                this.errorMessages = {};
            },
        },
    },

    methods: {
        parseVariations(raw) {
            if (!raw) return [];
            let parsed = raw;
            if (typeof raw === 'string') {
                try {
                    parsed = JSON.parse(raw);
                } catch (e) {
                    // Not JSON — fall back to newline-delimited string list.
                    return raw.split('\n')
                        .map(s => s.trim())
                        .filter(s => s !== '')
                        .map(label => ({ label, image: null }));
                }
            }
            if (!Array.isArray(parsed)) return [];
            return parsed.map(entry => {
                if (typeof entry === 'string') {
                    return { label: entry, image: null };
                }
                if (entry && typeof entry === 'object' && 'label' in entry) {
                    return {
                        label: String(entry.label ?? ''),
                        image: entry.image || null,
                    };
                }
                return { label: '', image: null };
            });
        },

        onUpdateLabel(index, value) {
            this.$set(this.localOptions, index, {
                ...this.localOptions[index],
                label: value,
            });
            this.syncToStore();
        },

        addOption() {
            this.localOptions.push({ label: '', image: null });
            this.optionKeys.push(`opt-${this.localOptions.length - 1}-${Date.now()}`);
            this.syncToStore();
        },

        onRemoveOption(index) {
            this.localOptions.splice(index, 1);
            this.optionKeys.splice(index, 1);
            this.$delete(this.errorMessages, index);
            this.syncToStore();
        },

        onRemoveImage(index) {
            this.$set(this.localOptions, index, {
                ...this.localOptions[index],
                image: null,
            });
            this.syncToStore();
        },

        async onPickFile(index, file) {
            if (this.uploadingIndex !== null) return;
            this.uploadingIndex = index;
            this.$delete(this.errorMessages, index);
            try {
                const { filename } = await this.$store.dispatch('uploadOptionImage', { file });
                this.$set(this.localOptions, index, {
                    ...this.localOptions[index],
                    image: filename,
                });
                this.syncToStore();
            } catch (err) {
                this.$set(this.errorMessages, index, err.message || 'Upload failed.');
            } finally {
                this.uploadingIndex = null;
            }
        },

        syncToStore() {
            const filtered = this.localOptions.filter(opt => (opt.label || '').trim() !== '' || opt.image);
            this.updateField('variations', JSON.stringify(filtered));
        },

        updateField(field, value) {
            this.$store.dispatch('updateQuestionField', {
                questionId: this.question.id,
                field,
                value,
            });
        },
    },
};
</script>

<style lang="less">
.variationAddBtn {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    margin-top: 4px;
    padding: 6px 12px;
    background: rgba(255, 255, 255, 0.08);
    border: 1px solid rgba(255, 255, 255, 0.15);
    border-radius: var(--border_radius);
    color: var(--dark_text_color);
    .rems(14);
    cursor: pointer;

    &:hover {
        background: rgba(255, 255, 255, 0.12);
    }
}
</style>
```

> Note: the store dispatches use unprefixed names (`uploadOptionImage`, `updateQuestionField`) because the existing builder store is registered without namespacing — confirm against current `$store.dispatch('updateQuestionField', ...)` calls in your local copy of `panel_variations.vue` before editing.

- [ ] **Step 2: Verify Vite compiles**

Watch `npm run dev` output. No errors should appear.

- [ ] **Step 3: Manual smoke test — legacy → new shape round trip**

Open the builder for form 6 in Playwright. Open the variations question. Confirm the existing string-array options ("Male", "Female") render as cards with empty thumbs. Add a third option, label it, save the draft, reload the builder. Confirm all three options round-trip as `{label, image: null}` (use browser devtools to inspect `state.sections` or the network payload of the draft save).

- [ ] **Step 4: Commit**

```
git add plugins/mtc_assessments/admin/js/components/assessment_builder/panels/panel_variations.vue
git commit -m "refactor(assessments): variations panel uses {label, image} object shape with VariationOptionCard"
```

---

## Task 9: Add drag-to-reorder to the variations panel

**Files:**
- Modify: `plugins/mtc_assessments/admin/js/components/assessment_builder/panels/panel_variations.vue`

The builder already uses `vuedraggable` (see `builder_left_sidebar.vue` line 81). We follow the same pattern.

- [ ] **Step 1: Update the template to wrap cards in `<draggable>`**

Replace the existing `<variation-option-card v-for ... />` block with:

```vue
                <draggable
                    v-model="localOptions"
                    :animation="150"
                    handle=".variationOptionCard__dragHandle"
                    @end="onReorderEnd"
                >
                    <variation-option-card
                        v-for="(option, index) in localOptions"
                        :key="optionKeys[index]"
                        :option="option"
                        :index="index"
                        :uploading="uploadingIndex === index"
                        :error-message="errorMessages[index] || ''"
                        :thumb-url="thumbUrls[index] || ''"
                        @update-label="value => onUpdateLabel(index, value)"
                        @pick-file="file => onPickFile(index, file)"
                        @remove-image="onRemoveImage(index)"
                        @remove-option="onRemoveOption(index)"
                    />
                </draggable>
```

- [ ] **Step 2: Import `vuedraggable` and register it**

Add to the `<script>` block alongside the existing import:

```javascript
import draggable from 'vuedraggable';
```

And add it to `components`:

```javascript
    components: {
        variationOptionCard,
        draggable,
    },
```

- [ ] **Step 3: Add the `onReorderEnd` method**

Inside `methods`, after `addOption`:

```javascript
        onReorderEnd() {
            // vuedraggable mutates localOptions in place via v-model;
            // we just need to resync keys (so Vue refreshes the cards)
            // and persist the new order to the store.
            this.optionKeys = this.localOptions.map((_, i) => `opt-${i}-${Date.now()}`);
            this.syncToStore();
        },
```

- [ ] **Step 4: Add a drag handle to `variation_option_card.vue`**

In `plugins/mtc_assessments/admin/js/components/assessment_builder/panels/variation_option_card.vue`, add a drag-handle button at the top of the card (just inside the root `<div>`, before the `__remove` button):

```vue
        <span
            class="variationOptionCard__dragHandle"
            title="Drag to reorder"
        >
            <builder-icon icon="fa fa-grip-vertical" />
        </span>
```

And add a LESS rule alongside the existing ones inside the `<style lang="less">` block:

```less
    &__dragHandle {
        position: absolute;
        top: 8px;
        left: 8px;
        width: 20px;
        height: 20px;
        display: flex;
        align-items: center;
        justify-content: center;
        color: var(--med_color);
        cursor: grab;
        opacity: 0.6;

        &:hover {
            opacity: 1;
        }

        &:active {
            cursor: grabbing;
        }
    }
```

- [ ] **Step 5: Manual smoke test — drag and reorder**

In Playwright, open the variations question in the builder. Add 3+ options with different labels. Drag option 3 by its grip handle to position 1. Confirm: order persists after save + reload, and any uploaded image stays attached to its label (i.e. the image moves with its option, not stays in slot N).

- [ ] **Step 6: Commit**

```
git add plugins/mtc_assessments/admin/js/components/assessment_builder/panels/panel_variations.vue plugins/mtc_assessments/admin/js/components/assessment_builder/panels/variation_option_card.vue
git commit -m "feat(assessments): drag-to-reorder for variation options"
```

---

## Task 10: `DraftService::publish()` orphan sweep — write the failing test first

**Files:**
- Create: `tests/Feature/AssessmentPublishOrphanSweepTest.php`

- [ ] **Step 1: Write the test**

```php
<?php

namespace Tests\Feature;

use MtcPharmacy\Assessments\Models\AssessmentDraft;
use MtcPharmacy\Assessments\Models\AssessmentDraftUploadedImage;
use MtcPharmacy\Assessments\Models\Form;
use MtcPharmacy\Assessments\Models\Question;
use MtcPharmacy\Assessments\Services\DraftService;

class AssessmentPublishOrphanSweepTest extends \PHPUnit\Framework\TestCase
{
    private ?int $testFormId = null;
    private array $touchedFiles = [];

    protected function tearDown(): void
    {
        if ($this->testFormId !== null) {
            AssessmentDraftUploadedImage::query()->where('form_id', $this->testFormId)->delete();
            Question::query()->where('form_id', $this->testFormId)->delete();
            AssessmentDraft::query()->where('form_id', $this->testFormId)->delete();
            Form::query()->where('id', $this->testFormId)->delete();
            $this->testFormId = null;
        }
        foreach ($this->touchedFiles as $path) {
            @unlink($path);
        }
        $this->touchedFiles = [];
        parent::tearDown();
    }

    private function createFormWithDraft(array $variations, ?array $draftUploads = null): Form
    {
        $form = Form::query()->create([
            'name' => 'Sweep test form ' . uniqid(),
            'is_active' => 1,
            'live' => 0,
        ]);
        $this->testFormId = $form->id;

        // Seed the draft with one variations question carrying the
        // requested variations payload.
        AssessmentDraft::query()->create([
            'form_id' => $form->id,
            'data' => [
                'form' => ['name' => $form->name],
                'sections' => [[
                    'id' => null,
                    'title' => 'Section',
                    'questions' => [[
                        'id' => -1,
                        '_tempId' => 'temp-q-1',
                        'question_type' => 'variations',
                        'question_text' => 'Pick one',
                        'variations' => json_encode($variations),
                        'is_required' => 1,
                        'is_active' => 1,
                    ]],
                ]],
            ],
            'is_in_sync' => false,
        ]);

        foreach ((array) $draftUploads as $filename) {
            AssessmentDraftUploadedImage::query()->create([
                'form_id' => $form->id,
                'filename' => $filename,
                'uploaded_at' => now(),
            ]);
        }

        return $form;
    }

    private function touchImageFile(string $filename): void
    {
        global $image_folders;
        if (empty($image_folders['assessment_option_images'])) {
            require_once SITE_PATH . '/shop/includes/image_folders.php';
        }
        foreach ($image_folders['assessment_option_images'] as $folder) {
            $path = SITE_PATH . '/' . $folder['path'] . '/' . $filename;
            @mkdir(dirname($path), 0777, true);
            file_put_contents($path, 'fake');
            $this->touchedFiles[] = $path;
        }
    }

    private function imageFileExists(string $filename): bool
    {
        global $image_folders;
        $thumb = $image_folders['assessment_option_images']['thumb'] ?? null;
        if (!$thumb) return false;
        return file_exists(SITE_PATH . '/' . $thumb['path'] . '/' . $filename);
    }

    public function test_publish_deletes_orphan_filenames_from_draft_uploads_not_in_published_variations(): void
    {
        $orphan = 'orphan-' . uniqid() . '.png';
        $kept = 'kept-' . uniqid() . '.png';
        $this->touchImageFile($orphan);
        $this->touchImageFile($kept);

        $variations = [
            ['label' => 'Yes', 'image' => $kept],
            ['label' => 'No', 'image' => null],
        ];
        $form = $this->createFormWithDraft($variations, [$orphan, $kept]);

        (new DraftService())->publish($form->id);

        $this->assertFalse($this->imageFileExists($orphan), 'orphan should be deleted');
        $this->assertTrue($this->imageFileExists($kept), 'referenced filename should be kept');
    }

    public function test_publish_clears_assessment_draft_uploaded_images_rows_after_sweep(): void
    {
        $orphan = 'row-' . uniqid() . '.png';
        $this->touchImageFile($orphan);

        $form = $this->createFormWithDraft(
            [['label' => 'A', 'image' => null]],
            [$orphan],
        );

        (new DraftService())->publish($form->id);

        $rows = AssessmentDraftUploadedImage::query()->where('form_id', $form->id)->count();
        $this->assertSame(0, $rows);
    }

    public function test_publish_deletes_previously_published_image_when_replaced(): void
    {
        $oldFilename = 'old-' . uniqid() . '.png';
        $newFilename = 'new-' . uniqid() . '.png';
        $this->touchImageFile($oldFilename);
        $this->touchImageFile($newFilename);

        // First publish: oldFilename is live on Question.variations
        $form = $this->createFormWithDraft(
            [['label' => 'A', 'image' => $oldFilename]],
            [$oldFilename],
        );
        (new DraftService())->publish($form->id);

        // Make sure oldFilename still exists after first publish
        // (referenced, not orphaned).
        $this->assertTrue($this->imageFileExists($oldFilename));

        // Second publish: variations swap to newFilename — oldFilename
        // is no longer referenced and should be swept.
        AssessmentDraft::query()->create([
            'form_id' => $form->id,
            'data' => [
                'form' => ['name' => $form->name],
                'sections' => [[
                    'id' => null,
                    'title' => 'Section',
                    'questions' => [[
                        'id' => Question::query()->where('form_id', $form->id)
                            ->where('question_type', 'variations')->value('id'),
                        'question_type' => 'variations',
                        'question_text' => 'Pick one',
                        'variations' => json_encode([
                            ['label' => 'A', 'image' => $newFilename],
                        ]),
                        'is_required' => 1,
                        'is_active' => 1,
                    ]],
                ]],
            ],
            'is_in_sync' => false,
        ]);
        AssessmentDraftUploadedImage::query()->create([
            'form_id' => $form->id,
            'filename' => $newFilename,
            'uploaded_at' => now(),
        ]);

        (new DraftService())->publish($form->id);

        $this->assertFalse($this->imageFileExists($oldFilename), 'old image should be swept');
        $this->assertTrue($this->imageFileExists($newFilename), 'new image should be kept');
    }

    public function test_publish_handles_legacy_string_array_variations_without_throwing(): void
    {
        $form = Form::query()->create([
            'name' => 'Legacy test form ' . uniqid(),
            'is_active' => 1,
            'live' => 0,
        ]);
        $this->testFormId = $form->id;

        AssessmentDraft::query()->create([
            'form_id' => $form->id,
            'data' => [
                'form' => ['name' => $form->name],
                'sections' => [[
                    'id' => null,
                    'title' => 'Section',
                    'questions' => [[
                        'id' => -1,
                        'question_type' => 'variations',
                        'question_text' => 'Legacy variation',
                        'variations' => json_encode(['Yes', 'No']),
                        'is_required' => 1,
                        'is_active' => 1,
                    ]],
                ]],
            ],
            'is_in_sync' => false,
        ]);

        $result = (new DraftService())->publish($form->id);
        $this->assertTrue($result['published']);
    }

    public function test_publish_with_no_image_options_is_a_noop_for_sweep(): void
    {
        $form = $this->createFormWithDraft([
            ['label' => 'A', 'image' => null],
            ['label' => 'B', 'image' => null],
        ]);

        $result = (new DraftService())->publish($form->id);
        $this->assertTrue($result['published']);

        $rows = AssessmentDraftUploadedImage::query()->where('form_id', $form->id)->count();
        $this->assertSame(0, $rows);
    }
}
```

- [ ] **Step 2: Run the test to verify it fails**

```
docker exec pharmacy-starter-20026-web ./vendor/bin/phpunit tests/Feature/AssessmentPublishOrphanSweepTest.php
```

Expected: most cases fail — the orphan file still exists, the draft-uploads rows still exist. The legacy + no-image cases may pass since publish itself works; they're regression guards.

- [ ] **Step 3: Commit (red)**

```
git add tests/Feature/AssessmentPublishOrphanSweepTest.php
git commit -m "test(assessments): failing tests for publish-time orphan image sweep"
```

---

## Task 11: Implement the orphan sweep in `DraftService::publish()`

**Files:**
- Modify: `plugins/mtc_assessments/src/Services/DraftService.php`

- [ ] **Step 1: Add the `AssessmentDraftUploadedImage` use statement**

At the top alongside the existing imports:

```php
use MtcPharmacy\Assessments\Models\AssessmentDraftUploadedImage;
```

- [ ] **Step 2: Capture pre-publish image filenames inside `publish()`**

Inside `publish()`, immediately before the `DB::transaction(...)` call, insert:

```php
        $previouslyPublishedImages = $this->collectImageFilenamesForForm($formId);
```

- [ ] **Step 3: Call the sweep after the transaction returns**

Replace the bare `return DB::transaction(...)` with assignment + sweep + return:

```php
        $result = DB::transaction(function () use ($form, $data, $draft, $live, $adminId, $adminName) {
            // ... existing transaction body unchanged ...
            return ['published' => true, 'form_id' => $form->id];
        });

        if (!empty($result['published'])) {
            $this->sweepDraftImages($formId, $previouslyPublishedImages);
        }

        return $result;
```

(Keep the entire transaction-body code exactly as it was — only the `return DB::transaction(...)` wrapper changes.)

- [ ] **Step 4: Add the helper methods to the bottom of the class**

```php
    /**
     * Collect every per-option image filename currently stored in any
     * variations-type question for the given form. Used before publish
     * to know which previously-published files might need sweeping if
     * they're no longer referenced after publish.
     */
    private function collectImageFilenamesForForm(int $formId): array
    {
        $filenames = [];
        $variationsRaw = Question::query()
            ->where('form_id', $formId)
            ->where('question_type', 'variations')
            ->pluck('variations')
            ->all();

        foreach ($variationsRaw as $raw) {
            foreach (Question::normalizeVariations($raw) as $entry) {
                if (!empty($entry['image'])) {
                    $filenames[] = $entry['image'];
                }
            }
        }

        return array_values(array_unique($filenames));
    }

    /**
     * Delete uploaded files that are not referenced by the new
     * published variations.
     *
     * Sources of candidate filenames:
     *   1. assessment_draft_uploaded_images rows for this form
     *      (everything uploaded during the draft cycle)
     *   2. The filename set captured before publish
     *      (previously-published images that may have been replaced)
     *
     * After deletion, the tracking rows for this form are cleared so a
     * fresh draft cycle starts clean.
     */
    private function sweepDraftImages(int $formId, array $previousImages): void
    {
        $currentImages = $this->collectImageFilenamesForForm($formId);

        $draftUploads = AssessmentDraftUploadedImage::query()
            ->where('form_id', $formId)
            ->pluck('filename')
            ->all();

        $candidates = array_unique(array_merge($previousImages, $draftUploads));
        $orphans = array_diff($candidates, $currentImages);

        if (!empty($orphans)) {
            global $image_folders;
            if (empty($image_folders['assessment_option_images'])) {
                require_once SITE_PATH . '/shop/includes/image_folders.php';
            }
            $folders = $image_folders['assessment_option_images'] ?? [];

            foreach ($orphans as $filename) {
                foreach ($folders as $folder) {
                    @unlink(SITE_PATH . '/' . $folder['path'] . '/' . $filename);
                }
            }
        }

        AssessmentDraftUploadedImage::query()
            ->where('form_id', $formId)
            ->delete();
    }
```

- [ ] **Step 5: Run the test to verify it passes**

```
docker exec pharmacy-starter-20026-web ./vendor/bin/phpunit tests/Feature/AssessmentPublishOrphanSweepTest.php
```

Expected: 5 tests pass.

- [ ] **Step 6: Run the existing builder tests to confirm no regression**

```
docker exec pharmacy-starter-20026-web ./vendor/bin/phpunit tests/Feature/AssessmentBuilderApiTest.php
```

Expected: all existing tests still pass.

- [ ] **Step 7: Commit**

```
git add plugins/mtc_assessments/src/Services/DraftService.php
git commit -m "feat(assessments): publish-time orphan image sweep in DraftService"
```

---

## Task 12: End-to-end browser verification

**Files:** none — manual verification.

Run all of these in Playwright against `http://localhost:81/plugins/mtc_assessments/admin/assessment/builder.php?id=6`, signed in as `mtcadmin / password`. Console must stay clean throughout; server logs (`docker logs pharmacy-starter-20026-web --tail 200`) must show no PHP fatals.

- [ ] **Step 1: Legacy → builder round-trip**

Open a variations question whose options were previously edited via `manage.question.php` (with images). Confirm the cards show their existing thumbnails. Save draft. Reload the builder. Confirm images still display.

- [ ] **Step 2: New upload happy path**

Click the empty-state thumb on a card. Pick a JPG. Confirm spinner appears, then thumb replaces placeholder. Save draft. Reload. Image survives. Publish. Confirm in DB: `SELECT variations FROM assessment_questions WHERE form_id = 6 AND question_type = 'variations';` contains the new filename. On disk, confirm the file exists in all three folders (`originals`, `normal`, `thumbs`) under `uploads/images/assessment_options/`.

- [ ] **Step 3: Replace image**

On a card with an image, click Replace. Pick a different file. New thumb appears. Publish. Confirm the OLD file is no longer on disk; the NEW file remains.

- [ ] **Step 4: Delete image**

On a card with an image, click Delete. Thumb returns to placeholder. Save → publish. Confirm filename is absent from the variations JSON in DB; file is unlinked from all three folders.

- [ ] **Step 5: Reorder options**

Add 4+ options, attach images to some. Drag option 3 to position 1 by the grip handle. Save → reload. Confirm order persists and each option's image moved with its label (not pegged to slot).

- [ ] **Step 6: Orphan sweep on abandoned uploads**

Pick a file on an empty card slot. Without saving the form, immediately remove that whole option with the × button. Publish the form. Confirm the uploaded file is no longer on disk and the `assessment_draft_uploaded_images` table has zero rows for this form.

```
docker exec pharmacy-starter-20026-mariadb mariadb -upharma_admin -ppharma_password pharma_db -e "SELECT * FROM assessment_draft_uploaded_images WHERE form_id = 6;"
```

Expected: 0 rows after publish.

- [ ] **Step 7: Error paths**

Try uploading an `.svg` (not in the allowed MIME list). Confirm: inline `.variationOptionCard__error` message, no broken state, no console errors, no server 500. Try a >5MB file. Confirm a clean inline error.

- [ ] **Step 8: Public form render**

Navigate to `/assessment/{form_id}` for a form whose variations question now has image options. Confirm: option rows render with the BEM `variations__row--has-image` modifier (you can verify via element inspector), and the `<img class="variations__image">` tags resolve to the thumbnail URLs.

- [ ] **Step 9: Concurrent upload guard**

Pick a file on card A. While it's still uploading (spinner visible), click the placeholder on card B. Confirm B's file picker is disabled or the click is a no-op.

- [ ] **Step 10: Commit any final cleanup**

If any test catches a missed defect, fix it, re-verify, and commit with `fix(assessments): <description>`.

---

## Task 13: Doc sync per /update-assessment-builder

The `/update-assessment-builder` skill requires updating the assessment-builder docs after any UX-level change. The `docs/assessment_builder/` folder is local-only via `.git/info/exclude` and not present in this checkout. If you re-introduce those docs locally:

- [ ] **Step 1:** Add a changelog entry at `docs/assessment_builder/logs/changelog.md` summarising the new image upload + drag-reorder UX.
- [ ] **Step 2:** Add the new files (`variation_option_card.vue`, the migration, the model) to `docs/assessment_builder/reference/file-map.md`.
- [ ] **Step 3:** Mention the new endpoint + tracking table in `docs/assessment_builder/reference/plugin-architecture.md`.

If the docs aren't present locally, skip this task — the spec file at `docs/superpowers/specs/2026-05-25-variations-image-upload-in-builder-design.md` is committed and serves as the durable record.

---

## Self-review notes

- All spec sections are covered: tracking table (Task 1-2), endpoint (Task 3-5), Vuex action (Task 6), card SFC (Task 7), data-shape migration (Task 8), drag reorder (Task 9), orphan sweep (Task 10-11), e2e tests (Task 12).
- TDD discipline: tests are written before implementation in Tasks 3-4 and 10-11. SFC tasks (7-9) are visual-first and verified by Playwright in Task 12 — there's no Vitest setup in the repo today.
- No placeholders. Every step contains the exact code or command.
- Type/name consistency:
  - Action name `uploadOptionImage` is consistent across the store action, controller method, and route.
  - Card emits (`update-label`, `pick-file`, `remove-image`, `remove-option`) match the panel's handlers.
  - Method names in DraftService (`collectImageFilenamesForForm`, `sweepDraftImages`) match across call sites.
- Backwards compat is handled in `parseVariations` (Task 8) and tested in Task 10 (`test_publish_handles_legacy_string_array_variations_without_throwing`).
