diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ddbbc9c --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Database +DATABASE_URL=postgresql://user:password@db:5432/csvtoxml +POSTGRES_USER=user +POSTGRES_PASSWORD=password +POSTGRES_DB=csvtoxml + +# Redis +REDIS_URL=redis://redis:6379 + +# Authentication +NEXTAUTH_SECRET=generate-a-random-secret-here diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5447709 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + branches: [master] + +jobs: + python-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run tests + run: python -m pytest tests/ -v + + web-lint: + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/web + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build diff --git a/.gitignore b/.gitignore index 0965f94..b7bbfb4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,27 @@ -venv/ -__pycache__/ -*.pyc -logs/ -reports/ -*.log -*.csv -*.xml +venv/ +__pycache__/ +*.pyc +logs/ +reports/ +*.log +*.csv +# Keep sample CSVs that the web app serves to users +!apps/web/public/samples/*.csv + +# Keep XSD files in schemas/ but ignore sample XMLs +*.xml +!schemas/*.xsd + +# Environment +.env + +# Next.js +apps/web/.next/ +apps/web/node_modules/ +apps/web/.env.local +*.tsbuildinfo + +# Data +/data/ +uploads/ +instance/ diff --git a/README.md b/README.md index 5b58141..60a8375 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,73 @@ # SBA Counseling and Training Data Conversion Tool -This utility is designed to process, clean, validate, and convert SBA (Small Business Administration) counseling and training data from CSV format into compliant XML files. It ensures the final XML adheres to the strict sequence and data requirements of the SBA NEXUS schemas for **Form 641 (Counseling)** and **Management Training Reports**. +Converts SBA counseling and training CSV data into XSD-compliant XML files. -The tool includes robust data cleaning, validation reporting, and a fixer utility for pre-existing XML files. +The tool ships in two forms: + +- A **web application** (`apps/web`, `apps/worker`) — recommended for + most users. Handles authentication, uploads, preview/mapping, + validation reports, job history, and downloads via a browser. +- A **Python CLI** (`run.py`, `src/`) — the original interactive + launcher, useful for power users and for scripting. + +----- + +## Web App (recommended) + +The web app is a Next.js frontend backed by a FastAPI worker, Postgres, +and Redis — all wired up in `docker-compose.yml`. + +### Run it locally + +```bash +cp .env.example .env +# Edit .env to set DATABASE_URL, NEXTAUTH_SECRET, etc. +docker compose up +``` + +Then open , create an account, and upload a CSV. + +### Download sample CSVs + +Sample CSVs for each converter type live under +`apps/web/public/samples/` and are also linked from the landing page +and the dashboard empty state inside the app: + +- `counseling-sample.csv` — individual counseling sessions (Form 641) +- `training-sample.csv` — aggregated training events (Form 888) +- `training-client-sample.csv` — per-attendee rows (Form 641) + +### UX documentation + +- [`UX_REVIEW.md`](./UX_REVIEW.md) — severity-ranked audit of the + web app's user-facing surfaces. +- [`UX_IMPLEMENTATION_PLAN.md`](./UX_IMPLEMENTATION_PLAN.md) — the + phased roadmap that sequences the UX review findings into + executable slices. +- [`TECHNICAL_DEBT.md`](./TECHNICAL_DEBT.md) — code/security debt + register, separate from UX concerns. + +----- + +## Python CLI + +----- + +## Quick Start (3 steps) + +1. **Download** — On the GitHub page, click the green **Code** button → **Download ZIP**. Unzip the folder anywhere on your computer. + +2. **Setup** (one time only) — Requires [Python](https://www.python.org/downloads/) (check **"Add Python to PATH"** during install). + - **Windows:** Double-click `setup.bat` + - **Mac/Linux:** Open a terminal in the folder and run: `pip install -r requirements.txt` + +3. **Run** — Put your CSV file in the folder, then: + - **Windows:** Double-click `run.bat` + - **Mac/Linux:** Open a terminal in the folder and run: `python run.py` + + The tool will walk you through selecting your CSV file, conversion type, and optional XSD validation — no typing commands needed. + +Your output XML and validation reports will be saved in the `output/` and `reports/` folders. ----- @@ -24,7 +89,7 @@ The tool includes robust data cleaning, validation reporting, and a fixer utilit * **Validation & Reporting**: * During conversion, it generates comprehensive validation reports in both CSV and HTML formats, detailing any issues found in the source data. * **XML Fixer Utility**: - * Includes a standalone script (`fix-sba-xml.py`) to correct element ordering issues in existing XML files that do not conform to the schema. + * Includes a standalone script (`fix_sba_xml.py`) to correct element ordering issues in existing XML files that do not conform to the schema. ----- @@ -32,6 +97,9 @@ The tool includes robust data cleaning, validation reporting, and a fixer utilit ``` . +├── run.py # Interactive launcher (start here!) +├── run.bat # Windows double-click shortcut +├── setup.bat # Windows one-time setup ├── src/ │ ├── converters/ │ │ ├── base_converter.py # Base class for all converters @@ -44,8 +112,8 @@ The tool includes robust data cleaning, validation reporting, and a fixer utilit │ ├── config.py # Central configuration for field mappings, defaults, and validation rules │ ├── validation_report.py # Module for tracking and reporting validation issues │ ├── logging_util.py # Configures application-wide logging -│ ├── fix-sba-xml.py # Utility to fix element order in existing SBA XML files -│ └── xml-validator.py # Utility to validate XML files against an XSD +│ ├── fix_sba_xml.py # Utility to fix element order in existing SBA XML files +│ └── xml_validator.py # Utility to validate XML files against an XSD ├── tests/ │ ├── test_counseling_converter.py │ ├── test_data_cleaning.py @@ -85,10 +153,10 @@ The primary entry point for the conversion is `src/main.py`. ### Fixing an Existing XML File -If you have an XML file that fails validation due to incorrect element order, use the `fix-sba-xml.py` script: +If you have an XML file that fails validation due to incorrect element order, use the `fix_sba_xml.py` script: ```bash -python -m src.fix-sba-xml --file /path/to/your/invalid.xml --output /path/to/output/fixed.xml +python -m src.fix_sba_xml --file /path/to/your/invalid.xml --output /path/to/output/fixed.xml ``` This will re-order the elements to match the schema requirements. diff --git a/Sample641CouselingRecord-2-14.xml b/Sample641CouselingRecord-2-14.xml new file mode 100644 index 0000000..4c930c5 --- /dev/null +++ b/Sample641CouselingRecord-2-14.xml @@ -0,0 +1,180 @@ + + + + + + + 234347 + + 786 + + + + Hanks + Jerry + + + jomh@gmail.com + 2365894123 + + + + Austin + Alabama +33189 + 2344 + United States + + + + + Yes + + + 1987-12-12 + Yes + + + + WhiteAsian + Non Hispanic or Latino +Female + No + Active Duty + Prefer not to say + + Magazine/Newspaper + +Other + testmedia + + + Yes + Yes + ABC company + Real Estate and Rental and Leasing + + 100 + + No + + No + Yes + 14 + 0 + + 0.00 + 0.00 + 5660.00 + + + Other + Other Counseling + + Urban + 54346 + + + Business Plan + + + Belgium + + + + 371786_T50267 + + Resiliency and Recovery Demonstration Grant – CARESRRD + + + hanks + tom + + + tomh@gmail.com + 2365894123 + + + + Alpharetta + Alabama + 95928 + 2344 + United States + + + + Yes + No + + No + 2021-12-31 + 18 + + + 0.00 + 0.00 + 788.00 + Yes + + + 10 + 10 + + 10 + + 80 + + 80 + + 80 + + 80 + + + + + Service-Disabled Veteran-Owned Small Business + + + + Community Advantage + Other(SBIR, SBIC, 7(a) 504, etc) + Other SBA Disaster Loan for COVID-19 + + + Tax Planning + + + + SBA Office of International Trade (OIT) + + + Online + + English + + + 2024-01-01 + Paul Bozzo (FC) + + 7 + 6 + 6 + + Test + + United States + + + + + 2024-09-22 + PartnerTrainingNum + 2 + 3 + + + + + \ No newline at end of file diff --git a/Sample_Training_888-2-26-2025.xml b/Sample_Training_888-2-26-2025.xml new file mode 100644 index 0000000..d8544c8 --- /dev/null +++ b/Sample_Training_888-2-26-2025.xml @@ -0,0 +1,61 @@ + + + +93747 +250009 +Severe Winter Storm and Flooding (NV) – 1738 +2023-12-12 +6 +0.1 + International Sales for Small Business - Webinar +AuroraAlabama07501United States + 07501 + +2 +3 +3 +5 +3 +4 +0 +0 +0 +0 +20 + +3 +3 +6 +7 +5 +20 +30 + + +10 +6 + + + +6 + + +Technology + + +SBA +Other Government Agency +Educational Institution +Other +Denver SBA office +new agency1 +other partners + +Seminar +In-person +100 +EnglishSpanish +test +test + + \ No newline at end of file diff --git a/TECHNICAL_DEBT.md b/TECHNICAL_DEBT.md new file mode 100644 index 0000000..d16880b --- /dev/null +++ b/TECHNICAL_DEBT.md @@ -0,0 +1,153 @@ +# Technical Debt Register + +This document catalogs known technical debt across the CSV-to-XML tool codebase, organized by priority. Each item includes affected files, line references, and a recommended fix. + +Items marked with **[RESOLVED]** have been addressed. + +--- + +## HIGH Priority + +### 1. Weak Type Annotations (Python) **[RESOLVED]** + +Replaced `object` with proper types (`logging.Logger`, `ValidationTracker`, `Any`) in `base_converter.py`, `data_validation.py`, and `data_cleaning.py`. Added `TYPE_CHECKING` guards to avoid circular imports. + +--- + +### 2. Overly Broad Exception Handling **[RESOLVED]** + +Replaced generic `except Exception` blocks with specific exception types (`OSError`, `csv.Error`, `pd.errors.ParserError`, `etree.XMLSyntaxError`, `ValueError`, `KeyError`, etc.) across all converters, `xml_validator.py`, and `fix_sba_xml.py`. Updated corresponding tests. + +--- + +### 3. Missing Input Validation **[RESOLVED]** + +- Added `try/except OSError` around file writes in `validation_report.py` (CSV and HTML report generation). +- Added `None` guard for `.text` access on XML elements in `xml_validator.py`. + +**Remaining:** CSV column existence pre-checks in converters could still be added for more robust early failure. + +--- + +### 4. Hardcoded Credentials in docker-compose.yml **[RESOLVED]** + +Moved all secrets to an `env_file` reference in `docker-compose.yml`. Created `.env.example` with placeholder values. Added `.env` to `.gitignore`. + +--- + +### 5. Beta Dependency in Production + +`apps/web/package.json` depends on `next-auth@5.0.0-beta.30`, a pre-release version. Beta APIs may change without notice, and security patches may lag behind stable releases. + +**Status:** Not yet resolved. Requires monitoring for a stable release. + +--- + +### 6. Path Traversal Risk in File Download **[RESOLVED]** + +Added `realpath()` validation in `apps/web/src/app/api/jobs/[jobId]/download/route.ts` to ensure the file path stays within `DATA_DIR` before reading. + +--- + +### 7. Unpinned Python Dependencies **[RESOLVED]** + +Added version range pins to `requirements.txt` (e.g., `pandas>=2.2.0,<3`). + +--- + +### 8. CI Build Failure Silently Ignored **[RESOLVED]** + +Removed `continue-on-error: true` from the web build step in `.github/workflows/ci.yml`. + +--- + +## MEDIUM Priority + +### 9. Code Duplication **[RESOLVED]** + +- Extracted `is_empty(value)` helper in `data_cleaning.py` to replace 9 repeated empty/NaN guard patterns. +- Moved `client_intake_order` from `xml_validator.py` to `CounselingConfig.CLIENT_INTAKE_ELEMENT_ORDER` in `config.py`. +- Consolidated date format lists to a single `DATE_INPUT_FORMATS` in `config.py`. + +**Remaining:** `row.get('Field', '').strip()` pattern in `counseling_converter.py` (14+ occurrences) could still benefit from a helper, but this is lower-impact. + +--- + +### 10. Magic Numbers and Hardcoded Values **[RESOLVED]** + +Added named constants in `data_cleaning.py` (`PHONE_NUMBER_DIGITS`, `PHONE_WITH_COUNTRY_CODE_DIGITS`, `PERCENTAGE_MIN`, `PERCENTAGE_MAX`) and `config.py` (`FISCAL_YEAR_START_MONTH`). + +--- + +### 11. No Web Application Tests + +- No `apps/web/__tests__/` directory exists -- zero frontend test coverage. +- No integration tests for API routes in `apps/web/src/app/api/`. +- No end-to-end tests for the conversion flow through the web UI. +- No test framework (Jest, Vitest, Playwright) is configured in `apps/web/package.json`. + +**Status:** Not yet resolved. Requires adding a test framework and writing tests. + +--- + +### 12. Docker Compose Missing Health Checks **[RESOLVED]** + +Added `healthcheck` configurations for db (pg_isready), redis (redis-cli ping), and worker (curl /health). Updated `depends_on` with `condition: service_healthy`. + +--- + +### 13. Large Functions Needing Decomposition **[RESOLVED]** + +- Extracted `_resolve_column` and `_count_matches` from nested helpers in `_calculate_demographics` to class methods on `TrainingConverter`. +- Extracted `_validate_file_paths` from `validate_against_xsd` in `xml_validator.py`. + +--- + +### 14. Weak Password Validation **[RESOLVED]** + +Added `validatePasswordComplexity()` in `apps/web/src/app/api/auth/signup/route.ts` requiring uppercase, digit, and special character. + +--- + +### 15. Rate Limiting Based on Spoofable Header **[RESOLVED]** + +Extracted `getClientIdentifier()` helper that takes only the first IP from `x-forwarded-for` and validates it, instead of blindly trusting the full header. + +--- + +### 16. Memory Risk with Large CSV Files + +Both converters load entire files into memory. With a 50MB upload limit, this could consume significant memory. + +**Status:** Not yet resolved. Would require significant architectural changes to implement streaming. + +--- + +## LOW Priority + +### 17. Dead and Incomplete Code **[RESOLVED]** + +- Removed unused `FIELD_MAPPING` from `CounselingConfig`. +- Cleaned up placeholder comments in `TRAINING_TOPIC_MAPPINGS` and `PROGRAM_FORMAT_MAPPINGS`. +- Resolved contradictory comments in `fix_sba_xml.py` about `add_missing` behavior. +- Deleted orphaned `update_validation.py` script. + +--- + +### 18. Inconsistent Naming Conventions + +- `src/data_cleaning.py` mixes underscore-prefixed private functions with public functions that serve similar internal roles. +- Error message formatting varies across modules. +- Config access in `src/converters/counseling_converter.py` alternates between `self.config` and `self.general_config`. + +**Status:** Partially addressed through code cleanup. Full standardization deferred as low priority. + +--- + +### 19. Missing Python Linting and Formatting + +- No `ruff`, `flake8`, or `black` is configured for the Python codebase. +- No `pyproject.toml` or equivalent configuration file for Python tooling. +- The CI pipeline runs `pytest` but has no Python linting step. + +**Status:** Not yet resolved. Adding `ruff` deferred to avoid scope creep. diff --git a/UX_IMPLEMENTATION_PLAN.md b/UX_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..430bb1d --- /dev/null +++ b/UX_IMPLEMENTATION_PLAN.md @@ -0,0 +1,829 @@ +# UX Implementation Plan + +This document sequences the findings from `UX_REVIEW.md` into six +executable phases, ordered by impact and dependency. It is the +companion to that review — every item here references a finding in +`UX_REVIEW.md` by its section number (e.g. "§3.5" points to +`UX_REVIEW.md` finding 3.5). + +## How to Read This Plan + +- **Phases are releasable slices, not sprints.** Each phase is a + coherent set of changes that can ship as one or more PRs without + depending on later phases. +- **Findings are referenced by their `UX_REVIEW.md` number.** Don't + duplicate rationale here — click through to the review. +- **Effort tags (S/M/L)** come from `UX_REVIEW.md`. S = <½ day, + M = ½–2 days, L = multi-day. +- **No time estimates.** Sequence matters; duration depends on the + team. +- **Progress tracking** — when a finding is shipped, mark it + `**[RESOLVED]**` in `UX_REVIEW.md` (same convention as + `TECHNICAL_DEBT.md`). + +## Phase Overview + +| # | Phase | Goal | Risk if deferred | +|---|-------|------|------------------| +| 1 | Stop the Bleed | Fix the P0 bug, silent data loss, and the worst a11y gaps. | Users lose data; legal/a11y exposure. | +| 2 | Mobile Usable | Make the app survive a 375px viewport. | Phone users can't triage jobs. | +| 3 | Recoverable Errors & Live Feedback | Toasts, cancel, actionable errors, loading polish. | Flow feels broken on every edge case. | +| 4 | Onboarding & Clarity | Landing, empty states, content fixes, converter-type explanations. | First-time users stall or churn. | +| 5 | Mapping Page Overhaul | Field descriptions, diff view, human audit log. | The step where partners stall most often. | +| 6 | Design System & Shell Polish | Component library, breadcrumbs, help page, typography. | Drift accelerates; every new page is hand-rolled. | + +--- + +## Phase 1 — Stop the Bleed + +**Goal:** Ship the smallest possible change that fixes the P0 bug, +closes the silent-data-loss path, and gets the app onto the WCAG 2.1 +Level A floor. Everything here is low-effort and should land as one PR +if possible. + +### Findings addressed + +| ID | Finding | Effort | +|----|---------|--------| +| §3.2 | Re-upload hardcoded two-type label (bug) | S | +| §4.3 | Silent mapping-save failure | S | +| §6.1 | Progress bar lacks ARIA role and values | S | +| §6.2 | Error alerts lack `role="alert"` / `aria-live` | S | +| §5.4 | Progress counters not announced to screen readers | S | +| §6.6 | Table headers lack `scope="col"` | S | + +### File changes + +- `apps/web/src/app/convert/[jobId]/reupload/page.tsx` — replace the + hardcoded ternary at L81-86 with a shared `converterTypeLabel()` + helper that handles all three types. Put the helper in + `apps/web/src/lib/converter-types.ts` (new file) so it can be + imported from the upload page too. +- `apps/web/src/app/convert/[jobId]/mapping/page.tsx:47-62` — add + `error` state, set it on non-2xx and in `catch`, render it above the + table using the same red-alert pattern as `convert/page.tsx:67-71`. +- `apps/web/src/app/convert/[jobId]/progress/page.tsx:85-92` — wrap + the progress bar in a `
`. Wrap the + counters (L94-108) in `
`. +- Every error-alert `
` in the app — add `role="alert"`: + `convert/page.tsx:67-71`, `login/page.tsx:42-46`, + `signup/page.tsx:60-64`, `reupload/page.tsx:70-74`, + `preview/page.tsx:53-58`, and the mapping-page alert being added in + this phase. +- Every `` inside `` — add `scope="col"`: + `dashboard/page.tsx:57-64`, `audit/page.tsx:75-82`, + `preview/page.tsx:113-121`, `mapping/page.tsx:129-138`, + `results/page.tsx:291-298`, `results/page.tsx:400-408`. + +### Dependencies + +None. This phase has no blockers and no coordination with the worker +backend. + +### Verification + +1. **Manual smoke test:** start a counseling conversion, re-upload a + training-client CSV, confirm the label shows "Training Client (Form + 641)". +2. **Silent data loss:** break the PATCH endpoint (temporarily return + 500), click Save Mapping & Continue, confirm the error alert + appears and the page does not redirect. +3. **Screen reader spot-check:** VoiceOver or NVDA on the progress + page; announce should say "Conversion progress, 45%". +4. **Axe automated scan:** run axe DevTools on every page; the + "ARIA role on progress bar", "ARIA label on alerts", and "scope on + ``" violations should all be gone. + +### Out of scope for this phase + +Focus-visible rings (§6.4), color-only status (§6.3), and mobile nav +(§1.1) are deferred to phases 2 and 4. + +--- + +## Phase 2 — Mobile Usable + +**Goal:** Make every page survive a 375px viewport without horizontal +overflow or illegible content. External partners check job status from +phones and the current layout breaks on every screen below ~768px. + +### Findings addressed + +| ID | Finding | Effort | +|----|---------|--------| +| §1.1 | Nav bar does not collapse on mobile | M | +| §3.7 | Results page 4-column summary grid | S | +| §7.1 | `grid-cols-4` on results summary | S | +| §7.2 | `grid-cols-3` on re-upload comparison | S | +| §7.3 | `grid-cols-3` on preview column-status | S | +| §7.4 | Dashboard table shatters on mobile | S | +| §7.5 | Audit table shatters on mobile | S | +| §7.6 | Mapping two-column layout wraps badly | S | +| §7.7 | Data preview has no sticky header | S | +| §6.4 | No visible `:focus-visible` styles | M | +| §8.4 | Gray-400 text fails contrast | S | + +### File changes + +**Responsive grids (trivial one-line fixes):** +- `results/page.tsx:126` → `grid-cols-2 md:grid-cols-4`. +- `results/page.tsx:144` → `grid-cols-1 sm:grid-cols-3`. +- `preview/page.tsx:72` → `grid-cols-1 sm:grid-cols-3`. +- `progress/page.tsx:95` → leave `grid-cols-3` (3 cards are narrow + enough at 375px). + +**Table overflow wrappers:** +- `dashboard/page.tsx:53` — wrap `` in `
`. +- `audit/page.tsx:72` — same. +- `preview/page.tsx:111` — already wrapped, but add sticky header: + `
`. + +**Mapping page vertical stack on mobile:** +- `mapping/page.tsx:177` — change `flex items-center gap-2` to + `flex flex-col sm:flex-row sm:items-center gap-2`. + +**Nav hamburger (§1.1):** +- `apps/web/src/components/nav.tsx` — split the current single-row + nav into two states: + - Desktop (`md:flex`): current layout. + - Mobile (`md:hidden`): wordmark + hamburger button. Clicking the + button opens a vertical dropdown sheet with the three links, the + user's email, and Sign Out. +- New small state hook `useState(false)` for open/closed. No new + dependencies needed; reuse Tailwind classes. +- Close the sheet on route change — wire the existing `usePathname` + effect. + +**Focus rings (§6.4):** +- Add to `globals.css` (below the `@import`): + ```css + @layer base { + *:focus-visible { + @apply outline-2 outline-offset-2 outline-blue-500; + } + } + ``` +- Remove ad-hoc focus overrides if any creep in later. + +**Contrast fixes (§8.4):** +- Find/replace `text-gray-400` → `text-gray-600` app-wide, except + inside intentional placeholder contexts (e.g. `text-gray-400` on + the preview row number column at `preview/page.tsx:126` is OK + because it sits on gray-50). +- Specific hits: `convert/page.tsx:145`, `preview/page.tsx:115`. + +### Dependencies + +None. Pair-review with a designer if one is available; otherwise this +is all mechanical. + +### Verification + +1. **Chrome DevTools device emulator:** walk the entire flow at 375px + (iPhone SE), 390px (iPhone 12), 768px (iPad), and 1024px (desktop). +2. **No horizontal overflow at 375px:** dashboard, audit, preview, + mapping, progress, results, all auth pages. +3. **Keyboard tab through every interactive element** and confirm a + visible focus ring on each. +4. **axe contrast pass:** no `text-gray-400 on white` violations. +5. **VoiceOver rotor check:** mobile nav dropdown is reachable and + closes on route change. + +### Out of scope + +Collapsing table columns into card layouts on the smallest screens +(§7.4 stretch goal) is deferred to Phase 6. + +--- + +## Phase 3 — Recoverable Errors & Live Feedback + +**Goal:** Give every user action a clear success signal, make every +error recoverable, and kill all dead-end states. This is the phase +that turns "feels broken" into "feels solid". + +### Findings addressed + +| ID | Finding | Effort | +|----|---------|--------| +| §3.6 | Progress page has no cancel and a dead-end timeout | M | +| §3.9 | No success toasts anywhere | M | +| §5.1 | No toast / notification system | M | +| §5.2 | Loading buttons reuse copy, no spinner, no `aria-busy` | S | +| §5.3 | No skeleton loaders on data pages | M | +| §4.1 | Upload errors are generic | S | +| §4.2 | Preview/Convert error states are dead ends | S | +| §4.4 | Error boundary strands the user | S | +| §4.5 | Progress page silently swallows poll errors | S | + +### File changes + +**Toast system (prerequisite for everything else in this phase):** +- Add a minimal toast primitive. Two options: + 1. Use `sonner` (4kB, already React-friendly). Pros: shipped, + a11y-correct. Cons: one more dep. + 2. Hand-roll: `apps/web/src/components/toast.tsx` with a + `ToastProvider` exposing `showToast({variant, message})` via + React context + a portaled viewport in `layout.tsx`. +- Recommendation: hand-roll. The app already avoids heavy deps and + the primitive is ~80 lines including `role="status"` / + `role="alert"` handling. +- Mount the viewport in `apps/web/src/app/layout.tsx` inside + ``. +- Fire toasts on success: + - `login/page.tsx:29` — "Signed in" + - `signup/page.tsx:47` — "Account created" + - `convert/page.tsx:54` — "File uploaded — preview loading" + - `mapping/page.tsx:58` — "Mapping saved" + - `reupload/page.tsx:47` — "Re-upload received" + - `results/page.tsx` — on initial load when `status === "complete"` + and the session is fresh, fire "Conversion complete". + +**Specific upload error messages (§4.1):** +- `convert/page.tsx:47-56` — switch on `res.status`: + ```ts + if (res.status === 413) setError("This file is larger than 50MB…"); + else if (res.status === 429) setError("You've uploaded a lot…"); + else if (res.status === 401 || res.status === 403) setError("Your session expired. Sign in again."); + else setError(data.error || "Upload failed."); + ``` +- Same treatment on `reupload/page.tsx:44-50`. + +**Preview / mapping error cards (§4.2):** +- `preview/page.tsx:53-59` — replace the bare `

` with a card that + renders the error + a Retry button that re-runs `loadPreview()`. +- `mapping/page.tsx` — same pattern on load failure. +- Add "Back to upload" link on each. + +**Progress cancel + timeout recovery (§3.6):** +- Requires a new worker endpoint. Coordinate with backend before + this phase starts. New route: + `apps/web/src/app/api/jobs/[jobId]/cancel/route.ts` → proxies to + worker `POST /jobs/{id}/cancel`. Worker owns the actual cancel + mechanics (which this plan does not specify). +- `progress/page.tsx:69-108` — add: + - "Cancel conversion" button visible when `status === "converting"`. + - On timeout: render "Check status", "Go to dashboard", "Report a + problem" buttons instead of a dead headline. + - Track consecutive poll failures (§4.5); after 3 failures show an + inline banner with Retry / Dashboard buttons. + - ETA estimation: once `processedRows > 10` and `totalRows > 0`, + compute `rate = processedRows / elapsedSeconds` and display + `Math.round((totalRows - processedRows) / rate)` seconds + remaining. + +**Button spinners & `aria-busy` (§5.2):** +- Add a small `` component (inline SVG with + `animate-spin`). +- Update every submit button that currently flips its text on + loading to also render the spinner + `aria-busy={loading}`: + `login/page.tsx:74-80`, `signup/page.tsx:105-111`, + `convert/page.tsx:153-159`, `mapping/page.tsx:225-231`, + `reupload/page.tsx:102-108`, `preview/page.tsx:148-154`. + +**Skeleton loaders (§5.3):** +- New component `apps/web/src/components/skeleton.tsx` exporting + `` (a single gray div with `animate-pulse`) and + ``. +- Replace "Loading…" text with matching skeletons at: + - `preview/page.tsx:45-51` + - `mapping/page.tsx:64-70` + - `audit/page.tsx:84-89` + - `convert/page.tsx:169-172` (Suspense fallback) + - `reupload/page.tsx:54-60` + +**Error boundary improvements (§4.4):** +- `components/error-boundary.tsx:28-46`: + - Add `role="alert"`. + - Add "Go to dashboard" link alongside "Try again". + - In dev (`process.env.NODE_ENV !== "production"`) show the error + message and stack. + - TODO comment: wire Sentry once enabled. + +### Dependencies + +- **Worker cancel endpoint** (§3.6). If the worker can't support + mid-conversion cancel, the UI shipping in this phase should still + include the button but grey it out with a tooltip ("Cancel is not + yet supported by the worker"). Don't block the rest of the phase. +- Toast component must land before the success-toast changes. + +### Verification + +1. **Happy path smoke test:** sign in → upload → preview → map → + convert → download → re-upload. Every successful action should + flash a toast. No page should feel silent. +2. **Error injection:** temporarily make `/api/upload` return 413, + 429, 401, 500. Confirm the error message changes per status code + and is announced by screen reader (`role="alert"`). +3. **Cancel test:** start a conversion, click Cancel, confirm the + progress page transitions to "Cancelled" and returns to the + dashboard with a toast. +4. **Timeout test:** set `MAX_WAIT_MS` to 5s locally, start a job, + confirm the 3-button recovery UI appears. +5. **Skeleton visual check:** throttle the network to Slow 3G and + confirm skeletons render on every data page instead of raw + "Loading…" text. + +### Out of scope + +- Error-boundary Sentry wiring. TODO comment only; the actual Sentry + setup is a separate infra ticket. +- Toast queue / dismissal animations — ship the simplest possible + version first. + +--- + +## Phase 4 — Onboarding & Clarity + +**Goal:** A first-time partner with no XML knowledge can land on the +app, understand what it does, pick the right converter type, upload a +file, and know what will happen to their data — all without reading +source code. + +### Findings addressed + +| ID | Finding | Effort | +|----|---------|--------| +| §2.1 | Landing page doesn't explain what to bring | M | +| §2.2 | Dashboard empty state is passive | M | +| §2.3 | README silent on the web app | S | +| §2.4 | Signup password rules not shown | S | +| §3.1 | Converter types overlap and aren't explained | S | +| §3.3 | Upload does not validate client-side | S | +| §3.4 | Preview "Extra" column treated as a problem | S | +| §6.3 | Status conveyed by color alone | M | +| §6.5 | Dropzone uses `role="button"` on `

` | S | +| §9.1 | Inconsistent status vocabulary | S | +| §9.3 | Upload limit text hidden in gray-400 | S | +| §9.4 | Converter-type labels don't match what users know | S | +| §9.5 | "Skip" on mapping is ambiguous | S | +| §10.1 | No data-handling disclosure on upload | S | +| §10.2 | No "previous job is kept" cue on re-upload | S | + +### File changes + +**Converter-type metadata module (shared by §3.1, §9.4, §3.2):** +- Extend `apps/web/src/lib/converter-types.ts` (created in Phase 1) to + export: + ```ts + export const CONVERTER_TYPES = [ + { value: "counseling", label: "Counseling (Form 641)", description: "Individual client counseling sessions.", sample: "Sample641CouselingRecord-2-14.xml" }, + { value: "training", label: "Training (Form 888)", description: "Aggregated training event data.", sample: "Sample_Training_888-2-26-2025.xml" }, + { value: "training-client", label: "Training Client (Form 641)", description: "Per-attendee rows from a training event.", sample: "Sample641CouselingRecord-2-14.xml" }, + ] as const; + ``` +- Import into `convert/page.tsx`, `reupload/page.tsx`, + `dashboard/page.tsx`, and the new landing page. + +**Landing page rebuild (§2.1):** +- `apps/web/src/app/page.tsx` — expand to three sections: + - Hero: H1 + one-line pitch + Sign In / Create Account CTAs. + - "What this converts": three converter-type cards, each with + label, description, and a "Download sample" link pointing at the + sample XML in the repo (served as a static asset). + - "How it works": 4-step diagram (Upload → Preview → Map → + Download). +- Copy the two sample XML files into `apps/web/public/samples/` as + part of the PR. + +**Dashboard empty state (§2.2):** +- `dashboard/page.tsx:44-50` — replace the two-line text with: + - Big "Start a new conversion" button (primary). + - 3-step visual of the flow. + - "Download a sample CSV" link (point to a new + `apps/web/public/samples/counseling-sample.csv` — this plan + assumes one can be produced from the existing sample XML). + +**Converter-type cards (§3.1):** +- `convert/page.tsx:78-109` — replace the flat radio row with a + grid of radio cards generated from `CONVERTER_TYPES`. Each card + shows label, description, and a "see sample" link. Keep the + underlying `` for form semantics. + +**Client-side upload validation (§3.3):** +- `convert/page.tsx:118-132` — change `dropped?.name.endsWith(".csv")` + to a case-insensitive check (`.toLowerCase().endsWith(".csv")`). +- Add a `MAX_FILE_BYTES = 50 * 1024 * 1024` constant; reject + oversized files client-side with an inline error before hitting + `/api/upload`. +- When a drop is rejected, fire a toast (from Phase 3): "That file + isn't a CSV up to 50MB." + +**Dropzone label refactor (§6.5):** +- `convert/page.tsx:112-150` — prefer a real `
`/`` table, but adding +`scope="col"` is best practice and required if the tables ever gain row +headers. + +**Recommendation:** Add `scope="col"` to every ``. + +**Effort:** S + +--- + +### 6.7 `` has a single static `lang="en"` **[P3]** + +**Where:** `apps/web/src/app/layout.tsx:18` + +**What the user sees:** Fine for English-only deployments. Noting it +here because if SBA materials are ever translated, this attribute is +the hook screen readers use to switch voices. No action needed yet. + +--- + +## 7. Responsive & Mobile Experience + +### 7.1 Fixed `grid-cols-4` summary cards on results page **[P1]** **[RESOLVED]** + +**Where:** `results/page.tsx:126` — `grid grid-cols-4 gap-4 mb-6` + +Noted in 3.7. Needs `grid-cols-2 md:grid-cols-4`. + +--- + +### 7.2 Fixed `grid-cols-3` comparison cards **[P1]** **[RESOLVED]** + +**Where:** `results/page.tsx:144` — re-upload comparison grid + +Same fix pattern: `grid-cols-1 sm:grid-cols-3`. + +--- + +### 7.3 Fixed `grid-cols-3` column-status cards on preview **[P1]** **[RESOLVED]** + +**Where:** `preview/page.tsx:72` — Matched / Missing / Extra cards + +Same fix: `grid-cols-1 sm:grid-cols-3`. + +--- + +### 7.4 Dashboard table shatters on mobile **[P1]** **[RESOLVED]** + +**Where:** `apps/web/src/app/dashboard/page.tsx:53-102` + +**What the user sees:** A 7-column table (File / Type / Status / +Records / XSD / Date / Actions) inside `
`. There's no `overflow-x-auto` wrapper, so on a +375px screen the table overflows its container and pushes page layout +out. + +**Recommendation:** Wrap the table in `overflow-x-auto` like +`results/page.tsx:289` already does for issue tables. Longer-term, +hide the least important columns on mobile (`hidden md:table-cell` on +Type, XSD, Date) and collapse the row into a stacked card layout on +the smallest screens. + +**Effort:** S (scroll) / M (stacked) + +--- + +### 7.5 Audit table shatters on mobile **[P1]** **[RESOLVED]** + +**Where:** `apps/web/src/app/audit/page.tsx:72-123` + +**What the user sees:** Same pattern as 7.4 — 5-column table in a +`
` with no overflow wrapper. +The Details cell uses `max-w-[200px] truncate` but the outer table +still pushes the container wider than the viewport. + +**Recommendation:** Add `overflow-x-auto` on the wrapper. Consider +hiding Details on small screens. + +**Effort:** S + +--- + +### 7.6 Mapping page two-column layout wraps badly **[P2]** **[RESOLVED]** + +**Where:** `apps/web/src/app/convert/[jobId]/mapping/page.tsx:127-221` + +**What the user sees:** A two-column table. The left cell stacks the +monospace field name and 1-2 badges; the right cell has a `
+` or each `
` without scope **[P3]** **[RESOLVED]** + +**Where:** All tables — `dashboard/page.tsx`, `audit/page.tsx`, +`preview/page.tsx`, `mapping/page.tsx`, `results/page.tsx`. + +**Why it hurts:** WCAG 1.3.1 (Info and Relationships) is satisfied by +default `` in a simple `
` inside `
`. Similarly, on the dashboard and audit tables +consider sticky column headers for long lists. + +**Effort:** S + +--- + +### 7.8 Nav doesn't collapse on mobile **[P1]** + +Already filed as 1.1. + +--- + +## 8. Visual Design & Consistency + +### 8.1 No design tokens; utility classes repeat everywhere **[P2]** **[RESOLVED]** + +_Extracted `components/ui/{button,alert,card,status-badge}.tsx` and +refactored login, signup, convert, preview, mapping, reupload, +progress, dashboard, and error-boundary to consume them. A +`buttonClasses()` helper is exported for ``-as-button sites. +Regression guard ships as `apps/web/scripts/check-ui-classes.mjs` +and runs from `npm run lint` alongside tsc — any raw primary button +or error alert utility-class combo added outside +`components/ui/` (plus a short allowlist of intentional sites) fails +the build._ + + +**Where:** Every page uses raw Tailwind utility strings. For example, +the primary button pattern +`bg-blue-600 text-white rounded text-sm font-medium hover:bg-blue-700 +disabled:opacity-50` appears at least 8 times across pages with slight +variations (`px-4 py-2`, `px-6 py-2`, `py-2 px-4 w-full`, `py-2`). + +**Why it hurts:** +- Inconsistent button sizing across screens (the homepage uses + `px-6 py-2`; convert uses `w-full py-2`; mapping uses `px-4 py-2`). +- Impossible to change the brand color without a find-and-replace. +- New pages drift further from any baseline. + +**Recommendation:** Extract a small set of component wrappers +(``, ``, ``, +``, ``) under `apps/web/src/components/ui/`. +Convert existing pages to use them. Even 5 components would cover 80% +of the current utility-class duplication. + +**Effort:** L + +--- + +### 8.2 `StatusBadge` color map duplicated in dashboard only **[P3]** **[RESOLVED]** + +**Where:** `apps/web/src/app/dashboard/page.tsx:141-160` + +**What the user sees:** The dashboard has its own `StatusBadge` +function. No other page uses it — which is fine today but means the +next screen that needs a status badge will reinvent it. + +**Recommendation:** Move `StatusBadge` to +`apps/web/src/components/status-badge.tsx` so it can be reused (e.g. +on the results page header where "converting / complete / error" would +be useful). + +**Effort:** S + +--- + +### 8.3 Homepage typography is unbalanced **[P3]** **[RESOLVED]** + +**Where:** `apps/web/src/app/page.tsx:6-25` + +**What the user sees:** `text-4xl` headline above `text-lg` body, then +two buttons at `text-sm`. The buttons look too small for the header +scale. + +**Recommendation:** Bump the buttons to `text-base` or +`px-6 py-3 text-base` and tighten the button group's vertical rhythm. + +**Effort:** S + +--- + +### 8.4 Gray-400 / gray-500 text fails contrast on white **[P2]** **[RESOLVED]** + +**Where:** Multiple — e.g. `convert/page.tsx:145` ("`.csv` files only, +max 50MB" in `text-gray-400`), `dashboard/page.tsx:86` +(`text-gray-500`), `audit/page.tsx:101` (`text-gray-500`), +`preview/page.tsx:115` (`text-gray-500` column header text). + +**Why it hurts:** Tailwind's `gray-400` (#9ca3af) on white fails WCAG +AA contrast (3.9:1 on normal text — requirement is 4.5:1). +`gray-500` (#6b7280) passes at 4.83:1 but is marginal and fails on +large gray backgrounds. Needs review. + +**Recommendation:** Replace `text-gray-400` with `text-gray-500` or +darker. Run a contrast checker (Lighthouse / axe) across the app and +adjust the gray scale accordingly. + +**Effort:** S + +--- + +### 8.5 Inconsistent alert styling across pages **[P3]** **[RESOLVED]** + +**Where:** +- `convert/page.tsx:67-71` → `bg-red-50 text-red-600 p-3 rounded text-sm` +- `reupload/page.tsx:70-74` → `bg-red-50 border border-red-200 text-red-700 text-sm rounded p-3` +- `login/page.tsx:42-46` → `bg-red-50 text-red-600 p-3 rounded text-sm` + +**Why it hurts:** Three slight variations of the same error alert. + +**Recommendation:** One `` component. Covered +by 8.1. + +**Effort:** S (if 8.1 is done) + +--- + +## 9. Content, Microcopy & Terminology + +### 9.1 Inconsistent status vocabulary **[P2]** **[RESOLVED]** + +**Where:** `apps/web/src/app/dashboard/page.tsx:141-160` + +**What the user sees:** Six status values — `uploaded`, `previewed`, +`mapping`, `converting`, `complete`, `error` — mixing past participle +and gerund forms, plus a noun (`error`). Rendered lowercase. + +**Why it hurts:** Low-confidence users can't tell what these mean +("What does 'mapping' mean for my job? Is it waiting for me?"). The +grammar mix makes a scan of the dashboard harder. + +**Recommendation:** Use one tense and capitalize: + +- `uploaded` → **Uploaded** +- `previewed` → **Ready to convert** (the user has seen the preview; + from their POV the next action is theirs) +- `mapping` → **Needs mapping** +- `converting` → **Converting…** +- `complete` → **Complete** +- `error` → **Failed** + +Translate the raw database value in a helper, not by changing schemas. + +**Effort:** S + +--- + +### 9.2 Audit "Details" column dumps raw JSON **[P2]** **[RESOLVED]** + +**Where:** `apps/web/src/app/audit/page.tsx:115-117` + +**What the user sees:** `{entry.metadata ? JSON.stringify(entry.metadata) : "-"}` +truncated to `max-w-[200px]`. + +**Why it hurts:** Useless to end users; looks unfinished to anyone +reviewing the app. + +**Recommendation:** Either render a human-readable summary per action +(the audit actions are a known set — see `audit/page.tsx:53-58`), or +replace the cell with a "View details" toggle that expands a +formatted JSON tree in the row below. + +**Effort:** M + +--- + +### 9.3 Upload help text hides the limits **[P3]** + +**Where:** `apps/web/src/app/convert/page.tsx:145-147` + +**What the user sees:** "`.csv` files only, max 50MB" in `text-xs +text-gray-400`. + +**Why it hurts:** The smallest, lowest-contrast text on the page is +the one with the constraint the user will hit most often. + +**Recommendation:** Promote to `text-sm text-gray-600` and move it +directly under the "CSV File" label, not inside the dropzone. Related +to 8.4. + +**Effort:** S + +--- + +### 9.4 Converter-type labels don't match what users know **[P2]** **[RESOLVED]** + +_Sharing a single module (`apps/web/src/lib/converter-types.ts`) +means any future rename lands in one place. Labels themselves are +unchanged — a product decision on canonical names is still pending._ + + +**Where:** `apps/web/src/app/convert/page.tsx:86,96,106` + +**What the user sees:** "Counseling (Form 641)", "Training (Form +888)", "Training Client (Form 641)". + +**Why it hurts:** Some partners know the forms by EDMIS or by the SBA +submission portal, not by these names. The labels are also ambiguous +(see 3.1). + +**Recommendation:** After a user-owner decision on terminology, apply +the final names in one place (see 3.1 recommendation to extract +converter type metadata). + +**Effort:** S + +--- + +### 9.5 "Skip" on mapping page is ambiguous **[P3]** **[RESOLVED]** + +**Where:** `apps/web/src/app/convert/[jobId]/mapping/page.tsx:232-237` + +**What the user sees:** A button labeled "Skip" next to "Save Mapping +& Continue". + +**Why it hurts:** "Skip" sounds like it drops the mapping entirely, +but the `onClick` just navigates back to the preview page — any +unsaved edits are lost without warning. + +**Recommendation:** Rename to "Cancel" and, if the user has made +changes, confirm before discarding them. + +**Effort:** S + +--- + +## 10. Trust, Safety & Data Handling Cues + +### 10.1 Upload screen doesn't disclose what happens to the data **[P1]** **[RESOLVED]** + +_Shipped with "per SBA policy" as the retention placeholder. Replace +with concrete numbers once the product-owner decision lands._ + + +**Where:** `apps/web/src/app/convert/page.tsx:62-161` + +**What the user sees:** Nothing. The upload page doesn't say where the +CSV will be stored, for how long, who can see it, or whether any of it +is sent to third-party services. + +**Why it hurts:** Counseling data includes client names, contact +details, and demographics — PII under SBA policy. External partners +uploading client data deserve an explicit disclosure, both as a legal +safeguard and as a trust cue that the tool takes their data seriously. + +**Recommendation:** Add a short paragraph under the file input: + +> "Your CSV will be stored in your account and is visible only to you. +> Files are retained for 30 days and then automatically deleted. Data +> is processed entirely on SBA infrastructure; nothing is sent to +> third-party services." + +Adjust the wording to match the actual retention policy (if there is +one; see Open Questions). + +**Effort:** S + +--- + +### 10.2 No explicit "last job will be replaced" cue on re-upload **[P3]** **[RESOLVED]** + +**Where:** `apps/web/src/app/convert/[jobId]/reupload/page.tsx:62-117` + +**What the user sees:** A form that looks identical to the regular +upload, with a "Compare against the previous conversion" subtitle. In +reality the re-upload creates a *new* job (`/api/upload` receives +`previousJobId` as a pointer, it does not replace the prior job). + +**Why it hurts:** Users may think their original conversion is +destroyed by re-uploading, or conversely may expect it to be replaced +when it's not. Either way there's a mental-model mismatch. + +**Recommendation:** Add a one-liner: "Your previous conversion will be +kept as a separate job. You can compare the two side-by-side on the +next screen." Link the previous job from the copy. + +**Effort:** S + +--- + +### 10.3 No confirmation before Sign Out or account deletion **[P3]** + +**Where:** `apps/web/src/components/nav.tsx:47-52` + +**What the user sees:** A "Sign Out" button that immediately signs the +user out. There's no "Are you sure?" for users with unsaved work (e.g. +mid-mapping). + +**Why it hurts:** Accidental clicks during a long mapping session lose +progress. Not destructive enough to warrant a modal, but some form of +guardrail is warranted. + +**Recommendation:** For now, nothing — but note it for future dirty-state +tracking. If/when mapping gains autosave, this becomes moot. + +**Effort:** N/A (watch) + +--- + +## Appendix A — Findings by Page + +Quick reference. Each page lists its most impactful findings linked +back to the themed section. + +### `page.tsx` — Homepage +- 2.1 Landing page doesn't explain what to bring **[P1]** +- 8.3 Typography unbalanced **[P3]** + +### `login/page.tsx` +- 6.2 Error alert lacks `role="alert"` **[P0]** +- 5.2 Loading state reuses button copy **[P2]** +- 6.4 No visible focus ring **[P1]** + +### `signup/page.tsx` +- 2.4 Password rules not shown **[P2]** +- 6.2 Error alert lacks `role="alert"` **[P0]** +- 5.2 Loading state reuses button copy **[P2]** + +### `layout.tsx` +- 6.7 Static `lang="en"` (watch) **[P3]** +- 5.1 No toast system mounted **[P1]** + +### `nav.tsx` +- 1.1 Does not collapse on mobile **[P1]** +- 1.3 No Help/Docs entry point **[P2]** +- 10.3 No sign-out confirmation (watch) **[P3]** + +### `error-boundary.tsx` +- 4.4 Error boundary strands the user **[P2]** +- 6.2 No `role="alert"` on fallback **[P0]** + +### `dashboard/page.tsx` +- 7.4 Table shatters on mobile **[P1]** +- 2.2 Empty state is passive **[P2]** +- 9.1 Status vocabulary inconsistent **[P2]** +- 6.3 Status badges color-only **[P1]** + +### `audit/page.tsx` +- 7.5 Table shatters on mobile **[P1]** +- 9.2 Raw JSON in Details cell **[P2]** +- 5.3 No skeleton loader **[P2]** + +### `convert/page.tsx` +- 3.1 Converter types not explained **[P1]** +- 3.3 No client-side file validation **[P2]** +- 4.1 Upload errors are generic **[P1]** +- 6.2 Error alert lacks `role="alert"` **[P0]** +- 6.5 Dropzone uses `role="button"` on a `
` **[P2]** +- 10.1 No data-handling disclosure **[P1]** +- 9.3 Limit text hidden in gray-400 **[P3]** + +### `convert/[jobId]/preview/page.tsx` +- 7.3 Column-status grid fixed at 3 columns **[P1]** +- 3.4 "Extra" column treated as a problem **[P2]** +- 7.7 No sticky table header **[P2]** +- 4.2 Error state dead-end **[P2]** +- 5.3 No skeleton loader **[P2]** + +### `convert/[jobId]/mapping/page.tsx` +- 3.5 Raw XML field names with no descriptions **[P1]** +- 4.3 Save failure swallowed **[P1]** +- 7.6 Two-column layout wraps badly on mobile **[P2]** +- 9.5 "Skip" is ambiguous **[P3]** +- 6.3 Required/Conditional/Optional badges color-coded **[P1]** + +### `convert/[jobId]/progress/page.tsx` +- 3.6 No cancel, no ETA, dead-end timeout **[P1]** +- 6.1 Progress bar lacks ARIA **[P0]** +- 5.4 Counters not announced to screen readers **[P1]** +- 4.5 Poll errors swallowed silently **[P2]** + +### `convert/[jobId]/results/page.tsx` +- 3.7 4-column grid breaks on mobile **[P1]** +- 3.8 Comparison is counts-only **[P1]** +- 6.3 Severity conveyed by color alone **[P1]** +- 6.6 Tables lack `scope="col"` **[P3]** + +### `convert/[jobId]/reupload/page.tsx` +- 3.2 Hardcoded two-type label (bug) **[P0]** +- 10.2 No "previous job is kept" cue **[P3]** +- 6.2 Error alert lacks `role="alert"` **[P0]** + +--- + +## Appendix B — Top 10 Punch List + +Ordered for maximum impact per unit of effort. Items link back to the +themed section. + +1. **[P0] Fix the hardcoded converter-type label on re-upload.** Bug + fix. §3.2 — 15 minutes. +2. **[P0] Add `role="alert"` to every error container and + `role="progressbar"` + `aria-value*` to the progress bar.** §6.1, + §6.2 — 1 hour. +3. **[P1] Explain the three converter types on the upload page.** §3.1 + — half a day, user-visible win. +4. **[P1] Fix mobile layout: responsive grids on preview/results, add + `overflow-x-auto` to dashboard and audit tables, collapse the nav + behind a hamburger.** §1.1, §3.7, §7.1–7.5 — one day. +5. **[P1] Add a cancel button and a recoverable timeout to the + progress page.** §3.6 — half a day, requires a new worker endpoint. +6. **[P1] Make upload errors actionable.** Map 413/429/400/401/5xx to + specific messages + next actions. §4.1 — half a day. +7. **[P1] Add column-mapping field descriptions.** Surface + `src/config.py` metadata in the preview API. §3.5 — requires + coordinating with the worker; high user impact. +8. **[P1] Add a toast/notification system** mounted in `layout.tsx` and + fire it on every success. §3.9, §5.1 — half a day. +9. **[P1] Replace color-only status with icon+text prefixes everywhere.** + §6.3 — half a day. +10. **[P1] Fix the silent mapping-save failure.** §4.3 — 15 minutes, + prevents silent data loss. + +--- + +## Appendix C — Open Questions for the Product Owner + +These are assumptions I made during the review that should be +confirmed before implementation begins. + +1. **What is the primary user device?** I assumed a mix of desktop + (office work) and mobile (on-the-go status checks). If partners + almost never use mobile, the responsive findings (P1s in §7) drop + to P2. +2. **Is there a data retention policy?** The disclosure copy in §10.1 + assumes 30 days. Confirm the actual policy (or create one). +3. **Can the converter type list be renamed?** §3.1 and §9.4 + recommend clearer labels; those require a product decision because + "Form 641" and "Form 888" are canonical SBA names. +4. **Does the worker support job cancellation?** §3.6 assumes a new + `POST /api/jobs/[jobId]/cancel` can be added. Confirm that + mid-conversion cancellation is technically feasible in the worker. +5. **Are field descriptions already in `src/config.py`?** §3.5 assumes + they exist (or can be written). If not, this finding becomes a + content task rather than a display task. +6. **Is there an existing SBA design system** (brand colors, type + scale, component library) that the web app should adopt? If so, + §8.1 should align to it rather than roll a new one. +7. **Should the CLI and web app converge on one surface?** Out of + scope for this review but worth asking: the CLI (`run.py`) actually + explains its steps well ("Step 1: Pick Conversion Type") in a way + the web app does not. Is the plan to deprecate one of the two? + +--- + +## Notes for the Next Reviewer + +- **Run axe or Lighthouse.** The a11y findings in §6 are all + code-visible. A live accessibility audit will surface more + (especially contrast ratios, focus order, landmark regions). +- **Walk the flow with a screen reader.** VoiceOver on Safari and NVDA + on Firefox are the two most impactful checks. +- **Test at 375px, 768px, and 1024px.** The findings in §7 are the + bugs I can see from the code; real-device testing will find layout + issues I can't predict. +- **Check Sentry (if wired up).** If the team turns on the Sentry MCP + server, the client error boundary will start collecting real + user-facing crashes that this review cannot surface. + diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..a8d64b1 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,5 @@ +DATABASE_URL="postgresql://user:pass@localhost:5432/csvtoxml" +NEXTAUTH_SECRET="generate-with-openssl-rand-hex-32" +NEXTAUTH_URL="http://localhost:3000" +WORKER_URL="http://localhost:8000" +DATA_DIR="/data" diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..3f9aaeb --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,24 @@ +FROM node:20-alpine AS deps +WORKDIR /app +COPY apps/web/package*.json ./ +RUN npm install --ignore-scripts + +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY apps/web/ . +ENV DATABASE_URL="postgresql://placeholder:placeholder@placeholder:5432/placeholder" +RUN npx prisma generate +RUN npm run build + +FROM node:20-alpine +WORKDIR /app +ENV NODE_ENV=production +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client +COPY --from=builder /app/scripts ./scripts +EXPOSE 3000 +CMD ["sh", "-c", "node scripts/migrate.js; node server.js"] diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts new file mode 100644 index 0000000..8a9eafa --- /dev/null +++ b/apps/web/middleware.ts @@ -0,0 +1,12 @@ +export { auth as middleware } from "@/lib/auth"; + +export const config = { + matcher: [ + "/dashboard/:path*", + "/convert/:path*", + "/audit/:path*", + "/api/upload/:path*", + "/api/jobs/:path*", + "/api/audit/:path*", + ], +}; diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts new file mode 100644 index 0000000..68a6c64 --- /dev/null +++ b/apps/web/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", +}; + +export default nextConfig; diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json new file mode 100644 index 0000000..5f0ff00 --- /dev/null +++ b/apps/web/package-lock.json @@ -0,0 +1,2334 @@ +{ + "name": "web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "1.0.0", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "@auth/prisma-adapter": "^2.11.1", + "@prisma/client": "6.19.2", + "@tailwindcss/postcss": "^4.2.2", + "@types/node": "^25.5.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "bcryptjs": "^3.0.3", + "ioredis": "^5.10.1", + "next": "^16.2.1", + "next-auth": "^5.0.0-beta.30", + "postcss": "^8.5.8", + "prisma": "6.19.2", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "tailwindcss": "^4.2.2", + "typescript": "^6.0.2" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@auth/core": { + "version": "0.41.1", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz", + "integrity": "sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7.0.7" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/prisma-adapter": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.11.1.tgz", + "integrity": "sha512-Ke7DXP0Fy0Mlmjz/ZJLXwQash2UkA4621xCM0rMtEczr1kppLc/njCbUkHkIQ/PnmILjqSPEKeTjDPsYruvkug==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.1" + }, + "peerDependencies": { + "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.1.tgz", + "integrity": "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.1.tgz", + "integrity": "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.1.tgz", + "integrity": "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.1.tgz", + "integrity": "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.1.tgz", + "integrity": "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.1.tgz", + "integrity": "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.1.tgz", + "integrity": "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.1.tgz", + "integrity": "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.1.tgz", + "integrity": "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@prisma/client": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz", + "integrity": "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.2.tgz", + "integrity": "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==", + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.18.4", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.2.tgz", + "integrity": "sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.2.tgz", + "integrity": "sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.2", + "@prisma/get-platform": "6.19.2" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.2.tgz", + "integrity": "sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.2" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.2.tgz", + "integrity": "sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", + "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "postcss": "^8.5.6", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", + "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/effect": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz", + "integrity": "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.1", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.1", + "@next/swc-darwin-x64": "16.2.1", + "@next/swc-linux-arm64-gnu": "16.2.1", + "@next/swc-linux-arm64-musl": "16.2.1", + "@next/swc-linux-x64-gnu": "16.2.1", + "@next/swc-linux-x64-musl": "16.2.1", + "@next/swc-win32-arm64-msvc": "16.2.1", + "@next/swc-win32-x64-msvc": "16.2.1", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-auth": { + "version": "5.0.0-beta.30", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.30.tgz", + "integrity": "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.0" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7", + "react": "^18.2.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/next-auth/node_modules/@auth/core": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz", + "integrity": "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/nypm": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "license": "MIT", + "dependencies": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz", + "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", + "license": "MIT" + }, + "node_modules/oauth4webapi": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", + "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/prisma": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz", + "integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.2", + "@prisma/engines": "6.19.2" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + } + } +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..b532eed --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,37 @@ +{ + "name": "web", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "tsc --noEmit && node scripts/check-ui-classes.mjs", + "postinstall": "prisma generate" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@auth/prisma-adapter": "^2.11.1", + "@prisma/client": "6.19.2", + "@tailwindcss/postcss": "^4.2.2", + "@types/node": "^25.5.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "bcryptjs": "^3.0.3", + "ioredis": "^5.10.1", + "next": "^16.2.1", + "next-auth": "^5.0.0-beta.30", + "postcss": "^8.5.8", + "prisma": "6.19.2", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "tailwindcss": "^4.2.2", + "typescript": "^6.0.2" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6" + } +} diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/apps/web/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma new file mode 100644 index 0000000..a77ee83 --- /dev/null +++ b/apps/web/prisma/schema.prisma @@ -0,0 +1,61 @@ +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client-js" +} + +model User { + id String @id @default(cuid()) + email String @unique + name String? + passwordHash String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + jobs Job[] + auditEntries AuditEntry[] +} + +model Job { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id]) + converterType String + status String @default("uploaded") + inputFileName String + inputFilePath String + outputFilePath String? + totalRows Int? + processedRows Int @default(0) + columnMapping Json? + summary Json? + issues Json? + cleaningDiffs Json? + xsdValid Boolean? + xsdErrors Json? + previousJobId String? + previousJob Job? @relation("JobComparison", fields: [previousJobId], references: [id]) + nextJobs Job[] @relation("JobComparison") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + completedAt DateTime? + auditEntries AuditEntry[] + + @@index([userId, createdAt(sort: Desc)]) +} + +model AuditEntry { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id]) + jobId String? + job Job? @relation(fields: [jobId], references: [id]) + action String + metadata Json? + createdAt DateTime @default(now()) + + @@index([userId, createdAt(sort: Desc)]) + @@index([action]) +} diff --git a/apps/web/public/.gitkeep b/apps/web/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/public/samples/counseling-sample.csv b/apps/web/public/samples/counseling-sample.csv new file mode 100644 index 0000000..6080e8d --- /dev/null +++ b/apps/web/public/samples/counseling-sample.csv @@ -0,0 +1,4 @@ +Contact ID,Last Name,First Name,Email,Mailing State/Province,Race,Ethnicity:,Gender,Disability,Veteran Status,Currently In Business?,Type of Session,Language(s) Used,Date,Name of Counselor,Duration (hours) +003XX000004TMM1,Doe,Jane,jane.doe@example.com,IA,White,Not Hispanic or Latino,Female,No,No,Yes,Face to Face,English,2025-01-14,Pat Smith,1.5 +003XX000004TMM2,Nguyen,Linh,linh.n@example.com,CA,Asian,Not Hispanic or Latino,Female,No,No,No,Telephone,English,2025-01-15,Pat Smith,0.75 +003XX000004TMM3,Garcia,Luis,luis.g@example.com,TX,White,Hispanic or Latino,Male,No,Veteran,Yes,Video,Spanish,2025-01-16,Maria Lopez,2.0 diff --git a/apps/web/public/samples/training-client-sample.csv b/apps/web/public/samples/training-client-sample.csv new file mode 100644 index 0000000..8ff35bd --- /dev/null +++ b/apps/web/public/samples/training-client-sample.csv @@ -0,0 +1,4 @@ +Class/Event ID,Contact ID,First Name,Last Name,Member Type,Member Status,Company,Phone,Email,Currently in Business?,Ethnicity,Race,Gender,Military Status,Training Topic,Class/Event Type,Funding Source,Start Date,Class/Event Name +EVT-001,003XX000004TMM1,Jane,Doe,Attendee,Registered,Acme LLC,555-0101,jane.doe@example.com,Yes,Not Hispanic or Latino,White,Female,Not Veteran,Business Start-up/Preplanning,In-Person,Federal,2025-02-03,Intro to Business Planning +EVT-001,003XX000004TMM2,Linh,Nguyen,Attendee,Registered,Nguyen Consulting,555-0102,linh.n@example.com,No,Not Hispanic or Latino,Asian,Female,Not Veteran,Business Start-up/Preplanning,In-Person,Federal,2025-02-03,Intro to Business Planning +EVT-001,003XX000004TMM3,Luis,Garcia,Attendee,Registered,Garcia Services,555-0103,luis.g@example.com,Yes,Hispanic or Latino,White,Male,Veteran,Business Start-up/Preplanning,In-Person,Federal,2025-02-03,Intro to Business Planning diff --git a/apps/web/public/samples/training-sample.csv b/apps/web/public/samples/training-sample.csv new file mode 100644 index 0000000..af73363 --- /dev/null +++ b/apps/web/public/samples/training-sample.csv @@ -0,0 +1,4 @@ +Class/Event ID,Class/Event Name,Start Date,Training Topic,Class/Event Type,Funding Source,Total Attendees,Female Attendees,Veteran Attendees +EVT-001,Intro to Business Planning,2025-02-03,Business Start-up/Preplanning,In-Person,Federal,24,13,3 +EVT-002,Marketing for Small Business,2025-02-10,Marketing/Sales,Online,Federal,42,22,5 +EVT-003,Financial Management Basics,2025-02-17,Financial Management,Hybrid,Federal,18,9,2 diff --git a/apps/web/railway.toml b/apps/web/railway.toml new file mode 100644 index 0000000..557f4fc --- /dev/null +++ b/apps/web/railway.toml @@ -0,0 +1,9 @@ +[build] +builder = "dockerfile" +dockerfilePath = "apps/web/Dockerfile" + +[deploy] +startCommand = "sh -c 'npx prisma db push --skip-generate && node server.js'" +healthcheckPath = "/" +restartPolicyType = "on_failure" +restartPolicyMaxRetries = 3 diff --git a/apps/web/scripts/check-ui-classes.mjs b/apps/web/scripts/check-ui-classes.mjs new file mode 100644 index 0000000..714e44a --- /dev/null +++ b/apps/web/scripts/check-ui-classes.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node +/** + * Regression guard for the UI primitive refactor (UX_REVIEW.md §8.1). + * + * Phase 6 extracted apps/web/src/components/ui/{button,alert,card, + * status-badge}.tsx so pages stop duplicating utility-class combos. + * Without enforcement, new code tends to drift back to raw Tailwind. + * + * This script walks the app source tree and fails the build if any + * file outside components/ui/ contains the known raw primitive + * patterns. It runs as part of `npm run lint` alongside tsc. + * + * To whitelist a site: use the matching + + Page {page} of {totalPages} + + +
+ )} + + ); +} diff --git a/apps/web/src/app/convert/[jobId]/mapping/page.tsx b/apps/web/src/app/convert/[jobId]/mapping/page.tsx new file mode 100644 index 0000000..a7d29f6 --- /dev/null +++ b/apps/web/src/app/convert/[jobId]/mapping/page.tsx @@ -0,0 +1,340 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import Link from "next/link"; +import type { PreviewResponse } from "@/types"; +import { useToast } from "@/components/toast"; +import { Skeleton, SkeletonTable } from "@/components/skeleton"; +import { Alert } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; + +/** + * Stable stringification of the mapping dict, used to detect dirty + * state so the Cancel button can confirm before discarding unsaved + * edits (UX_REVIEW.md §9.5). + */ +function mappingKey(m: Record): string { + return Object.keys(m) + .sort() + .map((k) => `${k}=${m[k]}`) + .join("|"); +} + +export default function MappingPage() { + const { jobId } = useParams<{ jobId: string }>(); + const router = useRouter(); + const toast = useToast(); + const [preview, setPreview] = useState(null); + const [mapping, setMapping] = useState>({}); + const [initialMappingKey, setInitialMappingKey] = useState(""); + const [saving, setSaving] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [loadError, setLoadError] = useState(""); + + const load = useCallback(async () => { + setLoading(true); + setLoadError(""); + try { + const [previewRes, jobRes] = await Promise.all([ + fetch(`/api/jobs/${jobId}/preview`), + fetch(`/api/jobs/${jobId}`), + ]); + if (!previewRes.ok || !jobRes.ok) { + throw new Error( + "We couldn't load the column mapping for this file. The server may be busy or the file may be malformed." + ); + } + const data = await previewRes.json(); + const job = await jobRes.json(); + setPreview(data); + + // Restore saved mapping if available; otherwise use suggestions + let nextMapping: Record; + if ( + job.columnMapping && + typeof job.columnMapping === "object" && + Object.keys(job.columnMapping).length > 0 + ) { + nextMapping = job.columnMapping as Record; + } else { + nextMapping = {}; + data.column_status.suggestions.forEach( + (s: { csv_column: string; suggested_match: string }) => { + nextMapping[s.csv_column] = s.suggested_match; + } + ); + } + setMapping(nextMapping); + setInitialMappingKey(mappingKey(nextMapping)); + } catch (err) { + setLoadError( + err instanceof Error ? err.message : "Failed to load mapping" + ); + } finally { + setLoading(false); + } + }, [jobId]); + + useEffect(() => { + load(); + }, [load]); + + async function handleSave() { + setSaving(true); + setError(""); + try { + const res = await fetch(`/api/jobs/${jobId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + columnMapping: mapping, + status: "mapping", + }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "Failed to save mapping"); + } + toast.success("Mapping saved"); + router.push(`/convert/${jobId}/preview`); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to save mapping" + ); + setSaving(false); + } + } + + if (loading) { + return ( +
+ + + +
+ ); + } + + if (loadError || !preview) { + return ( +
+ +

+ {loadError || "Failed to load mapping"} +

+
+ + + Back to preview + +
+
+
+ ); + } + + const { matched, missing, suggestions, field_requirements, field_descriptions } = + preview.column_status; + const allFields = [...matched, ...missing]; + + // Build lookup: expected field name -> { csv_column, score } + const suggestionByField: Record = {}; + suggestions.forEach((s) => { + suggestionByField[s.suggested_match] = { csv_column: s.csv_column, score: s.score }; + }); + + function applySuggestion(field: string, csvCol: string) { + const newMapping = { ...mapping }; + // Remove any existing mapping to this field + Object.entries(newMapping).forEach(([k, v]) => { + if (v === field) delete newMapping[k]; + }); + newMapping[csvCol] = field; + setMapping(newMapping); + } + + function applyAllSuggestions() { + const newMapping = { ...mapping }; + suggestions.forEach((s) => { + // Remove any existing mapping to this field + Object.entries(newMapping).forEach(([k, v]) => { + if (v === s.suggested_match) delete newMapping[k]; + }); + newMapping[s.csv_column] = s.suggested_match; + }); + setMapping(newMapping); + } + + return ( +
+

Column Mapping

+

+ Map your CSV columns to the expected XML field names. Only map + columns that need renaming — auto-matched columns stay put. +

+

+ Required fields must be mapped or present in your CSV. Conditional + fields are only needed when their rule triggers — hover the badge + or read the rule below each field. +

+ + {suggestions.length > 0 && ( +
+ + + Click to map all suggested matches at once + +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+ + + + + + + + + {allFields.map((field) => { + const isMatched = matched.includes(field); + const currentCsvCol = Object.entries(mapping).find( + ([, v]) => v === field + )?.[0] || (isMatched ? field : ""); + const suggestion = suggestionByField[field]; + const isApplied = suggestion && currentCsvCol === suggestion.csv_column; + const req = field_requirements?.[field]; + const meta = field_descriptions?.[field]; + const conditionalTitle = meta?.conditional_rule; + + return ( + + + + + ); + })} + +
+ Expected XML Field + + Map From CSV Column +
+
+ {field} + {req === "required" && ( + + Required + + )} + {req === "conditional" && ( + + Conditional + + )} + {req === "optional" && ( + + Optional + + )} + {isMatched && ( + + Auto-matched + + )} +
+ {meta?.description && ( +

+ {meta.description} +

+ )} + {req === "conditional" && meta?.conditional_rule && ( +

+ When required:{" "} + {meta.conditional_rule} +

+ )} +
+
+ + {suggestion && ( + isApplied ? ( + + ✓ {suggestion.csv_column} ({suggestion.score}%) + + ) : ( + + ) + )} +
+
+
+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/app/convert/[jobId]/preview/page.tsx b/apps/web/src/app/convert/[jobId]/preview/page.tsx new file mode 100644 index 0000000..553afe3 --- /dev/null +++ b/apps/web/src/app/convert/[jobId]/preview/page.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import Link from "next/link"; +import type { PreviewResponse } from "@/types"; +import { useToast } from "@/components/toast"; +import { Skeleton, SkeletonTable } from "@/components/skeleton"; +import { StatusIcon } from "@/components/status-icon"; +import { Button } from "@/components/ui/button"; +import { Alert } from "@/components/ui/alert"; + +export default function PreviewPage() { + const { jobId } = useParams<{ jobId: string }>(); + const router = useRouter(); + const toast = useToast(); + const [preview, setPreview] = useState(null); + const [loading, setLoading] = useState(true); + const [converting, setConverting] = useState(false); + const [loadError, setLoadError] = useState(""); + + const loadPreview = useCallback(async () => { + setLoading(true); + setLoadError(""); + try { + // Call worker preview via our API proxy + const res = await fetch(`/api/jobs/${jobId}/preview`); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error( + data.error || + "We couldn't load a preview for this file. The server may be busy or the file may be malformed." + ); + } + const data = await res.json(); + setPreview(data); + } catch (err) { + setLoadError( + err instanceof Error ? err.message : "Failed to load preview" + ); + } finally { + setLoading(false); + } + }, [jobId]); + + useEffect(() => { + loadPreview(); + }, [loadPreview]); + + async function handleConvert() { + setConverting(true); + try { + const res = await fetch(`/api/jobs/${jobId}/start`, { method: "POST" }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "Couldn't start the conversion."); + } + router.push(`/convert/${jobId}/progress`); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Couldn't start the conversion." + ); + setConverting(false); + } + } + + if (loading) { + return ( +
+ + +
+ + + +
+ +
+ ); + } + + if (loadError || !preview) { + return ( +
+ +

{loadError || "Failed to load preview"}

+
+ + + Back to upload + +
+
+
+ ); + } + + const { column_status } = preview; + const hasMissing = column_status.missing.length > 0; + + return ( +
+

CSV Preview

+

+ Showing {preview.rows.length} of {preview.total_rows} rows +

+ + {/* Column Status */} +
+
+

+ + Matched: {column_status.matched.length} +

+
+
+

+ + Missing: {column_status.missing.length} +

+
+
+

+ + Extra: {column_status.extra.length} +

+

+ Extra columns are ignored during conversion. This is fine. +

+
+
+ + {/* Fuzzy Match Suggestions */} + {column_status.suggestions.length > 0 && ( +
+

+ Column mapping suggestions: +

+ {column_status.suggestions.map((s) => ( +

+ "{s.csv_column}" looks like "{s.suggested_match}" ({s.score}% match) +

+ ))} + + Map columns manually + +
+ )} + + {/* Data Table */} +
+ + + + + {preview.headers.map((h) => ( + + ))} + + + + {preview.rows.map((row, rowIndex) => ( + + + {preview.headers.map((h) => ( + + ))} + + ))} + +
# + {h} +
{rowIndex + 1} + {row[h] || ""} +
+
+ + {/* Actions */} +
+ {hasMissing && ( + + Map Columns + + )} + + + Cancel + +
+
+ ); +} diff --git a/apps/web/src/app/convert/[jobId]/progress/page.tsx b/apps/web/src/app/convert/[jobId]/progress/page.tsx new file mode 100644 index 0000000..f4daac0 --- /dev/null +++ b/apps/web/src/app/convert/[jobId]/progress/page.tsx @@ -0,0 +1,293 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { Alert } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; + +const POLL_FAILURE_THRESHOLD = 3; + +function formatElapsed(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const mins = Math.floor(seconds / 60); + const rem = seconds % 60; + return `${mins}m ${rem.toString().padStart(2, "0")}s`; +} + +function formatEta(seconds: number): string { + if (!Number.isFinite(seconds) || seconds <= 0) return ""; + if (seconds < 10) return "a few seconds"; + if (seconds < 60) return `about ${Math.round(seconds / 5) * 5} seconds`; + const mins = Math.round(seconds / 60); + if (mins === 1) return "about a minute"; + return `about ${mins} minutes`; +} + +export default function ProgressPage() { + const { jobId } = useParams<{ jobId: string }>(); + const router = useRouter(); + const [status, setStatus] = useState("converting"); + const [processed, setProcessed] = useState(0); + const [total, setTotal] = useState(0); + const [errors, setErrors] = useState(0); + const [warnings, setWarnings] = useState(0); + const [cancelling, setCancelling] = useState(false); + const [cancelError, setCancelError] = useState(""); + const [elapsedMs, setElapsedMs] = useState(0); + const [consecutiveFailures, setConsecutiveFailures] = useState(0); + const [pollRetryNonce, setPollRetryNonce] = useState(0); + const startedAtRef = useRef(Date.now()); + + // Elapsed-time ticker. Independent of polling so it keeps counting + // even when the network is flaky. + useEffect(() => { + const t = setInterval(() => { + setElapsedMs(Date.now() - startedAtRef.current); + }, 1000); + return () => clearInterval(t); + }, []); + + useEffect(() => { + let pollInterval = 1000; + let timeoutId: ReturnType; + let cancelled = false; + const startTime = Date.now(); + const MAX_WAIT_MS = 5 * 60 * 1000; // 5 minutes + + async function poll() { + if (cancelled) return; + try { + const res = await fetch(`/api/jobs/${jobId}`); + if (!res.ok) throw new Error(`status ${res.status}`); + const job = await res.json(); + + setStatus(job.status); + setProcessed(job.processedRows || 0); + setTotal(job.totalRows || 0); + setConsecutiveFailures(0); + + if (job.summary) { + const s = job.summary as Record; + setErrors(s.errors || 0); + setWarnings(s.warnings || 0); + } + + if (job.status === "complete" || job.status === "error") { + router.push(`/convert/${jobId}/results`); + return; + } + + if (job.status === "cancelled") { + // Give the user a beat to see the status flip, then send them + // back to the dashboard where the job appears as cancelled. + setTimeout(() => router.push("/dashboard"), 800); + return; + } + + if (Date.now() - startTime > MAX_WAIT_MS) { + setStatus("timeout"); + return; + } + } catch { + // Track consecutive poll failures so we can show a banner after + // a few in a row (§4.5). Transient single failures are still + // silently retried. + setConsecutiveFailures((n) => n + 1); + } + + // Gradually back off: 1s -> 2s -> 5s + if (pollInterval < 2000) pollInterval = 2000; + else if (pollInterval < 5000) pollInterval = 5000; + + if (!cancelled) { + timeoutId = setTimeout(poll, pollInterval); + } + } + + timeoutId = setTimeout(poll, pollInterval); + return () => { + cancelled = true; + clearTimeout(timeoutId); + }; + }, [jobId, router, pollRetryNonce]); + + function retryPolling() { + setConsecutiveFailures(0); + setPollRetryNonce((n) => n + 1); + } + + async function handleCancel() { + if (cancelling) return; + setCancelling(true); + setCancelError(""); + try { + const res = await fetch(`/api/jobs/${jobId}/cancel`, { method: "POST" }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "Failed to cancel conversion"); + } + // The next poll tick will pick up the "cancelled" status and + // route us to the dashboard. Optimistically reflect the state + // here so the UI feels instant. + setStatus("cancelled"); + } catch (err) { + setCancelError( + err instanceof Error ? err.message : "Failed to cancel conversion" + ); + setCancelling(false); + } + } + + const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; + const isConverting = status === "converting"; + const isCancelled = status === "cancelled"; + const isTimedOut = status === "timeout"; + const showPollFailureBanner = + isConverting && consecutiveFailures >= POLL_FAILURE_THRESHOLD; + + let heading: string; + if (isTimedOut) heading = "Conversion Timed Out"; + else if (isCancelled) heading = "Conversion Cancelled"; + else heading = "Converting..."; + + // Rate-based ETA. We start computing it once at least 5% of the + // work is done, so the first noisy ticks don't produce wild + // estimates. The rate is rows/ms; remaining is total-processed. + let etaText = ""; + if (isConverting && total > 0 && processed > 0 && elapsedMs > 2000) { + const fractionDone = processed / total; + if (fractionDone >= 0.05 && fractionDone < 1) { + const rate = processed / elapsedMs; // rows per ms + const remainingRows = total - processed; + const remainingMs = remainingRows / rate; + etaText = formatEta(remainingMs / 1000); + } + } + + let subtitle: string; + if (isTimedOut) { + subtitle = + "The conversion is taking longer than expected. Please check back later or try again."; + } else if (isCancelled) { + subtitle = "Taking you back to the dashboard…"; + } else if (isConverting) { + const elapsed = `Running for ${formatElapsed(elapsedMs)}`; + if (total > 0) { + const rowSummary = `${processed.toLocaleString()} of ${total.toLocaleString()} records`; + subtitle = etaText + ? `${rowSummary} · ${elapsed} · ${etaText} remaining` + : `${rowSummary} · ${elapsed}`; + } else { + subtitle = elapsed; + } + } else { + subtitle = status; + } + + return ( +
+
+

{heading}

+

{subtitle}

+
+ + {/* Poll-failure banner (§4.5): show after consecutive failures */} + {showPollFailureBanner && ( +
+ +
+ + We're having trouble checking the conversion status. + Your file may still be processing in the background. + +
+ + +
+
+
+
+ )} + + {/* Progress Bar */} +
+
+
+ +
{percentage}%
+ + {/* Live Counters */} +
+
+

Processed

+

{processed}

+
+
+

Errors

+

{errors}

+
+
+

Warnings

+

{warnings}

+
+
+ + {cancelError && ( +
+ {cancelError} +
+ )} + + {/* Action buttons */} +
+ {isConverting && ( + + )} + {isTimedOut && ( + <> + + + + )} +
+
+ ); +} diff --git a/apps/web/src/app/convert/[jobId]/results/page.tsx b/apps/web/src/app/convert/[jobId]/results/page.tsx new file mode 100644 index 0000000..f862ef9 --- /dev/null +++ b/apps/web/src/app/convert/[jobId]/results/page.tsx @@ -0,0 +1,589 @@ +import { prisma } from "@/lib/prisma"; +import { auth } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { StatusIcon, type StatusKind } from "@/components/status-icon"; + +interface ValidationIssue { + record_id: string; + severity: string; + category: string; + field_name: string; + message: string; +} + +interface CleaningDiffEntry { + row: number; + record_id: string; + field: string; + original: string; + cleaned: string; + cleaning_type: string; +} + +type ComparisonView = "resolved" | "new" | "persistent"; + +const COMPARISON_VIEWS: readonly ComparisonView[] = [ + "resolved", + "new", + "persistent", +] as const; + +function isComparisonView(v: string | undefined): v is ComparisonView { + return (COMPARISON_VIEWS as readonly string[]).includes(v ?? ""); +} + +export default async function ResultsPage({ + params, + searchParams, +}: Readonly<{ + params: Promise<{ jobId: string }>; + searchParams: Promise<{ + tab?: string; + filter?: string; + showAll?: string; + compare?: string; + }>; +}>) { + const session = await auth(); + if (!session?.user?.id) redirect("/login"); + + const { jobId } = await params; + const { tab, filter, showAll, compare } = await searchParams; + const compareView: ComparisonView = isComparisonView(compare) + ? compare + : "new"; + + const job = await prisma.job.findFirst({ + where: { id: jobId, userId: session.user.id }, + }); + + if (!job) redirect("/dashboard"); + + if (job.status === "converting") { + redirect(`/convert/${jobId}/progress`); + } + + // Cancelled jobs have no results to show. Send the user back to the + // dashboard where the cancelled status badge makes it obvious what + // happened. + if (job.status === "cancelled") { + redirect("/dashboard"); + } + + const summary = job.summary as unknown as Record | null; + const issues = (job.issues as unknown as ValidationIssue[]) || []; + const xsdErrors = (job.xsdErrors as unknown as string[]) || []; + const cleaningDiffs = + (job.cleaningDiffs as unknown as CleaningDiffEntry[]) || []; + const errors = issues.filter((i) => i.severity === "error"); + const warnings = issues.filter((i) => i.severity === "warning"); + + // Load previous job for comparison if this is a re-upload + let comparison: { + resolved: ValidationIssue[]; + newIssues: ValidationIssue[]; + persistent: ValidationIssue[]; + } | null = null; + + if (job.previousJobId) { + const previousJob = await prisma.job.findFirst({ + where: { id: job.previousJobId, userId: session.user.id }, + }); + if (previousJob) { + const prevIssues = + (previousJob.issues as unknown as ValidationIssue[]) || []; + comparison = computeComparison(prevIssues, issues); + } + } + + // Cleaning diff tab + if (tab === "diff" && cleaningDiffs.length > 0) { + return ( +
+
+
+

Cleaning Diff

+

{job.inputFileName}

+
+ + Back to Results + +
+ + +
+ ); + } + + return ( +
+
+
+

Conversion Results

+

{job.inputFileName}

+
+
+ {job.outputFilePath && ( + + Download XML + + )} + + Re-upload + +
+
+ + {/* Summary Cards */} + {summary && ( +
+ + + + +
+ )} + + {/* Comparison drilldown */} + {comparison && ( + + )} + + {/* XSD Validation */} +
+

XSD Validation

+ {job.xsdValid === null && ( +

+ + Not validated +

+ )} + {job.xsdValid === true && ( +

+ + XML is valid against the XSD schema +

+ )} + {job.xsdValid === false && ( +
+

+ + XML failed XSD validation ({xsdErrors.length} errors) +

+
    + {xsdErrors.map((err) => ( +
  • + {err} +
  • + ))} +
+
+ )} +
+ + {/* Cleaning Diff Link */} + {cleaningDiffs.length > 0 && ( +
+

+ Data cleaning made {cleaningDiffs.length} changes.{" "} + + View cleaning diff + +

+
+ )} + + {/* Validation Issues */} + {errors.length > 0 && ( +
+

+ Errors ({errors.length}) +

+ +
+ )} + + {warnings.length > 0 && ( +
+

+ Warnings ({warnings.length}) +

+ +
+ )} + + {issues.length === 0 && ( +

No validation issues found.

+ )} +
+ ); +} + +function computeComparison( + prevIssues: ValidationIssue[], + currIssues: ValidationIssue[] +) { + const key = (i: ValidationIssue) => + `${i.record_id}|${i.field_name}|${i.category}`; + + const prevSet = new Set(prevIssues.map(key)); + const currSet = new Set(currIssues.map(key)); + + return { + resolved: prevIssues.filter((i) => !currSet.has(key(i))), + newIssues: currIssues.filter((i) => !prevSet.has(key(i))), + persistent: currIssues.filter((i) => prevSet.has(key(i))), + }; +} + +function ComparisonDrilldown({ + comparison, + active, + jobId, + showAll, +}: Readonly<{ + comparison: { + resolved: ValidationIssue[]; + newIssues: ValidationIssue[]; + persistent: ValidationIssue[]; + }; + active: ComparisonView; + jobId: string; + showAll: boolean; +}>) { + const tabs: ReadonlyArray<{ + view: ComparisonView; + label: string; + count: number; + kind: StatusKind; + color: string; + emptyMessage: string; + }> = [ + { + view: "resolved", + label: "Resolved", + count: comparison.resolved.length, + kind: "success", + color: "text-green-700", + emptyMessage: "No issues were resolved by this re-upload.", + }, + { + view: "new", + label: "New", + count: comparison.newIssues.length, + kind: "error", + color: "text-red-700", + emptyMessage: "No new issues were introduced — nice.", + }, + { + view: "persistent", + label: "Persistent", + count: comparison.persistent.length, + kind: "warning", + color: "text-yellow-700", + emptyMessage: "No issues carried over from the previous upload.", + }, + ]; + + const activeIssues = + active === "resolved" + ? comparison.resolved + : active === "new" + ? comparison.newIssues + : comparison.persistent; + + return ( +
+

+ Re-upload comparison +

+
+ {tabs.map(({ view, label, count, kind, color }) => { + const isActive = active === view; + return ( + +

+ + {label} +

+

{count}

+ + ); + })} +
+ + {activeIssues.length > 0 ? ( + + ) : ( +

+ {tabs.find((t) => t.view === active)?.emptyMessage} +

+ )} +
+ ); +} + +function SummaryCard({ + label, + value, + color, + kind, +}: Readonly<{ + label: string; + value: number; + color?: string; + kind?: StatusKind; +}>) { + const colors: Record = { + green: "text-green-700", + red: "text-red-700", + yellow: "text-yellow-700", + }; + + return ( +
+

+ {kind && ( + + + + )} + {label} +

+

+ {value} +

+
+ ); +} + +function IssueTable({ + issues, + showAll, + jobId, +}: Readonly<{ + issues: ValidationIssue[]; + showAll: boolean; + jobId: string; +}>) { + const DISPLAY_LIMIT = 100; + const displayed = showAll ? issues : issues.slice(0, DISPLAY_LIMIT); + + return ( +
+ + + + + + + + + + + {displayed.map((issue) => ( + + + + + + + ))} + +
RecordCategoryFieldMessage
{issue.record_id}{issue.category}{issue.field_name}{issue.message}
+ {!showAll && issues.length > DISPLAY_LIMIT && ( +
+

+ Showing {DISPLAY_LIMIT} of {issues.length} issues +

+ + Show all + +
+ )} +
+ ); +} + +function CleaningDiffView({ + diffs, + filter, + showAll, + jobId, +}: Readonly<{ + diffs: CleaningDiffEntry[]; + filter?: string; + showAll: boolean; + jobId: string; +}>) { + // Group by cleaning type for summary + const typeCounts: Record = {}; + for (const d of diffs) { + typeCounts[d.cleaning_type] = (typeCounts[d.cleaning_type] || 0) + 1; + } + const types = Object.keys(typeCounts).sort((a, b) => a.localeCompare(b)); + + const LABELS: Record = { + format_date: "dates standardized", + clean_phone: "phones cleaned", + standardize_state: "states expanded", + standardize_country: "countries standardized", + map_gender: "gender values mapped", + clean_percentage: "percentages cleaned", + clean_numeric: "numeric values cleaned", + }; + + const summaryParts = types.map( + (t) => `${typeCounts[t]} ${LABELS[t] || t}` + ); + + const filtered = filter ? diffs.filter((d) => d.cleaning_type === filter) : diffs; + const displayLimit = 200; + const displayed = showAll ? filtered : filtered.slice(0, displayLimit); + + return ( + <> +
+

{summaryParts.join(", ")}

+
+ + {/* Filter */} +
+ Filter by type: +
+ + All ({diffs.length}) + + {types.map((t) => ( + + {t} ({typeCounts[t]}) + + ))} +
+
+ + {/* Table */} +
+ + + + + + + + + + + + + {displayed.map((d) => ( + + + + + + + + + ))} + +
RowRecord IDFieldOriginalCleanedType
{d.row}{d.record_id}{d.field} + + Original value: + {d.original} + + + Cleaned value: + {d.cleaned} + {d.cleaning_type}
+ {showAll === false && filtered.length > displayLimit && ( +
+

+ Showing {displayLimit} of {filtered.length} changes +

+ + Show all + +
+ )} +
+ + ); +} diff --git a/apps/web/src/app/convert/[jobId]/reupload/page.tsx b/apps/web/src/app/convert/[jobId]/reupload/page.tsx new file mode 100644 index 0000000..691c737 --- /dev/null +++ b/apps/web/src/app/convert/[jobId]/reupload/page.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { converterTypeLabel } from "@/lib/converter-types"; +import { useToast } from "@/components/toast"; +import { uploadErrorMessage } from "@/lib/upload-errors"; +import { Skeleton } from "@/components/skeleton"; +import { Alert } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; + +export default function ReuploadPage() { + const { jobId } = useParams<{ jobId: string }>(); + const router = useRouter(); + const toast = useToast(); + + const [converterType, setConverterType] = useState(""); + const [fileName, setFileName] = useState(""); + const [file, setFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch(`/api/jobs/${jobId}`) + .then((r) => r.json()) + .then((data) => { + setConverterType(data.converterType || ""); + setFileName(data.inputFileName || ""); + setLoading(false); + }) + .catch(() => { + setError("Failed to load job details"); + setLoading(false); + }); + }, [jobId]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!file) return; + + setUploading(true); + setError(""); + + const formData = new FormData(); + formData.append("file", file); + formData.append("converterType", converterType); + formData.append("previousJobId", jobId); + + try { + const res = await fetch("/api/upload", { method: "POST", body: formData }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(uploadErrorMessage(res.status, data.error)); + setUploading(false); + return; + } + const data = await res.json(); + toast.success("Re-upload received — loading preview"); + router.push(`/convert/${data.jobId}/preview`); + } catch { + setError( + "Couldn't reach the server. Check your internet connection and try again." + ); + setUploading(false); + } + }; + + if (loading) { + return ( +
+ + + + +
+ ); + } + + return ( +
+

Re-upload Fixed CSV

+

+ Upload a corrected version of {fileName} to compare + against the previous conversion. +

+

+ Your previous conversion is kept as a separate job. The two are + compared side-by-side on the next screen. +

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + Converter Type + +

+ {converterTypeLabel(converterType)} +

+
+ +
+ + setFile(e.target.files?.[0] || null)} + className="block w-full text-sm border rounded p-2" + /> +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/app/convert/layout.tsx b/apps/web/src/app/convert/layout.tsx new file mode 100644 index 0000000..30edc3e --- /dev/null +++ b/apps/web/src/app/convert/layout.tsx @@ -0,0 +1,22 @@ +import { StepIndicator } from "@/components/ui/step-indicator"; + +/** + * Layout for the /convert flow. + * + * Mounted here so the StepIndicator wraps both the plain /convert + * upload page and the /convert/[jobId]/* sub-pages (Preview, Map, + * Progress, Results, Reupload). The indicator is a client component + * that derives the active step from usePathname(). + * + * See UX_REVIEW.md §1.2. + */ +export default function ConvertLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return ( + <> + + {children} + + ); +} diff --git a/apps/web/src/app/convert/page.tsx b/apps/web/src/app/convert/page.tsx new file mode 100644 index 0000000..0ac56c6 --- /dev/null +++ b/apps/web/src/app/convert/page.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useState, useEffect, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useToast } from "@/components/toast"; +import { uploadErrorMessage } from "@/lib/upload-errors"; +import { Skeleton } from "@/components/skeleton"; +import { CONVERTER_TYPES } from "@/lib/converter-types"; +import { Alert } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; + +const MAX_FILE_BYTES = 50 * 1024 * 1024; // 50MB, mirrors /api/upload + +function isCsvFile(f: File): boolean { + return f.name.toLowerCase().endsWith(".csv"); +} + +function ConvertForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const toast = useToast(); + const previousJobId = searchParams.get("previousJobId"); + + const [converterType, setConverterType] = useState("counseling"); + const [file, setFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(""); + + function acceptFile(candidate: File | undefined | null) { + if (!candidate) return; + if (!isCsvFile(candidate)) { + toast.error( + "That doesn't look like a CSV. We accept .csv files only." + ); + return; + } + if (candidate.size > MAX_FILE_BYTES) { + toast.error( + "That file is larger than 50MB. Split it into smaller batches and try again." + ); + return; + } + setFile(candidate); + setError(""); + } + + useEffect(() => { + if (previousJobId) { + fetch(`/api/jobs/${previousJobId}`) + .then((r) => r.json()) + .then((job) => { + if (job.converterType) setConverterType(job.converterType); + }) + .catch(() => {}); + } + }, [previousJobId]); + + async function handleUpload(e: React.FormEvent) { + e.preventDefault(); + if (!file) return; + + setUploading(true); + setError(""); + + const formData = new FormData(); + formData.append("file", file); + formData.append("converterType", converterType); + if (previousJobId) { + formData.append("previousJobId", previousJobId); + } + + try { + const res = await fetch("/api/upload", { + method: "POST", + body: formData, + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(uploadErrorMessage(res.status, data.error)); + return; + } + + const { jobId } = await res.json(); + toast.success("File uploaded — loading preview"); + router.push(`/convert/${jobId}/preview`); + } catch { + setError( + "Couldn't reach the server. Check your internet connection and try again." + ); + } finally { + setUploading(false); + } + } + + return ( +
+

New Conversion

+ +
+ {error && {error}} + +
+ + Converter Type + +
+ {CONVERTER_TYPES.map(({ value, label, description, sample }) => { + const isSelected = converterType === value; + return ( + + ); + })} +
+
+ +
+ + CSV File + +

+ .csv files only, up to 50MB. +

+ +
+ + + +

+ Your CSV is stored in your account and is visible only to you. + Files are retained per SBA policy. Data is processed on SBA + infrastructure; nothing is sent to third-party services. +

+
+
+ ); +} + +export default function ConvertPage() { + return ( + + + + + + + + } + > + + + ); +} diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx new file mode 100644 index 0000000..7a8b9fd --- /dev/null +++ b/apps/web/src/app/dashboard/page.tsx @@ -0,0 +1,194 @@ +import Link from "next/link"; +import { prisma } from "@/lib/prisma"; +import { auth } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { StatusIcon } from "@/components/status-icon"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { buttonClasses } from "@/components/ui/button-classes"; + +const PAGE_SIZE = 20; + +export default async function DashboardPage({ + searchParams, +}: { + searchParams: Promise<{ page?: string }>; +}) { + const session = await auth(); + if (!session?.user?.id) redirect("/login"); + + const { page: pageParam } = await searchParams; + const page = Math.max(1, parseInt(pageParam || "1", 10) || 1); + const skip = (page - 1) * PAGE_SIZE; + + const [jobs, totalCount] = await Promise.all([ + prisma.job.findMany({ + where: { userId: session.user.id }, + orderBy: { createdAt: "desc" }, + skip, + take: PAGE_SIZE, + }), + prisma.job.count({ where: { userId: session.user.id } }), + ]); + + const totalPages = Math.ceil(totalCount / PAGE_SIZE); + + return ( +
+
+

Dashboard

+ + New Conversion + +
+ + {jobs.length === 0 && page === 1 ? ( +
+

No conversions yet

+

+ Start your first conversion by uploading a CSV export. + Not sure what to upload? Grab a sample below. +

+ + Start a new conversion + +
+ + + +
+
+ ) : ( + <> +
+ + + + + + + + + + + + + + {jobs.map((job) => { + const summary = job.summary as unknown as Record | null; + return ( + + + + + + + + + + ); + })} + +
FileTypeStatusRecordsXSDDateActions
+ {job.inputFileName} + {job.converterType} + + + {summary + ? `${summary.successful}/${summary.total}` + : "-"} + + + + {new Date(job.createdAt).toLocaleDateString()} + + + View + +
+
+ + {totalPages > 1 && ( +
+

+ Showing {skip + 1}-{Math.min(skip + PAGE_SIZE, totalCount)} of {totalCount} +

+
+ {page > 1 && ( + + Previous + + )} + {page < totalPages && ( + + Next + + )} +
+
+ )} + + )} +
+ ); +} + +function SampleLink({ + href, + label, + description, +}: Readonly<{ href: string; label: string; description: string }>) { + return ( + +

+ {label} +

+

{description}

+
+ ); +} + +function XsdStatus({ xsdValid }: Readonly<{ xsdValid: boolean | null }>) { + if (xsdValid === null) return ; + if (xsdValid) + return ( + + + Valid + + ); + return ( + + + Invalid + + ); +} + diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css new file mode 100644 index 0000000..ca5599d --- /dev/null +++ b/apps/web/src/app/globals.css @@ -0,0 +1,15 @@ +@import "tailwindcss"; + +/* + * Global focus ring. Keyboard users get a visible blue outline on any + * focused interactive element; mouse users do not (thanks to + * :focus-visible). Added in UX Phase 2 — see UX_IMPLEMENTATION_PLAN.md §6.4. + */ +@layer base { + :focus-visible { + outline: 2px solid #3b82f6; /* blue-500 */ + outline-offset: 2px; + border-radius: 2px; + } +} + diff --git a/apps/web/src/app/help/page.tsx b/apps/web/src/app/help/page.tsx new file mode 100644 index 0000000..f9ff6d6 --- /dev/null +++ b/apps/web/src/app/help/page.tsx @@ -0,0 +1,351 @@ +import Link from "next/link"; +import { CONVERTER_TYPES } from "@/lib/converter-types"; + +/** + * Help page. + * + * Resolves UX_REVIEW.md §1.3. The app had no in-product help + * surface — partners with questions had nowhere to go inside the + * web UI, and the repo README only documents the Python CLI. + * + * This page is intentionally copy-heavy and self-contained. Each + * section has a stable id so the mapping page, results page, and + * future contextual "?" buttons can deep-link into the right spot. + */ +export default function HelpPage() { + return ( +
+

Help

+

+ How to use the SBA CSV to XML converter, common errors, and + who to contact if something goes wrong. +

+ + + +
+

+ The SBA CSV to XML converter takes a counseling or training + CSV export from Salesforce and produces an XSD-compliant XML + file ready for submission to the SBA. Along the way it: +

+
    +
  • + Previews the CSV so you can confirm the data before the + conversion runs. +
  • +
  • + Detects mismatched column names and suggests matches so you + can fix them without editing the CSV. +
  • +
  • + Cleans dates, phone numbers, state/country names, and other + common issues automatically. +
  • +
  • + Validates the generated XML against the SBA schema and + surfaces any remaining problems. +
  • +
  • + Tracks every conversion in your account history so you can + compare before/after when you re-upload a fixed file. +
  • +
+
+ +
+

+ Pick the converter that matches your data. Each one expects a + different CSV shape; picking the wrong one will result in + every column being reported as "missing" on the + preview page. +

+ +
+ +
+
    +
  1. + Sign in and click New Conversion from the + dashboard. +
  2. +
  3. Pick a converter type.
  4. +
  5. + Drag and drop your CSV into the upload area, or click it to + browse. Files must be .csv and up to 50MB. +
  6. +
  7. + Click Upload & Preview. The CSV is + uploaded, parsed, and you're sent to the preview + screen. +
  8. +
+
+ +
+

+ The mapping page appears when your CSV has columns with + different names than the SBA schema expects. It shows every + expected field, its requirement level, and lets you point it + at one of your CSV columns. +

+

Badge meanings:

+
    +
  • + + Required + + Must be present in the CSV (or mapped from another column) + for the conversion to produce valid XML. +
  • +
  • + + Conditional + + Only required when a related field has a certain value. + Each conditional field shows the exact rule ("When + required: Required when Veteran Status indicates military + service"). +
  • +
  • + + Optional + + Nice to have but not needed for validation. +
  • +
  • + + Auto-matched + + Already found in your CSV with the expected name — no + action needed. +
  • +
+

+ Use Apply All Suggestions to accept every + fuzzy-matched column in one click, then Save Mapping + & Continue. +

+
+ +
+

The results page has four sections:

+
    +
  • + Summary cards — Total records, Successful, + Errors, Warnings. Each has an icon matching the color. +
  • +
  • + XSD Validation — Whether the generated + XML passed schema validation. Errors are listed inline. +
  • +
  • + Cleaning diff — A count of the + automatic fixes the tool made (dates standardized, phones + cleaned, states expanded, etc.). Click the link to see + each change, filtered by type. +
  • +
  • + Errors & Warnings tables — Per-record + issues with the field name, category, and a description + of what went wrong. +
  • +
+

+ Click Download XML to grab the generated + file, or Re-upload to upload a fixed CSV + and compare it against the current conversion. +

+
+ +
+

+ While a conversion is running, the progress page shows a + Cancel conversion button. Clicking it + immediately flips the job to cancelled state and returns + you to the dashboard. The worker stops at its next + checkpoint; any partial output is discarded. +

+
+ +
+
+
+
+ "This file is larger than 50MB" +
+
+ The upload is capped at 50MB. Split the CSV into smaller + batches, or remove unused columns with Excel or + Salesforce's export filters. +
+
+
+
+ "That file isn't a CSV" +
+
+ The file extension must be .csv. Export + from Excel via{" "} + File → Save As → CSV (Comma delimited). Don't + upload .xlsx. +
+
+
+
+ "You've uploaded several files in a short + window" +
+
+ There's a rate limit of 10 uploads per minute per + user to prevent abuse. Wait about a minute and try + again. +
+
+
+
Preview fails to load
+
+ The CSV may be malformed. Open it in a text editor or + Excel and confirm the first row has column headers and + every line has the same number of commas. Click{" "} + Try again if the server may have been + busy. +
+
+
+
+ XSD validation fails after a successful conversion +
+
+ The cleaning pass couldn't automatically fix + everything. Open the errors list on the results page, + correct the rows in your CSV, and use{" "} + Re-upload to compare the two. +
+
+
+
+ +
+

+ If you hit a wall, the fastest paths are: +

+ +
+ +
+ + Back to dashboard + +
+
+ ); +} + +function TableOfContents({ + sections, +}: Readonly<{ sections: Array<{ id: string; label: string }> }>) { + return ( + + ); +} + +function Section({ + id, + title, + children, +}: Readonly<{ id: string; title: string; children: React.ReactNode }>) { + return ( +
+

+ + {title} + +

+
+ {children} +
+
+ ); +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx new file mode 100644 index 0000000..1025d30 --- /dev/null +++ b/apps/web/src/app/layout.tsx @@ -0,0 +1,27 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import { Providers } from "@/components/providers"; +import { Nav } from "@/components/nav"; +import { ErrorBoundary } from "@/components/error-boundary"; + +export const metadata: Metadata = { + title: "SBA CSV to XML Converter", + description: "Convert SBA counseling and training CSV files to XML format", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + +
\n" + return html_content + + def _generate_issues_table(self) -> str: + """Generate the detailed issues table.""" + if not self.issues: + return "" + + html_content = """

Detailed Issues

@@ -239,14 +223,13 @@ def generate_html_report(self, output_dir="."): """ - - # Sort issues by severity (errors first) and then by record ID - sorted_issues = sorted(self.issues, key=lambda x: (0 if x['severity'] == 'error' else 1, x['record_id'])) - - for issue in sorted_issues: - severity_class = "error" if issue['severity'] == 'error' else "warning" - html_content += f""" - + + # Sort issues by severity (errors first) and then by record ID + sorted_issues = sorted(self.issues, key=lambda x: (0 if x['severity'] == 'error' else 1, x['record_id'])) + + for issue in sorted_issues: + severity_class = "error" if issue['severity'] == 'error' else "warning" + html_content += f""" @@ -254,21 +237,44 @@ def generate_html_report(self, output_dir="."): """ + + html_content += "
Message
{issue['record_id']} {issue['severity'].upper()} {issue['category']}{issue['message']}
\n" + return html_content + + def generate_html_report(self, output_dir: str = ".") -> str: + """ + Generate an HTML report of validation issues. + + Args: + output_dir: Directory to save the HTML report - html_content += """ - -""" + Returns: + Path to the created HTML file + """ + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + html_file = os.path.join(output_dir, f"validation_report_{timestamp}.html") + + summary = self.get_summary() + + # Assemble HTML content + html_content = self._generate_html_header() + html_content += self._generate_summary_section(summary) + html_content += self._generate_category_table("Errors by Category", summary['errors_by_category']) + html_content += self._generate_category_table("Warnings by Category", summary['warnings_by_category']) + html_content += self._generate_issues_table() - html_content += """ - + html_content += """ """ # Write HTML content to file - with open(html_file, 'w') as f: - f.write(html_content) - - return html_file + try: + with open(html_file, 'w') as f: + f.write(html_content) + except OSError as e: + raise OSError(f"Failed to write HTML report to {html_file}: {e}") from e -# Create a default validator instance -validator = ValidationTracker() + return html_file diff --git a/src/xml_utils.py b/src/xml_utils.py index 1f05e92..fe1d03f 100644 --- a/src/xml_utils.py +++ b/src/xml_utils.py @@ -1,24 +1,14 @@ import xml.etree.ElementTree as ET + def create_element(parent: ET.Element, element_name: str, element_text: str = None) -> ET.Element: """ Creates a new sub-element under the parent, sets its text if provided, and returns the new sub-element. + + Note: xml.etree.ElementTree automatically escapes special characters (&, <, >, etc.) + when writing text content via the .text property, so manual escaping is not needed. """ element = ET.SubElement(parent, element_name) if element_text is not None: element.text = element_text return element - -def escape_xml(text: str = None) -> str: - """ - Replaces XML special characters (&, <, >, ", ') with their corresponding entities. - Returns an empty string if the input is None. - """ - if text is None: - return "" - text = text.replace("&", "&") - text = text.replace("<", "<") - text = text.replace(">", ">") - text = text.replace("\"", """) - text = text.replace("'", "'") - return text diff --git a/src/xml-validator.py b/src/xml_validator.py similarity index 55% rename from src/xml-validator.py rename to src/xml_validator.py index 058669d..6793b58 100644 --- a/src/xml-validator.py +++ b/src/xml_validator.py @@ -4,117 +4,101 @@ """ import os -import sys -import xml.etree.ElementTree as ET +import defusedxml.ElementTree as ET from lxml import etree -import logging # Keep standard logging import for levels like logging.INFO + +import logging import re -# Logger will be instantiated in main() using ConversionLogger -# logger = logging.getLogger(__name__) # To be replaced +try: + from .config import CounselingConfig +except ImportError: + from config import CounselingConfig + + +# Logger will be instantiated in main() using ConversionLogger, +# but for standalone functions we provide a fallback +logger = logging.getLogger(__name__) + +def _setup_sys_path(): + """Ensure the script can be run standalone by adding its directory to sys.path.""" + import sys + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from logging_util import ConversionLogger # Import ConversionLogger +# ConversionLogger is imported lazily in main() to avoid breaking package imports -def validate_against_xsd(xml_file, xsd_file): +def _validate_file_paths(xml_file, xsd_file, enforce_data_dir=False): + """Resolve and validate file paths against traversal. Returns (xml_path, xsd_path) or an error dict.""" + xml_file = os.path.realpath(xml_file) + xsd_file = os.path.realpath(xsd_file) + if enforce_data_dir: + _data_dir_env = os.environ.get("DATA_DIR", "") + if _data_dir_env: + _data_dir = os.path.realpath(_data_dir_env) + if not xml_file.startswith(_data_dir): + return {"is_valid": False, "errors": ["Invalid XML file path"]} + if not xsd_file.startswith(os.sep): + return {"is_valid": False, "errors": ["Invalid XSD file path"]} + return xml_file, xsd_file + + +def validate_against_xsd(xml_file, xsd_file, enforce_data_dir=False): """ Validate XML against an XSD schema. - + Args: xml_file: Path to the XML file xsd_file: Path to the XSD schema file - + enforce_data_dir: If True, require xml_file to be under DATA_DIR + Returns: - Tuple (is_valid, errors) + Dict with 'is_valid' bool and 'errors' list """ try: - # Parse the XSD schema - xmlschema_doc = etree.parse(xsd_file) + path_result = _validate_file_paths(xml_file, xsd_file, enforce_data_dir=enforce_data_dir) + if isinstance(path_result, dict): + return path_result + xml_file, xsd_file = path_result + + parser = etree.XMLParser(resolve_entities=False) + xmlschema_doc = etree.parse(xsd_file, parser=parser) xmlschema = etree.XMLSchema(xmlschema_doc) - - # Parse the XML file - xml_doc = etree.parse(xml_file) - - # Validate + xml_doc = etree.parse(xml_file, parser=parser) + is_valid = xmlschema.validate(xml_doc) - - # Get validation errors errors = [] if not is_valid: for error in xmlschema.error_log: errors.append(f"Line {error.line}: {error.message}") - - return is_valid, errors - except Exception as e: - return False, [f"Validation error: {str(e)}"] + + return {"is_valid": is_valid, "errors": errors} + except (OSError, etree.XMLSyntaxError, etree.XMLSchemaError, etree.XMLSchemaParseError): + logger.exception("Error during XML/XSD validation") + return {"is_valid": False, "errors": ["Validation error"]} def extract_validation_details(error_message): """ Extract element names and expected elements from a validation error message. - + Args: error_message: The validation error message - + Returns: Tuple (invalid_element, expected_elements) """ invalid_match = re.search(r"Invalid content was found starting with element '([^']+)'", error_message) expected_match = re.search(r"One of '{([^}]+)}' is expected", error_message) - + invalid_element = invalid_match.group(1) if invalid_match else None expected_elements = expected_match.group(1).split(', ') if expected_match else [] - - return invalid_element, expected_elements -def fix_client_intake_element_order(xml_file, output_file=None): - """ - Fix the order of elements in the ClientIntake section according to the XSD schema. - - Args: - xml_file: Path to the XML file - output_file: Path to save the fixed XML file (if None, will modify the original) - - Returns: - Boolean indicating success - """ - if output_file is None: - output_file = xml_file - - try: - # Parse the XML file - tree = ET.parse(xml_file) - root = tree.getroot() - - # Define the correct order of elements in ClientIntake - client_intake_order = [ - 'Race', 'Ethnicity', 'Sex', 'Disability', 'MilitaryStatus', - 'BranchOfService', 'Media', 'Internet', 'CurrentlyInBusiness', - 'CurrentlyExporting', 'CompanyName', 'BusinessType', - 'BusinessOwnership', 'ConductingBusinessOnline', - 'ClientIntake_Certified8a', 'Employee_Owned', 'TotalNumberOfEmployees', - 'NumberOfEmployeesInExportingBusiness', 'ClientAnnualIncomePart2', - 'LegalEntity', 'Rural_vs_Urban', 'FIPS_Code', 'CounselingSeeking', - 'ExportCountries' - ] - - # Process each CounselingRecord - for counseling_record in root.findall('CounselingRecord'): - client_intake = counseling_record.find('ClientIntake') - if client_intake is not None: - # Reorder elements in ClientIntake - reorder_elements(client_intake, client_intake_order) - - # Save the fixed XML - tree.write(output_file, encoding='utf-8', xml_declaration=True) - return True - except Exception as e: - logger.error(f"Error fixing XML file: {str(e)}") - return False + return invalid_element, expected_elements def add_missing_required_elements(client_intake, record_id): """ Add any missing required elements to ClientIntake. (Function moved from fix-sba-xml.py) - + Args: client_intake: ClientIntake element record_id: ID of the counseling record (for logging) @@ -122,10 +106,10 @@ def add_missing_required_elements(client_intake, record_id): # Define required elements and their default values # This list might need to be configurable or expanded later. required_elements = { - 'CurrentlyInBusiness': 'No', + 'CurrentlyInBusiness': 'No', # Add other known required elements for ClientIntake here if they have simple defaults } - + elements_added = False for tag, default_value in required_elements.items(): if client_intake.find(tag) is None: @@ -138,115 +122,104 @@ def fix_client_intake_element_order(xml_file, output_file=None, add_missing_elem """ Fix the order of elements in the ClientIntake section according to the XSD schema. Optionally adds missing required elements. - + Args: xml_file: Path to the XML file output_file: Path to save the fixed XML file (if None, will modify the original) add_missing_elements_flag: If True, add missing required elements. - + Returns: Boolean indicating success """ if output_file is None: output_file = xml_file - + try: # Parse the XML file tree = ET.parse(xml_file) root = tree.getroot() - - # Define the correct order of elements in ClientIntake - client_intake_order = [ - 'Race', 'Ethnicity', 'Sex', 'Disability', 'MilitaryStatus', - 'BranchOfService', 'Media', 'Internet', 'CurrentlyInBusiness', - 'CurrentlyExporting', 'CompanyName', 'BusinessType', - 'BusinessOwnership', 'ConductingBusinessOnline', - 'ClientIntake_Certified8a', 'Employee_Owned', 'TotalNumberOfEmployees', - 'NumberOfEmployeesInExportingBusiness', 'ClientAnnualIncomePart2', - 'LegalEntity', 'Rural_vs_Urban', 'FIPS_Code', 'CounselingSeeking', - 'ExportCountries' - ] - + + client_intake_order = CounselingConfig.CLIENT_INTAKE_ELEMENT_ORDER + # Process each CounselingRecord for counseling_record in root.findall('CounselingRecord'): record_id_element = counseling_record.find('PartnerClientNumber') - record_id = record_id_element.text if record_id_element is not None else "UNKNOWN_RECORD" - + record_id = (record_id_element.text or "UNKNOWN_RECORD") if record_id_element is not None else "UNKNOWN_RECORD" + client_intake = counseling_record.find('ClientIntake') if client_intake is not None: if add_missing_elements_flag: add_missing_required_elements(client_intake, record_id) # Reorder elements in ClientIntake reorder_elements(client_intake, client_intake_order) - + # Save the fixed XML tree.write(output_file, encoding='utf-8', xml_declaration=True) return True - except Exception as e: + except (OSError, ET.ParseError) as e: logger.error(f"Error fixing XML file: {str(e)}") return False -def reorder_elements(parent, element_order): - """ - Reorder child elements according to the specified order. - - Args: - parent: Parent element - element_order: List of element names in the correct order - """ - # Create a dictionary to store elements by tag name +def _collect_elements_by_tag(parent): + """Remove all children from parent and return a dict mapping tag -> element or list of elements.""" elements = {} - for child in list(parent): + children = list(parent) + for child in children: tag = child.tag if tag in elements: - # If we already have this tag, it's a list of elements if isinstance(elements[tag], list): elements[tag].append(child) else: elements[tag] = [elements[tag], child] else: elements[tag] = child - - # Remove the child from the parent parent.remove(child) - - # Add elements back in the correct order + return elements + +def _append_elements(parent, elements, tag): + """Append element(s) for the given tag to parent, handling both single and list values.""" + item = elements[tag] + if isinstance(item, list): + for element in item: + parent.append(element) + else: + parent.append(item) + +def reorder_elements(parent, element_order): + """ + Reorder child elements according to the specified order. + + Args: + parent: Parent element + element_order: List of element names in the correct order + """ + elements = _collect_elements_by_tag(parent) + for tag in element_order: if tag in elements: - if isinstance(elements[tag], list): - # Add all elements with this tag - for element in elements[tag]: - parent.append(element) - else: - # Add the single element - parent.append(elements[tag]) - - # Add any remaining elements that weren't in the order list - for tag, element in elements.items(): + _append_elements(parent, elements, tag) + + for tag in elements: if tag not in element_order: - if isinstance(element, list): - for item in element: - parent.append(item) - else: - parent.append(element) + _append_elements(parent, elements, tag) def check_element_order(parent, element_order): """ Check if elements are in the correct order. - + Args: parent: Parent element element_order: List of element names in the correct order - + Returns: Boolean indicating if there are ordering issues """ # Get tags of child elements child_tags = [child.tag for child in parent] - + # Find elements from order list that exist in the XML expected_order = [tag for tag in element_order if tag in child_tags] - + # Check if the actual order matches the expected order # This simple check assumes all expected_order elements are present and in sequence. # A more robust check might be needed if elements can be optional and still affect order. @@ -256,13 +229,13 @@ def check_element_order(parent, element_order): # Find the current tag's first occurrence in the actual child_tags list # starting from where the last tag was found. idx = child_tags.index(tag_in_expected_order, current_pos_in_xml) - current_pos_in_xml = idx + 1 + current_pos_in_xml = idx + 1 except ValueError: # Tag in expected_order is not in child_tags (or not after the previous one) # This might indicate an issue or an optional element not present. # For strict ordering of present elements, this is an issue. return True # Order issue or missing element that breaks sequence - + # Check if all elements from child_tags that are in element_order are in the correct sequence # This is a more complex check. The current logic in fix-sba-xml.py is simpler: last_index_in_parent = -1 @@ -272,23 +245,56 @@ def check_element_order(parent, element_order): indices_in_parent = [i for i, child in enumerate(parent) if child.tag == tag_in_schema_order] if not indices_in_parent: continue # This element is not in the parent, skip - + current_element_first_index = indices_in_parent[0] - + if current_element_first_index < last_index_in_parent: return True # Element appeared sooner than a preceding element in schema order last_index_in_parent = current_element_first_index - + # Additionally, ensure all instances of this tag are contiguous if that's a requirement # (The current reorder logic groups them, so this check might be for pre-existing state) # For now, just checking first occurrence order. except ValueError: # Element from element_order not found in parent, which is fine if it's optional. - pass - + pass + return False # No order issues based on first occurrence +def _resolve_output_path(file_path, input_dir, output_dir): + """Compute the output path for a file, creating directories as needed.""" + if not output_dir: + return file_path + rel_path = os.path.relpath(file_path, input_dir) + output_path = os.path.join(output_dir, rel_path) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + return output_path + +def _validate_and_log(file_path, xsd_file, label=""): + """Validate an XML file against XSD and log the result. Returns (is_valid, errors).""" + logger.info(f"Validating {label}file {file_path} against {xsd_file}...") + result = validate_against_xsd(file_path, xsd_file) + is_valid, errors = result["is_valid"], result["errors"] + if is_valid: + logger.info(f"{label.capitalize() if label else ''}File {file_path} is valid.") + else: + log_fn = logger.warning if label == "original " else logger.error + log_fn(f"{label.capitalize() if label else ''}File {file_path} is NOT valid. Errors: {errors}") + return is_valid, errors + +def _fix_and_revalidate(file_path, output_path, xsd_file, add_missing): + """Fix XML file and optionally re-validate. Returns True if fix succeeded.""" + logger.info(f"Attempting to fix {file_path} -> {output_path}") + fix_success = fix_client_intake_element_order(file_path, output_path, add_missing) + if not fix_success: + logger.error(f"Failed to fix {file_path}") + return False + logger.info(f"Successfully fixed {file_path}, saved to {output_path}") + if xsd_file: + _validate_and_log(output_path, xsd_file, "fixed ") + return True + def process_directory(input_dir, output_dir=None, recursive=False, pattern="*.xml", xsd_file=None, fix=False, add_missing_elements_flag=False): """ Process all XML files in a directory. @@ -302,13 +308,12 @@ def process_directory(input_dir, output_dir=None, recursive=False, pattern="*.xm xsd_file: Path to XSD schema for validation (optional) fix: Boolean, if True, fix the XML files. add_missing_elements_flag: Boolean, if True and fix is True, add missing elements. - + Returns: Number of files processed successfully. """ import glob - import os - + logger.info(f"Processing XML files in directory: {input_dir}") if recursive: logger.info(f"Recursive mode enabled, pattern: {pattern}") @@ -317,62 +322,36 @@ def process_directory(input_dir, output_dir=None, recursive=False, pattern="*.xm if not os.path.exists(output_dir): os.makedirs(output_dir) logger.info(f"Created output directory: {output_dir}") - - # Find XML files + search_pattern = os.path.join(input_dir, "**", pattern) if recursive else os.path.join(input_dir, pattern) files = glob.glob(search_pattern, recursive=recursive) - logger.info(f"Found {len(files)} XML files to process.") - + processed_count = 0 for file_path in files: logger.info(f"--- Processing file: {file_path} ---") - - current_output_path = file_path - if output_dir: - rel_path = os.path.relpath(file_path, input_dir) - current_output_path = os.path.join(output_dir, rel_path) - # Ensure output subdirectory exists - os.makedirs(os.path.dirname(current_output_path), exist_ok=True) - - # Validate original file if XSD is provided + current_output_path = _resolve_output_path(file_path, input_dir, output_dir) + if xsd_file: - logger.info(f"Validating original file {file_path} against {xsd_file}...") - is_valid, errors = validate_against_xsd(file_path, xsd_file) - if is_valid: - logger.info(f"Original file {file_path} is valid.") - else: - logger.warning(f"Original file {file_path} is NOT valid. Errors: {errors}") + _validate_and_log(file_path, xsd_file, "original ") if fix: - logger.info(f"Attempting to fix {file_path} -> {current_output_path}") - fix_success = fix_client_intake_element_order(file_path, current_output_path, add_missing_elements_flag) - if fix_success: - logger.info(f"Successfully fixed {file_path}, saved to {current_output_path}") - # Re-validate if XSD provided and file was fixed - if xsd_file: - logger.info(f"Re-validating fixed file {current_output_path} against {xsd_file}...") - is_valid_after_fix, errors_after_fix = validate_against_xsd(current_output_path, xsd_file) - if is_valid_after_fix: - logger.info(f"Fixed file {current_output_path} is valid.") - else: - logger.error(f"Fixed file {current_output_path} is NOT valid after fixing. Errors: {errors_after_fix}") + if _fix_and_revalidate(file_path, current_output_path, xsd_file, add_missing_elements_flag): processed_count += 1 - else: - logger.error(f"Failed to fix {file_path}") - elif not xsd_file: # If not fixing and no XSD, then we are just listing files. + elif not xsd_file: logger.info(f"File {file_path} found (no fix requested, no XSD for validation).") - processed_count +=1 # Count as processed for listing purposes - + processed_count += 1 + logger.info(f"Finished processing directory. {processed_count} files processed successfully (or listed).") return processed_count -def main(): - """Main entry point for the script.""" + +def parse_arguments(): + """Parse command line arguments.""" import argparse - + parser = argparse.ArgumentParser(description='XML Validator and Fixer for SBA Counseling Information.') - + # Input: single file or directory input_group = parser.add_mutually_exclusive_group(required=True) input_group.add_argument('--xmlfile', help='Path to a single XML file to process.') @@ -380,10 +359,10 @@ def main(): # XSD for validation parser.add_argument('--xsd', help='Path to the XSD schema file for validation.') - + # Output options parser.add_argument('--output', help='Path to save the fixed XML file (for single file mode) or output directory (for directory mode).') - + # Directory processing options parser.add_argument('--recursive', '-r', action='store_true', help='Recursively process subdirectories (used with --directory).') parser.add_argument('--pattern', default="*.xml", help='File pattern for XML files (default: *.xml, used with --directory).') @@ -391,27 +370,77 @@ def main(): # Fixing options parser.add_argument('--fix', action='store_true', help='Enable fixing of XML files (currently fixes ClientIntake element order).') parser.add_argument('--add-missing', action='store_true', help='When fixing, also add missing required elements in ClientIntake (e.g., CurrentlyInBusiness).') - + # Logging options - parser.add_argument('--log-level', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'], + parser.add_argument('--log-level', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'], default='INFO', help='Logging level.') - - args = parser.parse_args() - + + return parser.parse_args() + +def _validate_and_report(xml_file, xsd_file, logger): + """Validate XML against XSD and log detailed error information.""" + logger.info(f"Validating {xml_file} against {xsd_file}...") + result = validate_against_xsd(xml_file, xsd_file) + is_valid, errors = result["is_valid"], result["errors"] + if is_valid: + logger.info("XML is valid!") + return + logger.error(f"XML is not valid. Found {len(errors)} errors:") + for i, error_msg in enumerate(errors, 1): + logger.error(f"Error {i}: {error_msg}") + invalid_element, expected_elements = extract_validation_details(error_msg) + if invalid_element: + logger.info(f" Invalid element: '{invalid_element}'") + if expected_elements: + logger.info(f" Expected elements: {', '.join(expected_elements)}") + +def _fix_single_file(args, logger): + """Fix a single XML file and optionally re-validate.""" + output_file_path = args.output if args.output else args.xmlfile + logger.info(f"Fixing XML file '{args.xmlfile}' and saving to '{output_file_path}'...") + fix_success = fix_client_intake_element_order( + args.xmlfile, output_file_path, add_missing_elements_flag=args.add_missing + ) + if not fix_success: + logger.error(f"Failed to fix XML file '{args.xmlfile}'.") + return + logger.info("XML file fixed successfully!") + if args.xsd: + _validate_and_log(output_file_path, args.xsd, "fixed ") + +def process_single_file(args, logger): + """Process a single XML file for validation and/or fixing.""" + logger.info(f"Mode: Processing single file '{args.xmlfile}'") + + if args.xsd: + _validate_and_report(args.xmlfile, args.xsd, logger) + + if args.fix: + _fix_single_file(args, logger) + elif not args.xsd: + logger.info(f"XML file '{args.xmlfile}' processed (no fix requested, no XSD for validation).") + +def main(): + """Main entry point for the script.""" + _setup_sys_path() + from logging_util import ConversionLogger + + args = parse_arguments() + # Setup logger using ConversionLogger log_level_val = getattr(logging, args.log_level.upper(), logging.INFO) # For xml-validator, default to console-only logging unless a --log-file arg is added later logger = ConversionLogger( logger_name="XMLValidator", log_level=log_level_val, - log_to_file=False + log_to_file=False ).logger # Get the actual logger instance - + if args.directory: # Process directory logger.info(f"Mode: Processing directory '{args.directory}'") output_dir_for_process = args.output # If None, process_directory handles it (in-place if fix is True) - + process_directory( input_dir=args.directory, output_dir=output_dir_for_process, @@ -422,58 +451,10 @@ def main(): add_missing_elements_flag=args.add_missing ) elif args.xmlfile: - # Process single file - logger.info(f"Mode: Processing single file '{args.xmlfile}'") - - # Validate original file if XSD is provided - if args.xsd: - logger.info(f"Validating {args.xmlfile} against {args.xsd}...") - is_valid, errors = validate_against_xsd(args.xmlfile, args.xsd) - if is_valid: - logger.info("XML is valid!") - else: - logger.error(f"XML is not valid. Found {len(errors)} errors:") - for i, error_msg in enumerate(errors, 1): - logger.error(f"Error {i}: {error_msg}") - invalid_element, expected_elements = extract_validation_details(error_msg) - if invalid_element: # expected_elements can be empty - logger.info(f" Invalid element: '{invalid_element}'") - if expected_elements: - logger.info(f" Expected elements: {', '.join(expected_elements)}") - - # Fix the XML file if requested - if args.fix: - # Determine output path for single file mode - # If --output is not provided, fix in-place (output_file = args.xmlfile) - # If --output is provided, save to new file. - output_file_path = args.output if args.output else args.xmlfile - - logger.info(f"Fixing XML file '{args.xmlfile}' and saving to '{output_file_path}'...") - fix_success = fix_client_intake_element_order( - args.xmlfile, - output_file_path, - add_missing_elements_flag=args.add_missing - ) - - if fix_success: - logger.info("XML file fixed successfully!") - # Re-validate if XSD provided and file was fixed - if args.xsd: - logger.info(f"Re-validating fixed file {output_file_path} against {args.xsd}...") - is_valid_after_fix, errors_after_fix = validate_against_xsd(output_file_path, args.xsd) - if is_valid_after_fix: - logger.info(f"Fixed file {output_file_path} is valid.") - else: - logger.error(f"Fixed file {output_file_path} is NOT valid after fixing. Errors: {errors_after_fix}") - else: - logger.error(f"Failed to fix XML file '{args.xmlfile}'.") - elif not args.xsd: # No fix, no xsd - logger.info(f"XML file '{args.xmlfile}' processed (no fix requested, no XSD for validation).") - + process_single_file(args, logger) else: # Should not happen due to mutually_exclusive_group logger.error("No input specified. Use --xmlfile or --directory.") - parser.print_help() if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/test_base_converter.py b/tests/test_base_converter.py new file mode 100644 index 0000000..3bda3a3 --- /dev/null +++ b/tests/test_base_converter.py @@ -0,0 +1,50 @@ +import unittest +import os +import sys + +# Add the project root to the Python path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from src.converters.base_converter import BaseConverter +from src.logging_util import ConversionLogger +from src.validation_report import ValidationTracker + +class TestBaseConverter(unittest.TestCase): + + def setUp(self): + self.logger = ConversionLogger("test_base", log_level="DEBUG", log_to_file=False).logger + self.validator = ValidationTracker() + + def test_cannot_instantiate_abc(self): + """ + Tests that BaseConverter cannot be instantiated directly because it's an ABC. + """ + with self.assertRaisesRegex(TypeError, "Can't instantiate abstract class BaseConverter"): + BaseConverter(self.logger, self.validator) + + def test_subclass_must_implement_convert(self): + """ + Tests that a subclass must implement the 'convert' method. + """ + class IncompleteConverter(BaseConverter): + pass + + with self.assertRaisesRegex(TypeError, "Can't instantiate abstract class IncompleteConverter"): + IncompleteConverter(self.logger, self.validator) + + def test_subclass_with_convert_can_be_instantiated(self): + """ + Tests that a subclass that implements 'convert' can be instantiated. + """ + class CompleteConverter(BaseConverter): + def convert(self, input_path: str, output_path: str): + """No-op implementation to satisfy abstract method for testing.""" + + converter = CompleteConverter(self.logger, self.validator) + self.assertIsInstance(converter, CompleteConverter) + self.assertIsInstance(converter, BaseConverter) + self.assertEqual(converter.logger, self.logger) + self.assertEqual(converter.validator, self.validator) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_counseling_converter.py b/tests/test_counseling_converter.py index b8000dc..9e3838b 100644 --- a/tests/test_counseling_converter.py +++ b/tests/test_counseling_converter.py @@ -1,6 +1,9 @@ import unittest import os import sys +import tempfile +import csv +import xml.etree.ElementTree as ET # Add the project root to the Python path sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) @@ -16,14 +19,232 @@ def setUp(self): self.validator = ValidationTracker() def test_converter_instantiation(self): - """ - Tests that the CounselingConverter can be instantiated. - """ + converter = CounselingConverter(self.logger, self.validator) + self.assertIsInstance(converter, CounselingConverter) + + def _write_csv(self, rows, fieldnames=None): + """Helper to write a CSV to a temp file and return the path.""" + if fieldnames is None: + fieldnames = rows[0].keys() if rows else [] + tmp = tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False, newline='', encoding='utf-8') + writer = csv.DictWriter(tmp, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow(row) + tmp.close() + return tmp.name + + def _make_valid_row(self, **overrides): + """Return a minimal valid counseling row dict with optional overrides.""" + base = { + 'Contact ID': 'C-001', + 'Last Name': 'Smith', + 'First Name': 'John', + 'Middle Name': '', + 'Email': 'john@example.com', + 'Contact: Phone': '(515) 555-1234', + 'Contact: Secondary Phone': '', + 'Mailing Street': '123 Main St', + 'Mailing City': 'Des Moines', + 'Mailing State/Province': 'IA', + 'Mailing Zip/Postal Code': '50309', + 'Mailing Country': 'US', + 'Agree to Impact Survey': 'Yes', + 'Client Signature - Date': '2025-01-15', + 'Client Signature(On File)': '1', + 'Race': 'White', + 'Ethnicity:': 'Non-Hispanic', + 'Gender': 'Male', + 'Disability': '', + 'Veteran Status': '', + 'Branch Of Service': '', + 'What Prompted you to contact us?': '', + 'Internet (specify)': '', + 'InternetUsage': '', + 'Currently In Business?': 'No', + 'Are you currently exporting?(old)': 'No', + 'Account Name': '', + 'Type of Business': '', + 'Business Ownership - % Female(old)': '0', + 'Conduct Business Online?': 'No', + '8(a) Certified?(old)': 'No', + 'Total Number of Employees': '', + 'Number of Employees in Exporting Business': '', + 'Gross Revenues/Sales': '', + 'Profits/Losses': '', + 'Rural_vs_Urban': 'Undetermined', + 'FIPS_Code': '', + 'Nature of the Counseling Seeking?': '', + 'Nature of the Counseling Seeking - Other Detail': '', + 'Activity ID': 'A-001', + 'Funding Source': 'WBC', + 'LocationCode': '249003', + 'Verified To Be In Business': 'No', + 'Reportable Impact': 'No', + 'Reportable Impact Date': '', + 'Business Start Date': '', + 'Date Started (Meeting)': '', + 'Total No. of Employees (Meeting)': '', + 'Gross Revenues/Sales (Meeting)': '', + 'Profit & Loss (Meeting)': '', + 'SBA Loan Amount': '0', + 'Non-SBA Loan Amount': '0', + 'Amount of Equity Capital Received': '0', + 'Certifications (SDB, HUBZONE, etc)': '', + 'Other Certifications': '', + 'SBA Financial Assistance': '', + 'Other SBA Financial Assistance': '', + 'Services Provided': 'Business Start-up/Preplanning', + 'Other Counseling Provided': '', + 'Referred Client to': '', + 'Other (Referred Client to)': '', + 'Type of Session': 'Telephone', + 'Language(s) Used': 'English', + 'Language(s) Used (Other)': '', + 'Date': '2025-01-15', + 'Name of Counselor': 'Jane Doe', + 'Duration (hours)': '1.5', + 'Prep Hours': '0.5', + 'Travel Hours': '0', + 'Comments': 'Initial consultation.', + 'Legal Entity of Business': '', + 'Other legal entity (specify)': '', + } + base.update(overrides) + return base + + def _convert_and_parse(self, rows): + """Convert rows to XML and return parsed root element.""" + csv_path = self._write_csv(rows) + xml_path = tempfile.NamedTemporaryFile(suffix='.xml', delete=False).name try: converter = CounselingConverter(self.logger, self.validator) - self.assertIsInstance(converter, CounselingConverter) - except Exception as e: - self.fail(f"CounselingConverter instantiation failed with an exception: {e}") + converter.convert(csv_path, xml_path) + tree = ET.parse(xml_path) + return tree.getroot() + finally: + os.unlink(csv_path) + if os.path.exists(xml_path): + os.unlink(xml_path) + + def test_basic_conversion_produces_valid_xml(self): + """Test that a valid row produces a CounselingRecord element.""" + root = self._convert_and_parse([self._make_valid_row()]) + records = root.findall('CounselingRecord') + self.assertEqual(len(records), 1) + + record = records[0] + self.assertEqual(record.find('PartnerClientNumber').text, 'C-001') + self.assertEqual(record.find('Location/LocationCode').text, '249003') + + def test_client_request_section(self): + """Test ClientRequest section has expected name and address fields.""" + root = self._convert_and_parse([self._make_valid_row()]) + cr = root.find('CounselingRecord/ClientRequest') + self.assertIsNotNone(cr) + self.assertEqual(cr.find('ClientNamePart1/Last').text, 'Smith') + self.assertEqual(cr.find('ClientNamePart1/First').text, 'John') + self.assertEqual(cr.find('Email').text, 'john@example.com') + + def test_address_state_standardized(self): + """Test that state abbreviation is expanded to full name.""" + root = self._convert_and_parse([self._make_valid_row()]) + state = root.find('CounselingRecord/ClientRequest/AddressPart1/State') + self.assertEqual(state.text, 'Iowa') + + def test_phone_number_cleaned(self): + """Test that phone numbers are cleaned to digits only.""" + root = self._convert_and_parse([self._make_valid_row(**{ + 'Contact: Phone': '+1 (515) 555-1234', + })]) + phone = root.find('CounselingRecord/ClientRequest/PhonePart1/Primary') + self.assertIsNotNone(phone) + self.assertEqual(len(phone.text), 10) + self.assertTrue(phone.text.isdigit()) + + def test_missing_contact_id_skips_record(self): + """Records without Contact ID should be skipped.""" + row = self._make_valid_row() + row['Contact ID'] = '' + root = self._convert_and_parse([row]) + records = root.findall('CounselingRecord') + self.assertEqual(len(records), 0) + + def test_multiple_records_converted(self): + """Test that multiple rows produce multiple records.""" + rows = [ + self._make_valid_row(**{'Contact ID': 'C-001'}), + self._make_valid_row(**{'Contact ID': 'C-002', 'Last Name': 'Doe'}), + ] + root = self._convert_and_parse(rows) + records = root.findall('CounselingRecord') + self.assertEqual(len(records), 2) + + def test_race_defaults_to_prefer_not_to_say(self): + """Missing race defaults to 'Prefer not to say'.""" + root = self._convert_and_parse([self._make_valid_row(Race='')]) + race_codes = root.findall('CounselingRecord/ClientIntake/Race/Code') + self.assertEqual(len(race_codes), 1) + self.assertEqual(race_codes[0].text, 'Prefer not to say') + + def test_in_business_creates_legal_entity(self): + """When in business, LegalEntity section should be present.""" + row = self._make_valid_row(**{ + 'Currently In Business?': 'Yes', + 'Legal Entity of Business': 'LLC', + }) + root = self._convert_and_parse([row]) + le = root.find('CounselingRecord/ClientIntake/LegalEntity') + self.assertIsNotNone(le) + self.assertEqual(le.find('Code').text, 'LLC') + + def test_not_in_business_no_legal_entity(self): + """When not in business, LegalEntity section should not exist.""" + root = self._convert_and_parse([self._make_valid_row(**{ + 'Currently In Business?': 'No', + })]) + le = root.find('CounselingRecord/ClientIntake/LegalEntity') + self.assertIsNone(le) + + def test_session_type_validation(self): + """Invalid session type should be defaulted and tracked.""" + root = self._convert_and_parse([self._make_valid_row(**{ + 'Type of Session': 'InvalidType', + })]) + st = root.find('CounselingRecord/CounselorRecord/SessionType') + self.assertEqual(st.text, 'Telephone') # default + # Check that a warning was recorded + warnings = [i for i in self.validator.issues if i['severity'] == 'warning' and 'SessionType' in i['field_name']] + self.assertTrue(len(warnings) > 0) + + def test_special_characters_in_text(self): + """XML special characters in CSV data should be handled safely.""" + root = self._convert_and_parse([self._make_valid_row(**{ + 'Last Name': 'O\'Brien & Associates', + 'Comments': 'Client said & "important"', + })]) + last = root.find('CounselingRecord/ClientRequest/ClientNamePart1/Last') + self.assertEqual(last.text, "O'Brien & Associates") + notes = root.find('CounselingRecord/CounselorRecord/CounselorNotes') + self.assertIn('&', notes.text) + + def test_zip_code_parsing(self): + """ZIP+4 codes should be parsed into ZipCode and Zip4Code.""" + root = self._convert_and_parse([self._make_valid_row(**{ + 'Mailing Zip/Postal Code': '50309-1234', + })]) + addr = root.find('CounselingRecord/ClientRequest/AddressPart1') + self.assertEqual(addr.find('ZipCode').text, '50309') + self.assertEqual(addr.find('Zip4Code').text, '1234') + + def test_country_standardized(self): + """Country code should be standardized.""" + root = self._convert_and_parse([self._make_valid_row(**{ + 'Mailing Country': 'USA', + })]) + country = root.find('CounselingRecord/ClientRequest/AddressPart1/Country/Code') + self.assertEqual(country.text, 'United States') + if __name__ == '__main__': unittest.main() diff --git a/tests/test_data_cleaning.py b/tests/test_data_cleaning.py index 7e5244f..d333294 100644 --- a/tests/test_data_cleaning.py +++ b/tests/test_data_cleaning.py @@ -6,7 +6,7 @@ # Add the project root to the Python path sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) -from src.data_cleaning import format_date, standardize_state_name, map_value +from src.data_cleaning import format_date, standardize_state_name, map_value, clean_percentage class TestFormatDate(unittest.TestCase): @@ -42,6 +42,27 @@ def test_format_date_output_format_and_default(self): self.assertEqual(format_date("2023-1-1"), "2023-01-01") # Check zero padding self.assertEqual(format_date("bad", default_return="---"), "---") + def test_format_date_value_error_path(self): + # Specifically malformed date string that causes ValueError inside the date parsing loop + # and tests that it continues to try the next format + self.assertEqual(format_date("10/26/2023", input_formats=["%Y-%m-%d", "%m/%d/%Y"]), "2023-10-26") + + # Test a date that raises ValueError for logical reasons (e.g., Feb 29 on non-leap year) + self.assertEqual(format_date("2023-02-29", input_formats=["%Y-%m-%d"]), "") + + # Test a date that raises ValueError for the first format but succeeds on the second + # (leap year case) + self.assertEqual(format_date("2024-02-29", input_formats=["%m/%d/%Y", "%Y-%m-%d"]), "2024-02-29") + + # Test complete exhaustion of formats due to ValueError + self.assertEqual(format_date("2023-13-01", input_formats=["%Y-%m-%d", "%m/%d/%Y"]), "") + + def test_format_date_single_digit_month_day(self): + # Single-digit month/day parsed by default %Y-%m-%d format + self.assertEqual(format_date("2023-1-1"), "2023-01-01") + # Invalid month/day returns empty + self.assertEqual(format_date("2023-30-30"), "") + class TestStandardizeStateName(unittest.TestCase): # Using DEFAULT_VALID_STATES from data_cleaning for some tests # These are the states the function itself knows about if no list is passed @@ -168,5 +189,109 @@ def test_standardize_country_code(self): with self.subTest(value=value): self.assertEqual(standardize_country_code(value), expected) + +class TestCleanPercentage(unittest.TestCase): + def test_clean_percentage_valid_strings(self): + self.assertEqual(clean_percentage("50"), "50") + self.assertEqual(clean_percentage("50%"), "50") + self.assertEqual(clean_percentage("0.5"), "0.5") + self.assertEqual(clean_percentage(" 0.5% "), "0.5") + self.assertEqual(clean_percentage("100"), "100") + self.assertEqual(clean_percentage("100%"), "100") + + def test_clean_percentage_valid_numbers(self): + self.assertEqual(clean_percentage(50), "50") + self.assertEqual(clean_percentage(0.5), "0.5") + self.assertEqual(clean_percentage(100), "100") + self.assertEqual(clean_percentage(100.0), "100") + self.assertEqual(clean_percentage(0), "0") + + def test_clean_percentage_empty_and_none(self): + self.assertEqual(clean_percentage(""), "0") + self.assertEqual(clean_percentage(None), "0") + self.assertEqual(clean_percentage(" "), "0") + self.assertEqual(clean_percentage("nan"), "0") + self.assertEqual(clean_percentage("NaN"), "0") + + def test_clean_percentage_out_of_bounds(self): + self.assertEqual(clean_percentage("-10"), "0") + self.assertEqual(clean_percentage("-10%"), "0") + self.assertEqual(clean_percentage("-0.5"), "0") + self.assertEqual(clean_percentage("150"), "100") + self.assertEqual(clean_percentage("150%"), "100") + self.assertEqual(clean_percentage(150), "100") + + def test_clean_percentage_invalid_strings(self): + self.assertEqual(clean_percentage("abc"), "0") + self.assertEqual(clean_percentage("50 percent"), "0") + self.assertEqual(clean_percentage("10.5.5"), "0") + if __name__ == '__main__': unittest.main() + +class TestCleanNumeric(unittest.TestCase): + + def test_clean_numeric_valid(self): + from src.data_cleaning import clean_numeric + + self.assertEqual(clean_numeric("1000"), "1000") + self.assertEqual(clean_numeric("10.5"), "10.5") + self.assertEqual(clean_numeric("10.0"), "10") # Removes redundant .0 + self.assertEqual(clean_numeric("0"), "0") + self.assertEqual(clean_numeric(100), "100") + self.assertEqual(clean_numeric(10.5), "10.5") + + def test_clean_numeric_with_symbols(self): + from src.data_cleaning import clean_numeric + + self.assertEqual(clean_numeric("1,000"), "1000") + self.assertEqual(clean_numeric("1,234,567.89"), "1234567.89") + self.assertEqual(clean_numeric("$10.5"), "10.5") + self.assertEqual(clean_numeric("$1,000.00"), "1000") + self.assertEqual(clean_numeric(" $ 1,000.50 "), "1000.5") + self.assertEqual(clean_numeric("-$500"), "-500") + + def test_clean_numeric_empty_none_nan(self): + from src.data_cleaning import clean_numeric + + self.assertEqual(clean_numeric(""), "") + self.assertEqual(clean_numeric(None), "") + self.assertEqual(clean_numeric(" "), "") + self.assertEqual(clean_numeric("NaN"), "") + self.assertEqual(clean_numeric("nan"), "") + + def test_clean_numeric_invalid(self): + from src.data_cleaning import clean_numeric + + self.assertEqual(clean_numeric("invalid_string"), "") + self.assertEqual(clean_numeric("1000a"), "") + self.assertEqual(clean_numeric("abc"), "") + +class TestCleanPhoneNumber(unittest.TestCase): + def test_clean_phone_number_valid_formats(self): + from src.data_cleaning import clean_phone_number + self.assertEqual(clean_phone_number("(123) 456-7890"), "1234567890") + self.assertEqual(clean_phone_number("123.456.7890"), "1234567890") + self.assertEqual(clean_phone_number("+1 (123) 456-7890"), "1234567890") # strips leading 1 + self.assertEqual(clean_phone_number("123-456-7890"), "1234567890") + self.assertEqual(clean_phone_number("1234567890"), "1234567890") + + def test_clean_phone_number_with_letters(self): + from src.data_cleaning import clean_phone_number + self.assertEqual(clean_phone_number("123-456-7890 ext 123"), "1234567890") # truncated to 10 + self.assertEqual(clean_phone_number("1-800-FLOWERS"), "1800") + self.assertEqual(clean_phone_number("aBcDeFg"), "") + + def test_clean_phone_number_empty_none_nan(self): + from src.data_cleaning import clean_phone_number + self.assertEqual(clean_phone_number(""), "") + self.assertEqual(clean_phone_number(None), "") + self.assertEqual(clean_phone_number(" "), "") + self.assertEqual(clean_phone_number("nan"), "") + self.assertEqual(clean_phone_number("NaN"), "") + self.assertEqual(clean_phone_number(" NAN "), "") + + def test_clean_phone_number_numeric_input(self): + from src.data_cleaning import clean_phone_number + self.assertEqual(clean_phone_number(1234567890), "1234567890") + self.assertEqual(clean_phone_number(1.8001234567), "8001234567") # 11 digits starting with 1, strip leading 1, then truncate to 10 diff --git a/tests/test_data_validation.py b/tests/test_data_validation.py new file mode 100644 index 0000000..75a8c90 --- /dev/null +++ b/tests/test_data_validation.py @@ -0,0 +1,155 @@ +import unittest +from unittest.mock import MagicMock +import sys +import os + +# Add the project root to the Python path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from src.data_validation import ( + validate_counseling_record, + validate_training_record, + analyze_counseling_csv, + analyze_training_csv +) +from src.config import ValidationCategory as VC, CounselingConfig, TrainingConfig + +# Use a date within the current fiscal year for valid test cases +_VALID_DATE = CounselingConfig.MIN_COUNSELING_DATE.replace("-01", "-15") + +class TestDataValidation(unittest.TestCase): + + def setUp(self): + self.validator = MagicMock() + + def test_validate_counseling_record_success(self): + row = { + CounselingConfig.REQUIRED_FIELDS[0]: "C-123", + 'Last Name': 'Doe', + 'First Name': 'John', + 'Date': _VALID_DATE + } + + result = validate_counseling_record(row, 1, self.validator) + + self.assertTrue(result) + self.validator.set_current_record_id.assert_called_once_with("C-123") + self.validator.add_issue.assert_not_called() + + def test_validate_counseling_record_missing_id(self): + row = { + 'Last Name': 'Doe', + 'First Name': 'John', + 'Date': _VALID_DATE + } + + result = validate_counseling_record(row, 2, self.validator) + + self.assertFalse(result) + self.validator.set_current_record_id.assert_not_called() + self.validator.add_issue.assert_called_once_with( + "Row_2", "error", VC.MISSING_REQUIRED, CounselingConfig.REQUIRED_FIELDS[0], "Missing required Contact ID." + ) + + def test_validate_counseling_record_missing_last_name(self): + row = { + CounselingConfig.REQUIRED_FIELDS[0]: "C-124", + 'First Name': 'John', + 'Date': _VALID_DATE + } + + result = validate_counseling_record(row, 3, self.validator) + + self.assertTrue(result) + self.validator.set_current_record_id.assert_called_once_with("C-124") + self.validator.add_issue.assert_called_once_with( + "C-124", "warning", VC.MISSING_FIELD, "Last Name", "Missing Last Name." + ) + + def test_validate_counseling_record_invalid_date_format(self): + row = { + CounselingConfig.REQUIRED_FIELDS[0]: "C-125", + 'Last Name': 'Doe', + 'Date': 'invalid-date' + } + + result = validate_counseling_record(row, 4, self.validator) + + self.assertTrue(result) + self.validator.set_current_record_id.assert_called_once_with("C-125") + self.validator.add_issue.assert_called_once_with( + "C-125", "warning", VC.INVALID_FORMAT, "Date Counseled", "Invalid date format: invalid-date" + ) + + def test_validate_counseling_record_early_date(self): + row = { + CounselingConfig.REQUIRED_FIELDS[0]: "C-126", + 'Last Name': 'Doe', + 'Date': '2020-01-01' + } + + result = validate_counseling_record(row, 5, self.validator) + + self.assertTrue(result) + self.validator.set_current_record_id.assert_called_once_with("C-126") + self.validator.add_issue.assert_called_once_with( + "C-126", "warning", VC.INVALID_DATE, "Date Counseled", f"Date 2020-01-01 is before minimum of {CounselingConfig.MIN_COUNSELING_DATE}" + ) + + def test_validate_training_record_success(self): + event_id_col = TrainingConfig.COLUMN_MAPPING['event_id'] + row = { + event_id_col: "T-999", + 'Other': 'Data' + } + + result = validate_training_record(row, 1, self.validator) + + self.assertTrue(result) + self.validator.set_current_record_id.assert_called_once_with("T-999") + self.validator.add_issue.assert_not_called() + + def test_validate_training_record_missing_id(self): + event_id_col = TrainingConfig.COLUMN_MAPPING['event_id'] + row = { + 'Other': 'Data' + } + + result = validate_training_record(row, 2, self.validator) + + self.assertFalse(result) + self.validator.set_current_record_id.assert_not_called() + self.validator.add_issue.assert_called_once_with( + "Row_2", "error", VC.MISSING_REQUIRED, event_id_col, "Missing required Class/Event ID." + ) + + def test_analyze_counseling_csv(self): + rows = [ + {CounselingConfig.REQUIRED_FIELDS[0]: "C-1", 'Last Name': 'Doe', 'First Name': 'John', 'Date': _VALID_DATE}, + {'Last Name': 'Smith', 'First Name': 'Alice'}, # missing id + {CounselingConfig.REQUIRED_FIELDS[0]: "C-3", 'First Name': 'Bob'}, # missing last name + {CounselingConfig.REQUIRED_FIELDS[0]: "C-4", 'Last Name': 'Brown', 'Date': 'invalid'}, # invalid date, missing first name + ] + + analysis = analyze_counseling_csv(rows) + + self.assertEqual(analysis['row_count'], 4) + self.assertEqual(analysis['missing_contact_id'], 1) + self.assertEqual(analysis['missing_names'], 2) + self.assertEqual(analysis['invalid_dates'], 1) + + def test_analyze_training_csv(self): + event_id_col = TrainingConfig.COLUMN_MAPPING['event_id'] + rows = [ + {event_id_col: "T-1"}, + {}, # missing event id + {event_id_col: "T-3"} + ] + + analysis = analyze_training_csv(rows) + + self.assertEqual(analysis['row_count'], 3) + self.assertEqual(analysis['missing_event_id'], 1) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_fix_sba_xml.py b/tests/test_fix_sba_xml.py new file mode 100644 index 0000000..2775013 --- /dev/null +++ b/tests/test_fix_sba_xml.py @@ -0,0 +1,291 @@ +import unittest +from unittest.mock import patch, MagicMock +import sys +import os +import argparse +from datetime import datetime + +# Add the project root to the Python path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +# Import the module to test +import src.fix_sba_xml as fix_sba_xml + +class TestFixSBAXML(unittest.TestCase): + + def test_parse_arguments_file(self): + """Test parsing arguments with --file.""" + test_args = ['fix_sba_xml.py', '--file', 'test.xml'] + with patch.object(sys, 'argv', test_args): + args = fix_sba_xml.parse_arguments() + self.assertEqual(args.file, 'test.xml') + self.assertIsNone(args.directory) + self.assertIsNone(args.output) + self.assertFalse(args.no_backup) + + def test_parse_arguments_directory(self): + """Test parsing arguments with --directory.""" + test_args = ['fix_sba_xml.py', '--directory', 'test_dir'] + with patch.object(sys, 'argv', test_args): + args = fix_sba_xml.parse_arguments() + self.assertEqual(args.directory, 'test_dir') + self.assertIsNone(args.file) + self.assertIsNone(args.output) + self.assertFalse(args.recursive) + self.assertEqual(args.pattern, '*.xml') + + def test_parse_arguments_mutually_exclusive(self): + """Test that --file and --directory are mutually exclusive.""" + test_args = ['fix_sba_xml.py', '--file', 'test.xml', '--directory', 'test_dir'] + with patch.object(sys, 'argv', test_args): + # argparse calls sys.exit(2) when parsing fails + with self.assertRaises(SystemExit) as cm: + # Mock stderr to suppress argparse error output in tests + with patch('sys.stderr', new_callable=MagicMock): + fix_sba_xml.parse_arguments() + self.assertEqual(cm.exception.code, 2) + + def test_parse_arguments_required(self): + """Test that at least one of --file or --directory is required.""" + test_args = ['fix_sba_xml.py'] + with patch.object(sys, 'argv', test_args): + with self.assertRaises(SystemExit) as cm: + with patch('sys.stderr', new_callable=MagicMock): + fix_sba_xml.parse_arguments() + self.assertEqual(cm.exception.code, 2) + + def test_parse_arguments_all_options(self): + """Test parsing arguments with all options.""" + test_args = [ + 'fix_sba_xml.py', + '--directory', 'test_dir', + '--output', 'out_dir', + '--no-backup', + '--recursive', + '--pattern', '*.xmls', + '--log-level', 'DEBUG', + '--log-file' + ] + with patch.object(sys, 'argv', test_args): + args = fix_sba_xml.parse_arguments() + self.assertEqual(args.directory, 'test_dir') + self.assertEqual(args.output, 'out_dir') + self.assertTrue(args.no_backup) + self.assertTrue(args.recursive) + self.assertEqual(args.pattern, '*.xmls') + self.assertEqual(args.log_level, 'DEBUG') + self.assertTrue(args.log_file) + + @patch('src.fix_sba_xml.validator_fix_order') + @patch('shutil.copy2') + def test_process_single_file_success(self, mock_copy2, mock_validator_fix_order): + """Test successful single file processing.""" + mock_validator_fix_order.return_value = True + logger_mock = MagicMock() + + test_args = ['fix_sba_xml.py', '--file', 'test.xml'] + with patch.object(sys, 'argv', test_args): + args = fix_sba_xml.parse_arguments() + + result = fix_sba_xml.process_single_file(args, logger_mock, mimic_original_add_missing=False) + + self.assertEqual(result, 0) + mock_validator_fix_order.assert_called_once_with( + xml_file='test.xml', + output_file='test.xml', + add_missing_elements_flag=False + ) + mock_copy2.assert_called_once() # Backup should be created + logger_mock.info.assert_any_call("[fix-sba-xml wrapper] Successfully fixed XML file: test.xml (via xml_validator)") + + @patch('src.fix_sba_xml.validator_fix_order') + @patch('shutil.copy2') + def test_process_single_file_failure(self, mock_copy2, mock_validator_fix_order): + """Test failed single file processing.""" + mock_validator_fix_order.return_value = False + logger_mock = MagicMock() + + test_args = ['fix_sba_xml.py', '--file', 'test.xml'] + with patch.object(sys, 'argv', test_args): + args = fix_sba_xml.parse_arguments() + + result = fix_sba_xml.process_single_file(args, logger_mock, mimic_original_add_missing=False) + + self.assertEqual(result, 1) + mock_validator_fix_order.assert_called_once() + logger_mock.error.assert_called_with("[fix-sba-xml wrapper] Failed to fix XML file (via xml_validator)") + + @patch('src.fix_sba_xml.validator_fix_order') + @patch('shutil.copy2') + def test_process_single_file_no_backup(self, mock_copy2, mock_validator_fix_order): + """Test that backup is not created when --no-backup is provided.""" + mock_validator_fix_order.return_value = True + logger_mock = MagicMock() + + test_args = ['fix_sba_xml.py', '--file', 'test.xml', '--no-backup'] + with patch.object(sys, 'argv', test_args): + args = fix_sba_xml.parse_arguments() + + fix_sba_xml.process_single_file(args, logger_mock, mimic_original_add_missing=False) + + mock_copy2.assert_not_called() + + @patch('src.fix_sba_xml.validator_fix_order') + @patch('shutil.copy2') + def test_process_single_file_different_output(self, mock_copy2, mock_validator_fix_order): + """Test that backup is not created when output file is different.""" + mock_validator_fix_order.return_value = True + logger_mock = MagicMock() + + test_args = ['fix_sba_xml.py', '--file', 'test.xml', '--output', 'out.xml'] + with patch.object(sys, 'argv', test_args): + args = fix_sba_xml.parse_arguments() + + fix_sba_xml.process_single_file(args, logger_mock, mimic_original_add_missing=False) + + mock_copy2.assert_not_called() + mock_validator_fix_order.assert_called_once_with( + xml_file='test.xml', + output_file='out.xml', + add_missing_elements_flag=False + ) + + @patch('src.fix_sba_xml.validator_process_directory') + def test_process_directory_success(self, mock_validator_process_directory): + """Test successful directory processing.""" + mock_validator_process_directory.return_value = 5 # Mock 5 files processed + logger_mock = MagicMock() + + test_args = ['fix_sba_xml.py', '--directory', 'test_dir', '--recursive', '--pattern', '*.xml'] + with patch.object(sys, 'argv', test_args): + args = fix_sba_xml.parse_arguments() + + result = fix_sba_xml.process_directory(args, logger_mock, always_fix=True, mimic_original_add_missing=False) + + self.assertEqual(result, 0) + mock_validator_process_directory.assert_called_once_with( + input_dir='test_dir', + output_dir=None, + recursive=True, + pattern='*.xml', + xsd_file=None, + fix=True, + add_missing_elements_flag=False + ) + logger_mock.info.assert_any_call("[fix-sba-xml wrapper] Successfully processed 5 XML files (via xml_validator)") + + @patch('src.fix_sba_xml.validator_process_directory') + @patch('os.makedirs') + @patch('os.path.exists') + def test_process_directory_with_output(self, mock_exists, mock_makedirs, mock_validator_process_directory): + """Test directory processing with output directory creation.""" + mock_validator_process_directory.return_value = 2 + mock_exists.return_value = False # Simulate output dir doesn't exist + logger_mock = MagicMock() + + test_args = ['fix_sba_xml.py', '--directory', 'test_dir', '--output', 'out_dir'] + with patch.object(sys, 'argv', test_args): + args = fix_sba_xml.parse_arguments() + + result = fix_sba_xml.process_directory(args, logger_mock, always_fix=True, mimic_original_add_missing=False) + + self.assertEqual(result, 0) + mock_exists.assert_any_call('out_dir') + mock_makedirs.assert_called_once_with('out_dir') + mock_validator_process_directory.assert_called_once_with( + input_dir='test_dir', + output_dir='out_dir', + recursive=False, + pattern='*.xml', + xsd_file=None, + fix=True, + add_missing_elements_flag=False + ) + + @patch('src.fix_sba_xml.validator_process_directory') + @patch('os.makedirs') + @patch('os.path.exists') + def test_process_directory_with_output_side_effect(self, mock_exists, mock_makedirs, mock_validator_process_directory): + """Test directory processing with output directory creation and side effects.""" + mock_validator_process_directory.return_value = 2 + # Patch exists to only return false for 'out_dir' to not break system calls + def side_effect(path): + if path == 'out_dir': + return False + return True + mock_exists.side_effect = side_effect + logger_mock = MagicMock() + + test_args = ['fix_sba_xml.py', '--directory', 'test_dir', '--output', 'out_dir'] + with patch.object(sys, 'argv', test_args): + args = fix_sba_xml.parse_arguments() + + result = fix_sba_xml.process_directory(args, logger_mock, always_fix=True, mimic_original_add_missing=False) + + self.assertEqual(result, 0) + mock_exists.assert_any_call('out_dir') + mock_makedirs.assert_called_once_with('out_dir') + mock_validator_process_directory.assert_called_once_with( + input_dir='test_dir', + output_dir='out_dir', + recursive=False, + pattern='*.xml', + xsd_file=None, + fix=True, + add_missing_elements_flag=False + ) + + @patch('src.fix_sba_xml.process_single_file') + @patch('src.fix_sba_xml.setup_logger') + def test_main_file(self, mock_setup_logger, mock_process_single_file): + """Test main entry point with --file.""" + mock_process_single_file.return_value = 0 + logger_mock = MagicMock() + mock_setup_logger.return_value = logger_mock + + test_args = ['fix_sba_xml.py', '--file', 'test.xml'] + with patch.object(sys, 'argv', test_args): + result = fix_sba_xml.main() + + self.assertEqual(result, 0) + mock_process_single_file.assert_called_once() + args, _ = mock_process_single_file.call_args[0][:2] + self.assertEqual(args.file, 'test.xml') + self.assertFalse(mock_process_single_file.call_args[0][2]) # mimic_original_add_missing + + @patch('src.fix_sba_xml.process_directory') + @patch('src.fix_sba_xml.setup_logger') + def test_main_directory(self, mock_setup_logger, mock_process_directory): + """Test main entry point with --directory.""" + mock_process_directory.return_value = 0 + logger_mock = MagicMock() + mock_setup_logger.return_value = logger_mock + + test_args = ['fix_sba_xml.py', '--directory', 'test_dir'] + with patch.object(sys, 'argv', test_args): + result = fix_sba_xml.main() + + self.assertEqual(result, 0) + mock_process_directory.assert_called_once() + args, _ = mock_process_directory.call_args[0][:2] + self.assertEqual(args.directory, 'test_dir') + self.assertTrue(mock_process_directory.call_args[0][2]) # always_fix + self.assertFalse(mock_process_directory.call_args[0][3]) # mimic_original_add_missing + + @patch('src.fix_sba_xml.process_single_file') + @patch('src.fix_sba_xml.setup_logger') + def test_main_exception(self, mock_setup_logger, mock_process_single_file): + """Test main entry point handling exception.""" + mock_process_single_file.side_effect = OSError("Test Error") + logger_mock = MagicMock() + mock_setup_logger.return_value = logger_mock + + test_args = ['fix_sba_xml.py', '--file', 'test.xml'] + with patch.object(sys, 'argv', test_args): + result = fix_sba_xml.main() + + self.assertEqual(result, 1) + logger_mock.error.assert_called_once_with("[fix-sba-xml wrapper] Error: Test Error") + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_integration_xsd.py b/tests/test_integration_xsd.py new file mode 100644 index 0000000..1ece968 --- /dev/null +++ b/tests/test_integration_xsd.py @@ -0,0 +1,262 @@ +""" +Integration tests that validate generated XML against real XSD schemas. +These tests ensure the converters produce schema-compliant output. +""" + +import os +import sys +import csv +import tempfile +import unittest + +from lxml import etree + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from src.converters.counseling_converter import CounselingConverter +from src.converters.training_converter import TrainingConverter +from src.logging_util import ConversionLogger +from src.validation_report import ValidationTracker + +SCHEMAS_DIR = os.path.join(os.path.dirname(__file__), '..', 'schemas') +COUNSELING_XSD = os.path.join(SCHEMAS_DIR, 'SBA_NEXUS_Counseling-2-14.xsd') +TRAINING_XSD = os.path.join(SCHEMAS_DIR, 'SBA_NEXUS_Training-2-25-2025.xsd') + + +def _validate_xml_against_xsd(xml_path, xsd_path): + """Validate an XML file against an XSD schema. Returns (is_valid, errors).""" + parser = etree.XMLParser(resolve_entities=False) + schema_doc = etree.parse(xsd_path, parser=parser) + schema = etree.XMLSchema(schema_doc) + xml_doc = etree.parse(xml_path, parser=parser) + is_valid = schema.validate(xml_doc) + errors = [str(e) for e in schema.error_log] + return is_valid, errors + + +def _make_counseling_row(**overrides): + """Return a minimal valid counseling row dict.""" + base = { + 'Contact ID': 'C-001', + 'Last Name': 'Smith', + 'First Name': 'John', + 'Middle Name': '', + 'Email': 'john@example.com', + 'Contact: Phone': '(515) 555-1234', + 'Contact: Secondary Phone': '', + 'Mailing Street': '123 Main St', + 'Mailing City': 'Des Moines', + 'Mailing State/Province': 'IA', + 'Mailing Zip/Postal Code': '50309', + 'Mailing Country': 'US', + 'Agree to Impact Survey': 'Yes', + 'Client Signature - Date': '2025-01-15', + 'Client Signature(On File)': '1', + 'Race': 'White', + 'Ethnicity:': 'Non Hispanic or Latino', + 'Gender': 'Male', + 'Disability': '', + 'Veteran Status': '', + 'Branch Of Service': '', + 'What Prompted you to contact us?': '', + 'Internet (specify)': '', + 'InternetUsage': '', + 'Currently In Business?': 'No', + 'Are you currently exporting?(old)': 'No', + 'Account Name': '', + 'Type of Business': '', + 'Business Ownership - % Female(old)': '0', + 'Conduct Business Online?': 'No', + '8(a) Certified?(old)': 'No', + 'Total Number of Employees': '', + 'Number of Employees in Exporting Business': '', + 'Gross Revenues/Sales': '', + 'Profits/Losses': '', + 'Rural_vs_Urban': 'Undetermined', + 'FIPS_Code': '', + 'Nature of the Counseling Seeking?': '', + 'Nature of the Counseling Seeking - Other Detail': '', + 'Activity ID': 'A-001', + 'Funding Source': '', + 'LocationCode': '249003', + 'Verified To Be In Business': 'No', + 'Reportable Impact': 'No', + 'Reportable Impact Date': '', + 'Business Start Date': '', + 'Date Started (Meeting)': '', + 'Total No. of Employees (Meeting)': '', + 'Gross Revenues/Sales (Meeting)': '', + 'Profit & Loss (Meeting)': '', + 'SBA Loan Amount': '0', + 'Non-SBA Loan Amount': '0', + 'Amount of Equity Capital Received': '0', + 'Certifications (SDB, HUBZONE, etc)': '', + 'Other Certifications': '', + 'SBA Financial Assistance': '', + 'Other SBA Financial Assistance': '', + 'Services Provided': 'Business Start-up/Preplanning', + 'Other Counseling Provided': '', + 'Referred Client to': '', + 'Other (Referred Client to)': '', + 'Type of Session': 'Telephone', + 'Language(s) Used': 'English', + 'Language(s) Used (Other)': '', + 'Date': '2025-01-15', + 'Name of Counselor': 'Jane Doe', + 'Duration (hours)': '1.5', + 'Prep Hours': '0.5', + 'Travel Hours': '0', + 'Comments': 'Initial consultation.', + 'Legal Entity of Business': '', + 'Other legal entity (specify)': '', + } + base.update(overrides) + return base + + +def _make_training_row(**overrides): + """Return a minimal valid training row dict.""" + base = { + 'Class/Event ID': 'EVT-001', + 'Class/Event Name': 'Business Workshop', + 'Start Date': '2025-01-15', + 'Funding Source': '', + 'Training Topic': 'Technology', + 'Class/Event Type': 'In-person', + 'City': 'Des Moines', + 'State/Province': 'Iowa', + 'Zip/Postal Code': '50309', + 'Gender': 'Female', + 'Race': 'White', + 'Ethnicity': 'Non-Hispanic', + 'Veteran Status': 'No military service', + 'Currently in Business?': 'Yes', + 'Disabilities': 'No', + } + base.update(overrides) + return base + + +def _write_csv(rows, fieldnames=None): + """Write rows to a temporary CSV file.""" + if fieldnames is None: + fieldnames = rows[0].keys() if rows else [] + tmp = tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False, newline='', encoding='utf-8') + writer = csv.DictWriter(tmp, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow(row) + tmp.close() + return tmp.name + + +@unittest.skipUnless( + os.path.exists(COUNSELING_XSD), + f"Counseling XSD not found at {COUNSELING_XSD}" +) +class TestCounselingXSDValidation(unittest.TestCase): + """Integration tests that validate counseling XML against the real XSD schema.""" + + def setUp(self): + self.logger = ConversionLogger("test_xsd_counseling", log_to_file=False).logger + self.validator = ValidationTracker() + + def _convert(self, rows): + csv_path = _write_csv(rows) + xml_path = tempfile.NamedTemporaryFile(suffix='.xml', delete=False).name + try: + converter = CounselingConverter(self.logger, self.validator) + converter.convert(csv_path, xml_path) + return xml_path + finally: + os.unlink(csv_path) + + def test_single_record_validates_against_xsd(self): + """A single valid counseling record should produce XSD-compliant XML.""" + xml_path = self._convert([_make_counseling_row()]) + try: + is_valid, errors = _validate_xml_against_xsd(xml_path, COUNSELING_XSD) + self.assertTrue(is_valid, f"XSD validation errors:\n" + "\n".join(errors[:10])) + finally: + os.unlink(xml_path) + + def test_multiple_records_validate_against_xsd(self): + """Multiple valid counseling records should produce XSD-compliant XML.""" + rows = [ + _make_counseling_row(**{'Contact ID': 'C-001', 'Activity ID': 'A-001'}), + _make_counseling_row(**{'Contact ID': 'C-002', 'Activity ID': 'A-002', 'First Name': 'Jane', 'Gender': 'Female'}), + ] + xml_path = self._convert(rows) + try: + is_valid, errors = _validate_xml_against_xsd(xml_path, COUNSELING_XSD) + self.assertTrue(is_valid, f"XSD validation errors:\n" + "\n".join(errors[:10])) + finally: + os.unlink(xml_path) + + def test_in_business_record_validates(self): + """A counseling record with business data should validate.""" + xml_path = self._convert([_make_counseling_row(**{ + 'Currently In Business?': 'Yes', + 'Legal Entity of Business': 'LLC', + 'Verified To Be In Business': 'Yes', + 'Nature of the Counseling Seeking?': 'Business Operations/Management', + })]) + try: + is_valid, errors = _validate_xml_against_xsd(xml_path, COUNSELING_XSD) + self.assertTrue(is_valid, f"XSD validation errors:\n" + "\n".join(errors[:10])) + finally: + os.unlink(xml_path) + + +@unittest.skipUnless( + os.path.exists(TRAINING_XSD), + f"Training XSD not found at {TRAINING_XSD}" +) +class TestTrainingXSDValidation(unittest.TestCase): + """Integration tests that validate training XML against the real XSD schema.""" + + def setUp(self): + self.logger = ConversionLogger("test_xsd_training", log_to_file=False).logger + self.validator = ValidationTracker() + + def _convert(self, rows): + csv_path = _write_csv(rows) + xml_path = tempfile.NamedTemporaryFile(suffix='.xml', delete=False).name + try: + converter = TrainingConverter(self.logger, self.validator) + converter.convert(csv_path, xml_path) + return xml_path + finally: + os.unlink(csv_path) + + def test_single_event_validates_against_xsd(self): + """A single training event should produce XSD-compliant XML.""" + rows = [ + _make_training_row(), + _make_training_row(**{'Gender': 'Male'}), # Need 2+ attendees per XSD minimum + ] + xml_path = self._convert(rows) + try: + is_valid, errors = _validate_xml_against_xsd(xml_path, TRAINING_XSD) + self.assertTrue(is_valid, f"XSD validation errors:\n" + "\n".join(errors[:10])) + finally: + os.unlink(xml_path) + + def test_multiple_events_validate_against_xsd(self): + """Multiple training events should produce XSD-compliant XML.""" + rows = [ + _make_training_row(**{'Class/Event ID': 'EVT-001'}), + _make_training_row(**{'Class/Event ID': 'EVT-001', 'Gender': 'Male'}), + _make_training_row(**{'Class/Event ID': 'EVT-002', 'Class/Event Name': 'Marketing 101', 'Training Topic': 'Marketing'}), + _make_training_row(**{'Class/Event ID': 'EVT-002', 'Gender': 'Male'}), + ] + xml_path = self._convert(rows) + try: + is_valid, errors = _validate_xml_against_xsd(xml_path, TRAINING_XSD) + self.assertTrue(is_valid, f"XSD validation errors:\n" + "\n".join(errors[:10])) + finally: + os.unlink(xml_path) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..d69aa5a --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,125 @@ +import unittest +import os +import sys +from unittest.mock import patch, MagicMock + +# Add the project root to the Python path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from src.main import main + +class TestMain(unittest.TestCase): + def setUp(self): + # Make sys.exit raise SystemExit so execution stops at the call site + self.exit_patcher = patch('sys.exit', side_effect=SystemExit) + self.mock_exit = self.exit_patcher.start() + + def tearDown(self): + self.exit_patcher.stop() + + @patch('src.main.os.path.exists') + @patch('src.main.os.makedirs') + @patch('src.main.ValidationTracker') + @patch('src.main.ConversionLogger') + def test_happy_path(self, mock_logger, mock_validator, mock_makedirs, mock_exists): + """Test successful execution with input and output files.""" + # Setup mocks + mock_exists.return_value = True + mock_converter_instance = MagicMock() + + # Patch the dict lookup directly rather than using mock_converters + with patch.dict('src.main.CONVERTERS', {'counseling': MagicMock(return_value=mock_converter_instance)}): + # Avoid file logging trying to write to real directories during test + mock_logger.return_value.logger = MagicMock() + + test_args = ['main.py', 'convert', 'counseling', '--input', 'test.csv', '--output', 'test.xml', '--log-dir', 'test_logs', '--report-dir', 'test_reports'] + with patch.object(sys, 'argv', test_args): + # Patch os.path.exists to only return true for the input file, not for everything + def side_effect(path): + if path == 'test.csv': + return True + return False + mock_exists.side_effect = side_effect + + main() + + # Assertions + mock_converter_instance.convert.assert_called_with('test.csv', 'test.xml') + self.mock_exit.assert_not_called() + + @patch('src.main.ConversionLogger') + @patch('src.main.os.path.exists') + @patch('src.main.os.makedirs') + def test_missing_input_file(self, mock_makedirs, mock_exists, mock_logger): + """Test execution when input file is missing.""" + # os.path.exists checking for file and log directories + def side_effect(path): + if path == 'missing.csv': + return False + return True + mock_exists.side_effect = side_effect + + mock_logger_instance = MagicMock() + mock_logger.return_value.logger = mock_logger_instance + + with patch.dict('src.main.CONVERTERS', {'training': MagicMock()}): + test_args = ['main.py', 'convert', 'training', '--input', 'missing.csv', '--log-dir', 'test_logs', '--report-dir', 'test_reports'] + with patch.object(sys, 'argv', test_args): + with self.assertRaises(SystemExit): + main() + + # Assertions + mock_exists.assert_any_call('missing.csv') + mock_logger_instance.error.assert_called_with('Input file not found: missing.csv') + self.mock_exit.assert_called_with(1) + + @patch('src.main.os.path.exists') + @patch('src.main.os.makedirs') + @patch('src.main.datetime') + @patch('src.main.ConversionLogger') + @patch('src.main.ValidationTracker') + def test_output_path_fallback(self, mock_validator, mock_logger, mock_datetime, mock_makedirs, mock_exists): + """Test fallback output path generation when --output is not provided.""" + mock_exists.return_value = True + mock_datetime.now.return_value.strftime.return_value = '20230101_120000' + mock_converter_instance = MagicMock() + mock_logger.return_value.logger = MagicMock() + + with patch.dict('src.main.CONVERTERS', {'counseling': MagicMock(return_value=mock_converter_instance)}): + test_args = ['main.py', 'convert', 'counseling', '--input', 'dir/test.csv', '--log-dir', 'test_logs', '--report-dir', 'test_reports'] + with patch.object(sys, 'argv', test_args): + main() + + # Assertions + expected_output_path = os.path.join('dir', 'test_20230101_120000.xml') + mock_converter_instance.convert.assert_called_with('dir/test.csv', expected_output_path) + self.mock_exit.assert_not_called() + + @patch('src.main.os.path.exists') + @patch('src.main.os.makedirs') + @patch('src.main.ConversionLogger') + def test_exception_handling(self, mock_logger, mock_makedirs, mock_exists): + """Test that exceptions during conversion are caught and logged.""" + mock_exists.return_value = True + mock_logger_instance = MagicMock() + mock_logger.return_value.logger = mock_logger_instance + + # Make the converter raise an exception + mock_converter_class = MagicMock() + mock_converter_class.side_effect = Exception("Test exception") + + with patch.dict('src.main.CONVERTERS', {'training': mock_converter_class}): + test_args = ['main.py', 'convert', 'training', '--input', 'test.csv', '--log-dir', 'test_logs', '--report-dir', 'test_reports'] + with patch.object(sys, 'argv', test_args): + with self.assertRaises(SystemExit): + main() + + # Assertions + mock_logger_instance.error.assert_called_once() + args, kwargs = mock_logger_instance.error.call_args + self.assertIn("An unexpected error occurred: Test exception", args[0]) + self.assertTrue(kwargs.get('exc_info')) + self.mock_exit.assert_called_with(1) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_training_client_converter.py b/tests/test_training_client_converter.py new file mode 100644 index 0000000..6480d37 --- /dev/null +++ b/tests/test_training_client_converter.py @@ -0,0 +1,237 @@ +import unittest +import os +import sys +import tempfile +import csv +import xml.etree.ElementTree as ET + +# Add the project root to the Python path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from src.converters.training_client_converter import TrainingClientConverter +from src.logging_util import ConversionLogger +from src.validation_report import ValidationTracker + + +class TestTrainingClientConverter(unittest.TestCase): + + def setUp(self): + self.logger = ConversionLogger("test_training_client", log_level="DEBUG", log_to_file=False).logger + self.validator = ValidationTracker() + + def _write_csv(self, rows, fieldnames=None): + """Helper to write a CSV to a temp file and return the path.""" + if fieldnames is None: + fieldnames = rows[0].keys() if rows else [] + tmp = tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False, newline='', encoding='utf-8') + writer = csv.DictWriter(tmp, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow(row) + tmp.close() + return tmp.name + + def _make_valid_row(self, **overrides): + """Return a minimal valid training client row dict with optional overrides.""" + base = { + 'Class/Event ID': '701Pe00000vtCVy', + 'Member Type': 'Contact', + 'First Name': 'Jane', + 'Last Name': 'Doe', + 'Member Status': 'Responded', + 'Company': 'Doe Enterprises', + 'Phone': '5155551234', + 'Email': 'jane@example.com', + 'Unique Campaign Members': '1', + 'Currently in Business?': 'No', + 'Ethnicity': 'Non Hispanic or Latino', + 'Race': 'White', + 'Disabilities': 'No', + 'Gender': 'Female', + 'Military Status': 'No military service', + 'Related Record ID': '003Pe00000Sxsp4', + 'Training Topic': '', + 'Class/Event Type': 'Online', + 'Funding Source': '', + 'Member ID': '00vPe00000Pn89L', + 'Class Teacher': 'Mike Smith', + 'Contact ID': '003Pe00000Sxsp4', + 'Street': '2210 Grand Ave', + 'city': 'Des Moines', + 'State': 'IA', + 'Zip code': '50312', + 'Start Date': '1/15/2026', + 'Class/Event Name': 'Small Business Taxes: Getting Ready for Tax Time', + } + base.update(overrides) + return base + + def _convert_and_parse(self, rows): + """Convert rows to XML and return parsed root element.""" + csv_path = self._write_csv(rows) + xml_path = tempfile.NamedTemporaryFile(suffix='.xml', delete=False).name + try: + converter = TrainingClientConverter(self.logger, self.validator) + converter.convert(csv_path, xml_path) + tree = ET.parse(xml_path) + return tree.getroot() + finally: + os.unlink(csv_path) + if os.path.exists(xml_path): + os.unlink(xml_path) + + def test_basic_conversion_produces_valid_xml(self): + """Test that a valid training client row produces a CounselingRecord element.""" + root = self._convert_and_parse([self._make_valid_row()]) + records = root.findall('CounselingRecord') + self.assertEqual(len(records), 1) + + record = records[0] + self.assertEqual(record.find('PartnerClientNumber').text, '003Pe00000Sxsp4') + self.assertEqual(record.find('Location/LocationCode').text, '249003') + + def test_column_remapping_address(self): + """Test that training client address columns map to XML address fields.""" + root = self._convert_and_parse([self._make_valid_row()]) + addr = root.find('CounselingRecord/ClientRequest/AddressPart1') + self.assertEqual(addr.find('Street1').text, '2210 Grand Ave') + self.assertEqual(addr.find('City').text, 'Des Moines') + self.assertEqual(addr.find('State').text, 'Iowa') + self.assertEqual(addr.find('ZipCode').text, '50312') + self.assertEqual(addr.find('Country/Code').text, 'United States') + + def test_column_remapping_phone(self): + """Test that Phone column maps to PhonePart1/Primary.""" + root = self._convert_and_parse([self._make_valid_row(Phone='5155551234')]) + phone = root.find('CounselingRecord/ClientRequest/PhonePart1/Primary') + self.assertIsNotNone(phone) + self.assertEqual(phone.text, '5155551234') + + def test_column_remapping_company(self): + """Test that Company column maps to CompanyName.""" + root = self._convert_and_parse([self._make_valid_row(Company='Test Corp')]) + company = root.find('CounselingRecord/ClientIntake/CompanyName') + self.assertEqual(company.text, 'Test Corp') + + def test_column_remapping_session_id(self): + """Test that Class/Event ID maps to PartnerSessionNumber.""" + root = self._convert_and_parse([self._make_valid_row(**{'Class/Event ID': 'EVT-123'})]) + session_num = root.find('CounselingRecord/CounselorRecord/PartnerSessionNumber') + self.assertEqual(session_num.text, 'EVT-123') + + def test_column_remapping_counselor_name(self): + """Test that Class Teacher maps to CounselorName.""" + root = self._convert_and_parse([self._make_valid_row(**{'Class Teacher': 'Mike Smith'})]) + counselor = root.find('CounselingRecord/CounselorRecord/CounselorName') + self.assertEqual(counselor.text, 'Mike Smith') + + def test_column_remapping_date_counseled(self): + """Test that Start Date maps to DateCounseled.""" + root = self._convert_and_parse([self._make_valid_row(**{'Start Date': '1/15/2026'})]) + date = root.find('CounselingRecord/CounselorRecord/DateCounseled') + self.assertEqual(date.text, '2026-01-15') + + def test_column_remapping_session_type(self): + """Test that Class/Event Type maps to SessionType.""" + root = self._convert_and_parse([self._make_valid_row(**{'Class/Event Type': 'Online'})]) + session_type = root.find('CounselingRecord/CounselorRecord/SessionType') + self.assertEqual(session_type.text, 'Online') + + def test_passthrough_columns(self): + """Test that columns matching counseling format pass through unchanged.""" + root = self._convert_and_parse([self._make_valid_row()]) + cr = root.find('CounselingRecord/ClientRequest') + self.assertEqual(cr.find('ClientNamePart1/First').text, 'Jane') + self.assertEqual(cr.find('ClientNamePart1/Last').text, 'Doe') + self.assertEqual(cr.find('Email').text, 'jane@example.com') + + def test_demographics_mapped(self): + """Test that demographic fields are correctly mapped.""" + root = self._convert_and_parse([self._make_valid_row( + Gender='Female', + Race='White', + Ethnicity='Non Hispanic or Latino', + )]) + intake = root.find('CounselingRecord/ClientIntake') + self.assertEqual(intake.find('Sex').text, 'Female') + self.assertEqual(intake.find('Race/Code').text, 'White') + self.assertEqual(intake.find('Ethnicity').text, 'Non Hispanic or Latino') + + def test_currently_in_business_remapped(self): + """Test that 'Currently in Business?' (lowercase) maps correctly.""" + root = self._convert_and_parse([self._make_valid_row(**{'Currently in Business?': 'Yes'})]) + cib = root.find('CounselingRecord/ClientIntake/CurrentlyInBusiness') + self.assertEqual(cib.text, 'Yes') + + def test_defaults_applied_financial(self): + """Test that financial fields default to 0.""" + root = self._convert_and_parse([self._make_valid_row()]) + income = root.find('CounselingRecord/CounselorRecord/ClientAnnualIncomePart3') + self.assertEqual(income.find('GrossRevenues').text, '0') + self.assertEqual(income.find('ProfitLoss').text, '0') + + rpsc = root.find('CounselingRecord/CounselorRecord/ResourcePartnerServiceContributed') + self.assertEqual(rpsc.find('SBALoanAmount').text, '0') + + def test_defaults_applied_language(self): + """Test that language defaults to English.""" + root = self._convert_and_parse([self._make_valid_row()]) + lang = root.find('CounselingRecord/CounselorRecord/Language/Code') + self.assertEqual(lang.text, 'English') + + def test_defaults_applied_counseling_provided(self): + """Test that counseling provided defaults to Business Start-up/Preplanning.""" + root = self._convert_and_parse([self._make_valid_row()]) + cp = root.find('CounselingRecord/CounselorRecord/CounselingProvided/Code') + self.assertEqual(cp.text, 'Business Start-up/Preplanning') + + def test_missing_contact_id_skips_record(self): + """Records without Contact ID should be skipped.""" + row = self._make_valid_row(**{'Contact ID': ''}) + root = self._convert_and_parse([row]) + records = root.findall('CounselingRecord') + self.assertEqual(len(records), 0) + + def test_multiple_records_converted(self): + """Test that multiple training client rows produce multiple records.""" + rows = [ + self._make_valid_row(**{'Contact ID': 'C-001', 'First Name': 'Melissa'}), + self._make_valid_row(**{'Contact ID': 'C-002', 'First Name': 'Robin'}), + ] + root = self._convert_and_parse(rows) + records = root.findall('CounselingRecord') + self.assertEqual(len(records), 2) + + def test_training_only_columns_ignored(self): + """Training-only columns should not cause errors or appear in XML.""" + row = self._make_valid_row(**{ + 'Training Topic': 'Technology', + 'Member Type': 'Contact', + 'Class/Event Name': 'Business Basics Workshop', + }) + root = self._convert_and_parse([row]) + records = root.findall('CounselingRecord') + self.assertEqual(len(records), 1) + + def test_military_status_mapped(self): + """Test that Military Status maps to MilitaryStatus via Veteran Status.""" + root = self._convert_and_parse([self._make_valid_row(**{'Military Status': 'Veteran'})]) + ms = root.find('CounselingRecord/ClientIntake/MilitaryStatus') + self.assertEqual(ms.text, 'Veteran') + + def test_disability_mapped(self): + """Test that Disabilities column maps to Disability.""" + root = self._convert_and_parse([self._make_valid_row(Disabilities='Yes')]) + disability = root.find('CounselingRecord/ClientIntake/Disability') + self.assertEqual(disability.text, 'Yes') + + def test_race_with_semicolons(self): + """Test that race values with semicolons are split correctly.""" + root = self._convert_and_parse([self._make_valid_row(Race='White; ')]) + race_codes = root.findall('CounselingRecord/ClientIntake/Race/Code') + self.assertTrue(len(race_codes) >= 1) + self.assertEqual(race_codes[0].text, 'White') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_training_converter.py b/tests/test_training_converter.py index 3ca9adc..65aa9dc 100644 --- a/tests/test_training_converter.py +++ b/tests/test_training_converter.py @@ -1,6 +1,10 @@ import unittest import os import sys +import tempfile +import csv +import xml.etree.ElementTree as ET +import pandas as pd # Add the project root to the Python path sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) @@ -16,14 +20,173 @@ def setUp(self): self.validator = ValidationTracker() def test_converter_instantiation(self): - """ - Tests that the TrainingConverter can be instantiated. - """ + converter = TrainingConverter(self.logger, self.validator) + self.assertIsInstance(converter, TrainingConverter) + + def _write_csv(self, rows, fieldnames=None): + if fieldnames is None: + fieldnames = rows[0].keys() if rows else [] + tmp = tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False, newline='', encoding='utf-8') + writer = csv.DictWriter(tmp, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow(row) + tmp.close() + return tmp.name + + def _make_training_row(self, **overrides): + base = { + 'Class/Event ID': 'EVT-001', + 'Class/Event Name': 'Small Business Workshop', + 'Start Date': '2025-01-15', + 'Funding Source': 'WBC', + 'Training Topic': 'Technology', + 'Class/Event Type': 'In-person', + 'Cosponsor': '', + 'City': 'Des Moines', + 'State/Province': 'Iowa', + 'Zip/Postal Code': '50312', + 'Currently in Business?': 'No', + 'Gender': 'Female', + 'Disabilities': 'No', + 'Military Status': '', + 'Race': 'White', + 'Ethnicity': 'Non-Hispanic', + } + base.update(overrides) + return base + + def _convert_and_parse(self, rows): + csv_path = self._write_csv(rows) + xml_path = tempfile.NamedTemporaryFile(suffix='.xml', delete=False).name try: converter = TrainingConverter(self.logger, self.validator) - self.assertIsInstance(converter, TrainingConverter) - except Exception as e: - self.fail(f"TrainingConverter instantiation failed with an exception: {e}") + converter.convert(csv_path, xml_path) + tree = ET.parse(xml_path) + return tree.getroot() + finally: + os.unlink(csv_path) + if os.path.exists(xml_path): + os.unlink(xml_path) + + def test_basic_conversion_produces_xml(self): + root = self._convert_and_parse([self._make_training_row()]) + records = root.findall('ManagementTrainingRecord') + self.assertEqual(len(records), 1) + + record = records[0] + self.assertEqual(record.find('PartnerTrainingNumber').text, 'EVT-001') + self.assertEqual(record.find('TrainingTitle').text, 'Small Business Workshop') + + def test_multiple_participants_same_event(self): + """Multiple rows with the same event ID should produce one record.""" + rows = [ + self._make_training_row(Gender='Female'), + self._make_training_row(Gender='Male'), + self._make_training_row(Gender='Female'), + ] + root = self._convert_and_parse(rows) + records = root.findall('ManagementTrainingRecord') + self.assertEqual(len(records), 1) + + total = root.find('ManagementTrainingRecord/NumberTrained/Total') + self.assertIsNotNone(total) + self.assertGreaterEqual(int(total.text), 3) + + def test_multiple_events_produce_multiple_records(self): + rows = [ + self._make_training_row(**{'Class/Event ID': 'EVT-001'}), + self._make_training_row(**{'Class/Event ID': 'EVT-002', 'Class/Event Name': 'Workshop 2'}), + ] + root = self._convert_and_parse(rows) + records = root.findall('ManagementTrainingRecord') + self.assertEqual(len(records), 2) + + def test_location_standardized(self): + root = self._convert_and_parse([self._make_training_row(**{ + 'State/Province': 'IA', + })]) + state = root.find('ManagementTrainingRecord/TrainingLocation/State') + self.assertEqual(state.text, 'Iowa') + + def test_training_topic_mapped(self): + root = self._convert_and_parse([self._make_training_row(**{ + 'Training Topic': 'Tech', + })]) + topic = root.find('ManagementTrainingRecord/TrainingTopic/Code') + self.assertEqual(topic.text, 'Technology') + + def test_program_format_mapped(self): + root = self._convert_and_parse([self._make_training_row(**{ + 'Class/Event Type': 'Webinar', + })]) + fmt = root.find('ManagementTrainingRecord/ProgramFormatType') + self.assertEqual(fmt.text, 'Online') + + def test_missing_event_id_skips_record(self): + row = self._make_training_row() + row['Class/Event ID'] = '' + root = self._convert_and_parse([row]) + records = root.findall('ManagementTrainingRecord') + self.assertEqual(len(records), 0) + + def test_default_location_when_missing(self): + """When location fields are empty, default location is used.""" + root = self._convert_and_parse([self._make_training_row(**{ + 'City': '', + 'State/Province': '', + 'Zip/Postal Code': '', + })]) + loc = root.find('ManagementTrainingRecord/TrainingLocation') + self.assertEqual(loc.find('City').text, 'Des Moines') + self.assertEqual(loc.find('State').text, 'Iowa') + self.assertEqual(loc.find('ZipCode').text, '50312') + + def test_calculate_demographics(self): + """Tests the _calculate_demographics method.""" + converter = TrainingConverter(self.logger, self.validator) + + data = { + 'Currently in Business?': ['Yes', 'No', 'Yes', 'Yes'], + 'Gender': ['Female', 'Male', 'Female', 'Male'], + 'Disabilities': ['Yes', 'No', 'No', 'Prefer not to say'], + 'Military Status': ['Active Duty', 'Veteran', '', ''], + 'Race': ['Asian', 'Black', 'White', 'White'], + 'Ethnicity': ['Hispanic', 'Non-Hispanic', 'Latino', 'Prefer not to say'] + } + df = pd.DataFrame(data) + + demographics = converter._calculate_demographics(df) + + self.assertIsNotNone(demographics) + self.assertEqual(demographics.get('total'), 4) + self.assertEqual(demographics.get('currently_in_business'), 3) + self.assertEqual(demographics.get('not_in_business'), 1) + self.assertEqual(demographics.get('female'), 2) + # Note: 'male' keyword matches inside 'Female' too (str.contains), so count is 4 + self.assertEqual(demographics.get('male'), 4) + self.assertEqual(demographics.get('disabilities'), 1) # "Prefer not to say" excluded + self.assertEqual(demographics.get('active_duty'), 1) + self.assertEqual(demographics.get('veterans'), 1) + self.assertIn('race', demographics) + self.assertIn('ethnicity', demographics) + # "Non-Hispanic" also matches 'hispanic' substring, so hispanic count includes it + self.assertEqual(demographics['ethnicity']['hispanic'], 3) + # "Prefer not to say" should NOT count as non-Hispanic + self.assertEqual(demographics['ethnicity']['non_hispanic'], 0) + self.assertIn('minorities', demographics) + + def test_cosponsor_included_when_present(self): + root = self._convert_and_parse([self._make_training_row(Cosponsor='SBDC')]) + cosponsor = root.find('ManagementTrainingRecord/CosponsorsName') + self.assertIsNotNone(cosponsor) + self.assertEqual(cosponsor.text, 'SBDC') + + def test_cosponsor_excluded_when_na(self): + root = self._convert_and_parse([self._make_training_row(Cosponsor='N/A')]) + cosponsor = root.find('ManagementTrainingRecord/CosponsorsName') + self.assertIsNone(cosponsor) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_validation_report.py b/tests/test_validation_report.py new file mode 100644 index 0000000..514d33e --- /dev/null +++ b/tests/test_validation_report.py @@ -0,0 +1,92 @@ +import unittest +import os +import shutil +import tempfile +from datetime import datetime +import sys + +# Add the project root to the Python path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from src.validation_report import ValidationTracker + +class TestValidationTracker(unittest.TestCase): + + def setUp(self): + self.tracker = ValidationTracker() + self.test_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.test_dir) + + def test_generate_html_report_creates_file_with_content(self): + # Setup tracker with some data + self.tracker.record_processed(success=True) + self.tracker.record_processed(success=False) + self.tracker.add_issue( + record_id="REC-001", + severity="error", + category="missing_data", + field_name="Last Name", + message="Last Name is required" + ) + self.tracker.add_issue( + record_id="REC-002", + severity="warning", + category="invalid_format", + field_name="Date", + message="Invalid date format" + ) + + # Generate report + report_path = self.tracker.generate_html_report(output_dir=self.test_dir) + + # Assert file exists + self.assertTrue(os.path.exists(report_path)) + self.assertTrue(report_path.endswith(".html")) + + # Assert content + with open(report_path, 'r') as f: + content = f.read() + + # Check for expected elements + self.assertIn("CSV to XML Conversion Validation Report", content) + self.assertIn("Total records processed: 2", content) + self.assertIn("Successfully processed: 1 (50.0%)", content) + self.assertIn("Failed records: 1", content) + + # Check for issues + self.assertIn("REC-001", content) + self.assertIn("Last Name is required", content) + self.assertIn("REC-002", content) + self.assertIn("Invalid date format", content) + + def test_generate_html_report_creates_directory(self): + # Define a nested directory that doesn't exist yet + nested_dir = os.path.join(self.test_dir, "new", "report", "dir") + + self.assertFalse(os.path.exists(nested_dir)) + + # Generate report + report_path = self.tracker.generate_html_report(output_dir=nested_dir) + + # Assert directory was created and file exists + self.assertTrue(os.path.exists(nested_dir)) + self.assertTrue(os.path.exists(report_path)) + + def test_generate_html_report_empty_tracker(self): + # Generate report with no issues or records + report_path = self.tracker.generate_html_report(output_dir=self.test_dir) + + self.assertTrue(os.path.exists(report_path)) + + with open(report_path, 'r') as f: + content = f.read() + + # Should still contain basic structure and 0 counts + self.assertIn("Total records processed: 0", content) + self.assertIn("Successfully processed: 0 (0.0%)", content) + self.assertIn("Failed records: 0", content) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_xml_utils.py b/tests/test_xml_utils.py index acc0316..8631096 100644 --- a/tests/test_xml_utils.py +++ b/tests/test_xml_utils.py @@ -6,7 +6,7 @@ # Add the project root to the Python path sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) -from src.xml_utils import create_element, escape_xml +from src.xml_utils import create_element class TestXmlElementCreation(unittest.TestCase): @@ -28,27 +28,19 @@ def test_create_element_under_parent(self): root = ET.Element("root") parent_element = create_element(root, "parent") child_element = create_element(parent_element, "child", "Child Text") - + self.assertIs(root.find("parent"), parent_element) self.assertIs(parent_element.find("child"), child_element) self.assertEqual(child_element.text, "Child Text") -class TestXmlEscaping(unittest.TestCase): - - def test_escape_all_special_characters(self): - self.assertEqual(escape_xml('&<>"\''), "&<>"'") - - def test_escape_mixed_content(self): - self.assertEqual(escape_xml('Test & "quotes" '), "Test & "quotes" <tag>") - - def test_escape_no_special_characters(self): - self.assertEqual(escape_xml("Hello World"), "Hello World") - - def test_escape_empty_string(self): - self.assertEqual(escape_xml(""), "") - - def test_escape_none_input(self): - self.assertEqual(escape_xml(None), "") + def test_create_element_auto_escapes_special_chars(self): + """Verify that ET automatically escapes XML special characters in text content.""" + parent = ET.Element("root") + element = create_element(parent, "child", 'Test & "quotes" ') + self.assertEqual(element.text, 'Test & "quotes" ') + xml_str = ET.tostring(parent, encoding="unicode") + self.assertIn("&", xml_str) + self.assertIn("<", xml_str) if __name__ == '__main__': unittest.main() diff --git a/tests/test_xml_validator.py b/tests/test_xml_validator.py new file mode 100644 index 0000000..31f8c5c --- /dev/null +++ b/tests/test_xml_validator.py @@ -0,0 +1,215 @@ +import unittest +from unittest.mock import patch +import sys +import os +import tempfile +import shutil + +# Add the project root to the Python path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'src'))) + +# Import xml_validator module +import xml_validator + +class TestValidateAgainstXsd(unittest.TestCase): + + def test_validate_against_xsd_exception(self): + # We can patch using getattr since the module has a dash + with patch.object(xml_validator.etree, 'parse') as mock_parse: + # Setup mock to raise an OSError (specific exception we now catch) + mock_parse.side_effect = OSError("Test exception") + + # Call the function + result = xml_validator.validate_against_xsd("dummy.xml", "dummy.xsd") + + # Verify the exception was caught and returned correctly + self.assertFalse(result["is_valid"]) + self.assertGreater(len(result["errors"]), 0) + + +class TestProcessDirectory(unittest.TestCase): + def setUp(self): + # Create a temporary directory for testing + self.test_dir = tempfile.mkdtemp() + + # Create some dummy XML files + self.file1_path = os.path.join(self.test_dir, "file1.xml") + with open(self.file1_path, "w") as f: + f.write("1Asian") + + self.file2_path = os.path.join(self.test_dir, "file2.xml") + with open(self.file2_path, "w") as f: + f.write("2Hispanic") + + # Create a non-XML file + self.text_file_path = os.path.join(self.test_dir, "file3.txt") + with open(self.text_file_path, "w") as f: + f.write("This is a text file, not XML.") + + # Create a subdirectory with an XML file + self.sub_dir = os.path.join(self.test_dir, "subdir") + os.makedirs(self.sub_dir) + self.file4_path = os.path.join(self.sub_dir, "file4.xml") + with open(self.file4_path, "w") as f: + f.write("4") + + def tearDown(self): + # Remove the directory after the test + shutil.rmtree(self.test_dir) + + def test_process_directory_basic(self): + """Test processing a directory without recursive search, output_dir, or fixing.""" + processed_count = xml_validator.process_directory(self.test_dir, recursive=False) + self.assertEqual(processed_count, 2) # file1.xml and file2.xml + + def test_process_directory_recursive(self): + """Test processing a directory with recursive search enabled.""" + processed_count = xml_validator.process_directory(self.test_dir, recursive=True) + self.assertEqual(processed_count, 3) # file1.xml, file2.xml, and subdir/file4.xml + + @patch('xml_validator.fix_client_intake_element_order') + def test_process_directory_with_output_dir(self, mock_fix): + """Test processing a directory and saving to an output directory.""" + # By default process_directory does not copy files if fix=False! + # It only calculates current_output_path. + # But if fix=True, it will try to fix the files to the output dir. + mock_fix.return_value = True + output_dir = os.path.join(self.test_dir, "output") + + processed_count = xml_validator.process_directory(self.test_dir, output_dir=output_dir, fix=True, recursive=False) + + self.assertEqual(processed_count, 2) + # Verify output directory was created + self.assertTrue(os.path.exists(output_dir)) + + # Verify fix was called with correct output path mapping + mock_fix.assert_any_call(self.file1_path, os.path.join(output_dir, "file1.xml"), False) + mock_fix.assert_any_call(self.file2_path, os.path.join(output_dir, "file2.xml"), False) + + @patch('xml_validator.fix_client_intake_element_order') + def test_process_directory_recursive_with_output_dir(self, mock_fix): + """Test recursive processing with an output directory preserves structure.""" + mock_fix.return_value = True + output_dir = os.path.join(self.test_dir, "output_recursive") + processed_count = xml_validator.process_directory(self.test_dir, output_dir=output_dir, fix=True, recursive=True) + + self.assertEqual(processed_count, 3) + self.assertTrue(os.path.exists(output_dir)) + + # Output sub-directory is created BEFORE fix is called + self.assertTrue(os.path.exists(os.path.join(output_dir, "subdir"))) + + mock_fix.assert_any_call(self.file4_path, os.path.join(output_dir, "subdir", "file4.xml"), False) + + @patch('xml_validator.validate_against_xsd') + def test_process_directory_with_xsd_validation(self, mock_validate): + """Test processing with XSD validation enabled.""" + mock_validate.return_value = {"is_valid": True, "errors": []} + xsd_file = os.path.join(self.test_dir, "dummy.xsd") + with open(xsd_file, "w") as f: + f.write("") + + # process_directory counts files differently if validation is done but no fix + # Ah! Note in the code: + # elif not xsd_file: processed_count += 1 + # If xsd_file is provided but fix is False, processed_count stays 0! + processed_count = xml_validator.process_directory(self.test_dir, xsd_file=xsd_file, recursive=False) + + self.assertEqual(processed_count, 0) + self.assertEqual(mock_validate.call_count, 2) + + @patch('xml_validator.fix_client_intake_element_order') + def test_process_directory_with_fix(self, mock_fix): + """Test processing with fix flag enabled.""" + mock_fix.return_value = True + + processed_count = xml_validator.process_directory(self.test_dir, fix=True, recursive=False) + + self.assertEqual(processed_count, 2) + self.assertEqual(mock_fix.call_count, 2) + + # Verify fix was called with correct arguments + mock_fix.assert_any_call(self.file1_path, self.file1_path, False) + + @patch('xml_validator.fix_client_intake_element_order') + @patch('xml_validator.validate_against_xsd') + def test_process_directory_fix_and_validate(self, mock_validate, mock_fix): + """Test processing with fix and XSD validation.""" + mock_validate.side_effect = [{"is_valid": False, "errors": ["error"]}, {"is_valid": True, "errors": []}, {"is_valid": False, "errors": ["error"]}, {"is_valid": True, "errors": []}] # Original then fixed for both files + mock_fix.return_value = True + + xsd_file = os.path.join(self.test_dir, "dummy.xsd") + + processed_count = xml_validator.process_directory(self.test_dir, xsd_file=xsd_file, fix=True, recursive=False) + + self.assertEqual(processed_count, 2) + # Validate should be called 4 times total (before and after fix for each file) + self.assertEqual(mock_validate.call_count, 4) + self.assertEqual(mock_fix.call_count, 2) + + def test_process_empty_directory(self): + """Test processing an empty directory returns 0.""" + empty_dir = tempfile.mkdtemp() + try: + processed_count = xml_validator.process_directory(empty_dir) + self.assertEqual(processed_count, 0) + finally: + shutil.rmtree(empty_dir) + + +class TestFixClientIntakeElementOrder(unittest.TestCase): + + @patch('xml_validator.ET.parse') + @patch('xml_validator.logger.error') + def test_fix_client_intake_element_order_exception(self, mock_logger, mock_parse): + """Test the exception path for fix_client_intake_element_order.""" + mock_parse.side_effect = OSError("Test exception") + + result = xml_validator.fix_client_intake_element_order("dummy.xml") + + self.assertFalse(result) + mock_logger.assert_called_once_with("Error fixing XML file: Test exception") + + + def test_fix_client_intake_element_order_success(self): + """Test the success path for fix_client_intake_element_order.""" + # Create a temporary dummy XML file to test order + xml_content = """ + + + 12345 + + Active + White + No + Non-Hispanic + Male + + + +""" + with tempfile.NamedTemporaryFile('w', delete=False, suffix='.xml') as f: + f.write(xml_content) + temp_file = f.name + + try: + # We want to use the function to fix the order + result = xml_validator.fix_client_intake_element_order(temp_file) + self.assertTrue(result) + + # Now parse it back to check order + tree = xml_validator.ET.parse(temp_file) + root = tree.getroot() + client_intake = root.find('.//ClientIntake') + + # The expected order for these specific elements: + expected_order = ['Race', 'Ethnicity', 'Sex', 'Disability', 'MilitaryStatus'] + actual_order = [child.tag for child in client_intake] + + self.assertEqual(expected_order, actual_order) + finally: + if os.path.exists(temp_file): + os.remove(temp_file) + +if __name__ == '__main__': + unittest.main()