# Outreach Manual Email — CSV Upload Mode

**Date:** 2026-06-09
**Project:** phptest
**Feature:** Add a CSV-upload alternative to the existing filter-based segment selection on `outreach/manual-email`.

## Problem

The outreach "Manual Email" page lets an admin build a user segment via filters
(country, dates, subscribed flags, etc.), see a count, and send a Postmark
template to that segment via a detached background command. Admins sometimes
already have an explicit list of user IDs (exported elsewhere) and want to email
exactly those users without reconstructing them through filters.

## Goal

Let the admin choose, on the same page, between two send sources:

1. **Filter segment** — the current behaviour, unchanged.
2. **Upload CSV** — upload a file of user IDs; email goes to exactly those IDs,
   ignoring all filters.

Everything downstream of "produce a list of user IDs" — the `OutreachJob`
record, the `outreach:send-emails` command, progress polling, and
refresh-survival — is reused. Only the *source of the ID list* changes.

## Decisions (confirmed)

- **CSV vs filters:** CSV mode ignores all filters. Send to exactly the IDs in
  the file (still skipping deleted users and users with no email).
- **CSV format:** one user ID per line, single relevant column. Optional header
  row auto-detected and skipped. Non-numeric rows dropped. IDs deduped.
- **Invalid IDs:** missing / `bDeleted=1` / no-email IDs are counted toward
  **failed** during send (not pre-filtered out).
- **`iTotal`:** raw count of distinct numeric IDs parsed from the file.
- **UI:** a radio toggle at the top of the page switches between Filter and CSV.
- **Storage:** **no schema change.** Reuse the existing `OutreachJob.jFilters`
  JSON column with a discriminated shape for CSV jobs.

## Storage shape

Filter-mode jobs keep their current `jFilters` shape (the filter array). CSV-mode
jobs store:

```json
{ "mode": "csv", "userIds": [101, 102, 103] }
```

The command treats an absent or `"filter"` mode key exactly as today, so existing
records and the filter path are unaffected. Worst-case size is bounded by the
5 MB upload limit (well within the MySQL JSON/`max_allowed_packet` ceiling).

## CSV parsing rules

A `csvUserIds()` helper on the request parses the uploaded file:

1. Read the file, split into lines.
2. For each line, take the substring before the first comma (tolerates extra
   columns / trailing commas).
3. Trim whitespace.
4. If the first non-empty line is non-numeric, treat it as a header and skip it.
5. Keep positive integers only (drop non-numeric / `<= 0` rows).
6. Deduplicate, preserving order.

`iTotal` = count of the resulting distinct ID list.

## Component changes

### Request layer — `ComputeOutreachSegmentRequest`, `DispatchOutreachEmailRequest`

- New field `mode` → `required|in:filter,csv`.
- `rules()` is mode-aware:
  - `mode=filter` → current filter rules (unchanged).
  - `mode=csv` → `csvFile` → `required|file|mimes:csv,txt|max:5120`; filter
    fields are not required.
- `authorize()` (the `bOutreach` check) is unchanged.
- New `csvUserIds(): array` helper (shared by compute + dispatch) implementing
  the parsing rules above. Lives on `ComputeOutreachSegmentRequest` so the
  dispatch subclass inherits it.

### Controller — `OutreachController`

- `computeSegment`:
  - `mode=csv` → return `['count' => count(distinct IDs)]` (no DB hit).
  - `mode=filter` → unchanged.
- `dispatchSegmentEmail`:
  - Template-existence check stays first.
  - `mode=csv` → parse IDs; if empty → `422` (`No valid user IDs in CSV`);
    build `jFilters = ['mode' => 'csv', 'userIds' => [...]]`, `iTotal = count`,
    create job, spawn command (`spawnOutreachCommand` unchanged).
  - `mode=filter` → unchanged.

### Query — `OutreachQuery`

- New `fetchUsersByIds(array $ids): Collection` — selects `iUserID`,
  `dDateCreated` from `tbl_Users` where `bDeleted = 0` and `iUserID IN (...)`,
  keyed by `iUserID`. (No joins; CSV mode does not apply filter joins.)

### Command — `SendOutreachEmails`

Branch on `$filters['mode'] ?? 'filter'`:

- **filter** → current `getFilteredUsersChunked` path (unchanged).
- **csv** → `array_chunk($filters['userIds'], 200)`; per chunk:
  - `fetchUsersByIds($chunk)` and `userInfoQuery->fetchEmailsAndNames($chunk)`.
  - For each ID: if no user row, or no contact, or empty email → `failed++`.
  - Otherwise send the same Postmark template with identical merge vars
    (`name`, `unsubscribe_link`, `days_passed`) and the same success/failure
    accounting and per-chunk increments.

The shared per-email send logic should be factored so both paths use it (avoid
duplicating the Postmark call + accounting).

### View / JS — `resources/views/outreach/manualEmail.blade.php`

- Add a radio toggle at the top: `Send to: ( ) Filter segment  ( ) Upload CSV`
  (default Filter).
- CSV mode shows a file input and hides the filter form rows; Filter mode does
  the reverse.
- **Apply** and **Send** handlers:
  - Filter mode → current `.serialize()` + mandatory from/to-date check
    (unchanged).
  - CSV mode → build a `FormData` containing the file, `mode=csv`, the CSRF
    token, and (for Send) `templateAlias`; submit with `processData:false,
    contentType:false`. The from/to-date requirement is skipped.
- Confirm modal, progress bar, polling, and active-job resume on page load are
  unchanged (they only consume `jobId` / counts).

## Testing (Pest)

Extend `tests/Feature/OutreachManualEmailTest.php` and command/query unit tests:

- compute (CSV) returns the count of distinct numeric IDs.
- parsing: header row auto-skipped; duplicates collapsed; non-numeric rows
  dropped.
- dispatch (CSV) stores `jFilters` with `mode=csv` + `userIds` and correct
  `iTotal`; spawns a job.
- dispatch (CSV) with an empty / all-invalid file → `422`.
- dispatch (CSV) still `422`s on a missing Postmark template.
- command (CSV) sends to the stored IDs; a missing / no-email ID counts as
  failed; a valid ID counts as sent.
- existing filter-mode tests continue to pass unchanged.

Note: the test suite builds the sqlite schema via manual `CREATE TABLE`
statements. The no-schema storage approach means no fixture changes are required
for the `tbl_OutreachJobs` table.

## Out of scope

- Layering filters on top of CSV IDs (explicitly rejected).
- A dedicated `vMode` / `jUserIds` column (considered; rejected in favour of the
  no-schema approach).
- CSV columns beyond the first (ignored).
- Per-recipient template overrides / per-row merge data.
