# Story 3.1: CSV Upload & Import Job Creation

## Story

**As a** system administrator,
**I want** to upload a supplier enrichment CSV file and immediately see a new import job created with its status,
**So that** I know the file was accepted and processing is queued.

## Status

done

## Acceptance Criteria

**AC1:** Upload form at `/import-jobs/create` with `.csv` file input, CSRF token, and back link to `/import-jobs`.

**AC2:** Valid CSV under 50MB → stored as `{id}_{sanitized_filename}` in `public/uploads/csv/`, `import_jobs` record created with status `pending`, redirect to `/import-jobs/{id}` with flash "Import job created. Processing will begin shortly."

**AC3:** Non-CSV extension → error "Only CSV files are accepted. Please upload a file with a .csv extension." No file saved, no job created.

**AC4:** File over 50MB → error "File too large. Maximum upload size is 50MB."

**AC5:** `import_jobs` table with full schema: id, original_filename, stored_filepath, status, total_rows, processed_rows, success_rows, error_rows, created_by (FK users), created_at, started_at, completed_at, error_message. Indexes on status and created_by.

**AC6:** `/import-jobs` list shows all jobs ordered newest-first with status badge, counts, and link to detail. Empty state message when no jobs. "New Import" button.

## Tasks / Subtasks

- [x] **Task 1: Add dynamic route parameters to Router** (prerequisite)
  - [x] 1.1: Update `src/Router.php` `dispatch()` to try exact match first, then compile `{param}` placeholders to `(\d+)` regex patterns and pass captured values as positional arguments to the handler.

- [x] **Task 2: Create `import_jobs` migration** (AC: 5)
  - [x] 2.1: `db/migrations/20260523000001_create_import_jobs_table.php`. All columns per spec; FK from `created_by` → `users.id` (SET NULL on delete); indexes on `status` and `created_by`.
  - [x] 2.2: `vendor/bin/phinx migrate` — migrated successfully.

- [x] **Task 3: Create `ImportJobController`** (AC: 1–4, 6)
  - [x] 3.1: `index()` — queries all import jobs ordered newest-first; passes to view.
  - [x] 3.2: `showCreate()` — renders upload form.
  - [x] 3.3: `create()` — CSRF check; PHP upload error check; calls `validateUploadedFile()`; creates DB record (pessimistic `stored_filepath = ''`); moves file; updates `stored_filepath`; PRG to show page.
  - [x] 3.4: `show(int $id)` — fetches job by ID, 404 on missing.
  - [x] 3.5: `validateUploadedFile(array $file): ?string` — static; checks size ≤ 50MB and `.csv` extension (case-insensitive).
  - [x] 3.6: `sanitizeFilename(string $filename): string` — static; strips path components, replaces unsafe characters with `_`, collapses double-dots, appends `.csv` if missing.
  - [x] 3.7: `describeUploadError(int $code): string` — maps PHP UPLOAD_ERR_* codes to user-friendly messages.
  - [x] 3.8: `STATUS_BADGE` constant — maps all five status values to `badge-status-*` CSS class suffixes.

- [x] **Task 4: Create views** (AC: 1, 2, 6)
  - [x] 4.1: `src/Views/import-jobs/index.php` — table with clickable rows, status badges, empty state message, "New Import" button.
  - [x] 4.2: `src/Views/import-jobs/create.php` — file input with `.csv` accept attribute, CSRF token, back link, inline validation error.
  - [x] 4.3: `src/Views/import-jobs/show.php` — job summary card with all fields as `<dl>`, status badge, flash banner, error banner for failed jobs. "Polling indicator" shown for non-terminal statuses (Story 3.3 will wire AJAX to it).

- [x] **Task 5: Sidebar + routes** (AC: 1, 6)
  - [x] 5.1: `src/Views/layout.php` — Import Jobs sidebar link updated from `#` to `/import-jobs`.
  - [x] 5.2: `public/index.php` — `ImportJobController` instantiated; routes: `GET /import-jobs`, `GET /import-jobs/create`, `POST /import-jobs`, `GET /import-jobs/{id}`.

- [x] **Task 6: Tests** (AC: 3, 4)
  - [x] 6.1: `tests/Unit/ImportJobControllerValidationTest.php` — 16 tests: valid CSV passes, wrong extensions rejected, case-insensitive `.csv`, size boundary, error message content, sanitizeFilename edge cases (path traversal, spaces, special chars, empty, missing extension), STATUS_BADGE completeness.
  - [x] 6.2: Run full suite — 91 tests, 2 skipped, 0 failures.

## Dev Notes

### Pessimistic stored_filepath pattern

The job record is inserted with `stored_filepath = ''` before the file is moved. If `move_uploaded_file` fails, the orphaned record is deleted immediately. This avoids leaving a job record pointing at a non-existent file.

### Upload directory creation

`create()` creates `public/uploads/csv/` with `mkdir(0755, true)` on first use rather than requiring a pre-existing directory. A permission failure is surfaced as a flash error.

### Router dynamic parameters

`{param}` placeholders are constrained to `\d+` (positive integers only). This is correct for all current uses (numeric PKs) and prevents non-numeric path injection. Exact match is checked first so static routes like `/import-jobs/create` are never misidentified as `{id} = create`.

### File list

**New files:**
- `db/migrations/20260523000001_create_import_jobs_table.php`
- `src/Controllers/ImportJobController.php`
- `src/Views/import-jobs/index.php`
- `src/Views/import-jobs/create.php`
- `src/Views/import-jobs/show.php`
- `tests/Unit/ImportJobControllerValidationTest.php`
- `public/uploads/csv/` (directory)

**Modified files:**
- `src/Router.php` (dynamic route parameter support)
- `src/Views/layout.php` (Import Jobs sidebar link)
- `public/index.php` (ImportJobController + 4 routes)
