# Outreach CSV Upload Mode — 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 an "Upload CSV" alternative to the filter-based segment selection on `outreach/manual-email`, letting an admin email exactly the user IDs in an uploaded file.

**Architecture:** A radio toggle picks the send source. CSV mode parses the file into a deduped list of numeric user IDs, stores them in the existing `OutreachJob.jFilters` JSON column as `{"mode":"csv","userIds":[...]}` (no schema change), and the `outreach:send-emails` command branches on that mode. Missing / deleted / no-email IDs are counted as failed. `iTotal` = raw distinct IDs in the file.

**Tech Stack:** Laravel 11, PHP 8.2, Pest, jQuery + Bootstrap Blade view, Postmark.

**Spec:** `docs/superpowers/specs/2026-06-09-outreach-csv-upload-design.md`

**Note on commits:** The user commits manually. "Stage" steps below run `git add` only; do **not** run `git commit`/`push` — leave staged changes for the user.

---

## File Structure

| File | Responsibility | Change |
|---|---|---|
| `app/Http/Requests/ComputeOutreachSegmentRequest.php` | Mode-aware validation + `csvUserIds()` parser | Modify |
| `app/Http/Requests/DispatchOutreachEmailRequest.php` | Inherit mode-aware rules, keep `templateAlias` | Modify (verify) |
| `app/Http/Queries/OutreachQuery.php` | `fetchUsersByIds()` for CSV-mode lookups | Modify |
| `app/Http/Controllers/OutreachController.php` | Branch compute + dispatch on mode | Modify |
| `app/Console/Commands/SendOutreachEmails.php` | Branch send loop on mode; extract `sendToUser()` | Modify |
| `resources/views/outreach/manualEmail.blade.php` | Radio toggle, file input, FormData JS | Modify |
| `tests/Unit/OutreachQueryTest.php` | Test `fetchUsersByIds()` | Modify |
| `tests/Feature/OutreachManualEmailTest.php` | Test CSV compute + dispatch | Modify |
| `tests/Unit/SendOutreachEmailsCommandTest.php` | Test CSV command path | Modify |

---

## Task 1: Mode-aware request + CSV parser

**Files:**
- Modify: `app/Http/Requests/ComputeOutreachSegmentRequest.php`
- Test: `tests/Feature/OutreachManualEmailTest.php`

- [ ] **Step 1: Write the failing test** — append to `tests/Feature/OutreachManualEmailTest.php`:

```php
it('compute-segment returns distinct ID count for CSV mode (header skipped, deduped)', function () {
    $admin = makeOutreachAdmin(1);

    $csv = \Illuminate\Http\UploadedFile::fake()->createWithContent(
        'ids.csv',
        "iUserID\n101\n102\n102\nabc\n103\n"
    );

    $response = $this->actingAs($admin, 'admin')->post(route('outreach.compute-segment'), [
        'mode' => 'csv',
        'csvFile' => $csv,
    ]);

    $response->assertOk()->assertJson(['count' => 3]);
});
```

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

Run: `vendor/bin/pest --filter="distinct ID count for CSV"`
Expected: FAIL (validation rejects `mode`/`csvFile`, or count wrong).

- [ ] **Step 3: Make the request mode-aware** — edit `app/Http/Requests/ComputeOutreachSegmentRequest.php`. Add a `prepareForValidation()`, branch `rules()`, and add the `csvUserIds()` parser. Replace the class body's `rules()` and add the new methods:

```php
    /** Default mode to 'filter' so existing filter-only callers keep working. */
    protected function prepareForValidation(): void
    {
        if (! $this->has('mode')) {
            $this->merge(['mode' => 'filter']);
        }
    }

    public function rules(): array
    {
        if ($this->input('mode') === 'csv') {
            return [
                'mode' => 'required|in:filter,csv',
                'csvFile' => 'required|file|mimes:csv,txt|max:5120',
            ];
        }

        return [
            'mode' => 'required|in:filter,csv',
            'dDateFrom' => 'nullable|date',
            'dDateTo' => 'nullable|date|after_or_equal:dDateFrom',
            'SmokingStatus' => 'nullable|string',
            'vCountry' => 'nullable|string',
            'vDeviceType' => 'nullable|in:ios,android',
            'bSubscribed' => 'required|in:0,1',
            'bTestUser' => 'required|in:0,1',
            'bPreviouslySubscribed' => 'nullable|in:,0,1',
            'dFirstSubDate' => 'nullable|in:,null,notnull',
            'bActive' => 'nullable|in:,0,1',
            'bTransactSubscribed' => 'nullable|in:,0,1',
            'bEmailSubscribed' => 'nullable|in:,0,1',
        ];
    }

    /**
     * Parse the uploaded CSV into a deduped list of positive integer user IDs.
     * Takes the first column of each line, auto-skips a non-numeric header row,
     * and drops non-numeric / non-positive values.
     *
     * @return array<int>
     */
    public function csvUserIds(): array
    {
        $file = $this->file('csvFile');
        if (! $file) {
            return [];
        }

        $contents = file_get_contents($file->getRealPath());
        if ($contents === false) {
            return [];
        }

        $lines = preg_split('/\r\n|\r|\n/', $contents);

        $ids = [];
        $seen = [];
        $firstCellSeen = false;

        foreach ($lines as $line) {
            $parts = explode(',', $line);
            $cell = trim($parts[0] ?? '');
            if ($cell === '') {
                continue;
            }

            // First non-empty cell: if it is non-numeric, treat the line as a header and skip.
            if (! $firstCellSeen) {
                $firstCellSeen = true;
                if (! ctype_digit($cell)) {
                    continue;
                }
            }

            if (! ctype_digit($cell)) {
                continue;
            }

            $id = (int) $cell;
            if ($id <= 0 || isset($seen[$id])) {
                continue;
            }

            $seen[$id] = true;
            $ids[] = $id;
        }

        return $ids;
    }
```

Keep the existing `attributes()` and `filters()` methods unchanged. Add `'mode' => 'Mode'` and `'csvFile' => 'CSV File'` to the `attributes()` array.

- [ ] **Step 4: Branch the controller's `computeSegment`** — edit `app/Http/Controllers/OutreachController.php`:

```php
    public function computeSegment(ComputeOutreachSegmentRequest $request)
    {
        if ($request->input('mode') === 'csv') {
            return response()->json(['count' => count($request->csvUserIds())]);
        }

        $count = $this->outreachQuery->countFilteredUsers($request->filters());

        return response()->json(['count' => $count]);
    }
```

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

Run: `vendor/bin/pest --filter="distinct ID count for CSV"`
Expected: PASS (count = 3).

- [ ] **Step 6: Run the full outreach feature suite to confirm filter mode still works**

Run: `vendor/bin/pest tests/Feature/OutreachManualEmailTest.php`
Expected: all PASS (existing filter tests post without `mode`, defaulted to `filter`).

- [ ] **Step 7: Stage**

```bash
git add app/Http/Requests/ComputeOutreachSegmentRequest.php app/Http/Controllers/OutreachController.php tests/Feature/OutreachManualEmailTest.php
```

---

## Task 2: Dispatch CSV jobs

**Files:**
- Modify: `app/Http/Controllers/OutreachController.php`
- Modify: `app/Http/Requests/DispatchOutreachEmailRequest.php` (verify only)
- Test: `tests/Feature/OutreachManualEmailTest.php`

- [ ] **Step 1: Write the failing tests** — append to `tests/Feature/OutreachManualEmailTest.php`:

```php
it('dispatch stores a CSV-mode job with userIds and raw distinct iTotal', function () {
    $admin = makeOutreachAdmin(1);

    $postmark = Mockery::mock(PostmarkService::class);
    $postmark->shouldReceive('templateExists')->with('reengage-001')->andReturnTrue();
    app()->instance(PostmarkService::class, $postmark);

    $csv = \Illuminate\Http\UploadedFile::fake()->createWithContent(
        'ids.csv',
        "iUserID\n201\n202\n202\nbad\n203\n"
    );

    $response = $this->actingAs($admin, 'admin')->post(route('outreach.dispatch'), [
        'mode' => 'csv',
        'csvFile' => $csv,
        'templateAlias' => 'reengage-001',
    ]);

    $response->assertOk()->assertJson(['total' => 3]);

    $job = OutreachJob::find($response->json('jobId'));
    expect($job)->not->toBeNull()
        ->and($job->jFilters['mode'])->toBe('csv')
        ->and($job->jFilters['userIds'])->toBe([201, 202, 203])
        ->and($job->iTotal)->toBe(3)
        ->and($job->vTemplateAlias)->toBe('reengage-001');
});

it('dispatch returns 422 when CSV has no valid IDs', function () {
    $admin = makeOutreachAdmin(1);

    $postmark = Mockery::mock(PostmarkService::class);
    $postmark->shouldReceive('templateExists')->with('reengage-001')->andReturnTrue();
    app()->instance(PostmarkService::class, $postmark);

    $csv = \Illuminate\Http\UploadedFile::fake()->createWithContent('ids.csv', "iUserID\nabc\n\n");

    $this->actingAs($admin, 'admin')->post(route('outreach.dispatch'), [
        'mode' => 'csv',
        'csvFile' => $csv,
        'templateAlias' => 'reengage-001',
    ])->assertStatus(422)->assertJsonFragment(['error' => 'No valid user IDs in CSV']);

    expect(OutreachJob::count())->toBe(0);
});
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `vendor/bin/pest --filter="CSV-mode job"` then `vendor/bin/pest --filter="no valid IDs"`
Expected: FAIL (dispatch still treats input as filter mode).

- [ ] **Step 3: Branch the controller's `dispatchSegmentEmail`** — edit `app/Http/Controllers/OutreachController.php`, replacing the body between the template check and `createJob`:

```php
    public function dispatchSegmentEmail(DispatchOutreachEmailRequest $request)
    {
        $templateAlias = $request->input('templateAlias');

        // Validate Postmark template before creating the job
        if (! $this->postmarkService->templateExists($templateAlias)) {
            return response()->json([
                'error' => "Postmark template not found: {$templateAlias}",
            ], 422);
        }

        $adminEmail = optional($request->user('admin'))->vEmailId;

        if ($request->input('mode') === 'csv') {
            $userIds = $request->csvUserIds();
            if (empty($userIds)) {
                return response()->json(['error' => 'No valid user IDs in CSV'], 422);
            }
            $filters = ['mode' => 'csv', 'userIds' => $userIds];
            $total = count($userIds);
        } else {
            $filters = $request->filters();
            $total = $this->outreachQuery->countFilteredUsers($filters);
        }

        $outreachJob = $this->outreachJobQuery->createJob($filters, $templateAlias, $adminEmail, $total);

        // Spawn the Artisan command as a detached background process
        $this->spawnOutreachCommand($outreachJob->iOutreachJobID);

        return response()->json([
            'jobId' => $outreachJob->iOutreachJobID,
            'total' => $total,
        ]);
    }
```

- [ ] **Step 4: Verify `DispatchOutreachEmailRequest`** — open `app/Http/Requests/DispatchOutreachEmailRequest.php`. Its `rules()` already does `array_merge(parent::rules(), ['templateAlias' => 'required|string|max:200'])`. Since `parent::rules()` is now mode-aware, no change is needed — confirm it reads exactly that and leave it. (The `csvUserIds()` helper is inherited from the parent.)

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

Run: `vendor/bin/pest --filter="CSV-mode job"` then `vendor/bin/pest --filter="no valid IDs"`
Expected: PASS.

- [ ] **Step 6: Run the full feature suite**

Run: `vendor/bin/pest tests/Feature/OutreachManualEmailTest.php`
Expected: all PASS.

- [ ] **Step 7: Stage**

```bash
git add app/Http/Controllers/OutreachController.php tests/Feature/OutreachManualEmailTest.php
```

---

## Task 3: `OutreachQuery::fetchUsersByIds()`

**Files:**
- Modify: `app/Http/Queries/OutreachQuery.php`
- Test: `tests/Unit/OutreachQueryTest.php`

- [ ] **Step 1: Write the failing test** — append to `tests/Unit/OutreachQueryTest.php` (the `beforeEach` already seeds user 1 = live, user 2 = deleted, user 3 = test user/live):

```php
it('fetchUsersByIds returns existing non-deleted users keyed by id', function () {
    $query = new OutreachQuery;

    $result = $query->fetchUsersByIds([1, 2, 999]);

    expect($result->has(1))->toBeTrue()      // live
        ->and($result->has(2))->toBeFalse()  // deleted
        ->and($result->has(999))->toBeFalse() // non-existent
        ->and($result->get(1)->dDateCreated)->toBe('2026-03-01 10:00:00');
});

it('fetchUsersByIds returns empty collection for empty input', function () {
    expect((new OutreachQuery)->fetchUsersByIds([])->isEmpty())->toBeTrue();
});
```

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

Run: `vendor/bin/pest --filter="fetchUsersByIds"`
Expected: FAIL ("Call to undefined method ... fetchUsersByIds").

- [ ] **Step 3: Add the method** — in `app/Http/Queries/OutreachQuery.php`, add `use Illuminate\Support\Collection;` at the top with the other imports, then add this method to the class:

```php
    /**
     * Fetch existing, non-deleted users by an explicit ID list (CSV mode).
     * Returns iUserID + dDateCreated keyed by iUserID. IDs with no live row are absent.
     *
     * @param  array<int>  $ids
     */
    public function fetchUsersByIds(array $ids): Collection
    {
        if (empty($ids)) {
            return collect();
        }

        return DB::table('tbl_Users')
            ->select('iUserID', 'dDateCreated')
            ->where('bDeleted', 0)
            ->whereIn('iUserID', $ids)
            ->get()
            ->keyBy('iUserID');
    }
```

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

Run: `vendor/bin/pest --filter="fetchUsersByIds"`
Expected: PASS.

- [ ] **Step 5: Stage**

```bash
git add app/Http/Queries/OutreachQuery.php tests/Unit/OutreachQueryTest.php
```

---

## Task 4: Command CSV send path (extract `sendToUser`, branch on mode)

**Files:**
- Modify: `app/Console/Commands/SendOutreachEmails.php`
- Test: `tests/Unit/SendOutreachEmailsCommandTest.php`

- [ ] **Step 1: Write the failing test** — append to `tests/Unit/SendOutreachEmailsCommandTest.php`:

```php
it('CSV mode sends to stored IDs and counts missing/no-email as failed', function () {
    $job = OutreachJob::create([
        'jFilters' => ['mode' => 'csv', 'userIds' => [1, 2, 3]],
        'vTemplateAlias' => 'outreach-test',
        'vAdminEmail' => 'admin@test.local',
        'vStatus' => 'running',
        'iTotal' => 3,
        'iSent' => 0,
        'iFailed' => 0,
        'dDateCreated' => now(),
    ]);

    // User 1 exists with email -> sent. User 2 exists but no email -> failed. User 3 missing -> failed.
    $outreachQuery = Mockery::mock(OutreachQuery::class);
    $outreachQuery->shouldReceive('fetchUsersByIds')->with([1, 2, 3])->andReturn(collect([
        1 => (object) ['iUserID' => 1, 'dDateCreated' => now()->subDays(10)->toDateTimeString()],
        2 => (object) ['iUserID' => 2, 'dDateCreated' => now()->subDays(5)->toDateTimeString()],
    ]));
    app()->instance(OutreachQuery::class, $outreachQuery);

    $userInfoQuery = Mockery::mock(UserInfoQuery::class);
    $userInfoQuery->shouldReceive('fetchEmailsAndNames')->with([1, 2, 3])->andReturn(collect([
        1 => (object) ['iUserID' => 1, 'vEmail' => 'a@test.com', 'vName' => 'Alice'],
        2 => (object) ['iUserID' => 2, 'vEmail' => null, 'vName' => 'Bob'],
        3 => (object) ['iUserID' => 3, 'vEmail' => 'c@test.com', 'vName' => 'Cara'],
    ]));
    app()->instance(UserInfoQuery::class, $userInfoQuery);

    $postmark = Mockery::mock(PostmarkService::class);
    $postmark->shouldReceive('sendTemplate')->once()->andReturn((object) ['ErrorCode' => 0]);
    app()->instance(PostmarkService::class, $postmark);

    $this->artisan("outreach:send-emails {$job->iOutreachJobID}")->assertSuccessful();

    $job->refresh();
    expect($job->iSent)->toBe(1)
        ->and($job->iFailed)->toBe(2)
        ->and($job->vStatus)->toBe('complete');
});
```

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

Run: `vendor/bin/pest --filter="CSV mode sends to stored IDs"`
Expected: FAIL (command ignores `mode`, calls `getFilteredUsersChunked`).

- [ ] **Step 3: Refactor the command** — replace the body of `handle()` in `app/Console/Commands/SendOutreachEmails.php` (from the `$filters = $job->jFilters;` line through the end of the chunk closure) and add two private methods. The full `handle()` becomes:

```php
    public function handle(
        OutreachQuery $outreachQuery,
        UserInfoQuery $userInfoQuery,
        PostmarkService $postmark,
    ): int {
        $job = OutreachJob::find($this->argument('jobId'));

        if (! $job) {
            $this->error('OutreachJob not found.');

            return self::FAILURE;
        }

        if (! $job->isRunning()) {
            $this->error('OutreachJob is not in running state.');

            return self::FAILURE;
        }

        $templates = config('postmark.templates');
        $templateConfig = collect($templates)->firstWhere('alias', $job->vTemplateAlias);

        $filters = $job->jFilters;

        if (($filters['mode'] ?? 'filter') === 'csv') {
            foreach (array_chunk($filters['userIds'] ?? [], 200) as $chunk) {
                $users = $outreachQuery->fetchUsersByIds($chunk);
                $contacts = $userInfoQuery->fetchEmailsAndNames($chunk);

                $chunkSent = 0;
                $chunkFailed = 0;

                foreach ($chunk as $userId) {
                    $user = $users->get($userId);
                    if (! $user) {
                        $chunkFailed++;

                        continue;
                    }

                    $ok = $this->sendToUser(
                        $job, $userId, $user->dDateCreated, $contacts->get($userId), $postmark, $templateConfig
                    );
                    $ok ? $chunkSent++ : $chunkFailed++;
                }

                $job->increment('iSent', $chunkSent);
                $job->increment('iFailed', $chunkFailed);
                $job->update(['dDateUpdated' => now()]);
            }
        } else {
            $outreachQuery->getFilteredUsersChunked($filters, 200,
                function ($users) use ($job, $userInfoQuery, $postmark, $templateConfig) {
                    $userIds = $users->pluck('iUserID')->all();
                    $contacts = $userInfoQuery->fetchEmailsAndNames($userIds);

                    $chunkSent = 0;
                    $chunkFailed = 0;

                    foreach ($users as $user) {
                        $ok = $this->sendToUser(
                            $job, $user->iUserID, $user->dDateCreated, $contacts->get($user->iUserID), $postmark, $templateConfig
                        );
                        $ok ? $chunkSent++ : $chunkFailed++;
                    }

                    $job->increment('iSent', $chunkSent);
                    $job->increment('iFailed', $chunkFailed);
                    $job->update(['dDateUpdated' => now()]);
                });
        }

        $job->update([
            'vStatus' => 'complete',
            'dDateUpdated' => now(),
        ]);

        $this->info("Complete. Sent: {$job->fresh()->iSent}, Failed: {$job->fresh()->iFailed}");

        return self::SUCCESS;
    }

    /**
     * Send the job's Postmark template to a single user. Returns true on success.
     * A null/empty-email contact is treated as a failure.
     */
    private function sendToUser(
        OutreachJob $job,
        int $userId,
        $dDateCreated,
        $contact,
        PostmarkService $postmark,
        ?array $templateConfig,
    ): bool {
        if (! $contact || empty($contact->vEmail)) {
            return false;
        }

        $daysPassed = number_format(floor(
            Carbon::parse($dDateCreated)->diffInDays(Carbon::now()),
        ));

        $tag = $templateConfig['tag'] ?? str_replace('-template', '', $job->vTemplateAlias);

        $response = $postmark->sendTemplate(
            $contact->vEmail,
            $job->vTemplateAlias,
            [
                'name' => $contact->vName,
                'unsubscribe_link' => front_url('emails/unsubscribe-request/' . encryptData($userId)),
                'days_passed' => $daysPassed,
            ],
            null,
            null,
            $tag,
            $templateConfig['stream'] ?? 'prod-other-emails',
        );

        $ok = $response && isset($response->ErrorCode) && $response->ErrorCode == 0;
        if (! $ok) {
            Log::warning('Outreach email failed', [
                'outreach_job_id' => $job->iOutreachJobID,
                'user_id' => $userId,
                'template' => $job->vTemplateAlias,
            ]);
        }

        return $ok;
    }
```

Remove the now-unused `use stdClass;` import only if it is no longer referenced anywhere in the file (the commented-out test block referenced it; the live code does not). Leave the other imports (`Carbon`, `Log`) in place — they are still used by `sendToUser`.

- [ ] **Step 4: Run the new CSV command test**

Run: `vendor/bin/pest --filter="CSV mode sends to stored IDs"`
Expected: PASS (sent 1, failed 2).

- [ ] **Step 5: Run the existing command test to confirm the filter path still works**

Run: `vendor/bin/pest tests/Unit/SendOutreachEmailsCommandTest.php`
Expected: all PASS (the existing "sends template to every segment user" test sends 2, fails 0).

- [ ] **Step 6: Stage**

```bash
git add app/Console/Commands/SendOutreachEmails.php tests/Unit/SendOutreachEmailsCommandTest.php
```

---

## Task 5: View — radio toggle, file input, FormData JS

**Files:**
- Modify: `resources/views/outreach/manualEmail.blade.php`

This task is UI; verification is manual (browser) since there is no JS test harness. Make the edits, then sanity-check that the existing feature tests (which assert the page renders) still pass.

- [ ] **Step 1: Add the mode toggle above the filter form.** In `resources/views/outreach/manualEmail.blade.php`, immediately after the opening `<div class="x_content">` (the one wrapping `<form id="outreachFilterForm">`, around line 14) and **before** `<form id="outreachFilterForm" ...>`, insert:

```html
                {{-- Mode toggle: filter segment vs CSV upload --}}
                <div class="row" style="margin-bottom:16px;">
                    <div class="col-md-12">
                        <label class="control-label" style="margin-right:12px;">Send to:</label>
                        <label style="margin-right:20px; font-weight:normal;">
                            <input type="radio" name="outreachMode" value="filter" checked> Filter segment
                        </label>
                        <label style="font-weight:normal;">
                            <input type="radio" name="outreachMode" value="csv"> Upload CSV (user IDs)
                        </label>
                    </div>
                </div>
```

- [ ] **Step 2: Wrap the filter field rows so they can be hidden.** The filter input rows are the three `<div class="row">` blocks between the `@csrf` line and the Apply-button row (currently around lines 18–133). Wrap exactly those three rows in a container. Add `<div id="outreachFilterFields">` right after `@csrf`, and add the closing `</div>` immediately before the `<div class="row" style="margin-top:20px;">` that holds the `#applyFiltersBtn` button. Do **not** include the Apply-button row inside the wrapper.

- [ ] **Step 3: Add the CSV file input block.** Immediately after the new `</div>` that closes `#outreachFilterFields` (and before the Apply-button row), insert:

```html
                    {{-- CSV upload (hidden unless CSV mode) --}}
                    <div id="outreachCsvFields" class="row" style="display:none;">
                        <div class="col-md-6">
                            <label class="control-label">CSV file — one user ID per line (optional header row is ignored)</label>
                            <input type="file" id="outreachCsvFile" name="csvFile" accept=".csv,.txt" class="form-control">
                            <span class="text-danger error" id="error_csvFile"></span>
                        </div>
                    </div>
```

- [ ] **Step 4: Add the mode-toggle JS handler.** Inside the existing `$(function() { ... })` block (after the `progressLabel` consts, before the `$("#applyFiltersBtn").click(...)` handler), add:

```javascript
            function currentOutreachMode() {
                return $('input[name="outreachMode"]:checked').val() || 'filter';
            }

            $('input[name="outreachMode"]').on('change', function() {
                $('.error').html('');
                $('#outreachSendSection').hide();
                progressWrap.style.display = 'none';
                if (currentOutreachMode() === 'csv') {
                    $('#outreachFilterFields').hide();
                    $('#outreachCsvFields').show();
                } else {
                    $('#outreachCsvFields').hide();
                    $('#outreachFilterFields').show();
                }
            });
```

- [ ] **Step 5: Branch the Apply (`#applyFiltersBtn`) handler.** Replace the existing `$("#applyFiltersBtn").click(function() { ... });` handler with this mode-aware version:

```javascript
            $("#applyFiltersBtn").click(function() {
                var mode = currentOutreachMode();
                $('.error').html('');
                progressWrap.style.display = 'none';

                if (mode === 'csv') {
                    if (!document.getElementById('outreachCsvFile').files[0]) {
                        alert('Please choose a CSV file.');
                        return false;
                    }
                } else {
                    var fromDate = $('#dDateFrom').val();
                    var toDate = $('#dDateTo').val();
                    if (fromDate == "" || toDate == "") {
                        alert('Please select both From and To dates.');
                        return false;
                    }
                    if (new Date(fromDate) > new Date(toDate)) {
                        alert("From date cannot be greater than the To date");
                        return false;
                    }
                }

                $(this).attr('disabled', true).text('Computing...');
                $('#outreachSendSection').hide(function () {
                    $('#outreachUserCount').text(0);
                });

                var ajaxOpts = {
                    url: '{{ route('outreach.compute-segment') }}',
                    type: 'POST',
                    success: function(response) {
                        $('#outreachSendSection').show(function () {
                            $('#outreachUserCount').text(response.count);
                        });
                    },
                    error: function(xhr) {
                        if (xhr.responseJSON && xhr.responseJSON.errors) {
                            $.each(xhr.responseJSON.errors, function(key, messages) {
                                $('#error_' + key).html(messages[0]);
                            });
                        }
                    },
                    complete: function() {
                        $("#applyFiltersBtn").attr('disabled', false).text('Apply');
                    }
                };

                if (mode === 'csv') {
                    var fd = new FormData();
                    fd.append('mode', 'csv');
                    fd.append('csvFile', document.getElementById('outreachCsvFile').files[0]);
                    fd.append('_token', '{{ csrf_token() }}');
                    ajaxOpts.data = fd;
                    ajaxOpts.processData = false;
                    ajaxOpts.contentType = false;
                } else {
                    ajaxOpts.data = $('#outreachFilterForm').serialize() + '&mode=filter';
                }

                $.ajax(ajaxOpts);
            });
```

- [ ] **Step 6: Branch the dispatch (`#outreachConfirmYes`) handler.** Replace the `$.ajax({ ... })` call inside `$('#outreachConfirmYes').click(...)` so the POST body is built per-mode. Replace the part from `var templateAlias = $('#outreachTemplateAlias').val().trim();` through the `$.ajax({ ... });` call with:

```javascript
                var templateAlias = $('#outreachTemplateAlias').val().trim();
                var mode = currentOutreachMode();

                var ajaxOpts = {
                    url: '{{ route('outreach.dispatch') }}',
                    type: 'POST',
                    success: function(response) {
                        if (!response || !response.jobId) {
                            progressLabel.textContent = 'Failed to dispatch.';
                            $('#sendOutreachBtn').attr('disabled', false);
                            return;
                        }
                        pollStatus(response.jobId);
                    },
                    error: function(xhr) {
                        if (xhr.responseJSON && xhr.responseJSON.error) {
                            $('#error_outreachTemplateAlias').html(xhr.responseJSON.error);
                            progressWrap.style.display = 'none';
                        } else {
                            progressLabel.textContent = 'Failed to dispatch.';
                        }
                        $('#sendOutreachBtn').attr('disabled', false);
                    }
                };

                if (mode === 'csv') {
                    var fd = new FormData();
                    fd.append('mode', 'csv');
                    fd.append('csvFile', document.getElementById('outreachCsvFile').files[0]);
                    fd.append('templateAlias', templateAlias);
                    fd.append('_token', '{{ csrf_token() }}');
                    ajaxOpts.data = fd;
                    ajaxOpts.processData = false;
                    ajaxOpts.contentType = false;
                } else {
                    ajaxOpts.data = $('#outreachFilterForm').serialize()
                        + '&mode=filter'
                        + '&templateAlias=' + encodeURIComponent(templateAlias);
                }

                $.ajax(ajaxOpts);
```

- [ ] **Step 7: Render check.** Run the feature tests that load the page to confirm the Blade still compiles/renders:

Run: `vendor/bin/pest tests/Feature/OutreachManualEmailTest.php --filter="allows admins"`
Expected: PASS (page returns 200).

- [ ] **Step 8: Manual browser smoke test** (cannot be automated). As an admin with `bOutreach=1`, on `outreach/manual-email`:
  1. Default mode = Filter; the filter form + Apply work exactly as before.
  2. Select "Upload CSV" → filter fields hide, file input shows.
  3. Choose a CSV of user IDs, click Apply → count appears.
  4. Enter a Postmark template, Send → confirm modal → progress bar advances; on refresh the running job resumes.
  5. Switch back to Filter → file input hides, filter fields return.

- [ ] **Step 9: Stage**

```bash
git add resources/views/outreach/manualEmail.blade.php
```

---

## Task 6: Full regression run

- [ ] **Step 1: Run the full outreach test set**

Run: `vendor/bin/pest tests/Feature/OutreachManualEmailTest.php tests/Unit/OutreachQueryTest.php tests/Unit/SendOutreachEmailsCommandTest.php`
Expected: all PASS.

- [ ] **Step 2: Run Pint on the changed PHP files**

Run: `vendor/bin/pint app/Http/Requests/ComputeOutreachSegmentRequest.php app/Http/Controllers/OutreachController.php app/Http/Queries/OutreachQuery.php app/Console/Commands/SendOutreachEmails.php`
Expected: no style errors (or auto-fixed).

- [ ] **Step 3: Stage any Pint fixups**

```bash
git add -A
```

Leave all staged changes for the user to review and commit.

---

## Self-Review Notes (verified against spec)

- **CSV ignores filters / exact IDs** → Task 4 CSV branch chunks `userIds` directly; no filter joins. ✓
- **One ID per line, header auto-skip, dedupe, drop non-numeric** → Task 1 `csvUserIds()`. ✓
- **Missing/deleted/no-email → failed** → Task 4: missing (`! $user`) and empty-email (`sendToUser` returns false) both increment `chunkFailed`; `fetchUsersByIds` excludes `bDeleted=1`. ✓
- **`iTotal` = raw distinct IDs** → Task 2: `$total = count($userIds)`. ✓
- **No schema change; `jFilters` = `{mode, userIds}`** → Tasks 2 & 4. ✓
- **Radio toggle UI** → Task 5. ✓
- **Method-name consistency** → `csvUserIds()`, `fetchUsersByIds()`, `sendToUser()` used identically across tasks. ✓
- **Existing tests unaffected** → `prepareForValidation()` defaults `mode=filter`; command filter branch preserves original behaviour; Tasks 1/2/4 each re-run the prior suites. ✓
