# Direct Outreach — Manual Email 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:** Build the "Direct Outreach → Manual Email" admin page so ops can segment users via filters and fire a Postmark template to the resulting segment, with a live progress indicator.

**Architecture:** Admin page with two sections. Section 1 = filter form → POST to `computeSegment` returns user count. Section 2 reveals with count + Postmark template input + Send button → POST to `dispatchSegmentEmail` starts a queued `SendOutreachEmailsJob` that writes progress to the Cache. Browser polls `segmentStatus` every second to update a progress bar. Reuses existing `PostmarkService`, `UserInfoQuery`, and the template-model conventions from `NewYearEmailController`.

**Tech Stack:** Laravel 11, PHP 8.2, Pest PHP tests, jQuery + Bootstrap (Gentelella theme), wildbit/postmark-php, Laravel cache+queue.

---

## Open Clarifications (resolve before coding)

Before starting Task 1, confirm with the product owner:

1. **`bMarketingSubscribed` does not exist** in `tbl_UserConfig` or `tbl_Users` (verified via grep + data-dictionary). The closest match is `tbl_UserConfig.bEmailSubscribed`. This plan **assumes the spec's "bMarketingSubscribed selector" maps to `bEmailSubscribed`**. If a new column is actually wanted, add a migration task — but don't guess; ask first.
2. **Device filter values**: the existing `resources/views/outreach/manualEmail.blade.php` dropdown uses `android` / `ios` (targets `tbl_Users.vDeviceType`). Plan follows that.
3. **Segment count scale**: if a typical segment is under ~2000 users, a single queued job is fine. For larger segments we'd need chunked jobs. Plan uses a single job but chunks the send loop in memory so the cache counter updates every 25 sends.

Everything else is pinned below.

## Table of data sources (for reference while coding)

| Filter field | Column | Table | Notes |
|---|---|---|---|
| dDateCreated from/to | `dDateCreated` | `tbl_Users` (alias `u`) | Inclusive range `[from 00:00:00, to 23:59:59]` |
| SmokingStatus | `SmokingStatus` | `tbl_Users` | From `UserQuery::getSmokingStatuses()` |
| Country | `vCountry` | `tbl_UserConfig` (alias `uc`) | From `UserConfigQuery::getAllCountries()` |
| Device | `vDeviceType` | `tbl_Users` | Values: `android`, `ios` |
| bSubscribed toggle (default off) | `bSubscribed` | `tbl_UserPrograms` (alias `upm`) | **Always applied.** Off = `upm.bSubscribed = 0`, On = `upm.bSubscribed = 1` |
| bPreviouslySubscribed selector | `bPreviouslySubscribed` | `tbl_UserPrograms` | Values: `''` (no filter), `0`, `1` |
| dFirstSubDate selector | `dFirstSubDate` | `tbl_UserPrograms` | Values: `''` (no filter), `null`, `notnull` |
| bActive selector | `bActive` | `tbl_Users` | Values: `''`, `0`, `1` |
| bTestUser toggle (default off) | `bTestUser` | `tbl_Users` | **Always applied.** Off = `bTestUser = 0`, On = `bTestUser = 1` |
| bTransactSubscribed selector | `bTransactSubscribed` | `tbl_UserConfig` | Values: `''`, `0`, `1` |
| bMarketingSubscribed selector | `bEmailSubscribed` | `tbl_UserConfig` | Per clarification above |
| **Hardcoded always** | `bDeleted = 0` | `tbl_Users` | Never exposed in form |

Join graph: `u` ⟕ `uc ON u.iUserID = uc.iUserID` ⟕ `upm ON u.iUserID = upm.iUserID AND u.iProgramID = upm.iProgramID`. This matches the existing join style in `NewYearEmailQuery::subscribedIncompleteProgram`.

## File Structure

Files to create:
- `app/Http/Queries/OutreachQuery.php` — single class with `buildFilteredUsersQuery(array $filters): Builder`, `countFilteredUsers(array $filters): int`, `getFilteredUsersChunked(array $filters, int $chunkSize, callable $cb): void`, and a private `applyFilters` helper. Builder-based so count and fetch share one source of truth.
- `app/Jobs/SendOutreachEmailsJob.php` — queued job that chunks over the segment, sends via `PostmarkService::sendTemplate`, and updates cache progress.
- `app/Services/OutreachProgressService.php` — thin wrapper around `Cache` for `initialize(jobId, total)`, `incrementSent(jobId)`, `incrementFailed(jobId)`, `markComplete(jobId)`, `markInvalidTemplate(jobId)`, `status(jobId)`. Using a service (rather than poking Cache directly) keeps keys consistent and the tests simple.
- `app/Http/Requests/ComputeOutreachSegmentRequest.php` — validates filter input types.
- `app/Http/Requests/DispatchOutreachEmailRequest.php` — validates filter input + `templateAlias`.
- `resources/views/outreach/manualEmail.blade.php` — **rewrite** existing minimal view.
- `resources/js/outreach-manual-email.js` — filter form handling, AJAX calls, progress polling, confirm modal. Added as a Vite entry.
- `tests/Feature/OutreachManualEmailTest.php` — covers: access control, filter count endpoint, invalid template rejection, job dispatch, progress polling.
- `tests/Unit/OutreachQueryTest.php` — covers each filter's effect on the builder in isolation using an in-memory sqlite fixture set.

Files to modify:
- `app/Http/Controllers/OutreachController.php` — add `computeSegment`, `dispatchSegmentEmail`, `segmentStatus`. Update `manualEmail` to pass an initial empty count context.
- `routes/web.php` — add the three new POST/GET routes inside the existing `outreach` prefix group, and wire a middleware that enforces `bOutreach = 1`. Verify the existing group already requires admin auth (it's inside the admin middleware group).
- `app/Http/Middleware/AdminMiddleware.php` — **do not** modify. We add section permission via a small inline closure on the route group instead (see Task 2) so we don't couple AdminMiddleware to per-section flags.
- `vite.config.js` — register `resources/js/outreach-manual-email.js` as a new input.
- `config/postmark.php` — no change unless we add a config stream for outreach; default stream is fine per clarification.

Why these splits:
- `OutreachQuery` owns the segment query — it is reused by count, preview, and the job. Keeping count+fetch in the same class makes the guarantee "the count matches the recipients we mail" obvious.
- `SendOutreachEmailsJob` isolates the loop so the HTTP request returns instantly and can tolerate 10k+ users.
- `OutreachProgressService` exists so tests can fake progress without pinning cache-key strings.
- The JS file is its own Vite entry to avoid bloating `app.js` for non-outreach pages.

## Access Control

Existing code already ships the access gate:
- `tbl_Admin.bOutreach` column added via `database/migrations/2026_04_10_000000_add_bOutreach_column_in_tbl_Admin_table.php`.
- `Admin::$fillable` includes `bOutreach`.
- Sidebar menu (`resources/views/components/sidebar.blade.php:50-57`) only renders the section when `Auth::guard('admin')->user()->bOutreach == 1`.
- Route `outreach.manual-email` exists at `routes/web.php:118-121` inside the admin-auth group.

Task 2 adds the **server-side** enforcement (sidebar check is UX only). We block every outreach route with an inline abort if `bOutreach != 1`.

---

## Task 1: OutreachQuery — build the filtered-users query

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

- [ ] **Step 1.1: Write failing test — default filters return only bDeleted=0, bTestUser=0 users**

```php
<?php

use App\Http\Queries\OutreachQuery;
use Illuminate\Support\Facades\DB;

beforeEach(function () {
    DB::statement('CREATE TABLE tbl_Users (iUserID INTEGER PRIMARY KEY, iProgramID INTEGER, dDateCreated TEXT, SmokingStatus TEXT, vDeviceType TEXT, bActive INTEGER, bTestUser INTEGER, bDeleted INTEGER)');
    DB::statement('CREATE TABLE tbl_UserConfig (iUserID INTEGER PRIMARY KEY, vCountry TEXT, bTransactSubscribed INTEGER, bEmailSubscribed INTEGER)');
    DB::statement('CREATE TABLE tbl_UserPrograms (iUserID INTEGER, iProgramID INTEGER, bSubscribed INTEGER, bPreviouslySubscribed INTEGER, dFirstSubDate TEXT)');

    // user 1: live, non-test, not subscribed, not previously subscribed
    DB::table('tbl_Users')->insert(['iUserID' => 1, 'iProgramID' => 1, 'dDateCreated' => '2026-03-01 10:00:00', 'SmokingStatus' => 'Smoker', 'vDeviceType' => 'ios', 'bActive' => 1, 'bTestUser' => 0, 'bDeleted' => 0]);
    DB::table('tbl_UserConfig')->insert(['iUserID' => 1, 'vCountry' => 'India', 'bTransactSubscribed' => 1, 'bEmailSubscribed' => 1]);
    DB::table('tbl_UserPrograms')->insert(['iUserID' => 1, 'iProgramID' => 1, 'bSubscribed' => 0, 'bPreviouslySubscribed' => 0, 'dFirstSubDate' => null]);

    // user 2: deleted — must always be excluded
    DB::table('tbl_Users')->insert(['iUserID' => 2, 'iProgramID' => 1, 'dDateCreated' => '2026-03-01 10:00:00', 'SmokingStatus' => 'Smoker', 'vDeviceType' => 'ios', 'bActive' => 1, 'bTestUser' => 0, 'bDeleted' => 1]);
    DB::table('tbl_UserConfig')->insert(['iUserID' => 2, 'vCountry' => 'India', 'bTransactSubscribed' => 1, 'bEmailSubscribed' => 1]);
    DB::table('tbl_UserPrograms')->insert(['iUserID' => 2, 'iProgramID' => 1, 'bSubscribed' => 0, 'bPreviouslySubscribed' => 0, 'dFirstSubDate' => null]);

    // user 3: test user — excluded by default (bTestUser toggle off)
    DB::table('tbl_Users')->insert(['iUserID' => 3, 'iProgramID' => 1, 'dDateCreated' => '2026-03-01 10:00:00', 'SmokingStatus' => 'Smoker', 'vDeviceType' => 'ios', 'bActive' => 1, 'bTestUser' => 1, 'bDeleted' => 0]);
    DB::table('tbl_UserConfig')->insert(['iUserID' => 3, 'vCountry' => 'India', 'bTransactSubscribed' => 1, 'bEmailSubscribed' => 1]);
    DB::table('tbl_UserPrograms')->insert(['iUserID' => 3, 'iProgramID' => 1, 'bSubscribed' => 0, 'bPreviouslySubscribed' => 0, 'dFirstSubDate' => null]);
});

it('excludes deleted and test users by default', function () {
    $query = new OutreachQuery();

    $count = $query->countFilteredUsers([
        'bSubscribed' => 0,
        'bTestUser' => 0,
    ]);

    expect($count)->toBe(1);
});
```

- [ ] **Step 1.2: Run the test — expect fail**

Run: `vendor/bin/pest tests/Unit/OutreachQueryTest.php --filter="excludes deleted and test users"`
Expected: FAIL with "Class OutreachQuery not found".

- [ ] **Step 1.3: Create minimal OutreachQuery with default-filter support**

```php
<?php

namespace App\Http\Queries;

use Carbon\Carbon;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;

class OutreachQuery
{
    /**
     * Build the base filtered users query (no select yet).
     * Always applies bDeleted = 0, and always applies bSubscribed and bTestUser (they are toggles with explicit values).
     *
     * @param array $filters Keys: dDateFrom, dDateTo, SmokingStatus, vCountry, vDeviceType,
     *                       bSubscribed (0|1, required), bTestUser (0|1, required),
     *                       bPreviouslySubscribed ('' |0|1), dFirstSubDate ('' |'null'|'notnull'),
     *                       bActive ('' |0|1), bTransactSubscribed ('' |0|1),
     *                       bMarketingSubscribed ('' |0|1)
     */
    public function buildFilteredUsersQuery(array $filters): Builder
    {
        $q = DB::table('tbl_Users as u')
            ->join('tbl_UserConfig as uc', 'u.iUserID', '=', 'uc.iUserID')
            ->join('tbl_UserPrograms as upm', function ($join) {
                $join->on('u.iUserID', '=', 'upm.iUserID')
                    ->on('u.iProgramID', '=', 'upm.iProgramID');
            })
            ->where('u.bDeleted', 0)
            ->where('u.bTestUser', (int) ($filters['bTestUser'] ?? 0))
            ->where('upm.bSubscribed', (int) ($filters['bSubscribed'] ?? 0));

        $this->applyOptionalFilters($q, $filters);

        return $q;
    }

    public function countFilteredUsers(array $filters): int
    {
        return $this->buildFilteredUsersQuery($filters)->count('u.iUserID');
    }

    /**
     * Stream users through a callback in fixed-size chunks.
     * Uses orderBy + offset pagination on u.iUserID for deterministic traversal.
     */
    public function getFilteredUsersChunked(array $filters, int $chunkSize, callable $cb): void
    {
        $this->buildFilteredUsersQuery($filters)
            ->select('u.iUserID', 'u.dDateCreated')
            ->orderBy('u.iUserID')
            ->chunk($chunkSize, $cb);
    }

    private function applyOptionalFilters(Builder $q, array $filters): void
    {
        if (! empty($filters['dDateFrom']) && ! empty($filters['dDateTo'])) {
            $q->whereBetween('u.dDateCreated', [
                Carbon::parse($filters['dDateFrom'])->format('Y-m-d') . ' 00:00:00',
                Carbon::parse($filters['dDateTo'])->format('Y-m-d') . ' 23:59:59',
            ]);
        }

        if (! empty($filters['SmokingStatus'])) {
            $q->where('u.SmokingStatus', $filters['SmokingStatus']);
        }

        if (! empty($filters['vCountry'])) {
            $q->where('uc.vCountry', $filters['vCountry']);
        }

        if (! empty($filters['vDeviceType'])) {
            $q->where('u.vDeviceType', $filters['vDeviceType']);
        }

        if (isset($filters['bPreviouslySubscribed']) && $filters['bPreviouslySubscribed'] !== '') {
            $q->where('upm.bPreviouslySubscribed', (int) $filters['bPreviouslySubscribed']);
        }

        if (isset($filters['dFirstSubDate']) && $filters['dFirstSubDate'] !== '') {
            if ($filters['dFirstSubDate'] === 'null') {
                $q->whereNull('upm.dFirstSubDate');
            } elseif ($filters['dFirstSubDate'] === 'notnull') {
                $q->whereNotNull('upm.dFirstSubDate');
            }
        }

        if (isset($filters['bActive']) && $filters['bActive'] !== '') {
            $q->where('u.bActive', (int) $filters['bActive']);
        }

        if (isset($filters['bTransactSubscribed']) && $filters['bTransactSubscribed'] !== '') {
            $q->where('uc.bTransactSubscribed', (int) $filters['bTransactSubscribed']);
        }

        if (isset($filters['bMarketingSubscribed']) && $filters['bMarketingSubscribed'] !== '') {
            $q->where('uc.bEmailSubscribed', (int) $filters['bMarketingSubscribed']);
        }
    }
}
```

- [ ] **Step 1.4: Run the test — expect pass**

Run: `vendor/bin/pest tests/Unit/OutreachQueryTest.php --filter="excludes deleted and test users"`
Expected: PASS.

- [ ] **Step 1.5: Add failing test for each optional filter (one assertion per)**

Each sub-test seeds a distinct user with the single attribute under test set to a non-default value, then asserts the filter selects/deselects it. Add each as its own `it(...)` block — do not collapse into a loop, so a failure points at one filter.

```php
it('filters by date range', function () {
    // Insert user with dDateCreated = 2026-02-01 — excluded when range is Mar 1 → Mar 31
    DB::table('tbl_Users')->insert(['iUserID' => 10, 'iProgramID' => 1, 'dDateCreated' => '2026-02-01 10:00:00', 'SmokingStatus' => 'Smoker', 'vDeviceType' => 'ios', 'bActive' => 1, 'bTestUser' => 0, 'bDeleted' => 0]);
    DB::table('tbl_UserConfig')->insert(['iUserID' => 10, 'vCountry' => 'India', 'bTransactSubscribed' => 1, 'bEmailSubscribed' => 1]);
    DB::table('tbl_UserPrograms')->insert(['iUserID' => 10, 'iProgramID' => 1, 'bSubscribed' => 0, 'bPreviouslySubscribed' => 0, 'dFirstSubDate' => null]);

    $query = new OutreachQuery();
    $count = $query->countFilteredUsers([
        'bSubscribed' => 0, 'bTestUser' => 0,
        'dDateFrom' => '2026-03-01', 'dDateTo' => '2026-03-31',
    ]);

    // Only user 1 from beforeEach matches (dDateCreated = 2026-03-01)
    expect($count)->toBe(1);
});

it('filters by SmokingStatus', function () {
    DB::table('tbl_Users')->where('iUserID', 1)->update(['SmokingStatus' => 'Restarted']);
    $count = (new OutreachQuery())->countFilteredUsers([
        'bSubscribed' => 0, 'bTestUser' => 0, 'SmokingStatus' => 'Smoker',
    ]);
    expect($count)->toBe(0);
});

it('filters by country (join on tbl_UserConfig)', function () {
    $count = (new OutreachQuery())->countFilteredUsers([
        'bSubscribed' => 0, 'bTestUser' => 0, 'vCountry' => 'USA',
    ]);
    expect($count)->toBe(0);
});

it('filters by device type', function () {
    $count = (new OutreachQuery())->countFilteredUsers([
        'bSubscribed' => 0, 'bTestUser' => 0, 'vDeviceType' => 'android',
    ]);
    expect($count)->toBe(0);
});

it('filters by bPreviouslySubscribed = 1', function () {
    $count = (new OutreachQuery())->countFilteredUsers([
        'bSubscribed' => 0, 'bTestUser' => 0, 'bPreviouslySubscribed' => 1,
    ]);
    expect($count)->toBe(0);
});

it('treats empty bPreviouslySubscribed as no filter', function () {
    $count = (new OutreachQuery())->countFilteredUsers([
        'bSubscribed' => 0, 'bTestUser' => 0, 'bPreviouslySubscribed' => '',
    ]);
    expect($count)->toBe(1);
});

it('filters dFirstSubDate null vs notnull', function () {
    DB::table('tbl_UserPrograms')->where('iUserID', 1)->update(['dFirstSubDate' => '2025-01-01 00:00:00']);
    $q = new OutreachQuery();

    expect($q->countFilteredUsers([
        'bSubscribed' => 0, 'bTestUser' => 0, 'dFirstSubDate' => 'null',
    ]))->toBe(0);

    expect($q->countFilteredUsers([
        'bSubscribed' => 0, 'bTestUser' => 0, 'dFirstSubDate' => 'notnull',
    ]))->toBe(1);
});

it('filters by bActive, bTransactSubscribed, bMarketingSubscribed, and bSubscribed toggle', function () {
    DB::table('tbl_Users')->where('iUserID', 1)->update(['bActive' => 0]);
    DB::table('tbl_UserConfig')->where('iUserID', 1)->update(['bTransactSubscribed' => 0, 'bEmailSubscribed' => 0]);
    DB::table('tbl_UserPrograms')->where('iUserID', 1)->update(['bSubscribed' => 1]);

    $q = new OutreachQuery();

    expect($q->countFilteredUsers(['bSubscribed' => 1, 'bTestUser' => 0, 'bActive' => 0]))->toBe(1);
    expect($q->countFilteredUsers(['bSubscribed' => 1, 'bTestUser' => 0, 'bActive' => 1]))->toBe(0);
    expect($q->countFilteredUsers(['bSubscribed' => 1, 'bTestUser' => 0, 'bTransactSubscribed' => 0]))->toBe(1);
    expect($q->countFilteredUsers(['bSubscribed' => 1, 'bTestUser' => 0, 'bMarketingSubscribed' => 0]))->toBe(1);
});
```

- [ ] **Step 1.6: Run all OutreachQuery tests — expect all pass**

Run: `vendor/bin/pest tests/Unit/OutreachQueryTest.php`
Expected: all pass. If any fail, fix `applyOptionalFilters` until green. Do not weaken the tests.

- [ ] **Step 1.7: Commit**

```bash
git add app/Http/Queries/OutreachQuery.php tests/Unit/OutreachQueryTest.php
git commit -m "feat(outreach): add OutreachQuery for filtered user segment"
```

---

## Task 2: Routes + server-side access control

**Files:**
- Modify: `routes/web.php:118-121`

- [ ] **Step 2.1: Write failing test — non-outreach admin is blocked**

Add to `tests/Feature/OutreachManualEmailTest.php`:

```php
<?php

use App\Models\Admin;

it('blocks admins without bOutreach flag', function () {
    $admin = Admin::factory()->create(['bOutreach' => 0, 'bDeleted' => 0, 'bActive' => 1]);

    $this->actingAs($admin, 'admin')
        ->get(route('outreach.manual-email'))
        ->assertForbidden();
});

it('allows admins with bOutreach = 1', function () {
    $admin = Admin::factory()->create(['bOutreach' => 1, 'bDeleted' => 0, 'bActive' => 1]);

    $this->actingAs($admin, 'admin')
        ->get(route('outreach.manual-email'))
        ->assertOk();
});
```

If `Admin` doesn't have a factory, create `database/factories/AdminFactory.php` first (check `database/factories/` — if no others exist, create the minimum needed for these tests). The factory only needs: `vFirstName`, `vEmailId` (unique), `vPassword` (fake hash), `bActive`, `bDeleted`, `bOutreach`. Refer to any existing factory in `database/factories/` for structural pattern.

- [ ] **Step 2.2: Run tests — expect fail**

Run: `vendor/bin/pest tests/Feature/OutreachManualEmailTest.php --filter="bOutreach"`
Expected: FAIL (currently no check — either 200 when should be 403, or missing route).

- [ ] **Step 2.3: Wrap outreach routes in a bOutreach-check closure**

Replace `routes/web.php:118-121`:

```php
Route::prefix('outreach')
    ->middleware([function ($request, $next) {
        abort_unless(optional(\Illuminate\Support\Facades\Auth::guard('admin')->user())->bOutreach == 1, 403);
        return $next($request);
    }])
    ->group(function () {
        Route::get('/manual-email', [OutreachController::class, 'manualEmail'])->name('outreach.manual-email');
        Route::post('/manual-email/compute-segment', [OutreachController::class, 'computeSegment'])->name('outreach.compute-segment');
        Route::post('/manual-email/dispatch', [OutreachController::class, 'dispatchSegmentEmail'])->name('outreach.dispatch');
        Route::get('/manual-email/status/{jobId}', [OutreachController::class, 'segmentStatus'])->name('outreach.status');
    });
```

- [ ] **Step 2.4: Run tests — expect pass**

Run: `vendor/bin/pest tests/Feature/OutreachManualEmailTest.php --filter="bOutreach"`
Expected: PASS.

- [ ] **Step 2.5: Commit**

```bash
git add routes/web.php tests/Feature/OutreachManualEmailTest.php database/factories/AdminFactory.php
git commit -m "feat(outreach): gate manual-email routes behind bOutreach flag"
```

---

## Task 3: Progress service (Cache wrapper)

**Files:**
- Create: `app/Services/OutreachProgressService.php`
- Test: `tests/Unit/OutreachProgressServiceTest.php`

- [ ] **Step 3.1: Write failing test**

```php
<?php

use App\Services\OutreachProgressService;

it('initializes, increments, and completes progress for a job', function () {
    $svc = new OutreachProgressService();

    $svc->initialize('job-abc', 10);
    expect($svc->status('job-abc'))->toMatchArray([
        'state' => 'running', 'total' => 10, 'sent' => 0, 'failed' => 0,
    ]);

    $svc->incrementSent('job-abc');
    $svc->incrementSent('job-abc');
    $svc->incrementFailed('job-abc');
    expect($svc->status('job-abc'))->toMatchArray([
        'state' => 'running', 'total' => 10, 'sent' => 2, 'failed' => 1,
    ]);

    $svc->markComplete('job-abc');
    expect($svc->status('job-abc')['state'])->toBe('complete');
});

it('marks invalid template without running', function () {
    $svc = new OutreachProgressService();
    $svc->initialize('job-xyz', 5);
    $svc->markInvalidTemplate('job-xyz', 'bad-alias');

    $status = $svc->status('job-xyz');
    expect($status['state'])->toBe('invalid_template')
        ->and($status['error'])->toContain('bad-alias');
});

it('returns null status for unknown job', function () {
    expect((new OutreachProgressService())->status('nope'))->toBeNull();
});
```

- [ ] **Step 3.2: Run — expect fail**

Run: `vendor/bin/pest tests/Unit/OutreachProgressServiceTest.php`
Expected: FAIL with class-not-found.

- [ ] **Step 3.3: Implement OutreachProgressService**

```php
<?php

namespace App\Services;

use Illuminate\Support\Facades\Cache;

class OutreachProgressService
{
    private const TTL_SECONDS = 3600; // 1 hour

    public function initialize(string $jobId, int $total): void
    {
        Cache::put($this->key($jobId), [
            'state' => 'running',
            'total' => $total,
            'sent'  => 0,
            'failed' => 0,
            'error' => null,
        ], self::TTL_SECONDS);
    }

    public function incrementSent(string $jobId): void
    {
        $data = Cache::get($this->key($jobId));
        if (! $data) {
            return;
        }
        $data['sent']++;
        Cache::put($this->key($jobId), $data, self::TTL_SECONDS);
    }

    public function incrementFailed(string $jobId): void
    {
        $data = Cache::get($this->key($jobId));
        if (! $data) {
            return;
        }
        $data['failed']++;
        Cache::put($this->key($jobId), $data, self::TTL_SECONDS);
    }

    public function markComplete(string $jobId): void
    {
        $data = Cache::get($this->key($jobId));
        if (! $data) {
            return;
        }
        $data['state'] = 'complete';
        Cache::put($this->key($jobId), $data, self::TTL_SECONDS);
    }

    public function markInvalidTemplate(string $jobId, string $templateAlias): void
    {
        $data = Cache::get($this->key($jobId)) ?? [
            'state' => 'running', 'total' => 0, 'sent' => 0, 'failed' => 0,
        ];
        $data['state'] = 'invalid_template';
        $data['error'] = "Postmark template not found: {$templateAlias}";
        Cache::put($this->key($jobId), $data, self::TTL_SECONDS);
    }

    public function status(string $jobId): ?array
    {
        return Cache::get($this->key($jobId));
    }

    private function key(string $jobId): string
    {
        return "outreach:job:{$jobId}";
    }
}
```

- [ ] **Step 3.4: Run — expect pass**

Run: `vendor/bin/pest tests/Unit/OutreachProgressServiceTest.php`
Expected: PASS.

- [ ] **Step 3.5: Commit**

```bash
git add app/Services/OutreachProgressService.php tests/Unit/OutreachProgressServiceTest.php
git commit -m "feat(outreach): add OutreachProgressService for job progress"
```

---

## Task 4: Postmark template validation

**Why this task exists:** spec says "First check if this is a valid postmark template (show error if not)". `PostmarkService::sendPredefinedTemplate` only checks our `config/postmark.php` keys — it does not hit Postmark. For manual outreach, the admin types an arbitrary template alias, so we need a real API check.

**Files:**
- Modify: `app/Services/PostmarkService.php`
- Test: `tests/Unit/PostmarkServiceTest.php` (create if not present)

- [ ] **Step 4.1: Write failing test — templateExists returns true/false based on Postmark response**

```php
<?php

use App\Services\PostmarkService;
use Postmark\PostmarkClient;

it('returns true when Postmark returns a template', function () {
    $mockClient = Mockery::mock(PostmarkClient::class);
    $mockClient->shouldReceive('getTemplate')->with('valid-alias')->andReturn((object) ['TemplateId' => 123]);

    $svc = new PostmarkService();
    (function () use ($mockClient) { $this->client = $mockClient; })->call($svc);

    expect($svc->templateExists('valid-alias'))->toBeTrue();
});

it('returns false when Postmark throws (404 template not found)', function () {
    $mockClient = Mockery::mock(PostmarkClient::class);
    $mockClient->shouldReceive('getTemplate')->with('bad-alias')->andThrow(new \Exception('Template not found'));

    $svc = new PostmarkService();
    (function () use ($mockClient) { $this->client = $mockClient; })->call($svc);

    expect($svc->templateExists('bad-alias'))->toBeFalse();
});
```

- [ ] **Step 4.2: Run — expect fail**

Run: `vendor/bin/pest tests/Unit/PostmarkServiceTest.php --filter="templateExists"`
Expected: FAIL with "Call to undefined method".

- [ ] **Step 4.3: Add templateExists to PostmarkService**

Append this method to `app/Services/PostmarkService.php` (inside the class, after `sendFeedbackEmail`):

```php
    /**
     * Verify that a Postmark template alias exists in the current server.
     * Catches any exception (auth, 404, network) and returns false.
     */
    public function templateExists(string $templateAlias): bool
    {
        try {
            $template = $this->client->getTemplate($templateAlias);
            return $template !== null;
        } catch (\Throwable $e) {
            \Illuminate\Support\Facades\Log::warning('Postmark templateExists check failed', [
                'alias' => $templateAlias,
                'error' => $e->getMessage(),
            ]);
            return false;
        }
    }
```

- [ ] **Step 4.4: Run — expect pass**

Run: `vendor/bin/pest tests/Unit/PostmarkServiceTest.php`
Expected: PASS.

- [ ] **Step 4.5: Commit**

```bash
git add app/Services/PostmarkService.php tests/Unit/PostmarkServiceTest.php
git commit -m "feat(postmark): add templateExists for admin-entered template validation"
```

---

## Task 5: SendOutreachEmailsJob

**Files:**
- Create: `app/Jobs/SendOutreachEmailsJob.php`
- Test: `tests/Unit/SendOutreachEmailsJobTest.php`

- [ ] **Step 5.1: Write failing test — job sends to each user in chunked segment and updates progress**

```php
<?php

use App\Http\Queries\OutreachQuery;
use App\Http\Queries\UserInfoQuery;
use App\Jobs\SendOutreachEmailsJob;
use App\Services\OutreachProgressService;
use App\Services\PostmarkService;

it('sends template to every segment user and updates progress counters', function () {
    $outreachQuery = Mockery::mock(OutreachQuery::class);
    $outreachQuery->shouldReceive('countFilteredUsers')->andReturn(2);
    $outreachQuery->shouldReceive('getFilteredUsersChunked')
        ->andReturnUsing(function ($filters, $chunkSize, $cb) {
            $cb(collect([
                (object) ['iUserID' => 1, 'dDateCreated' => now()->subDays(10)->toDateTimeString()],
                (object) ['iUserID' => 2, 'dDateCreated' => now()->subDays(3)->toDateTimeString()],
            ]));
        });

    $userInfoQuery = Mockery::mock(UserInfoQuery::class);
    // Assume a method fetchEmailsAndNames(array $userIds) — add it in Task 5.3 if missing
    $userInfoQuery->shouldReceive('fetchEmailsAndNames')->with([1, 2])->andReturn(collect([
        (object) ['iUserID' => 1, 'vEmail' => 'a@test.com', 'vName' => 'Alice'],
        (object) ['iUserID' => 2, 'vEmail' => 'b@test.com', 'vName' => 'Bob'],
    ])->keyBy('iUserID'));

    $postmark = Mockery::mock(PostmarkService::class);
    $postmark->shouldReceive('templateExists')->with('outreach-test')->andReturnTrue();
    $postmark->shouldReceive('sendTemplate')->twice()->andReturn((object) ['ErrorCode' => 0]);

    $progress = new OutreachProgressService();
    $progress->initialize('job-1', 2);

    (new SendOutreachEmailsJob('job-1', ['bSubscribed' => 0, 'bTestUser' => 0], 'outreach-test'))
        ->handle($outreachQuery, $userInfoQuery, $postmark, $progress);

    $status = $progress->status('job-1');
    expect($status['sent'])->toBe(2)
        ->and($status['failed'])->toBe(0)
        ->and($status['state'])->toBe('complete');
});

it('marks invalid template and does not attempt sending', function () {
    $outreachQuery = Mockery::mock(OutreachQuery::class);
    $outreachQuery->shouldNotReceive('getFilteredUsersChunked');
    $userInfoQuery = Mockery::mock(UserInfoQuery::class);

    $postmark = Mockery::mock(PostmarkService::class);
    $postmark->shouldReceive('templateExists')->with('bad')->andReturnFalse();
    $postmark->shouldNotReceive('sendTemplate');

    $progress = new OutreachProgressService();
    $progress->initialize('job-2', 5);

    (new SendOutreachEmailsJob('job-2', [], 'bad'))
        ->handle($outreachQuery, $userInfoQuery, $postmark, $progress);

    expect($progress->status('job-2')['state'])->toBe('invalid_template');
});
```

- [ ] **Step 5.2: Run — expect fail**

Run: `vendor/bin/pest tests/Unit/SendOutreachEmailsJobTest.php`
Expected: FAIL with "Class SendOutreachEmailsJob not found".

- [ ] **Step 5.3: Add fetchEmailsAndNames to UserInfoQuery if absent**

Read `app/Http/Queries/UserInfoQuery.php`. If there is no method that returns `iUserID`, `vEmail`, `vName` in one call (there may be a method on `UserProfileQuery` for `vName` — check both), add this on `UserInfoQuery`:

```php
    /**
     * Fetch emails plus display names for the given user IDs.
     * Crosses databases: tbl_UserInfo is on qsuserinfo, tbl_UserProfile is on default mysql.
     */
    public function fetchEmailsAndNames(array $userIds)
    {
        if (empty($userIds)) {
            return collect();
        }

        $emails = \Illuminate\Support\Facades\DB::connection('qsuserinfo')
            ->table('tbl_UserInfo')
            ->select('iUserID', 'vEmail')
            ->whereIn('iUserID', $userIds)
            ->get()
            ->keyBy('iUserID');

        $profiles = \Illuminate\Support\Facades\DB::table('tbl_UserProfile')
            ->select('iUserID', 'vName')
            ->whereIn('iUserID', $userIds)
            ->get()
            ->keyBy('iUserID');

        return collect($userIds)->map(function ($id) use ($emails, $profiles) {
            $row = new \stdClass();
            $row->iUserID = $id;
            $row->vEmail = $emails[$id]->vEmail ?? null;
            $row->vName  = $profiles[$id]->vName ?? '';
            return $row;
        })->filter(fn ($r) => $r->vEmail !== null)->keyBy('iUserID');
    }
```

- [ ] **Step 5.4: Implement the job**

Create `app/Jobs/SendOutreachEmailsJob.php`:

```php
<?php

namespace App\Jobs;

use App\Http\Queries\OutreachQuery;
use App\Http\Queries\UserInfoQuery;
use App\Services\OutreachProgressService;
use App\Services\PostmarkService;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

class SendOutreachEmailsJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $timeout = 3600;

    public function __construct(
        public string $jobId,
        public array  $filters,
        public string $templateAlias,
    ) {}

    public function handle(
        OutreachQuery $outreachQuery,
        UserInfoQuery $userInfoQuery,
        PostmarkService $postmark,
        OutreachProgressService $progress,
    ): void {
        if (! $postmark->templateExists($this->templateAlias)) {
            $progress->markInvalidTemplate($this->jobId, $this->templateAlias);
            return;
        }

        $outreachQuery->getFilteredUsersChunked($this->filters, 200, function ($users) use ($userInfoQuery, $postmark, $progress) {
            $userIds = $users->pluck('iUserID')->all();
            $contacts = $userInfoQuery->fetchEmailsAndNames($userIds);

            foreach ($users as $user) {
                $contact = $contacts->get($user->iUserID);
                if (! $contact || empty($contact->vEmail)) {
                    $progress->incrementFailed($this->jobId);
                    continue;
                }

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

                $response = $postmark->sendTemplate(
                    $contact->vEmail,
                    $this->templateAlias,
                    [
                        'name'             => $contact->vName,
                        'unsubscribe_link' => front_url('emails/unsubscribe-request/' . encryptData($user->iUserID)),
                        'days_passed'      => $daysPassed,
                    ],
                );

                $ok = $response && isset($response->ErrorCode) && $response->ErrorCode == 0;
                if ($ok) {
                    $progress->incrementSent($this->jobId);
                } else {
                    $progress->incrementFailed($this->jobId);
                    Log::warning('Outreach email failed', [
                        'job_id' => $this->jobId,
                        'user_id' => $user->iUserID,
                        'template' => $this->templateAlias,
                    ]);
                }
            }
        });

        $progress->markComplete($this->jobId);
    }
}
```

- [ ] **Step 5.5: Run — expect pass**

Run: `vendor/bin/pest tests/Unit/SendOutreachEmailsJobTest.php`
Expected: PASS. If the test fails because sqlite in-memory cache store is array-scoped per test, ensure `phpunit.xml` uses `CACHE_STORE=array`. If not, set it there (one-line change, permissible).

- [ ] **Step 5.6: Commit**

```bash
git add app/Jobs/SendOutreachEmailsJob.php app/Http/Queries/UserInfoQuery.php tests/Unit/SendOutreachEmailsJobTest.php phpunit.xml
git commit -m "feat(outreach): SendOutreachEmailsJob with progress tracking"
```

---

## Task 6: Controller endpoints — computeSegment, dispatchSegmentEmail, segmentStatus

**Files:**
- Modify: `app/Http/Controllers/OutreachController.php`
- Create: `app/Http/Requests/ComputeOutreachSegmentRequest.php`
- Create: `app/Http/Requests/DispatchOutreachEmailRequest.php`

- [ ] **Step 6.1: Write failing feature tests**

Add to `tests/Feature/OutreachManualEmailTest.php`:

```php
use App\Jobs\SendOutreachEmailsJob;
use App\Services\OutreachProgressService;
use Illuminate\Support\Facades\Queue;

it('compute-segment returns user count as JSON', function () {
    // seed: 1 eligible user in phpunit's in-memory sqlite — reuse OutreachQueryTest fixture OR create a helper
    $this->seedOneOutreachableUser(); // implement on base TestCase

    $admin = \App\Models\Admin::factory()->create(['bOutreach' => 1, 'bActive' => 1, 'bDeleted' => 0]);

    $response = $this->actingAs($admin, 'admin')->postJson(route('outreach.compute-segment'), [
        'bSubscribed' => 0,
        'bTestUser' => 0,
    ]);

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

it('dispatch endpoint queues SendOutreachEmailsJob and returns jobId', function () {
    Queue::fake();
    $this->seedOneOutreachableUser();

    $admin = \App\Models\Admin::factory()->create(['bOutreach' => 1, 'bActive' => 1, 'bDeleted' => 0]);

    $response = $this->actingAs($admin, 'admin')->postJson(route('outreach.dispatch'), [
        'bSubscribed' => 0,
        'bTestUser' => 0,
        'templateAlias' => 'reengage-001',
    ]);

    $response->assertOk()->assertJsonStructure(['jobId']);
    Queue::assertPushed(SendOutreachEmailsJob::class, function ($job) {
        return $job->templateAlias === 'reengage-001';
    });
});

it('dispatch returns 422 when templateAlias missing', function () {
    $admin = \App\Models\Admin::factory()->create(['bOutreach' => 1, 'bActive' => 1, 'bDeleted' => 0]);

    $this->actingAs($admin, 'admin')
        ->postJson(route('outreach.dispatch'), ['bSubscribed' => 0, 'bTestUser' => 0])
        ->assertStatus(422);
});

it('status endpoint returns current job progress', function () {
    $admin = \App\Models\Admin::factory()->create(['bOutreach' => 1, 'bActive' => 1, 'bDeleted' => 0]);
    app(OutreachProgressService::class)->initialize('test-job-1', 5);
    app(OutreachProgressService::class)->incrementSent('test-job-1');

    $response = $this->actingAs($admin, 'admin')->getJson(route('outreach.status', 'test-job-1'));

    $response->assertOk()->assertJson([
        'state' => 'running', 'total' => 5, 'sent' => 1, 'failed' => 0,
    ]);
});

it('status endpoint returns 404 for unknown job', function () {
    $admin = \App\Models\Admin::factory()->create(['bOutreach' => 1, 'bActive' => 1, 'bDeleted' => 0]);

    $this->actingAs($admin, 'admin')
        ->getJson(route('outreach.status', 'missing-job'))
        ->assertNotFound();
});
```

In `tests/TestCase.php` (or a trait), add the seeder helper. Before adding, read `tests/TestCase.php` — if there is already a seeding convention, follow it.

```php
protected function seedOneOutreachableUser(): void
{
    // Reuses the schema created in beforeEach — if the schema isn't already in the feature test,
    // create it inline here via DB::statement(...) copied verbatim from OutreachQueryTest beforeEach.
}
```

If duplication becomes ugly, extract the schema setup into `tests/Helpers/OutreachSchema.php` with a single static `setup(): void` method and call it from both tests. Do not over-generalize.

- [ ] **Step 6.2: Run — expect fail**

Run: `vendor/bin/pest tests/Feature/OutreachManualEmailTest.php`
Expected: FAIL (missing controller methods / missing routes wired in Task 2).

- [ ] **Step 6.3: Create FormRequest classes**

`app/Http/Requests/ComputeOutreachSegmentRequest.php`:

```php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class ComputeOutreachSegmentRequest extends FormRequest
{
    public function authorize(): bool
    {
        return optional($this->user('admin'))->bOutreach == 1;
    }

    public function rules(): array
    {
        return [
            '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',
            'bMarketingSubscribed' => 'nullable|in:,0,1',
        ];
    }

    /** Filter payload to the OutreachQuery-expected array shape. */
    public function filters(): array
    {
        return $this->only([
            'dDateFrom', 'dDateTo', 'SmokingStatus', 'vCountry', 'vDeviceType',
            'bSubscribed', 'bTestUser', 'bPreviouslySubscribed', 'dFirstSubDate',
            'bActive', 'bTransactSubscribed', 'bMarketingSubscribed',
        ]);
    }
}
```

`app/Http/Requests/DispatchOutreachEmailRequest.php`:

```php
<?php

namespace App\Http\Requests;

class DispatchOutreachEmailRequest extends ComputeOutreachSegmentRequest
{
    public function rules(): array
    {
        return array_merge(parent::rules(), [
            'templateAlias' => 'required|string|max:200',
        ]);
    }
}
```

- [ ] **Step 6.4: Update OutreachController**

Rewrite `app/Http/Controllers/OutreachController.php`:

```php
<?php

namespace App\Http\Controllers;

use App\Http\Queries\OutreachQuery;
use App\Http\Queries\UserConfigQuery;
use App\Http\Queries\UserQuery;
use App\Http\Requests\ComputeOutreachSegmentRequest;
use App\Http\Requests\DispatchOutreachEmailRequest;
use App\Jobs\SendOutreachEmailsJob;
use App\Services\OutreachProgressService;
use Illuminate\Support\Str;

class OutreachController extends Controller
{
    public function __construct(
        protected UserQuery $userQuery,
        protected UserConfigQuery $userConfigQuery,
        protected OutreachQuery $outreachQuery,
        protected OutreachProgressService $progress,
    ) {}

    public function manualEmail()
    {
        return view('outreach.manualEmail', [
            'smokingStatuses' => $this->userQuery->getSmokingStatuses(),
            'countries' => $this->userConfigQuery->getAllCountries(),
        ]);
    }

    public function computeSegment(ComputeOutreachSegmentRequest $request)
    {
        $count = $this->outreachQuery->countFilteredUsers($request->filters());
        return response()->json(['count' => $count]);
    }

    public function dispatchSegmentEmail(DispatchOutreachEmailRequest $request)
    {
        $filters = $request->filters();
        $total = $this->outreachQuery->countFilteredUsers($filters);

        $jobId = (string) Str::uuid();
        $this->progress->initialize($jobId, $total);

        SendOutreachEmailsJob::dispatch($jobId, $filters, $request->input('templateAlias'));

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

    public function segmentStatus(string $jobId)
    {
        $status = $this->progress->status($jobId);
        if ($status === null) {
            abort(404);
        }
        return response()->json($status);
    }
}
```

- [ ] **Step 6.5: Run — expect pass**

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

- [ ] **Step 6.6: Commit**

```bash
git add app/Http/Controllers/OutreachController.php app/Http/Requests/ComputeOutreachSegmentRequest.php app/Http/Requests/DispatchOutreachEmailRequest.php tests/Feature/OutreachManualEmailTest.php tests/TestCase.php
git commit -m "feat(outreach): controller endpoints for compute, dispatch, status"
```

---

## Task 7: Blade view — two-section filter + send form

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

- [ ] **Step 7.1: Replace view with the complete two-section layout**

Full rewrite — do not attempt a partial edit. Copy this verbatim:

```blade
@extends('layouts.app')

@section('title', 'Direct Outreach — Manual Email')

@section('content')
<div class="row">
    <div class="col-md-12 col-xs-12">
        <div class="x_panel">
            <div class="x_title">
                <h2>Manual Email</h2>
                <div class="clearfix"></div>
            </div>

            <div class="x_content">
                {{-- Section 1: filters --}}
                <form id="outreachFilterForm" class="form-horizontal">
                    @csrf
                    <div class="row">
                        <div class="col-md-4">
                            <label class="control-label">Created From</label>
                            <input type="date" name="dDateFrom" class="form-control">
                        </div>
                        <div class="col-md-4">
                            <label class="control-label">Created To</label>
                            <input type="date" name="dDateTo" class="form-control">
                        </div>
                        <div class="col-md-4">
                            <label class="control-label">Smoking Status</label>
                            <select name="SmokingStatus" class="form-control">
                                <option value="">All</option>
                                @foreach ($smokingStatuses as $s)
                                    <option value="{{ $s->SmokingStatus }}">{{ $s->SmokingStatus ?: 'Null' }}</option>
                                @endforeach
                            </select>
                        </div>
                    </div>

                    <div class="row" style="margin-top:12px;">
                        <div class="col-md-4">
                            <label class="control-label">Country</label>
                            <select name="vCountry" class="form-control">
                                <option value="">All</option>
                                @foreach ($countries as $c)
                                    <option value="{{ $c->vCountry }}">{{ $c->vCountry }}</option>
                                @endforeach
                            </select>
                        </div>
                        <div class="col-md-4">
                            <label class="control-label">Device</label>
                            <select name="vDeviceType" class="form-control">
                                <option value="">All</option>
                                <option value="android">Android</option>
                                <option value="ios">iOS</option>
                            </select>
                        </div>
                        <div class="col-md-4">
                            <label class="control-label">bActive</label>
                            <select name="bActive" class="form-control">
                                <option value="">No filter</option>
                                <option value="1">Active</option>
                                <option value="0">Inactive</option>
                            </select>
                        </div>
                    </div>

                    <div class="row" style="margin-top:12px;">
                        <div class="col-md-4">
                            <label class="control-label">bPreviouslySubscribed</label>
                            <select name="bPreviouslySubscribed" class="form-control">
                                <option value="">No filter</option>
                                <option value="1">Yes</option>
                                <option value="0">No</option>
                            </select>
                        </div>
                        <div class="col-md-4">
                            <label class="control-label">dFirstSubDate</label>
                            <select name="dFirstSubDate" class="form-control">
                                <option value="">No filter</option>
                                <option value="null">Null</option>
                                <option value="notnull">Not Null</option>
                            </select>
                        </div>
                        <div class="col-md-4">
                            <label class="control-label">bTransactSubscribed</label>
                            <select name="bTransactSubscribed" class="form-control">
                                <option value="">No filter</option>
                                <option value="1">Yes</option>
                                <option value="0">No</option>
                            </select>
                        </div>
                    </div>

                    <div class="row" style="margin-top:12px;">
                        <div class="col-md-4">
                            <label class="control-label">bMarketingSubscribed</label>
                            <select name="bMarketingSubscribed" class="form-control">
                                <option value="">No filter</option>
                                <option value="1">Yes</option>
                                <option value="0">No</option>
                            </select>
                        </div>
                        <div class="col-md-4">
                            <label class="control-label">bSubscribed</label><br>
                            <input type="checkbox" id="bSubscribedToggle" name="bSubscribedToggle" value="1">
                            <span>Include only subscribed users</span>
                            <input type="hidden" id="bSubscribed" name="bSubscribed" value="0">
                        </div>
                        <div class="col-md-4">
                            <label class="control-label">bTestUser</label><br>
                            <input type="checkbox" id="bTestUserToggle" name="bTestUserToggle" value="1">
                            <span>Include only test users</span>
                            <input type="hidden" id="bTestUser" name="bTestUser" value="0">
                        </div>
                    </div>

                    <div class="row" style="margin-top:20px;">
                        <div class="col-md-12">
                            <button type="button" class="btn btn-success" id="applyFiltersBtn">Apply</button>
                        </div>
                    </div>
                </form>

                {{-- Section 2: shown after Apply --}}
                <div id="outreachSendSection" class="x_panel" style="margin-top:20px; display:none;">
                    <div class="x_title"><h3>Send Email</h3><div class="clearfix"></div></div>
                    <div class="x_content">
                        <p id="outreachUserCountMessage"><strong><span id="outreachUserCount">0</span></strong> users fall within this criteria</p>
                        <div class="form-group">
                            <label class="control-label">Postmark Template Alias</label>
                            <input type="text" id="outreachTemplateAlias" class="form-control" placeholder="e.g. reengage-january">
                        </div>
                        <button type="button" class="btn btn-primary" id="sendOutreachBtn">Send Email</button>

                        <div id="outreachProgressWrap" style="margin-top:20px; display:none;">
                            <div class="progress">
                                <div id="outreachProgressBar" class="progress-bar progress-bar-success" style="width:0%">0%</div>
                            </div>
                            <p id="outreachProgressLabel">Starting...</p>
                        </div>
                    </div>
                </div>

                {{-- Confirm modal --}}
                <div id="outreachConfirmModal" class="modal" tabindex="-1" role="dialog" style="display:none;">
                    <div class="modal-dialog" role="document">
                        <div class="modal-content">
                            <div class="modal-header"><h4 class="modal-title">Confirm Send</h4></div>
                            <div class="modal-body"><p id="outreachConfirmBody"></p></div>
                            <div class="modal-footer">
                                <button type="button" class="btn btn-default" id="outreachConfirmNo">No</button>
                                <button type="button" class="btn btn-primary" id="outreachConfirmYes">Yes</button>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>

<script>
window.outreachRoutes = {
    compute: "{{ route('outreach.compute-segment') }}",
    dispatch: "{{ route('outreach.dispatch') }}",
    statusBase: "{{ url('outreach/manual-email/status') }}",
};
window.outreachCsrf = "{{ csrf_token() }}";
</script>
@vite('resources/js/outreach-manual-email.js')
@endsection
```

- [ ] **Step 7.2: Verify the page renders**

Run: `vendor/bin/pest tests/Feature/OutreachManualEmailTest.php --filter="allows admins with bOutreach"`
Expected: PASS.

- [ ] **Step 7.3: Commit**

```bash
git add resources/views/outreach/manualEmail.blade.php
git commit -m "feat(outreach): manual-email two-section filter + send UI"
```

---

## Task 8: Frontend JS — filter apply, confirm, send, progress poll

**Files:**
- Create: `resources/js/outreach-manual-email.js`
- Modify: `vite.config.js`

- [ ] **Step 8.1: Register the Vite entry**

Read `vite.config.js`. Add `'resources/js/outreach-manual-email.js'` to the `input` array of `laravel({ ... })`. Example (the exact syntax depends on what's there — preserve existing entries):

```js
laravel({
    input: [
        'resources/css/app.css',
        'resources/js/app.js',
        'resources/js/outreach-manual-email.js',
    ],
    refresh: true,
}),
```

- [ ] **Step 8.2: Write the JS module**

Create `resources/js/outreach-manual-email.js`:

```js
// Outreach → Manual Email client controller.
// Wires filter Apply, segment count display, confirm modal, dispatch, and status polling.

document.addEventListener('DOMContentLoaded', () => {
    const form = document.getElementById('outreachFilterForm');
    if (!form) return;

    const applyBtn = document.getElementById('applyFiltersBtn');
    const sendSection = document.getElementById('outreachSendSection');
    const userCountEl = document.getElementById('outreachUserCount');
    const templateInput = document.getElementById('outreachTemplateAlias');
    const sendBtn = document.getElementById('sendOutreachBtn');
    const confirmModal = document.getElementById('outreachConfirmModal');
    const confirmBody = document.getElementById('outreachConfirmBody');
    const confirmYes = document.getElementById('outreachConfirmYes');
    const confirmNo = document.getElementById('outreachConfirmNo');
    const progressWrap = document.getElementById('outreachProgressWrap');
    const progressBar = document.getElementById('outreachProgressBar');
    const progressLabel = document.getElementById('outreachProgressLabel');

    const bSubscribedToggle = document.getElementById('bSubscribedToggle');
    const bTestUserToggle = document.getElementById('bTestUserToggle');
    const bSubscribedHidden = document.getElementById('bSubscribed');
    const bTestUserHidden = document.getElementById('bTestUser');

    const syncToggles = () => {
        bSubscribedHidden.value = bSubscribedToggle.checked ? '1' : '0';
        bTestUserHidden.value = bTestUserToggle.checked ? '1' : '0';
    };
    bSubscribedToggle.addEventListener('change', syncToggles);
    bTestUserToggle.addEventListener('change', syncToggles);

    const collectFilters = () => {
        syncToggles();
        const data = new FormData(form);
        data.delete('bSubscribedToggle');
        data.delete('bTestUserToggle');
        const obj = {};
        for (const [k, v] of data.entries()) obj[k] = v;
        return obj;
    };

    const postJson = async (url, body) => {
        const res = await fetch(url, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Accept': 'application/json',
                'X-CSRF-TOKEN': window.outreachCsrf,
                'X-Requested-With': 'XMLHttpRequest',
            },
            body: JSON.stringify(body),
        });
        return { ok: res.ok, status: res.status, data: await res.json().catch(() => ({})) };
    };

    applyBtn.addEventListener('click', async () => {
        applyBtn.disabled = true;
        try {
            const { ok, data } = await postJson(window.outreachRoutes.compute, collectFilters());
            if (!ok) {
                alert('Failed to compute segment. Check filters.');
                return;
            }
            userCountEl.textContent = data.count;
            sendSection.style.display = 'block';
        } finally {
            applyBtn.disabled = false;
        }
    });

    const openConfirm = (alias, count) => {
        confirmBody.textContent = `Are you sure you want to send the ${alias} email to ${count} users?`;
        confirmModal.style.display = 'block';
    };
    const closeConfirm = () => { confirmModal.style.display = 'none'; };

    confirmNo.addEventListener('click', closeConfirm);

    sendBtn.addEventListener('click', () => {
        const alias = (templateInput.value || '').trim();
        if (!alias) {
            alert('Enter a Postmark template alias.');
            return;
        }
        openConfirm(alias, userCountEl.textContent);
    });

    confirmYes.addEventListener('click', async () => {
        closeConfirm();
        sendBtn.disabled = true;
        progressWrap.style.display = 'block';
        progressBar.style.width = '0%';
        progressBar.textContent = '0%';
        progressLabel.textContent = 'Dispatching...';

        const body = { ...collectFilters(), templateAlias: templateInput.value.trim() };
        const { ok, data } = await postJson(window.outreachRoutes.dispatch, body);
        if (!ok || !data.jobId) {
            progressLabel.textContent = 'Failed to dispatch.';
            sendBtn.disabled = false;
            return;
        }

        pollStatus(data.jobId);
    });

    const pollStatus = (jobId) => {
        const statusUrl = `${window.outreachRoutes.statusBase}/${encodeURIComponent(jobId)}`;
        const tick = async () => {
            const res = await fetch(statusUrl, { headers: { 'Accept': 'application/json' } });
            if (!res.ok) {
                progressLabel.textContent = 'Lost progress tracking.';
                sendBtn.disabled = false;
                return;
            }
            const s = await res.json();

            if (s.state === 'invalid_template') {
                progressLabel.textContent = s.error || 'Invalid Postmark template.';
                progressBar.classList.remove('progress-bar-success');
                progressBar.classList.add('progress-bar-danger');
                sendBtn.disabled = false;
                return;
            }

            const done = (s.sent || 0) + (s.failed || 0);
            const pct = s.total > 0 ? Math.round((done / s.total) * 100) : 0;
            progressBar.style.width = `${pct}%`;
            progressBar.textContent = `${pct}%`;
            progressLabel.textContent = `Sent ${s.sent || 0} / ${s.total} (failed ${s.failed || 0})`;

            if (s.state === 'complete') {
                progressLabel.textContent = `Done. Sent ${s.sent}, failed ${s.failed}.`;
                sendBtn.disabled = false;
                return;
            }

            setTimeout(tick, 1000);
        };
        tick();
    };
});
```

- [ ] **Step 8.3: Build front-end to confirm no syntax errors**

Run: `npm run build`
Expected: build completes; the new entry appears in the manifest. If the project's composer dev command is preferred, run that and watch for errors instead.

- [ ] **Step 8.4: Manual smoke test (local)**

1. Log in as an admin with `bOutreach = 1`.
2. Navigate to "Direct Outreach → Manual Email".
3. Leave all optional filters empty, leave both toggles off, click Apply.
4. Verify Section 2 appears with a non-zero count.
5. Enter a known-bad template alias (e.g. `zzz-does-not-exist`), click Send Email, confirm Yes.
6. Verify the progress label ends with "Postmark template not found: zzz-does-not-exist".
7. Enter a known-good template, Send, confirm, and verify progress ticks to complete.

Check off this step only after all six checks pass.

- [ ] **Step 8.5: Commit**

```bash
git add resources/js/outreach-manual-email.js vite.config.js
git commit -m "feat(outreach): manual-email JS controller with progress polling"
```

---

## Task 9: Full test suite + static analysis

- [ ] **Step 9.1: Run all new + existing tests**

Run: `vendor/bin/pest`
Expected: all green. If a pre-existing unrelated test is red, stop and ask — do not "fix" unrelated failures as part of this plan.

- [ ] **Step 9.2: Run static analysis**

Run: `vendor/bin/phpstan analyse app/Http/Queries/OutreachQuery.php app/Http/Controllers/OutreachController.php app/Jobs/SendOutreachEmailsJob.php app/Services/OutreachProgressService.php app/Http/Requests/ComputeOutreachSegmentRequest.php app/Http/Requests/DispatchOutreachEmailRequest.php`
Expected: 0 errors at the project's configured level. Fix any issues found before moving on.

- [ ] **Step 9.3: Format**

Run: `vendor/bin/pint app/Http/Queries/OutreachQuery.php app/Http/Controllers/OutreachController.php app/Jobs/SendOutreachEmailsJob.php app/Services/OutreachProgressService.php app/Http/Requests/`
Expected: files formatted (usually 0 style warnings after).

- [ ] **Step 9.4: Final commit**

```bash
git add -A
git commit -m "chore(outreach): fix phpstan/pint findings"
```

Only commit if step 9.2 or 9.3 produced changes.

---

## Self-review checklist (for the plan author)

Verified against spec:

- **1.a** (`bOutreach` column) — already done, not in plan. Confirmed via existing migration.
- **1.b** (only bOutreach=1 admin) — Task 2 enforces server-side; sidebar already enforces UI.
- **Tab "Manual Email"** — Task 7 view.
- **Filters: all optional except bSubscribed + bTestUser** — Task 1 `applyOptionalFilters` + Task 6 FormRequest validation (`required|in:0,1` on those two; `nullable` on all others).
- **bDeleted = 0 hardcoded** — Task 1, line `->where('u.bDeleted', 0)`; no filter exposes it.
- **dDateCreated from/to** — ✅
- **SmokingStatus** — ✅
- **Country** — ✅
- **Device** — ✅
- **bSubscribed toggle (default off)** — hidden input default `0`, toggle wires to `1`.
- **bPreviouslySubscribed selector (3 states)** — ✅ (`''`, `0`, `1`)
- **dFirstSubDate null/notnull selector** — ✅
- **bActive selector** — ✅
- **bTestUser toggle (default off)** — ✅
- **bTransactSubscribed selector** — ✅
- **bMarketingSubscribed selector** — mapped to `bEmailSubscribed` (see Open Clarification #1). Ask before coding if the mapping is wrong.
- **Apply button reveals Section 2** — Task 7 HTML + Task 8 JS.
- **Section 2: count + Postmark Template input + Send button** — ✅
- **Confirm popup with Yes (right) / No (left)** — HTML order: No then Yes, Bootstrap modal-footer flexes right-to-left by default; spec says "Yes (right) No (left)" — order in the markup is No first, Yes second, which renders Yes on the right. ✅
- **On No → close popup** — ✅ (`confirmNo` handler)
- **On Yes → validate template → send → show progress** — ✅ (dispatch creates job, job calls `templateExists` first, progress polling shows state=invalid_template on failure)
- **Dynamic data `name`, `unsubscribe_link`, `days_passed`** — Task 5 job template model.
- **days_passed format `"3,643" = Floor(Today - dDateCreated)`** — uses `number_format(floor(...))` matching existing `NewYearEmailController` convention.
- **Progress + completion indicator** — Task 8 progress bar + label.

Placeholder scan: no TBD / TODO / "similar to" / uncited types. All type/method names match across tasks (`OutreachQuery::countFilteredUsers`, `OutreachQuery::getFilteredUsersChunked`, `OutreachProgressService::{initialize,incrementSent,incrementFailed,markComplete,markInvalidTemplate,status}`, `PostmarkService::templateExists`, `SendOutreachEmailsJob(jobId, filters, templateAlias)`, `UserInfoQuery::fetchEmailsAndNames`).

Type consistency note: `UserInfoQuery::fetchEmailsAndNames` is new; Task 5.3 adds it. It is referenced in Task 5.1 test and Task 5.4 job.
