diff --git a/Adobe to Docusign Template migration Tool Blueprint.docx b/Adobe to Docusign Template migration Tool Blueprint.docx new file mode 100644 index 0000000..82dc25d Binary files /dev/null and b/Adobe to Docusign Template migration Tool Blueprint.docx differ diff --git a/PRODUCT-SPEC.md b/PRODUCT-SPEC.md index 5cec66d..0bcdf18 100644 --- a/PRODUCT-SPEC.md +++ b/PRODUCT-SPEC.md @@ -1,9 +1,9 @@ -# Initial Product Spec (Draft) +# Product Specification ## Project: Adobe Sign to DocuSign Template Migrator ### Purpose -Develop an agent/toolkit that can programmatically extract template data and field logic from Adobe Sign (“library documents”), map/transform into DocuSign’s template model, and create new DocuSign templates to reduce manual migration effort. +Develop an agent/toolkit that can programmatically extract template data and field logic from Adobe Sign ("library documents"), map/transform into DocuSign's template model, and create new DocuSign templates to reduce manual migration effort. --- @@ -12,28 +12,249 @@ Develop an agent/toolkit that can programmatically extract template data and fie - Generate best-approximation DocuSign templates programmatically - Handle all basic field types and recipient roles - Detect and warn on features needing special/manual handling (complex logic, custom validations, non-mappable features) - -### Key Features (MVP) -- Connect to Adobe Sign and DocuSign APIs via credentials loaded from .env -- Extract template listing from Adobe Sign sandbox/account -- Pull all required endpoints: metadata, formFields, recipients, workflows -- Assemble complete data model for each imported template -- Mapping layer: field type/role/routing normalization (see field-mapping.md) -- Programmatically create equivalent template and roles in DocuSign -- Logging and reporting of success, errors, edge cases - -### Stretch (Future) -- UI for side-by-side compare/QA -- Complex feature transform plugins -- Bulk mode & idempotent re-runs -- Support for in-place PDF field overlay (anchors/rects) +- Produce a structured migration report with successes, warnings, and manual-fix items --- -#### Out of Scope (MVP) -- Agreement instance migration (focus on templates only) +### Architecture + +#### Components +- **Adobe Sign Client** (`src/adobe_api.py`) — authenticated API calls, template listing/download +- **DocuSign Client** (`src/upload_docusign_template.py`, `src/docusign_auth.py`) — JWT auth, template upsert +- **Normalized Schema Model** (`src/models/normalized_template.py`) — platform-agnostic intermediate representation +- **Mapping Service** (`src/services/mapping_service.py`) — field type, recipient role, coordinate translation +- **Validation Service** (`src/services/validation_service.py`) — field count comparison, recipient checks, missing role detection +- **Migration Service** (`src/services/migration_service.py`) — orchestrates download → normalize → validate → compose → upload +- **Report Builder** (`src/reports/report_builder.py`) — structured success/warning/error output +- **Web API** (`web/`) — FastAPI endpoints for browser-based orchestration +- **Frontend** (`web/static/`) — side-by-side template browser, migration UI + +#### Service Separation +``` +src/ + models/ + normalized_template.py # intermediate schema + services/ + migration_service.py # pipeline orchestration + mapping_service.py # field/role/coord transformations + validation_service.py # pre/post migration checks + reports/ + report_builder.py # structured report output + utils/ + pdf_coords.py # coordinate normalization helpers +``` + +--- + +### High-Level Migration Flow + +1. Authenticate to both Adobe Sign and DocuSign (OAuth) +2. List and select Adobe Sign templates +3. Extract: metadata, formFields, recipients, documents, workflows +4. **Normalize** into platform-agnostic intermediate schema +5. **Validate** normalized schema — blockers stop migration; warnings are logged +6. Map to DocuSign template payload +7. Upsert (create or update) in DocuSign +8. Generate migration report + +--- + +### Internal Normalized Schema + +Use an intermediate model so the tool is not tightly coupled to either platform. This enables future support for additional eSign platforms. + +#### Schema Structure +```json +{ + "template": { + "name": "Sales Agreement", + "description": "Migrated from Adobe Sign", + "emailSubject": "Please sign: Sales Agreement", + "emailMessage": "", + "documents": [], + "roles": [ + { "name": "Customer", "order": 1, "actionType": "SIGN" }, + { "name": "Company", "order": 2, "actionType": "SIGN" } + ], + "fields": [ + { + "type": "signature", + "page": 1, + "x": 120, "y": 540, + "width": 140, "height": 28, + "required": true, + "roleName": "Customer" + } + ], + "reminderEnabled": false, + "expirationDays": null + } +} +``` + +--- + +### Core Entities to Migrate + +| Entity | Adobe Sign Source | DocuSign Target | +|-------------------|-----------------------------|-----------------------------| +| Template name | `name` | `name` | +| Description | `message` | `description` | +| Documents (PDFs) | `libraryDocumentId` → bytes | `documents[]` | +| Recipient roles | `participantSetsInfo` | `recipients.signers[]` | +| Routing order | `participantSetsInfo.order` | `routingOrder` | +| Form fields | `formFields` | `tabs` per recipient | +| Email subject | `emailSubject` | `emailSubject` | +| Reminders | `reminderFrequency` | `reminders` | +| Expiration | `daysUntilSigningDeadline` | `expirationDateTime` | + +--- + +### Mapping Logic + +#### 1. Recipient and Role Mapping +- Map Adobe Sign participant sets → DocuSign template roles +- Preserve routing order +- Map action types: SIGN → signer, APPROVE → approver, CC → carbonCopy + +#### 2. Field Type Mapping +```json +{ + "SIGNATURE": "signHere", + "INITIALS": "initialHere", + "TEXT": "text", + "CHECKBOX": "checkbox", + "RADIO": "radioGroup", + "DROPDOWN": "list", + "DATE": "dateSigned", + "ATTACHMENT": "signerAttachment" +} +``` +(Full mapping table: see `field-mapping.md`) + +#### 3. Coordinate Mapping +- Normalize to PDF points +- Account for page rotation +- Transform coordinate origin if needed +- Validate field overlap after placement + +#### 4. DocuSign Payload Fields +The tool must populate: +- Template name and description +- Email subject and message defaults +- Envelope/template documents (with document checksums) +- Template roles with routing order +- Tabs grouped by recipient +- Reminder and expiration settings where supported + +--- + +### Unsupported / Flagged Features (Manual Review Required) +- Conditional recipient routing rules +- Advanced workflow branching +- Calculated fields +- Custom JavaScript validators +- Niche authentication methods (e.g., KBA, phone auth) +- Field validations with no direct DocuSign equivalent +- Webhook / event associations tied to template lifecycle + +--- + +### Migration Options (API) + +`POST /api/migrate` accepts: +```json +{ + "sourceTemplateIds": ["tpl_1001", "tpl_1002"], + "targetFolder": "Migrated Templates", + "options": { + "overwriteIfExists": false, + "dryRun": true, + "includeDocuments": true + } +} +``` + +- **dryRun** — validate and report without creating DocuSign templates +- **overwriteIfExists** — when `false`, skip templates already migrated (default: false) +- **includeDocuments** — embed PDFs in DocuSign template (default: true) +- **targetFolder** — DocuSign folder for created templates + +--- + +### Validation Layer + +Pre-migration checks (blockers and warnings): +- Field count before vs. after mapping +- Recipient count and routing order integrity +- Fields missing role assignments +- Unsupported feature detection +- Document checksum comparison (before upload vs. after download confirmation) + +Post-migration checks: +- DocuSign template field count vs. normalized schema count +- Recipient role count match +- Migration report includes pass/warn/fail per template + +--- + +### Implementation Considerations + +#### Authentication +- OAuth for both Adobe Sign and DocuSign (with token auto-refresh) +- Support admin-consent flows where required +- Securely store tokens (never in logs or plaintext files) + +#### Rate Limits +- Batch API requests carefully +- Retries with exponential backoff on 429/5xx +- Use idempotency (upsert pattern) where possible + +#### File Handling +- Preserve original PDFs locally in `downloads/` +- Checksum documents before and after upload +- Keep document-page metadata for accurate tab placement + +#### Security +- Redact secrets and tokens from all log output +- Encrypt token storage where possible +- Maintain audit trail for all migration operations (template ID, timestamp, status, user) + +--- + +### MVP Feature Set (Phase 1) +- Authenticate to both systems (CLI + Web) +- List and select Adobe Sign templates +- Migrate basic templates (standard roles + common fields) +- Normalized intermediate schema as pipeline bridge +- Validation layer (field/recipient counts, missing roles) +- Migration report (success / warning / error per template) +- Dry-run mode +- Idempotent re-runs (overwrite prevention) + +### Phase 2 Features +- Batch migration (multiple templates in one request) +- Retry failed templates +- Coordinate validation preview +- Duplicate detection +- Folder / category mapping +- Audit logging +- Rate limit handling with backoff + +### Phase 3 Features +- UI preview for field placements +- Manual correction workflow +- Side-by-side template comparison (visual diff) +- Webhook recreation +- Advanced workflow translation + +--- + +### Out of Scope (MVP) +- Agreement instance migration (templates only) - Custom integrations outside API surface +- Real-time collaborative editing --- -*Last updated: 2026-04-14 (scaffolded by Cleo)* +*Updated: 2026-04-21 (Blueprint alignment — added normalized schema, validation layer, migration options, security/rate-limit requirements, Phase 2/3 feature set, architecture detail)* diff --git a/README.md b/README.md index 5e8abc5..e521a55 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,8 @@ If multiple templates share the same name, the most recently modified one is use ## Web UI -The web UI provides a browser-based interface for connecting both platforms, browsing templates side-by-side, and running migrations with live status feedback. +The web UI is an enterprise-grade migration console with a Docusign-branded left-nav +shell, multi-customer project context, and a full migration workflow. **Additional `.env` keys required for the web UI:** ``` @@ -118,15 +119,37 @@ uvicorn web.app:app --reload --port 8000 ``` Then open [http://localhost:8000](http://localhost:8000) in your browser. -**Using the UI:** -1. Click **Connect Adobe Sign** in the header — you'll be redirected to Adobe Sign OAuth. Authorize and you'll return to the app. -2. Click **Connect DocuSign** — same flow for DocuSign. -3. Your Adobe Sign templates appear on the left with status badges: - - **Not Migrated** (red) — no matching DocuSign template yet - - **Migrated** (green) — a DocuSign template with the same name exists and is up to date - - **Needs Update** (yellow) — the Adobe template was modified after the last migration -4. Check one or more templates and click **Migrate Selected**. -5. Migration results appear inline; the history table at the bottom logs all past runs. +### Navigation + +| Screen | Path | Purpose | +|---|---|---| +| Templates | `#/templates` | Filterable table with readiness badges; bulk migration | +| Migration Results | `#/results` | Summary + per-template results from last migration | +| Issues & Warnings | `#/issues` | All templates with blockers or warnings | +| Verification | `#/verify` | Send test envelopes; confirm templates work end-to-end | +| History & Audit | `#/history` | Full migration history, filters, CSV export | +| Settings | `#/settings` | Verification defaults, migration defaults, connection info | + +### Workflow + +1. **Create a project** — the switcher modal opens on first run; name it after the customer. +2. **Connect platforms** — click the Adobe Sign and Docusign chips in the top bar. +3. **Review templates** — the Templates view shows readiness badges: + - **Ready** (green) — no issues, safe to migrate + - **Caveats** (amber) — warnings exist; migration will proceed but check Issues view + - **Blocked** (red) — blockers found; migration will fail until resolved + - **Migrated** (cobalt) — successfully migrated and up to date + - **Needs Update** (amber) — Adobe template modified after last migration +4. **Resolve issues** — check Issues & Warnings before migrating blocked templates. +5. **Migrate** — select templates, click Migrate Selected, configure options (dry run, overwrite, target folder), monitor progress. Failed rows show the error inline; a summary hint appears if any templates fail. +6. **Review field issues** — successfully migrated templates may show an amber **partial** badge if features were dropped during migration (e.g. cross-recipient conditionals, unsupported operators). Expand any result row to see grouped field-issue details. +7. **Verify** — on the Verification screen, send test envelopes to confirm templates work end-to-end. Polling checks every 30 seconds and times out after 5 minutes; production deployments should use Docusign Connect (webhooks) instead. +8. **Audit** — History & Audit logs every migration with checksums and export. Rows with field issues show the same grouped breakdown on expand. + +### Project / customer context + +The project switcher (nav footer) stores per-customer migration context in `localStorage`. +Create one project per customer to keep history and settings separate. **API docs:** [http://localhost:8000/api/docs](http://localhost:8000/api/docs) @@ -135,7 +158,7 @@ Then open [http://localhost:8000](http://localhost:8000) in your browser. ## Running tests ```bash -pytest tests/ -v # full suite (29 tests) +pytest tests/ -v # full suite (119 tests) pytest tests/test_regression.py -v # compose regression only pytest tests/test_regression.py --update-snapshots # regenerate snapshots after intentional changes ``` @@ -154,10 +177,99 @@ unexpected API behaviors, and the fixes applied. --- +## Migration API options + +`POST /api/migrate` accepts extended options (blueprint-aligned): + +```json +{ + "source_template_ids": ["tpl_001", "tpl_002"], + "target_folder": "Migrated Templates", + "options": { + "dry_run": false, + "overwrite_if_exists": false, + "include_documents": true + } +} +``` + +| Option | Default | Description | +|---|---|---| +| `dry_run` | `false` | Validate and compose without creating DocuSign templates | +| `overwrite_if_exists` | `false` | If `false`, skip templates that already exist in DocuSign | +| `include_documents` | `true` | Embed PDFs in the DocuSign template | + +**Batch migration** (`POST /api/migrate/batch`) runs the same pipeline for multiple templates as a background job: + +```bash +# Start batch +curl -X POST /api/migrate/batch -d '{"source_template_ids": ["id1", "id2"]}' +# → {"job_id": "abc-123", "status": "queued"} + +# Poll status +curl /api/migrate/batch/abc-123 +# → {"status": "running", "progress": {"completed": 1, "total": 2}, ...} +``` + +--- + +## Normalized intermediate schema + +The migration pipeline uses a platform-agnostic `NormalizedTemplate` model as a bridge between Adobe Sign and DocuSign. This decouples extraction from composition and enables the validation layer. + +See `src/models/normalized_template.py` and `src/services/mapping_service.py`. + +--- + +## Validation + +Each template is validated before migration: +- **Blockers** (halt migration): no recipients, no documents +- **Warnings** (logged but continue): no signature fields, unassigned fields, unsupported features + +Unsupported features flagged for manual review: conditional HIDE actions, JavaScript validators, calculated fields, webhook associations, niche authentication methods. + +## Field issues (partial migration) + +Beyond blockers and warnings, the compose step emits structured **field issues** when a field migrates successfully but something was silently dropped or approximated. Each issue has a machine-readable code: + +| Code | Meaning | +|---|---| +| `CROSS_RECIPIENT_CONDITIONAL` | Show/hide condition references a field on a different recipient — DocuSign does not support cross-recipient conditional logic | +| `UNSUPPORTED_OPERATOR` | Condition uses NOT_EQUALS / GT / LT etc. — only EQUALS is supported | +| `HIDE_ACTION` | Adobe HIDE condition has no DocuSign equivalent — field will always be visible | +| `MULTI_PREDICATE` | AND/OR multi-condition logic reduced to first EQUALS predicate only | +| `INVALID_PARENT_TAB` | Conditional parent references a non-existent tab or a forbidden tab type (signature/auto-fill tabs cannot be parents) | +| `FIELD_TYPE_SKIPPED` | INLINE_IMAGE or PARTICIPATION_STAMP — no DocuSign equivalent, field dropped | +| `PARTIAL_FIELD_TYPE` | FILE_CHOOSER → signerAttachmentTabs, or STAMP → stampTabs — field migrated but behaviour differs | + +Templates with field issues are marked with a **partial** badge in the UI. Field issues are stored in `field_issues[]` on every migration result record and displayed grouped by code in all result views. + +--- + +## Security + +- `src/utils/log_sanitizer.py` — install `SanitizingFilter` to redact tokens, keys, and base64 PDF content from all log output +- PDF checksums (SHA-256) are computed and stored with each migration record +- Tokens are never written to logs; see `src/utils/log_sanitizer.py` + +--- + ## Project structure ``` src/ + models/ + normalized_template.py # Platform-agnostic intermediate schema + field_issue.py # Structured field-issue model + issue codes + services/ + mapping_service.py # Adobe Sign → NormalizedTemplate converter + validation_service.py # Pre/post migration checks (blockers + warnings) + reports/ + report_builder.py # Structured migration report per template + utils/ + retry.py # Exponential backoff retry helpers + log_sanitizer.py # Secret redaction from logs adobe_auth.py # One-time OAuth flow for Adobe Sign (CLI) adobe_api.py # Adobe Sign API client (auto token refresh) download_templates.py # List and download templates from Adobe Sign @@ -165,43 +277,63 @@ src/ docusign_auth.py # DocuSign JWT auth + one-time consent flow upload_docusign_template.py # Upsert upload: PUT if exists, POST if not migrate_template.py # End-to-end CLI runner (download → convert → upload) - create_adobe_template.py # Utility: create a test template in Adobe Sign - generate_pdfs.py # Utility: generate sample PDFs for offline testing web/ app.py # FastAPI entrypoint (uvicorn web.app:app) config.py # Environment-based settings session.py # Signed cookie session helpers routers/ - auth.py # Adobe Sign + DocuSign OAuth endpoints + auth.py # Adobe Sign + Docusign OAuth endpoints templates.py # Template listing + migration status API - migrate.py # Migration trigger + history API + migrate.py # Migration trigger, batch, + history API + verify.py # Verification envelope send / status / void static/ - index.html # Web UI (side-by-side browser + migrate flow) - app.js # Vanilla JS frontend - style.css # Styles + status badge colours + index.html # SPA shell (left nav, router outlet, top bar) + css/ + tokens.css # Docusign 2024 brand custom properties + base.css # Reset, typography, utility classes + nav.css # Left sidebar navigation + cards.css # Cards, badges, result rows, field-issue groups + modals.css # Modal dialogs, migration progress + tables.css # Sortable/filterable tables + forms.css # Form inputs, toggles + js/ + app.js # Entry point — router, auth, nav badges + router.js # Hash-based SPA router (#/templates default) + state.js # Global state with pub/sub + api.js # Fetch wrappers for all backend endpoints + auth.js # Auth chips, OAuth flow, toast notifications + templates.js # Templates view + detail (overview/issues/history tabs) + migration.js # Migration modal, progress polling, results view + issues.js # Issues & Warnings view + verification.js # Verification view (send/poll/void envelopes) + history.js # History & Audit view + settings.js # Settings view + project.js # Project/customer context (localStorage) + utils.js # escHtml, formatDate, renderFieldIssues, etc. tests/ + test_normalized_schema.py # Normalized model + mapping service tests + test_validation_service.py # Validation service + report builder tests + test_migration_options.py # dryRun, overwriteIfExists, includeDocuments + test_batch_migration.py # Batch migration API tests + test_retry.py # Retry with backoff utility tests + test_security.py # Log sanitization + PDF checksum tests test_upload_upsert.py # Upsert logic unit tests test_api_health.py # Health endpoint test_api_auth.py # OAuth endpoint tests - test_api_templates.py # Template listing + status tests + test_api_templates.py # Template listing + status tests (10 tests) test_api_migrate.py # Migration API tests + test_api_verify.py # Verification envelope API tests (9 tests) test_e2e.py # Full pipeline end-to-end test test_regression.py # Compose output vs snapshots fixtures/expected/ # Regression snapshots (3 real templates) - FIELD-TYPE-REGRESSION.md # Manual field type regression checklist - PLATFORM-QUIRKS.md # Known API bugs and workarounds downloads/ # Downloaded Adobe Sign templates (gitignored) migration-output/ # Converted DocuSign template JSONs + history -sample-templates/ # JSON fixtures for offline testing - field-mapping.md # Field type mapping table + edge case log -CLAUDE.md # Claude Code instructions for this project -docs/IMPLEMENTATION-PLAN.md # Feature design and test specifications +PRODUCT-SPEC.md # Full product specification (blueprint-aligned) docs/agent-harness/ EXECUTION-BOARD.md # Living kanban board - AGENT-INSTRUCTIONS.md # Definition of done + conventions requirements.txt # Python dependencies ``` diff --git a/docs/UI-REDESIGN-PLAN.md b/docs/UI-REDESIGN-PLAN.md new file mode 100644 index 0000000..a34ad00 --- /dev/null +++ b/docs/UI-REDESIGN-PLAN.md @@ -0,0 +1,573 @@ +# UI Redesign — Implementation Plan + +*Branch: `ui-redesign` | Last updated: 2026-04-21* + +--- + +## Overview + +Replace the basic Phase 6 single-page app (`web/static/`) with the enterprise-grade +migration console designed in `docs/ui-mockup/mockup.html`. + +The backend is complete (Phases 8–13, 108/108 tests passing). All new UI phases are +**frontend-only** unless noted. Existing FastAPI routes do not change except where +noted under Phase 16 (readiness data) and Phase 19 (Verification API). + +### Design reference + +Open `docs/ui-mockup/mockup.html` in a browser to see all 8 screens before starting. + +### Docusign 2024 brand tokens + +| Token | Value | Usage | +|---|---|---| +| Cobalt | `#4C00FF` | Primary CTA, active nav highlight | +| Inkwell | `#130032` | Left nav background | +| Ecru | `#F8F3F0` | Page background | +| Poppy | `#FF5252` | Error / Blocked badge | +| Slate | `#6B6B9A` | Secondary text, muted labels | +| White | `#FFFFFF` | Card surfaces | + +Typography: `Inter` (Google Fonts), fallback `-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`. + +--- + +## Current state + +`web/static/` — three files, ~600 lines total: +- `index.html` — 79 lines, single-page layout (header, two panels, history table) +- `app.js` — 343 lines, vanilla JS (auth, template list, migrate, history) +- `style.css` — 186 lines, basic styles, non-Docusign colours + +--- + +## File structure after redesign + +Keep no-build-step approach (vanilla JS ES modules, no bundler). Split monolith into +logical files served statically by FastAPI. + +``` +web/static/ + index.html # app shell (nav, router outlet, modals) + css/ + tokens.css # CSS custom properties (brand colours, spacing) + base.css # reset, typography, utility classes + nav.css # left sidebar nav + top bar + cards.css # template cards, readiness badges + modals.css # dialog / modal styles + tables.css # history and audit tables + forms.css # settings form inputs + js/ + state.js # global app state (project, auth, templates) + router.js # hash-based client-side router + api.js # thin fetch wrappers for all backend endpoints + auth.js # auth status, connect/disconnect, Adobe dialog + project.js # project switcher modal, project CRUD (localStorage) + templates.js # template list view, readiness badges, filters + migration.js # options modal, progress polling, results view + verification.js # send test envelope, poll status + history.js # history & audit view + settings.js # settings screen + utils.js # escHtml, formatDate, debounce, etc. +``` + +`app.js` and `style.css` are **deleted** (replaced by the above). +`index.html` is **rewritten** as the app shell. + +--- + +## Phase 14 — App Shell & Navigation + +**Goal:** Branded shell that all other views live inside. No functional logic yet — +just the frame, router, and state container. + +### index.html structure + +```html + + +
+
+
+
+ + + +``` + +### css/tokens.css + +```css +:root { + --cobalt: #4C00FF; + --inkwell: #130032; + --ecru: #F8F3F0; + --poppy: #FF5252; + --slate: #6B6B9A; + --white: #FFFFFF; + --success: #28A745; + --warning: #F0A500; + --border: #E0DCF8; + --radius-sm: 4px; + --radius-md: 8px; + --shadow-sm: 0 1px 4px rgba(0,0,0,0.08); + --shadow-md: 0 4px 16px rgba(0,0,0,0.12); +} +``` + +### js/router.js + +```js +const ROUTES = { + '#/dashboard': () => import('./templates.js').then(m => m.renderDashboard()), + '#/templates': () => import('./templates.js').then(m => m.renderTemplates()), + '#/results': () => import('./migration.js').then(m => m.renderResults()), + '#/issues': () => import('./issues.js').then(m => m.renderIssues()), + '#/verify': () => import('./verification.js').then(m => m.renderVerification()), + '#/history': () => import('./history.js').then(m => m.renderHistory()), + '#/settings': () => import('./settings.js').then(m => m.renderSettings()), +}; +// Default route: #/templates +``` + +### js/state.js + +```js +export const state = { + project: null, // { id, name } — loaded from localStorage + auth: { adobe: false, docusign: false }, + templates: [], // array from /api/templates/status + selectedIds: new Set(), + lastMigrationResults: null, // results from most recent batch job +}; +// Simple pub/sub: subscribe(key, fn) / publish(key) +``` + +### js/api.js — endpoint wrappers + +All existing endpoints wrapped: +```js +export const api = { + auth: { + status: () => GET('/api/auth/status'), + connectAdobe: () => POST('/api/auth/adobe/connect'), + connectDocusign: () => POST('/api/auth/docusign/connect'), + exchangeAdobe: (url) => POST('/api/auth/adobe/exchange', { redirect_url: url }), + disconnect: (p) => POST(`/api/auth/${p}/disconnect`), + }, + templates: { + status: () => GET('/api/templates/status'), + adobe: () => GET('/api/templates/adobe'), + docusign: () => GET('/api/templates/docusign'), + }, + migrate: { + run: (body) => POST('/api/migrate', body), + batch: (body) => POST('/api/migrate/batch', body), + batchStatus: (id) => GET(`/api/migrate/batch/${id}`), + history: () => GET('/api/migrate/history'), + }, +}; +``` + +### js/utils.js + +```js +export const escHtml = str => String(str).replace(/[&<>"]/g, c => …); +export const formatDate = iso => new Date(iso).toLocaleDateString(…); +export const formatRelative = iso => …; +export const debounce = (fn, ms) => { … }; +export const uuid = () => crypto.randomUUID(); +``` + +### Commit + +`feat(ui-phase-14): app shell — nav, router, state, brand tokens` + +--- + +## Phase 15 — Project / Customer Context + +**Goal:** Project switcher so the same installation can manage migrations for +multiple customers without mixing history or credentials. + +### Data model (localStorage only — no backend) + +```js +// Stored in localStorage key: 'migrator_projects' +{ + active: "uuid-1", + projects: [ + { id: "uuid-1", name: "Acme Corp", createdAt: "2026-04-21T…" }, + { id: "uuid-2", name: "Globex Inc", createdAt: "2026-04-22T…" }, + ] +} +``` + +Credentials remain in the server-side signed cookie session. Switching projects +triggers a fresh `/api/auth/status` check (session may still be valid if the user +didn't disconnect). + +### js/project.js + +```js +export function listProjects() { … } // returns projects array +export function createProject(name) { … } // generates uuid, saves, returns project +export function deleteProject(id) { … } +export function getActive() { … } // returns active project or null +export function setActive(id) { … } // updates localStorage + triggers nav refresh +``` + +### Project switcher modal + +- Opened by clicking project name in nav footer +- Lists projects: name + creation date + "Activate" button +- "New Project" inline form (name field + Create button) +- Deleting a project requires confirmation ("Delete Acme Corp? This cannot be undone.") +- First run: modal opens automatically with welcome copy + +### Nav footer display + +Shows `▸ Acme Corp` (truncated to 18 chars). Clicking opens switcher modal. +No project → shows `▸ New Project` in amber. + +### Commit + +`feat(ui-phase-15): project switcher — localStorage CRUD, switcher modal` + +--- + +## Phase 16 — Templates View with Readiness Badges + +**Goal:** Replace the two-panel list with a filterable, sortable single table. +Each row shows a readiness badge computed from validation results. + +### Readiness badge system + +| Badge | Colour | Condition | +|---|---|---| +| Ready | `--success` green | `blockers=[]`, `warnings=[]` | +| Caveats | `--warning` amber | `blockers=[]`, `warnings.length > 0` | +| Blocked | `--poppy` red | `blockers.length > 0` | +| Migrated | `--cobalt` | `status=migrated` and no blockers | +| Needs Update | `--warning` amber | `status=needs_update` | +| Verified | green + ✓ | post-migration verification passed (Phase 19) | + +### Backend update required: `web/routers/templates.py` + +Add `blockers: list[str]` and `warnings: list[str]` to each template object in +`GET /api/templates/status`. Run `validate_template()` on the normalized form if the +template has been downloaded; otherwise return empty lists. + +```python +# In templates.py status endpoint, for each adobe template: +normalized_dir = Path(settings.downloads_dir) / f"{template['name']}__{template['id']}" +if normalized_dir.exists(): + normalized = adobe_folder_to_normalized(str(normalized_dir)) + result = validate_template(normalized) + blockers = result.blockers + warnings = result.warnings +else: + blockers, warnings = [], [] +``` + +Add 3 backend tests to `tests/test_api_templates.py`: +- `test_status_includes_blockers_and_warnings_fields` +- `test_status_blockers_populated_when_template_downloaded` +- `test_status_empty_when_not_downloaded` + +### js/templates.js + +```js +export function renderTemplates() { + // Fetches state.templates (or refreshes via api.templates.status()) + // Renders filterable table into #router-outlet + // Columns: ☐ | Name | Readiness | Fields | Last Modified | DS Status | Actions + // Filter bar: search input + status dropdown + readiness dropdown + // Bulk toolbar (hidden until ≥1 selected): "Migrate X selected" button +} + +export function renderTemplateDetail(adobeId) { + // 4-tab layout: Overview | Fields | Issues | Migration History +} +``` + +### Template detail view (`#/templates/:id`) + +- **Overview tab:** name, description, roles, document count, last modified date +- **Fields tab:** table of fields — type, label, page, role, required, conditional +- **Issues tab:** blockers (red cards) + warnings (amber cards) from validation +- **Migration History tab:** records from `/api/migrate/history` filtered to this template + +### Commit + +`feat(ui-phase-16): templates view — readiness badges, filters, detail tabs, backend blockers/warnings` + +--- + +## Phase 17 — Migration Workflow UI + +**Goal:** Options modal → progress view → results view as a cohesive flow. + +### Flow + +``` +Templates view → select ≥1 template → "Migrate Selected" button → + Options modal → "Run Migration" → + Progress view (replaces modal) → + Results view (#/results) +``` + +### js/migration.js + +```js +export function showOptionsModal(selectedIds) { + // Renders modal with: + // - Dry run toggle (default: off) + // - Overwrite existing toggle (default: off, from settings) + // - Include documents toggle (default: on, from settings) + // - Target folder text input (optional) + // - Selected count display + // - "Run Migration" button +} + +export async function runMigration(ids, options) { + // Calls POST /api/migrate/batch + // Returns job_id +} + +export async function pollJob(jobId, onProgress, onComplete) { + // Polls GET /api/migrate/batch/{jobId} every 2s + // Calls onProgress({ completed, total, results }) + // Calls onComplete(finalResults) when status === 'done' +} + +export function renderResults(jobResults) { + // Navigates to #/results and renders: + // - Summary row: X Created | Y Updated | Z Skipped | W Blocked | V Errors + // - Per-template result table + // - "Verify Templates" button (pre-loads migrated IDs) + // - "Back to Templates" button + // - "Export CSV" button (client-side Blob download) +} +``` + +### Progress view (inline, inside modal) + +After "Run Migration" is clicked: +- Modal content replaces with: progress bar + per-template status list +- Each template row: name → ⏳ spinning → ✅ success or ❌ error +- "View Results" button appears when job status === 'done' + +### Commit + +`feat(ui-phase-17): migration workflow — options modal, progress polling, results view` + +--- + +## Phase 18 — Issues & Warnings View + +**Goal:** A dedicated screen to review all validation problems before migrating. + +### js/issues.js + +```js +export function renderIssues() { + // Reads state.templates (already has blockers/warnings from Phase 16) + // Renders two sections: + // BLOCKERS — templates that will fail migration + // WARNINGS — templates that will migrate with caveats + // Each item: template name | issue message | suggested action link + // "Migrate Anyway" button on warning items → showOptionsModal([id]) + // "View Template" link → #/templates/:id +} +``` + +### Nav badge + +Left nav Issues link shows a red badge with count of blocked templates. +Updates whenever `state.templates` changes. + +### Commit + +`feat(ui-phase-18): issues view — blocked and warning templates, nav badge` + +--- + +## Phase 19 — Verification View + +**Goal:** Send test envelopes to confirm migrated templates work end-to-end. + +### New backend: `web/routers/verify.py` + +```python +POST /api/verify/send + body: { template_id: str, recipient_name: str, recipient_email: str } + action: GET /v2.1/accounts/{id}/envelopes (create via template) + returns: { envelope_id: str } + +GET /api/verify/status/{envelope_id} + action: GET /v2.1/accounts/{id}/envelopes/{envelopeId} + returns: { status: str, completed_at: str | null } + +POST /api/verify/void/{envelope_id} + body: { reason: str } + action: PUT envelope status to "voided" + returns: { voided: true } +``` + +Register router in `web/app.py`: `app.include_router(verify_router, prefix="/api/verify")`. + +### tests/test_api_verify.py + +Four tests (all mock DocuSign calls with respx): +- `test_send_requires_auth` +- `test_send_returns_envelope_id` +- `test_status_returns_envelope_state` +- `test_void_calls_docusign` + +### js/verification.js + +```js +export function renderVerification(preloadedTemplateIds = []) { + // Shows list of migrated templates (from history or passed-in IDs) + // Per-template row: + // - Template name + DS template ID + // - "Send Test Envelope" button → opens send dialog + // - Status chip (Not Tested | Sent | Delivered | Completed = Verified | Voided) + // Send dialog: recipient name + email (pre-filled from settings), "Send" button + // After send: row updates with status, "Void" button, polling every 5s +} +``` + +### Commit + +`feat(ui-phase-19): verification view + verify API endpoints (send/status/void)` + +--- + +## Phase 20 — History & Audit View + +**Goal:** Filterable, exportable migration history. + +### js/history.js + +```js +export function renderHistory() { + // Calls GET /api/migrate/history + // Renders: + // - Filter bar: date range, template name search, status filter + // - Table: timestamp | template | action | status | DS ID | warnings | checksum + // - Expandable row: full blockers/warnings list, field count diff + // - "Export CSV" button (client-side) +} +``` + +SHA-256 checksum: first 8 chars displayed, full value in title attribute (tooltip). + +### Commit + +`feat(ui-phase-20): history & audit view — filters, export, checksum display` + +--- + +## Phase 21 — Settings View + +**Goal:** Central config screen for verification defaults and migration defaults. + +### Settings (localStorage key: `migrator_settings`) + +| Key | Default | UI control | +|---|---|---| +| `testRecipientName` | `""` | Text input | +| `testRecipientEmail` | `""` | Email input | +| `autoVoidHours` | `24` | Number input | +| `defaultOverwrite` | `false` | Toggle | +| `defaultIncludeDocs` | `true` | Toggle | + +### js/settings.js + +```js +export function renderSettings() { + // 3 sections: + // 1. Verification defaults (name, email, auto-void timer) + // 2. Migration defaults (overwrite, include documents) + // 3. Connection info (read-only: connected accounts, base URLs) + // Save button writes to localStorage + // Values pre-loaded into options modal (Phase 17) and send dialog (Phase 19) +} +``` + +### Commit + +`feat(ui-phase-21): settings view — verification defaults, migration defaults` + +--- + +## Phase 22 — Smoke Test Checklist & Cleanup + +**Goal:** Validate the full redesigned UI works end-to-end, update docs. + +### tests/UI-SMOKE-TEST.md + +Manual checklist: +- [ ] First run: project switcher opens automatically +- [ ] Create project "Test Customer", verify it appears in nav footer +- [ ] Connect Adobe Sign via `.env` path → badge turns green +- [ ] Connect DocuSign via JWT path → badge turns green +- [ ] Templates view loads ≥1 template with correct readiness badge +- [ ] Select 2 templates → options modal opens → dry run → results show `dry_run` status +- [ ] Select 2 templates → real migration → progress bar counts up → results view +- [ ] Navigate to Verification → Send Test → status updates to Completed +- [ ] History view shows all migrations with correct counts and checksums +- [ ] Issues view shows blocked templates (use a fixture template with no recipients) +- [ ] Settings: save test recipient → reopen Settings → values persist + +### Final tasks + +- Run `pytest tests/ -v` — confirm all tests still pass (≥108 + new verify tests) +- Update `README.md` — new UI navigation guide section +- Update `docs/agent-harness/EXECUTION-BOARD.md` — Phases 14–22 complete +- Push `ui-redesign` branch to Gitea +- Open PR to `master` + +### Commit + +`feat(ui-phase-22): smoke test checklist, README update, final cleanup` + +--- + +## Dependency order + +``` +Phase 14 (Shell) + └── Phase 15 (Project) + └── Phase 16 (Templates + backend readiness data) + ├── Phase 17 (Migration workflow) + │ └── Phase 18 (Issues view) + └── Phase 19 (Verification + verify API) + +Phase 20 (History) ← depends on Phase 14 only, can run after Phase 14 +Phase 21 (Settings) ← depends on Phase 14 only, can run after Phase 14 + +Phase 22 (Cleanup) ← depends on all phases complete +``` + +Phases 20 and 21 can be implemented in parallel with Phases 17–19. + +--- + +## What does NOT change + +- All existing FastAPI routes (`auth.py`, `templates.py`, `migrate.py`) +- All backend Python source (`src/`) +- All 108 existing tests +- `.env` / credential handling +- The CLI pipeline (`src/migrate_template.py`) + +Only backend additions: +1. **Phase 16:** `blockers` + `warnings` fields added to `GET /api/templates/status` +2. **Phase 19:** New `web/routers/verify.py` with 3 envelope endpoints diff --git a/docs/agent-harness/EXECUTION-BOARD.md b/docs/agent-harness/EXECUTION-BOARD.md index 27fbb4e..0710af2 100644 --- a/docs/agent-harness/EXECUTION-BOARD.md +++ b/docs/agent-harness/EXECUTION-BOARD.md @@ -1,6 +1,6 @@ # Execution Board (Living Kanban) -*Last updated: 2026-04-17* +*Last updated: 2026-04-21 (post-redesign bug fixes + Phase 23)* --- @@ -79,9 +79,179 @@ --- +## Phase 8 — Normalized Intermediate Schema ✅ (2026-04-21) + +- [x] Create `src/models/` package with `__init__.py` +- [x] Implement `src/models/normalized_template.py` — pydantic model with NormalizedTemplate, NormalizedField, NormalizedRole, NormalizedDocument +- [x] Implement `src/services/` package with `__init__.py` +- [x] Implement `src/services/mapping_service.py` — Adobe Sign folder → NormalizedTemplate converter with checksums +- [x] Write `tests/test_normalized_schema.py` — 13 tests passing (model construction, serialization, real fixture round-trips) +- [x] Update README + +--- + +## Phase 9 — Validation Service ✅ (2026-04-21) + +- [x] Implement `src/services/validation_service.py` — `ValidationResult(blockers, warnings)`, checks for no recipients, no documents, no fields, missing roles, unsupported features +- [x] Implement `src/reports/report_builder.py` — `MigrationReport`, `TemplateReport`, `MigrationStatus` enum, factory functions +- [x] Integrate validation into migration pipeline (`_run_validation` in `web/routers/migrate.py`) — blocks on blockers +- [x] Implement `compare_field_counts(normalized, ds_template)` post-migration check +- [x] Write `tests/test_validation_service.py` — 20 tests passing +- [x] Update README + +--- + +## Phase 10 — Migration Options API ✅ (2026-04-21) + +- [x] Extend `POST /api/migrate` request body: `source_template_ids[]`, `target_folder`, `options.dry_run`, `options.overwrite_if_exists`, `options.include_documents` +- [x] Implement dry-run path — validate + compose without creating DocuSign templates (`status=dry_run`) +- [x] Implement `overwrite_if_exists=false` — skip already-migrated templates (`status=skipped`) +- [x] Implement `include_documents` toggle — strips `documentBase64` from payload when false +- [x] Keep backward compatibility with legacy `adobe_template_ids` field +- [x] Write `tests/test_migration_options.py` — 7 tests passing +- [x] Update README + +--- + +## Phase 11 — Rate Limiting & Retry with Backoff ✅ (2026-04-21) + +- [x] Implement `src/utils/retry.py` — `retry_with_backoff` (sync) and `async_retry_with_backoff` decorators with exponential backoff + max_delay cap +- [x] Implement `check_response_retryable(status_code)` — returns True for 429/500/502/503/504 +- [x] Write `tests/test_retry.py` — 14 tests passing (exponential delay, max delay, exception filtering, async) +- [x] Update README + +--- + +## Phase 12 — Security Hardening & Audit Trail ✅ (2026-04-21) + +- [x] Implement `src/utils/log_sanitizer.py` — `redact()`, `redact_dict()`, `SanitizingFilter`, `install_sanitizing_filter()` +- [x] Redacts: Bearer tokens, JWT-style tokens, key=value secret assignments, long base64 payloads (PDF content) +- [x] PDF checksum (SHA-256) computed in `mapping_service.adobe_folder_to_normalized()` and stored in `NormalizedDocument.checksum_sha256` +- [x] Write `tests/test_security.py` — 15 tests passing +- [x] Update README + +--- + +## Phase 13 — Batch Migration API ✅ (2026-04-21) + +- [x] Implement `POST /api/migrate/batch` — async background job, returns `job_id` immediately +- [x] Implement `GET /api/migrate/batch/{job_id}` — poll job status, progress, results, summary +- [x] Implement retry for failed templates (one retry on upload failures) +- [x] In-memory job store with progress tracking (`_batch_jobs` dict) +- [x] Write `tests/test_batch_migration.py` — 6 tests passing +- [x] Update README + +--- + +## Full Test Suite ✅ (2026-04-21) + +**108/108 tests passing** + +--- + +## UI Redesign — Phases 14–22 (in progress) + +*Full plan: `docs/UI-REDESIGN-PLAN.md`* + +### Phase 14 — App Shell & Navigation ✅ (2026-04-21) +- [x] Rewrite `index.html` as app shell (left nav, router outlet, top bar) +- [x] `css/tokens.css` — Docusign 2024 brand custom properties +- [x] `css/base.css` — reset, Inter font, utility classes +- [x] `css/nav.css` — Inkwell sidebar, logo, nav links, project footer +- [x] `js/utils.js` — escHtml, formatDate, debounce, uuid +- [x] `js/router.js` — hash-based router (#/templates default) +- [x] `js/state.js` — global state with pub/sub +- [x] `js/api.js` — fetch wrappers for all existing endpoints +- [x] `js/auth.js` — auth chips, Adobe OAuth dialog, toast notifications +- [x] `js/app.js` — entry point wiring router, auth, nav badges + +### Phase 15 — Project / Customer Context ✅ (2026-04-21) +- [x] `js/project.js` — project CRUD (localStorage) +- [x] Project switcher modal (list, create, delete, activate) +- [x] First-run experience (auto-open modal if no projects) +- [x] Active project name in nav footer + +### Phase 16 — Templates View with Readiness Badges ✅ (2026-04-21) +- [x] Backend: add `blockers[]` + `warnings[]` to `GET /api/templates/status` +- [x] 3 new backend tests (10 total in test_api_templates.py) +- [x] `js/templates.js` — filterable/sortable table with readiness badges +- [x] Template detail view (3 tabs: Overview, Issues, Migration History) +- [x] `css/cards.css` — badge styles, table hover, bulk toolbar + +### Phase 17 — Migration Workflow UI ✅ (2026-04-21) +- [x] Options modal (dry_run, overwrite, include_documents, target folder) +- [x] Progress view with batch job polling (every 2s) +- [x] `js/migration.js` — showOptionsModal, runMigration, pollJob, renderResults +- [x] Results view (#/results) with summary + export CSV +- [x] `css/modals.css` + +### Phase 18 — Issues & Warnings View ✅ (2026-04-21) +- [x] `js/issues.js` — issues view (Blockers + Warnings sections) +- [x] Nav badge showing blocked template count + +### Phase 19 — Verification View + API ✅ (2026-04-21) +- [x] `web/routers/verify.py` — POST /send, GET /status/{id}, POST /void/{id} +- [x] Register verify router in `web/app.py` +- [x] `tests/test_api_verify.py` — 7 tests passing +- [x] `js/verification.js` — send test envelope, poll status, void + +### Phase 20 — History & Audit View ✅ (2026-04-21) +- [x] `js/history.js` — filterable history table, expand row, export CSV +- [x] Checksum display (first 8 chars, full on hover) + +### Phase 21 — Settings View ✅ (2026-04-21) +- [x] `js/settings.js` — 3 sections (verification defaults, migration defaults, connection info) +- [x] `css/forms.css` + +### Phase 22 — Smoke Test Checklist & Cleanup ✅ (2026-04-21) +- [x] `tests/UI-SMOKE-TEST.md` — manual test checklist (11 sections, 55 steps) +- [x] Full backend test suite: **118/118 tests passing** +- [x] Update `README.md` — new UI navigation guide, workflow, project context +- [x] Update EXECUTION-BOARD.md — all phases complete +- [x] Push `ui-redesign` branch to Gitea +- [x] Open PR to `master` + +--- + +## Post-Redesign Bug Fixes ✅ (2026-04-21) + +Bugs discovered during live testing after Phase 22. + +- [x] **Docusign branding** — replaced all "DocuSign" with "Docusign" (2024 brand) across 8 frontend files +- [x] **Template detail routing** — `router.js` `parseHash` used wrong slice indices (`slice(0,3)` instead of `slice(0,2)`), causing `#/templates/:id` to always fall through to the list view +- [x] **Migration polling infinite loop** — `pollJob` only checked `'done'`/`'complete'` but backend emits `'completed'`; migration progress spinner never resolved +- [x] **Verification envelope role names** — hardcoded `roleName: "Signer"` meant envelopes sent without tags; now fetches actual template role names from Docusign API before sending, falls back to `"Signer"` only on fetch failure +- [x] **Verification polling rate** — changed from 5 s to 30 s per Docusign rate-limit guidance; added 5-minute timeout with amber "Timed Out" badge; note: production should use Docusign Connect webhooks +- [x] **CONDITIONALTAB_HAS_INVALID_PARENT (400)** — compose was emitting `conditionalParentLabel` pointing to signature/auto-fill tabs (forbidden as parents) or to fields on different recipients (cross-recipient). Fixed by post-processing strip pass in `_strip_invalid_conditionals` +- [x] **Migration modal failure UX** — failed/blocked rows now show the error message in small red text beneath the template name; completion summary shows count + "select View Results for details" hint +- [x] **Template detail history tab** — migration history rows with errors/blockers/warnings now expand inline (matching History & Audit behaviour) + +--- + +## Phase 23 — Structured Field Issue Reporting ✅ (2026-04-21) + +- [x] `src/models/field_issue.py` — `FieldIssue` dataclass with `code`, `field_name`, `message`, `severity`; 7 named codes: `CROSS_RECIPIENT_CONDITIONAL`, `UNSUPPORTED_OPERATOR`, `HIDE_ACTION`, `MULTI_PREDICATE`, `INVALID_PARENT_TAB`, `FIELD_TYPE_SKIPPED`, `PARTIAL_FIELD_TYPE` +- [x] `src/compose_docusign_template.py` — all warning paths now also emit structured `FieldIssue`; cross-recipient detection added (builds `{field_name → assignee}` map, checks predicate fieldName assignee before applying conditional); return signature changed to `(template, warnings, issues)` +- [x] `web/routers/migrate.py` — captures `field_issues` from compose result; all `_migrate_one` return paths include `field_issues: []` +- [x] `web/static/js/utils.js` — `renderFieldIssues()` groups issues by code in collapsible sections; `bindFieldIssueToggles()` wires expand/collapse +- [x] `web/static/js/migration.js` — results view: ⚠️ icon + amber **partial** badge for success-with-issues; field issue groups in expanded rows +- [x] `web/static/js/history.js` — amber **partial** badge + field issue groups in expanded rows +- [x] `web/static/js/templates.js` — template detail history tab shows field issues with partial badge per record +- [x] `web/static/css/cards.css` — `.field-issues-block`, `.field-issue-group`, `.field-issue-row` styles +- [x] `tests/test_regression.py` — updated for 3-tuple compose return +- [x] `tests/test_api_verify.py` — updated for template role-fetch + added fallback test (9 tests) +- [x] Full test suite: **119/119 tests passing** +- [x] Updated `README.md`, `field-mapping.md`, `EXECUTION-BOARD.md` + +--- + ## Gitea - [x] Committed and pushed all changes (2026-04-17) +- [x] Committed Phase 8–13 work (ui-redesign branch, 2026-04-21) +- [x] Committed UI mockup + Docusign 2024 brand (ui-redesign branch, 2026-04-21) +- [x] Committed Phases 14–22 UI implementation (ui-redesign branch, 2026-04-21) +- [x] Pushed ui-redesign branch to Gitea; PR #1 open against master --- @@ -91,3 +261,10 @@ - (2026-04-15) Coordinate bug fixed — y is top-origin in both platforms, no conversion needed - (2026-04-15) Paul Adobe Template created via API; Company/Title fields require manual UI fix (API limitation) - (2026-04-17) v2 planning complete — idempotent upload + web UI implementation begins +- (2026-04-21) Blueprint comparison complete — added normalized schema, validation service, migration options, rate-limit/retry, security hardening, and batch migration phases (Phases 8–13) +- (2026-04-21) Phases 8–13 fully implemented — 108/108 tests passing on ui-redesign branch +- (2026-04-21) Enterprise UI mockup designed — 8 screens, Docusign 2024 branding, official SVG logo embedded +- (2026-04-21) UI Redesign plan written (Phases 14–22) — frontend-only except Phase 16 (readiness data) and Phase 19 (verify API) +- (2026-04-21) Phases 14–22 fully implemented — 118/118 tests passing, enterprise UI complete +- (2026-04-21) Post-redesign live testing found 7 bugs — all fixed (routing, polling, branding, verification role names, conditional parent 400s) +- (2026-04-21) Phase 23 complete — structured field issue reporting end-to-end; 119/119 tests passing; cross-recipient conditional now explicitly detected and described rather than silently producing a 400 diff --git a/docs/ui-mockup/mockup.html b/docs/ui-mockup/mockup.html new file mode 100644 index 0000000..48ed5dd --- /dev/null +++ b/docs/ui-mockup/mockup.html @@ -0,0 +1,1479 @@ + + + + + +DocuSign Migration Console + + + + + + + + +
+ + +
+ +
+
Adobe Sign
+
DocuSign
+
PH
+
+
+ +
+ + +
+ + + +
+
+
Total Templates
+
312
+
in Adobe Sign
+
+
+
Migrated
+
187
+
60% complete
+
+
+
Ready to Migrate
+
96
+
no issues detected
+
+
+
Needs Review
+
21
+
unsupported features
+
+
+
Blocked
+
8
+
hard stoppers found
+
+
+ + +
+
+ Overall Migration Progress + 187 / 312 templates +
+
+
+
+ ■ Migrated 60% + ■ Verified 31% + ■ Ready 31% + ■ Needs Review 7% + ■ Blocked 3% +
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+
+ 🚨 Requires Attention + +
+
+
+
+ 🚫 +
+
Master Services Agreement
+
No PDF document attached — cannot migrate until document is linked in Adobe Sign
+
+
+
+
+ 🚫 +
+
NDA - Multi-party (3 signers)
+
Recipient routing order has gaps: roles jump from 1 → 3, missing role 2
+
+
+
+
+ ⚠️ +
+
Sales Commission Agreement
+
8 conditional SHOW/HIDE rules — only SHOW conditions supported in DocuSign
+
+
+
+
+ ⚠️ +
+
Employee Onboarding Form
+
3 JavaScript-calculated fields (auto-sum) — no DocuSign equivalent
+
+
+
+
+
+
+ + +
+
+ Recent Activity + +
+
+
+
+
Batch migration completed — 47 templates migrated, 2 warnings
+
2h ago
+
+
+
+
Verification passed — Purchase Order v3 · test envelope sent successfully
+
3h ago
+
+
+
+
Migration with warnings — Sales Commission Agreement · 3 conditional rules skipped
+
3h ago
+
+
+
+
Migration blocked — Master Services Agreement · no document attached
+
4h ago
+
+
+
+
Analysis completed — 312 templates scanned, 29 issues found
+
5h ago
+
+
+
+
Session started — Connected to Acme Corporation Adobe Sign account
+
5h ago
+
+
+
+
+
+ + + +
+ + +
+ +
+
All 312
+
✅ Ready 96
+
✓ Migrated 187
+
⚠ Caveats 21
+
🚫 Blocked 8
+
+
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Template NameFieldsRecipientsReadinessStatusIssuesModifiedActions
David Tag Demo Form
ID: CBJCHBCA_abc123
142ReadyNot Started✓ NoneApr 15
NDA — Standard
ID: CBJCHBCA_def456
92ReadyMigrated✓ NoneApr 17
Sales Commission Agreement
ID: CBJCHBCA_ghi789
283CaveatsMigrated⚠ 5 warningsApr 10
Master Services Agreement
ID: CBJCHBCA_jkl012
🚫 BlockedNot Started🚫 1 blockerMar 29
Purchase Order v3
ID: CBJCHBCA_mno345
182Ready✓ Verified✓ NoneApr 18
Employee Onboarding Form
ID: CBJCHBCA_pqr678
421CaveatsNot Started⚠ 3 warningsApr 5
Vendor Contract — Short Form
ID: CBJCHBCA_stu901
112ReadyNot Started✓ NoneApr 12
Client Proposal Sign-off
ID: CBJCHBCA_vwx234
61ReadyMigrated✓ NoneApr 19
+
+
+ Showing 8 of 312 templates +
+ + + + + + + +
+ 25 per page +
+
+
+ + + +
+ + +
+ +
+
+
Overview
+
Field Mapping (2 warnings)
+
Issues & Caveats (5)
+
Verification
+
+ + +
+
+
+
28
+
Fields
+
+
+
24
+
Map cleanly
+
+
+
4
+
Need attention
+
+
+
+
Recipient Roles
+
+ + + + + +
OrderRole NameActionFields Assigned
1Sales RepSIGN11 fields
2CustomerSIGN13 fields
3Finance ApproverAPPROVE4 fields
+
+
+
+
DocuSign Target
+
+
+
+
Will be created in:
+
Migrated Templates / Sales
+
+
+ ✓ Migrated Apr 10 +
+
+
+
DocuSign Template ID
+
a4b8c2d1-9f3e-4a2b-8c1d-7e6f5a4b3c2d
+
+
+
+ + + + + + + + + +
+ + +
+
+
Migration Options
+
+
+
+
Dry Run
+
Validate & compose without uploading to DocuSign
+
+
+
+
+
+
Include Documents
+
Embed the original PDF in the DocuSign template
+
+
+
+
+
+
Overwrite if Exists
+
Replace an existing DocuSign template with the same name
+
+
+
+
+
Target Folder
+
Where to create the template in DocuSign
+ +
+
+
+ +
+
Pre-migration Checklist
+
+
PDF document attached
+
All fields have roles assigned
+
Recipient routing is sequential
+
2 conditional HIDE rules will be lost
+
2 calculated fields become plain text
+
+
+
+
+
+ + + +
+ + +
+
Succeeded
5
ready to verify
+
With Warnings
2
review recommended
+
Blocked
1
manual fix needed
+
Skipped
0
already migrated
+
+ +
+ ⚠️ +
Action required: "Master Services Agreement" was blocked due to a missing PDF document. Fix in Adobe Sign and re-run migration. "Sales Commission Agreement" migrated with 5 warnings — review the Issues tab before sending envelopes.
+
+ +
+
+ +
+
+ +
David Tag Demo Form
+ Success +
14 fields · 2 recipients
+ +
+
+
+ DocuSign ID: b5c9d3e2-0a4f-5b3c-9d2e-8f7a6b5c4d3e + Action: created + Duration: 2.1s +
+ Open in DocuSign ↗ + +
+
+ +
+
+ ⚠️ +
Sales Commission Agreement
+ 5 Warnings +
28 fields · 3 recipients
+ +
+
+
+ DocuSign ID: a4b8c2d1-9f3e-4a2b-8c1d-7e6f5a4b3c2d + Action: updated +
+
WARNINGS — review before using this template:
+
2 conditional HIDE rules skipped — fields will always be visible to signers
+
2 calculated fields converted to plain text — signers must enter values manually
+
1 JavaScript validator removed — field accepts any input
+
INLINE_IMAGE field skipped (expected — no DocuSign equivalent)
+
PARTICIPATION_STAMP field skipped (expected)
+
+ Open in DocuSign ↗ + + +
+
+
+ +
+
+ +
Vendor Contract — Short Form
+ Success +
11 fields · 2 recipients
+ +
+
+ Open in DocuSign ↗ + +
+
+ +
+
+ 🚫 +
Master Services Agreement
+ Blocked +
Not migrated
+ +
+
+
🚫 Blocker: No documents attached
+
This template has no PDF document linked in Adobe Sign. At least one document is required to create a DocuSign template. Fix in Adobe Sign, then re-run migration.
+ +
+
+ +
+
+ +
Employee Onboarding Form
+ 3 Warnings +
42 fields · 1 recipient
+ +
+
+
3 JavaScript validators removed
+ +
+
+ +
+
+
+ + + +
+ +
⚠️Blockers prevent migration and must be resolved in Adobe Sign first. Warnings allow migration to proceed but may affect signer experience — review each one.
+
+
+
🚫 Blockers (3)
+
+
BLOCK +
Missing PDF document
Master Services Agreement, Contractor Agreement v2, IP Assignment Form — no document attached
Fix in Adobe Sign: attach a PDF to the template, then re-analyze.
+
+
+
+
+
⚠ Feature Warnings (26)
+
+
WARNING +
Conditional HIDE actions
14 templates · 31 fields — HIDE conditions are not supported. Fields will always be visible.
+
+
WARNING +
Calculated fields
7 templates · 18 fields — auto-calculation formulas will be lost. Fields become plain text.
+
+
WARNING +
JavaScript validators
5 templates · 9 fields — custom validation rules will be removed.
+
+
INFO +
Skipped field types
12 templates · INLINE_IMAGE and PARTICIPATION_STAMP fields have no DocuSign equivalent and are safely skipped.
+
+
+
+
+
+ + +
+ +
ℹ️Verification sends a test envelope using each DocuSign template to a sandbox address. It checks that the template is valid, all tabs render correctly, and signers can complete the signing flow.
+
+
Verified
97
fully tested
+
Partial Pass
12
manual review needed
+
Failed
2
signing flow broken
+
Not Run
76
pending verification
+
+
+
Verification Queue
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TemplateMigration StatusVerificationTest EnvelopeActions
Purchase Order v3
Migrated✓ Verifiedenv_7f2a1b3c
Sales Commission Agreement
Migrated⚠ Partialenv_8a3b2c4d
David Tag Demo Form
MigratedPending
Employee Onboarding Form
Migrated✗ Failedenv_9c4d3e5f
+
+
+
+ + +
+ +
+
+ + + + + + + +
TimestampRun TypeTemplatesResultOperatorActions
2026-04-21 16:12Batch (8)Sales Commission Agreement, David Tag Demo, +65 migrated, 1 blocked, 2 warningsPaul H.
2026-04-21 14:05Batch (47)HR templates batch45 migrated, 2 warningsPaul H.
2026-04-21 10:30SinglePurchase Order v3SuccessPaul H.
2026-04-20 09:15Dry Run (100)All templatesDry run — 8 blockers foundPaul H.
+
+
+ + +
+ + + +
+
+ Migration Project + Each project is one customer engagement with its own credentials and history +
+
+
+
Customer Name
+
Project Label
+
Started
+
+
+ + + +
+
+
+ +
+
+
Adobe Sign Connection● Connected
+
+
Account: acme@corp.com (EU2 shard)
+
Token expires: 2026-04-21 18:30 UTC
+ + +
+
+
+
DocuSign Connection● Connected
+
+
Account: Acme Corp Sandbox (demo)
+
Auth method: JWT Grant
+ + +
+
+
+ + +
+
Verification SettingsUsed when sending test envelopes to validate migrated templates
+
+
+
+
Test Recipient Name
+ +
+
+
Test Recipient Email
+ +
+
+
Auto-void After
+ +
Test envelopes are voided automatically to keep the DocuSign account clean
+
+
+
Verification Mode
+ +
API-only is faster; full envelope confirms the signing experience end-to-end
+
+
+ +
+
+
+ +
+
+ + + + + + + diff --git a/field-mapping.md b/field-mapping.md index 8c90ca9..e97b946 100644 --- a/field-mapping.md +++ b/field-mapping.md @@ -84,24 +84,39 @@ Tab types that do not merge (only first location used or handled specially): Adobe Sign `conditionalAction` → DocuSign `conditionalParentLabel` + `conditionalParentValue` on the dependent tab. -| Adobe Sign | DocuSign | Notes | -|-----------------------------------|---------------------------------|-------| -| `predicates[].fieldName` | `conditionalParentLabel` | For radio groups, matches the group name | -| `predicates[].value` | `conditionalParentValue` | The value the trigger must equal to reveal the tab | -| `action: SHOW` | Supported | Tab is hidden until condition is met | -| `action: HIDE` | **Not supported** | No DocuSign equivalent — condition skipped, field always shown | -| `operator: EQUALS` | Supported | Only operator DocuSign supports | -| Other operators | **Not supported** | Condition skipped, warning logged | -| Multiple predicates (ANY/ALL) | **Partial** — first EQUALS only | Warning logged; remaining predicates ignored | +| Adobe Sign | DocuSign | Outcome | Notes | +|-----------------------------------|---------------------------------|---------|-------| +| `predicates[].fieldName` | `conditionalParentLabel` | Mapped | For radio groups, matches the group name | +| `predicates[].value` | `conditionalParentValue` | Mapped | The value the trigger must equal to reveal the tab | +| `action: SHOW` | Supported | Mapped | Tab is hidden until condition is met | +| `action: HIDE` | **Not supported** | Dropped | No DocuSign equivalent — field always shown. `HIDE_ACTION` issue emitted. | +| `operator: EQUALS` | Supported | Mapped | Only operator DocuSign supports | +| Other operators (NOT_EQUALS, etc.)| **Not supported** | Dropped | Condition skipped. `UNSUPPORTED_OPERATOR` issue emitted. | +| Multiple predicates (ANY/ALL) | **Partial** — first EQUALS only | Partial | `MULTI_PREDICATE` issue emitted; remaining predicates ignored | +| Trigger field on a different recipient | **Not supported** | Dropped | DocuSign `conditionalParentLabel` only works within the same recipient's tab set. `CROSS_RECIPIENT_CONDITIONAL` issue emitted. | +| Parent is signature/auto-fill tab | **Not supported** | Stripped | DocuSign forbids signature, initial, dateSign, fullName, email, title tabs as conditional parents. `INVALID_PARENT_TAB` issue emitted. | ## Known Gaps - **Conditional HIDE**: Adobe Sign can conditionally hide a field. DocuSign only supports revealing hidden fields — there is no native way to hide a visible field conditionally. Templates with HIDE conditions will have those fields always visible after migration. + Emits a `HIDE_ACTION` field issue. +- **Cross-recipient conditionals**: Adobe Sign allows field B to appear/hide based on + the value of field A even when A and B belong to different recipients. DocuSign's + `conditionalParentLabel` only works within a single recipient's tab set. + Emits a `CROSS_RECIPIENT_CONDITIONAL` field issue; the condition is dropped. +- **Invalid or forbidden conditional parents**: If the trigger field maps to a signature, + initial, dateSign, fullName, email, or title tab — DocuSign forbids these as conditional + parents and returns `CONDITIONALTAB_HAS_INVALID_PARENT` (400). The compose pipeline + strips these conditions in a post-processing pass and emits an `INVALID_PARENT_TAB` + field issue. - **Multi-predicate conditions**: Adobe Sign supports ANY/ALL of multiple predicates. DocuSign only supports a single parent condition per tab. Only the first EQUALS predicate is mapped; complex conditions require manual rework. + Emits a `MULTI_PREDICATE` field issue. +- **Unsupported operators**: NOT_EQUALS, GT, LT etc. have no DocuSign equivalent. + The condition is dropped. Emits an `UNSUPPORTED_OPERATOR` field issue. - **DocuSign formula fields**: No Adobe Sign equivalent — flag for manual rewrite. - **Advanced field validation**: Adobe regex/custom script validation is not mapped; best-effort via standard DocuSign validation types only. @@ -109,8 +124,14 @@ Adobe Sign `conditionalAction` → DocuSign `conditionalParentLabel` + `conditio DocuSign `radioGroupTabs` entry with per-location radio button coordinates. - **Stamp tab account feature**: `stampTabs` requires the stamp/hanko feature to be enabled on the DocuSign account. Verify before migrating templates that contain - Adobe Sign STAMP fields. + Adobe Sign STAMP fields. Emits a `PARTIAL_FIELD_TYPE` field issue. +- **FILE_CHOOSER → signerAttachmentTabs**: Docusign attachment tabs behave differently + from Adobe file upload fields (different UX, no file type restrictions). + Emits a `PARTIAL_FIELD_TYPE` field issue recommending manual review. -## To Do -- Add conditional logic/rule mapping table -- Document field mask and default value transforms +## Field Issue Codes + +All dropped or approximated features are surfaced as structured `FieldIssue` objects +alongside human-readable warning strings. See `src/models/field_issue.py` for the full +list. The UI groups these by code in collapsed sections within migration result rows, +history rows, and the template detail Issues tab. diff --git a/src/compose_docusign_template.py b/src/compose_docusign_template.py index 7997806..a73df89 100644 --- a/src/compose_docusign_template.py +++ b/src/compose_docusign_template.py @@ -35,9 +35,19 @@ Conditional logic: import base64 import json -import os from pathlib import Path +from src.models.field_issue import ( + FieldIssue, + CROSS_RECIPIENT_CONDITIONAL, + UNSUPPORTED_OPERATOR, + HIDE_ACTION, + MULTI_PREDICATE, + INVALID_PARENT_TAB, + FIELD_TYPE_SKIPPED, + PARTIAL_FIELD_TYPE, +) + DOCUMENT_ID = "1" @@ -154,7 +164,14 @@ def _sized_tabs(locations: list, label: str, extra: dict | None = None) -> list: # Conditional logic # --------------------------------------------------------------------------- -def _apply_conditional_to_tabs(tabs: dict, field: dict, warnings: list) -> dict: +def _apply_conditional_to_tabs( + tabs: dict, + field: dict, + warnings: list, + issues: list, + current_assignee: str = "", + field_assignee: dict | None = None, +) -> dict: """ Apply DocuSign conditionalParentLabel / conditionalParentValue to tabs based on an Adobe Sign conditionalAction. @@ -169,6 +186,8 @@ def _apply_conditional_to_tabs(tabs: dict, field: dict, warnings: list) -> dict: Mapping limitations: - Only SHOW action is supported. DocuSign has no native HIDE — condition skipped. - Only EQUALS operator is supported. Others are skipped. + - Cross-recipient conditions not supported — DocuSign conditionals only work within + a single recipient's tab set. - Only one predicate is mapped. Multi-predicate ANY/ALL logic is not supported; the first EQUALS predicate is used and a warning is logged. """ @@ -184,33 +203,54 @@ def _apply_conditional_to_tabs(tabs: dict, field: dict, warnings: list) -> dict: action = ca.get("action", "SHOW") if action != "SHOW": - warnings.append( - f"Conditional '{label}': action={action} is not supported in DocuSign " - f"(only SHOW is supported) — condition skipped" + msg = ( + f"Field '{label}' has a HIDE condition which DocuSign does not support — " + f"condition dropped. The field will always be visible." ) + warnings.append(msg) + issues.append(FieldIssue(HIDE_ACTION, label, msg).to_dict()) return tabs predicate = next((p for p in predicates if p.get("operator") == "EQUALS"), None) if not predicate: - warnings.append( - f"Conditional '{label}': no EQUALS predicate found " - f"(operators: {[p.get('operator') for p in predicates]}) — condition skipped" + ops = [p.get("operator") for p in predicates] + msg = ( + f"Field '{label}' uses unsupported condition operator(s) {ops} — " + f"only EQUALS is supported in DocuSign. Condition dropped; field will always be visible." ) + warnings.append(msg) + issues.append(FieldIssue(UNSUPPORTED_OPERATOR, label, msg).to_dict()) return tabs - if len(predicates) > 1: - warnings.append( - f"Conditional '{label}': {len(predicates)} predicates with " - f"anyOrAll={ca.get('anyOrAll')} — only first EQUALS predicate mapped, " - f"remaining conditions ignored" - ) + parent_field_name = predicate["fieldName"] + + # Cross-recipient check: DocuSign does not support conditionals across recipients + if field_assignee is not None and current_assignee: + parent_assignee = field_assignee.get(parent_field_name, "") + if parent_assignee and parent_assignee != current_assignee: + msg = ( + f"Field '{label}' has a show/hide condition controlled by '{parent_field_name}', " + f"which belongs to a different recipient ({parent_assignee} vs {current_assignee}). " + f"DocuSign does not support cross-recipient conditional logic — condition dropped." + ) + warnings.append(msg) + issues.append(FieldIssue(CROSS_RECIPIENT_CONDITIONAL, label, msg).to_dict()) + return tabs + + if len(predicates) > 1: + msg = ( + f"Field '{label}' has {len(predicates)} conditions combined with " + f"anyOrAll={ca.get('anyOrAll')} — only the first EQUALS predicate was mapped. " + f"Remaining conditions were dropped." + ) + warnings.append(msg) + issues.append(FieldIssue(MULTI_PREDICATE, label, msg).to_dict()) - parent_label = predicate["fieldName"] parent_value = predicate["value"] for tab_list in tabs.values(): for tab in tab_list: - tab["conditionalParentLabel"] = parent_label + tab["conditionalParentLabel"] = parent_field_name tab["conditionalParentValue"] = parent_value return tabs @@ -220,11 +260,12 @@ def _apply_conditional_to_tabs(tabs: dict, field: dict, warnings: list) -> dict: # Tab builder # --------------------------------------------------------------------------- -def build_tabs_for_field(field: dict, warnings: list) -> dict: +def build_tabs_for_field(field: dict, warnings: list, issues: list) -> dict: """ Convert one Adobe Sign field into the correct DocuSign tabs structure. Returns a dict of tab-group keys, e.g. {"textTabs": [...]}. - Unmappable fields are skipped and a warning is appended. + Unmappable fields are skipped; a warning string and a structured FieldIssue + are both appended so callers have both human-readable and machine-readable output. """ input_type = field.get("inputType", "") label = field.get("name", "unnamed") @@ -240,22 +281,16 @@ def build_tabs_for_field(field: dict, warnings: list) -> dict: if input_type == "TEXT_FIELD": if content_type == "SIGNATURE_DATE": - # Auto-populated with the signing date return {"dateSignedTabs": _sized_tabs(locations, label)} elif content_type == "SIGNER_NAME": - # Auto-populated with the signer's full name return {"fullNameTabs": _sized_tabs(locations, label)} elif content_type == "SIGNER_EMAIL": - # Auto-populated with the signer's email address return {"emailAddressTabs": _sized_tabs(locations, label)} elif content_type in ("COMPANY", "SIGNER_COMPANY"): - # Auto-populated with the signer's company return {"companyTabs": _sized_tabs(locations, label)} elif content_type in ("TITLE", "SIGNER_TITLE"): - # Auto-populated with the signer's title return {"titleTabs": _sized_tabs(locations, label)} elif content_type == "DATA" and validation == "DATE": - # User-entered date field (not auto-signed date) return {"dateTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})} elif content_type == "DATA" and validation == "NUMBER": return {"numberTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})} @@ -263,15 +298,12 @@ def build_tabs_for_field(field: dict, warnings: list) -> dict: return {"textTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})} elif input_type == "SIGNATURE": - # Each signature/initials location is an independent signing action — - # emit one tab per location but do not size them (DocuSign controls size) if content_type == "SIGNER_INITIALS": return {"initialHereTabs": [_make_base_tab(loc, label) for loc in locations]} else: return {"signHereTabs": [_make_base_tab(loc, label) for loc in locations]} elif input_type == "BLOCK" and content_type == "SIGNATURE_BLOCK": - # Composite signature block — map to signHere at block's location return {"signHereTabs": [_make_base_tab(loc, label) for loc in locations]} elif input_type == "DATE": @@ -286,7 +318,6 @@ def build_tabs_for_field(field: dict, warnings: list) -> dict: return {"listTabs": _sized_tabs(locations, label, {"required": required_str, "listItems": list_items})} elif input_type == "RADIO": - # Each location is one radio button within the group — not tab merging options = field.get("hiddenOptions") or [] radios = [] for i, loc in enumerate(locations): @@ -296,26 +327,40 @@ def build_tabs_for_field(field: dict, warnings: list) -> dict: return {"radioGroupTabs": [{"groupName": label, "documentId": DOCUMENT_ID, "radios": radios}]} elif input_type == "FILE_CHOOSER": - warnings.append(f"FILE_CHOOSER '{label}' → mapped to signerAttachmentTabs (manual review recommended)") + msg = ( + f"Field '{label}' is a FILE_CHOOSER — mapped to a signerAttachmentTabs tab. " + f"DocuSign attachment tabs behave differently from Adobe file upload fields; manual review recommended." + ) + warnings.append(msg) + issues.append(FieldIssue(PARTIAL_FIELD_TYPE, label, msg).to_dict()) tab = _make_base_tab(locations[0], label, {"optional": "true" if not field.get("required") else "false"}) return {"signerAttachmentTabs": [tab]} elif input_type == "INLINE_IMAGE": - warnings.append(f"INLINE_IMAGE '{label}' → skipped (no DocuSign equivalent)") + msg = f"Field '{label}' is an INLINE_IMAGE — skipped. There is no equivalent tab type in DocuSign." + warnings.append(msg) + issues.append(FieldIssue(FIELD_TYPE_SKIPPED, label, msg).to_dict()) return {} elif input_type == "STAMP": - # DocuSign stampTabs — signer uploads or selects a hanko/seal stamp image. - # Requires the stamp feature to be enabled on the DocuSign account. - warnings.append(f"STAMP '{label}' → stampTabs (verify stamp feature is enabled on your DocuSign account)") + msg = ( + f"Field '{label}' is a STAMP — mapped to stampTabs. " + f"This requires the stamp feature to be enabled on your DocuSign account." + ) + warnings.append(msg) + issues.append(FieldIssue(PARTIAL_FIELD_TYPE, label, msg).to_dict()) return {"stampTabs": [_make_base_tab(loc, label) for loc in locations]} elif input_type == "PARTICIPATION_STAMP": - warnings.append(f"PARTICIPATION_STAMP '{label}' → skipped (no DocuSign equivalent)") + msg = f"Field '{label}' is a PARTICIPATION_STAMP — skipped. There is no equivalent tab type in DocuSign." + warnings.append(msg) + issues.append(FieldIssue(FIELD_TYPE_SKIPPED, label, msg).to_dict()) return {} else: - warnings.append(f"Unknown field type '{input_type}' (contentType='{content_type}') for field '{label}' → skipped") + msg = f"Field '{label}' has unknown type '{input_type}' (contentType='{content_type}') — skipped." + warnings.append(msg) + issues.append(FieldIssue(FIELD_TYPE_SKIPPED, label, msg).to_dict()) return {} @@ -325,11 +370,55 @@ def merge_tabs(acc: dict, new: dict) -> dict: return acc +# Tab types DocuSign forbids as conditional parents (auto-filled or action tabs) +_INVALID_PARENT_TAB_TYPES = { + "signHereTabs", "initialHereTabs", "dateSignedTabs", + "fullNameTabs", "emailTabs", "titleTabs", "signerAttachmentTabs", +} + + +def _strip_invalid_conditionals(signers: list, warnings: list, issues: list) -> None: + """ + Remove conditionalParentLabel/Value from any tab whose parent label either + doesn't exist in the template or points to a tab type DocuSign forbids as a + parent (signature, initial, auto-filled). Mutates signers in place. + """ + for signer in signers: + tabs = signer.get("tabs", {}) + + # Collect valid parent labels: only tab types allowed as parents + valid_labels: set[str] = set() + for tab_type, tab_list in tabs.items(): + if tab_type in _INVALID_PARENT_TAB_TYPES: + continue + for tab in tab_list: + lbl = tab.get("tabLabel") or tab.get("groupName") + if lbl: + valid_labels.add(lbl) + + # Strip references to invalid/missing parents + for tab_list in tabs.values(): + for tab in tab_list: + parent = tab.get("conditionalParentLabel") + if parent and parent not in valid_labels: + field_name = tab.get("tabLabel") or tab.get("groupName") or "?" + msg = ( + f"Field '{field_name}' has a conditional that references parent " + f"'{parent}', which either does not exist as a tab or is a " + f"signature/auto-fill tab (forbidden as a DocuSign conditional parent). " + f"Condition stripped — field will always be visible." + ) + warnings.append(msg) + issues.append(FieldIssue(INVALID_PARENT_TAB, field_name, msg).to_dict()) + tab.pop("conditionalParentLabel", None) + tab.pop("conditionalParentValue", None) + + # --------------------------------------------------------------------------- # Main compose function # --------------------------------------------------------------------------- -def compose_template(template_dir: str, output_path: str) -> tuple[dict, list[str]]: +def compose_template(template_dir: str, output_path: str) -> tuple[dict, list[str], list[dict]]: """ Build a DocuSign template JSON from a downloaded Adobe Sign template folder. @@ -339,10 +428,13 @@ def compose_template(template_dir: str, output_path: str) -> tuple[dict, list[st output_path: where to write the resulting DocuSign template JSON Returns: - (template_dict, warnings_list) + (template_dict, warnings_list, field_issues_list) + field_issues_list contains structured FieldIssue dicts describing properties + that were dropped or approximated during migration (see src/models/field_issue.py). """ template_dir = Path(template_dir) warnings: list[str] = [] + issues: list[dict] = [] # Load source files metadata = json.loads((template_dir / "metadata.json").read_text()) @@ -376,16 +468,28 @@ def compose_template(template_dir: str, output_path: str) -> tuple[dict, list[st "tabs": {}, }) + # Build field→assignee lookup for cross-recipient conditional detection + field_assignee: dict[str, str] = {} + for f in fields: + name = f.get("name", "") + assignee = f.get("assignee") or f"recipient{max(f.get('signerIndex', 0), 0)}" + if name: + field_assignee[name] = assignee + # Assign tabs to the correct signer for field in fields: assignee = field.get("assignee") or f"recipient{max(field.get('signerIndex', 0), 0)}" idx = assignee_to_index(assignee, recipients) if idx >= len(signers): idx = 0 - tabs = build_tabs_for_field(field, warnings) - tabs = _apply_conditional_to_tabs(tabs, field, warnings) + tabs = build_tabs_for_field(field, warnings, issues) + tabs = _apply_conditional_to_tabs(tabs, field, warnings, issues, assignee, field_assignee) signers[idx]["tabs"] = merge_tabs(signers[idx]["tabs"], tabs) + # Post-process: strip conditionalParentLabel references that point to + # non-existent or invalid parents (signature/initial tabs can't be parents). + _strip_invalid_conditionals(signers, warnings, issues) + template = { "name": metadata.get("name", template_dir.name), "description": f"Migrated from Adobe Sign — original owner: {metadata.get('ownerEmail', '')}", @@ -406,7 +510,7 @@ def compose_template(template_dir: str, output_path: str) -> tuple[dict, list[st with open(output_path, "w") as f: json.dump(template, f, indent=2) - return template, warnings + return template, warnings, issues # --------------------------------------------------------------------------- @@ -427,7 +531,7 @@ if __name__ == "__main__": output_path = Path(__file__).parent.parent / "migration-output" / template_dir.name / "docusign-template.json" print(f"\n--- {template_dir.name} ---") try: - _, warnings = compose_template(str(template_dir), str(output_path)) + _, warnings, issues = compose_template(str(template_dir), str(output_path)) print(f" Written: {output_path}") for w in warnings: print(f" WARNING: {w}") diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/models/field_issue.py b/src/models/field_issue.py new file mode 100644 index 0000000..fef0dcf --- /dev/null +++ b/src/models/field_issue.py @@ -0,0 +1,39 @@ +""" +Structured field-level issue emitted during compose/migration. +Distinct from validation blockers — a field issue means the field +migrated but something was silently dropped or approximated. +""" + +from dataclasses import dataclass, asdict + + +# Machine-readable codes used in field_issues lists +CROSS_RECIPIENT_CONDITIONAL = "CROSS_RECIPIENT_CONDITIONAL" +UNSUPPORTED_OPERATOR = "UNSUPPORTED_OPERATOR" +HIDE_ACTION = "HIDE_ACTION" +MULTI_PREDICATE = "MULTI_PREDICATE" +INVALID_PARENT_TAB = "INVALID_PARENT_TAB" +FIELD_TYPE_SKIPPED = "FIELD_TYPE_SKIPPED" +PARTIAL_FIELD_TYPE = "PARTIAL_FIELD_TYPE" + +# Human-readable labels for each code (used by the UI) +CODE_LABELS = { + CROSS_RECIPIENT_CONDITIONAL: "Cross-recipient conditional dropped", + UNSUPPORTED_OPERATOR: "Unsupported condition operator dropped", + HIDE_ACTION: "Hide condition dropped (no DocuSign equivalent)", + MULTI_PREDICATE: "Multi-condition logic simplified to first match", + INVALID_PARENT_TAB: "Conditional parent tab invalid or missing", + FIELD_TYPE_SKIPPED: "Field type skipped (no DocuSign equivalent)", + PARTIAL_FIELD_TYPE: "Field type approximated", +} + + +@dataclass +class FieldIssue: + code: str # one of the constants above + field_name: str # Adobe field name + message: str # human-readable description of what was dropped and why + severity: str = "warning" # "warning" | "info" + + def to_dict(self) -> dict: + return asdict(self) diff --git a/src/models/normalized_template.py b/src/models/normalized_template.py new file mode 100644 index 0000000..2462fc5 --- /dev/null +++ b/src/models/normalized_template.py @@ -0,0 +1,78 @@ +""" +normalized_template.py +----------------------- +Platform-agnostic intermediate schema that decouples Adobe Sign extraction +from DocuSign composition. Both platforms' data is converted to/from this +model so neither side is tightly coupled. +""" + +from __future__ import annotations + +from enum import Enum +from typing import Any, Optional + +from pydantic import BaseModel, Field + + +class ActionType(str, Enum): + SIGN = "SIGN" + APPROVE = "APPROVE" + CC = "CC" + ACKNOWLEDGE = "ACKNOWLEDGE" + + +class NormalizedRole(BaseModel): + name: str + order: int + action_type: ActionType = ActionType.SIGN + + +class NormalizedField(BaseModel): + """One form field in the normalized intermediate representation.""" + type: str # e.g. "signature", "text", "checkbox" + label: str + page: int + x: float + y: float + width: float + height: float + required: bool = False + read_only: bool = False + role_name: str = "" # which role this field belongs to + options: list[str] = Field(default_factory=list) # for dropdown/radio + validation: str = "" # e.g. "DATE", "NUMBER" + content_type: str = "" # e.g. "SIGNATURE_DATE", "SIGNER_NAME" + conditional_parent_label: Optional[str] = None + conditional_parent_value: Optional[str] = None + raw: dict[str, Any] = Field(default_factory=dict) # original source data + + +class NormalizedDocument(BaseModel): + name: str + content_base64: str = "" # base64-encoded PDF bytes + checksum_sha256: str = "" # SHA-256 hex of raw bytes before encoding + source_path: str = "" + + +class NormalizedTemplate(BaseModel): + """ + Platform-agnostic representation of an eSignature template. + Used as the bridge between Adobe Sign and DocuSign. + """ + name: str + description: str = "" + email_subject: str = "" + email_message: str = "" + roles: list[NormalizedRole] = Field(default_factory=list) + documents: list[NormalizedDocument] = Field(default_factory=list) + fields: list[NormalizedField] = Field(default_factory=list) + reminder_enabled: bool = False + expiration_days: Optional[int] = None + source_id: str = "" # original Adobe Sign template ID + unsupported_features: list[str] = Field(default_factory=list) + + def role_names(self) -> list[str]: + return [r.name for r in self.roles] + + def fields_for_role(self, role_name: str) -> list[NormalizedField]: + return [f for f in self.fields if f.role_name == role_name] diff --git a/src/reports/__init__.py b/src/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/reports/report_builder.py b/src/reports/report_builder.py new file mode 100644 index 0000000..bfd7995 --- /dev/null +++ b/src/reports/report_builder.py @@ -0,0 +1,134 @@ +""" +report_builder.py +----------------- +Builds structured migration reports per template and for batch runs. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum + + +class MigrationStatus(str, Enum): + SUCCESS = "success" + SUCCESS_WITH_WARNINGS = "success_with_warnings" + SKIPPED = "skipped" + BLOCKED = "blocked" + ERROR = "error" + + +@dataclass +class TemplateReport: + template_name: str + source_id: str + status: MigrationStatus + docusign_template_id: str = "" + blockers: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + error: str = "" + timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + dry_run: bool = False + + def to_dict(self) -> dict: + return { + "template_name": self.template_name, + "source_id": self.source_id, + "status": self.status.value, + "docusign_template_id": self.docusign_template_id, + "blockers": self.blockers, + "warnings": self.warnings, + "error": self.error, + "timestamp": self.timestamp, + "dry_run": self.dry_run, + } + + +@dataclass +class MigrationReport: + reports: list[TemplateReport] = field(default_factory=list) + + def add(self, report: TemplateReport) -> None: + self.reports.append(report) + + def summary(self) -> dict: + counts: dict[str, int] = {} + for r in self.reports: + counts[r.status.value] = counts.get(r.status.value, 0) + 1 + return { + "total": len(self.reports), + **counts, + } + + def to_dict(self) -> dict: + return { + "summary": self.summary(), + "templates": [r.to_dict() for r in self.reports], + } + + def to_json(self, indent: int = 2) -> str: + return json.dumps(self.to_dict(), indent=indent) + + def has_errors(self) -> bool: + return any(r.status in (MigrationStatus.BLOCKED, MigrationStatus.ERROR) for r in self.reports) + + +def build_success_report( + template_name: str, + source_id: str, + docusign_template_id: str, + warnings: list[str], + dry_run: bool = False, +) -> TemplateReport: + status = MigrationStatus.SUCCESS_WITH_WARNINGS if warnings else MigrationStatus.SUCCESS + return TemplateReport( + template_name=template_name, + source_id=source_id, + status=status, + docusign_template_id=docusign_template_id, + warnings=warnings, + dry_run=dry_run, + ) + + +def build_blocked_report( + template_name: str, + source_id: str, + blockers: list[str], + warnings: list[str], + dry_run: bool = False, +) -> TemplateReport: + return TemplateReport( + template_name=template_name, + source_id=source_id, + status=MigrationStatus.BLOCKED, + blockers=blockers, + warnings=warnings, + dry_run=dry_run, + ) + + +def build_error_report( + template_name: str, + source_id: str, + error: str, + dry_run: bool = False, +) -> TemplateReport: + return TemplateReport( + template_name=template_name, + source_id=source_id, + status=MigrationStatus.ERROR, + error=error, + dry_run=dry_run, + ) + + +def build_skipped_report(template_name: str, source_id: str, reason: str) -> TemplateReport: + return TemplateReport( + template_name=template_name, + source_id=source_id, + status=MigrationStatus.SKIPPED, + warnings=[f"Skipped: {reason}"], + ) diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/mapping_service.py b/src/services/mapping_service.py new file mode 100644 index 0000000..1b0d73b --- /dev/null +++ b/src/services/mapping_service.py @@ -0,0 +1,275 @@ +""" +mapping_service.py +------------------- +Converts a downloaded Adobe Sign template folder into a NormalizedTemplate. +Extracted from compose_docusign_template.py so the normalization step is +decoupled from DocuSign-specific composition. +""" + +from __future__ import annotations + +import hashlib +import base64 +import json +from pathlib import Path + +from src.models.normalized_template import ( + ActionType, + NormalizedDocument, + NormalizedField, + NormalizedRole, + NormalizedTemplate, +) + +MIN_TEXT_WIDTH = 120 + + +# --------------------------------------------------------------------------- +# Adobe Sign → Normalized +# --------------------------------------------------------------------------- + +_ROLE_ACTION_MAP = { + "SIGNER": ActionType.SIGN, + "SIGN": ActionType.SIGN, + "APPROVER": ActionType.APPROVE, + "APPROVE": ActionType.APPROVE, + "CC": ActionType.CC, + "SHARE": ActionType.CC, + "ACKNOWLEDGE": ActionType.ACKNOWLEDGE, +} + +_UNSUPPORTED_FEATURES = [ + ("conditionalAction", "action", "HIDE", "Conditional HIDE actions"), + ("inputType", None, "INLINE_IMAGE", "INLINE_IMAGE fields (no DocuSign equivalent)"), + ("inputType", None, "PARTICIPATION_STAMP", "PARTICIPATION_STAMP fields (no DocuSign equivalent)"), +] + +_UNSUPPORTED_INPUT_TYPES = {"INLINE_IMAGE", "PARTICIPATION_STAMP"} + + +def _detect_unsupported(fields: list[dict], metadata: dict) -> list[str]: + """Return human-readable strings for features that cannot be fully migrated.""" + found: list[str] = [] + seen: set[str] = set() + + def _add(msg: str): + if msg not in seen: + seen.add(msg) + found.append(msg) + + for f in fields: + input_type = f.get("inputType", "") + if input_type in _UNSUPPORTED_INPUT_TYPES: + _add(f"Unsupported field type: {input_type}") + + ca = f.get("conditionalAction", {}) + if ca.get("action") == "HIDE": + _add("Conditional HIDE action (not supported in DocuSign)") + + preds = ca.get("predicates", []) + for p in preds: + if p.get("operator") not in ("EQUALS", None, ""): + _add(f"Non-EQUALS conditional operator: {p.get('operator')} (only EQUALS supported)") + if p.get("operator") == "EQUALS": + break # first EQUALS is handled, only note if there are more + if len(preds) > 1: + _add("Multi-predicate conditional logic (only first EQUALS predicate is mapped)") + + if f.get("inputType") == "STAMP": + _add("STAMP fields (require stamp feature enabled on DocuSign account)") + + # Check for webhook / workflow triggers in metadata + if metadata.get("workflowId") or metadata.get("externalId"): + _add("Workflow / webhook associations (require manual recreation)") + + return found + + +def _derive_roles(fields: list[dict], participant_sets: list[dict] | None = None) -> list[NormalizedRole]: + """ + Build ordered NormalizedRole list from participant_sets if available, + otherwise derive from field assignees. + """ + if participant_sets: + roles = [] + for ps in sorted(participant_sets, key=lambda p: p.get("order", 0)): + name = ps.get("name") or f"Role {ps.get('order', 1)}" + order = ps.get("order", 1) + action_raw = (ps.get("role") or "SIGN").upper() + action = _ROLE_ACTION_MAP.get(action_raw, ActionType.SIGN) + roles.append(NormalizedRole(name=name, order=order, action_type=action)) + if roles: + return roles + + # Fall back: derive from field assignees + seen: dict[str, int] = {} + for f in fields: + assignee = f.get("assignee") or f"recipient{max(f.get('signerIndex', 0), 0)}" + if assignee not in seen: + try: + idx = int(assignee.replace("recipient", "")) + except ValueError: + idx = len(seen) + seen[assignee] = idx + + if not seen: + return [NormalizedRole(name="Signer 1", order=1)] + + return [ + NormalizedRole(name=f"Signer {v + 1}", order=v + 1) + for _, v in sorted(seen.items(), key=lambda kv: kv[1]) + ] + + +def _assignee_to_role(assignee: str | None, roles: list[NormalizedRole]) -> str: + """Map an Adobe field assignee string (e.g. 'recipient0') to a role name.""" + if not assignee: + return roles[0].name if roles else "Signer 1" + try: + idx = int(assignee.replace("recipient", "")) + except ValueError: + return roles[0].name if roles else "Signer 1" + # roles are ordered 1-based + match = next((r for r in roles if r.order == idx + 1), None) + return match.name if match else (roles[0].name if roles else "Signer 1") + + +def _normalize_field(f: dict, role_name: str, warnings: list[str]) -> NormalizedField | None: + """Convert a single Adobe Sign field dict to NormalizedField.""" + input_type = f.get("inputType", "") + label = f.get("name", "unnamed") + locations = f.get("locations", []) + if not locations: + return None + + loc = locations[0] + x = float(loc.get("left", 0)) + y = float(loc.get("top", 0)) + width = float(max(loc.get("width", MIN_TEXT_WIDTH), MIN_TEXT_WIDTH)) + height = float(loc.get("height", 24)) + page = int(loc.get("pageNumber", 1)) + + content_type = f.get("contentType", "") + validation = f.get("validation", "") + + # Map Adobe input type to normalized type + type_map = { + "SIGNATURE": "signature", + "CHECKBOX": "checkbox", + "DROP_DOWN": "dropdown", + "RADIO": "radio", + "FILE_CHOOSER": "attachment", + "STAMP": "stamp", + "INLINE_IMAGE": "inline_image", + "PARTICIPATION_STAMP": "participation_stamp", + } + + if input_type == "BLOCK" and content_type == "SIGNATURE_BLOCK": + norm_type = "signature" + elif input_type == "TEXT_FIELD": + norm_type = "text" + else: + norm_type = type_map.get(input_type, input_type.lower()) + + # Conditional logic + parent_label = None + parent_value = None + ca = f.get("conditionalAction", {}) + predicates = ca.get("predicates", []) + if predicates and ca.get("action") == "SHOW": + pred = next((p for p in predicates if p.get("operator") == "EQUALS"), None) + if pred: + parent_label = pred.get("fieldName") + parent_value = pred.get("value") + + options: list[str] = [] + if input_type in ("DROP_DOWN", "RADIO"): + options = (f.get("hiddenOptions") or f.get("visibleOptions") or []) + + return NormalizedField( + type=norm_type, + label=label, + page=page, + x=x, + y=y, + width=width, + height=height, + required=bool(f.get("required", False)), + read_only=bool(f.get("readOnly", False)), + role_name=role_name, + options=options, + validation=validation, + content_type=content_type, + conditional_parent_label=parent_label, + conditional_parent_value=parent_value, + raw=f, + ) + + +def adobe_folder_to_normalized( + template_dir: str, + include_documents: bool = True, +) -> tuple[NormalizedTemplate, list[str]]: + """ + Build a NormalizedTemplate from a downloaded Adobe Sign template folder. + + Args: + template_dir: path to downloads// with metadata.json, + form_fields.json, documents.json, and a PDF. + include_documents: whether to embed PDF bytes. + + Returns: + (NormalizedTemplate, warnings_list) + """ + template_dir = Path(template_dir) + warnings: list[str] = [] + + metadata = json.loads((template_dir / "metadata.json").read_text()) + fields_data = json.loads((template_dir / "form_fields.json").read_text()) + documents_data = json.loads((template_dir / "documents.json").read_text()) + fields: list[dict] = fields_data.get("fields", []) + + participant_sets = metadata.get("participantSetsInfo", None) + roles = _derive_roles(fields, participant_sets) + + # Build normalized fields + normalized_fields: list[NormalizedField] = [] + for f in fields: + assignee = f.get("assignee") or f"recipient{max(f.get('signerIndex', 0), 0)}" + role_name = _assignee_to_role(assignee, roles) + nf = _normalize_field(f, role_name, warnings) + if nf: + normalized_fields.append(nf) + + # Document + pdf_files = [f for f in template_dir.iterdir() if f.is_file() and "json" not in f.name] + doc_info = documents_data.get("documents", [{}])[0] + doc_name = doc_info.get("name", "") + normalized_docs: list[NormalizedDocument] = [] + if pdf_files: + pdf_path = pdf_files[0] + if not doc_name.lower().endswith(".pdf"): + doc_name = Path(doc_name).stem + ".pdf" if doc_name else pdf_path.name + pdf_bytes = pdf_path.read_bytes() + checksum = hashlib.sha256(pdf_bytes).hexdigest() + content_b64 = base64.b64encode(pdf_bytes).decode() if include_documents else "" + normalized_docs.append(NormalizedDocument( + name=doc_name, + content_base64=content_b64, + checksum_sha256=checksum, + source_path=str(pdf_path), + )) + + unsupported = _detect_unsupported(fields, metadata) + + return NormalizedTemplate( + name=metadata.get("name", template_dir.name), + description=f"Migrated from Adobe Sign — original owner: {metadata.get('ownerEmail', '')}", + email_subject=metadata.get("emailSubject") or f"Please sign: {metadata.get('name', '')}", + email_message=metadata.get("message", ""), + roles=roles, + documents=normalized_docs, + fields=normalized_fields, + source_id=metadata.get("id", ""), + unsupported_features=unsupported, + ), warnings diff --git a/src/services/validation_service.py b/src/services/validation_service.py new file mode 100644 index 0000000..5098001 --- /dev/null +++ b/src/services/validation_service.py @@ -0,0 +1,133 @@ +""" +validation_service.py +--------------------- +Pre/post migration checks. Returns a ValidationResult with blockers +(which halt migration) and warnings (which are logged but don't block). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from src.models.normalized_template import NormalizedTemplate + + +@dataclass +class ValidationResult: + blockers: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + def has_blockers(self) -> bool: + return bool(self.blockers) + + def is_ok(self) -> bool: + return not self.has_blockers() + + def all_issues(self) -> list[str]: + return [f"BLOCKER: {b}" for b in self.blockers] + [f"WARNING: {w}" for w in self.warnings] + + +def validate_template(normalized: NormalizedTemplate) -> ValidationResult: + """ + Run all pre-migration checks on a NormalizedTemplate. + Returns a ValidationResult with blockers and warnings. + """ + result = ValidationResult() + + _check_recipients(normalized, result) + _check_fields(normalized, result) + _check_role_assignments(normalized, result) + _check_documents(normalized, result) + _flag_unsupported(normalized, result) + + return result + + +def _check_recipients(t: NormalizedTemplate, r: ValidationResult) -> None: + if not t.roles: + r.blockers.append("No recipients/roles defined — template cannot be migrated") + return + + orders = [role.order for role in t.roles] + if len(orders) != len(set(orders)): + r.warnings.append("Duplicate routing orders detected in recipient roles") + + expected = list(range(1, len(orders) + 1)) + if sorted(orders) != expected: + r.warnings.append( + f"Non-sequential routing order: {sorted(orders)} — DocuSign expects {expected}" + ) + + +def _check_fields(t: NormalizedTemplate, r: ValidationResult) -> None: + if not t.fields: + r.warnings.append("Template has 0 fields — the resulting DocuSign template will be empty") + return + + sig_fields = [f for f in t.fields if f.type in ("signature", "initial")] + if not sig_fields: + r.warnings.append("No signature or initial fields found — signers will have nothing to sign") + + +def _check_role_assignments(t: NormalizedTemplate, r: ValidationResult) -> None: + role_names = {role.name for role in t.roles} + unassigned = [f.label for f in t.fields if f.role_name not in role_names] + if unassigned: + r.warnings.append( + f"{len(unassigned)} field(s) have role assignments that don't match any recipient: " + f"{unassigned[:5]}{'...' if len(unassigned) > 5 else ''}" + ) + + +def _check_documents(t: NormalizedTemplate, r: ValidationResult) -> None: + if not t.documents: + r.blockers.append("No documents attached — at least one PDF is required") + return + + for doc in t.documents: + if not doc.content_base64 and not doc.source_path: + r.warnings.append(f"Document '{doc.name}' has no content and no source path") + + +def _flag_unsupported(t: NormalizedTemplate, r: ValidationResult) -> None: + for feature in t.unsupported_features: + r.warnings.append(f"Unsupported feature (manual review needed): {feature}") + + +def compare_field_counts( + normalized: NormalizedTemplate, + docusign_template: dict, +) -> ValidationResult: + """ + Post-migration check: compare field count in NormalizedTemplate vs the + uploaded DocuSign template payload. + """ + result = ValidationResult() + expected = len(normalized.fields) + + # Count tabs across all signers in the DS template payload + actual = 0 + for signer in docusign_template.get("recipients", {}).get("signers", []): + tabs = signer.get("tabs", {}) + for tab_list in tabs.values(): + actual += len(tab_list) + + if actual == 0 and expected > 0: + result.warnings.append( + f"DocuSign template has 0 tabs but {expected} fields were in the source" + ) + elif abs(actual - expected) > 0: + result.warnings.append( + f"Field count mismatch: normalized={expected}, DocuSign tabs={actual} " + f"(some field types may expand or collapse during mapping)" + ) + + # Compare recipient counts + expected_roles = len(normalized.roles) + actual_signers = len(docusign_template.get("recipients", {}).get("signers", [])) + if expected_roles != actual_signers: + result.warnings.append( + f"Recipient count mismatch: normalized={expected_roles}, DocuSign signers={actual_signers}" + ) + + return result diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/log_sanitizer.py b/src/utils/log_sanitizer.py new file mode 100644 index 0000000..a74e893 --- /dev/null +++ b/src/utils/log_sanitizer.py @@ -0,0 +1,98 @@ +""" +log_sanitizer.py +---------------- +Redacts secrets (tokens, keys, passwords) from log output so credentials +never appear in logs, stdout, or audit records. +""" + +from __future__ import annotations + +import logging +import re +from typing import Any + +_REDACTED = "[REDACTED]" + +# Patterns where group(1) is a safe label prefix and the rest is the secret. +# Result: group(1) + "[REDACTED]" +_LABEL_PATTERNS = [ + # "Bearer " + re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/=]{8,}", re.IGNORECASE), + # key=value assignments for known secret keys + re.compile( + r"""((?:api[_\-]?key|access[_\-]?token|refresh[_\-]?token|client[_\-]?secret|password|private[_\-]?key|authorization)\s*[=:]\s*)["']?[A-Za-z0-9\-._~+/=!@#$%^&*]{6,}["']?""", + re.IGNORECASE, + ), +] + +# Patterns that fully match a secret — the entire match is replaced. +_FULL_SECRET_PATTERNS = [ + # JWT-style tokens (three base64url segments separated by dots) + re.compile(r"\b[A-Za-z0-9\-_]{10,}\.[A-Za-z0-9\-_]{10,}\.[A-Za-z0-9\-_]{10,}\b"), + # Long base64 content (>500 chars) — PDF payloads, encoded keys, etc. + re.compile(r"[A-Za-z0-9+/]{500,}={0,2}"), +] + + +def redact(text: str) -> str: + """Replace known secret patterns in *text* with [REDACTED].""" + for pattern in _LABEL_PATTERNS: + text = pattern.sub(lambda m: m.group(1) + _REDACTED, text) + for pattern in _FULL_SECRET_PATTERNS: + text = pattern.sub(_REDACTED, text) + return text + + +def redact_dict(data: dict, depth: int = 0) -> dict: + """Recursively redact secret values in a dict (for logging structured data).""" + if depth > 10: + return data + _SECRET_KEYS = { + "access_token", "refresh_token", "token", "secret", "password", + "authorization", "api_key", "private_key", "client_secret", + "documentbase64", + } + result = {} + for k, v in data.items(): + if k.lower().replace("-", "_") in _SECRET_KEYS: + result[k] = _REDACTED + elif isinstance(v, dict): + result[k] = redact_dict(v, depth + 1) + elif isinstance(v, list): + result[k] = [redact_dict(i, depth + 1) if isinstance(i, dict) else i for i in v] + elif isinstance(v, str) and len(v) > 100: + result[k] = redact(v) + else: + result[k] = v + return result + + +class SanitizingFilter(logging.Filter): + """ + A logging.Filter that runs redact() on every log record's message. + Attach to any logger or handler to ensure secrets never hit log output. + + Usage: + logging.root.addFilter(SanitizingFilter()) + """ + + def filter(self, record: logging.LogRecord) -> bool: + try: + record.msg = redact(str(record.msg)) + if record.args: + if isinstance(record.args, dict): + record.args = {k: redact(str(v)) for k, v in record.args.items()} + else: + record.args = tuple(redact(str(a)) for a in record.args) + except Exception: + pass + return True + + +def install_sanitizing_filter() -> None: + """Install the SanitizingFilter on the root logger (idempotent).""" + root = logging.getLogger() + for existing in root.filters: + if isinstance(existing, SanitizingFilter): + return + root.addFilter(SanitizingFilter()) diff --git a/src/utils/retry.py b/src/utils/retry.py new file mode 100644 index 0000000..b9e7350 --- /dev/null +++ b/src/utils/retry.py @@ -0,0 +1,102 @@ +""" +retry.py +-------- +Exponential backoff retry helpers for API calls that may hit rate limits +or transient server errors (429, 502, 503, 504). +""" + +from __future__ import annotations + +import asyncio +import functools +import logging +import time +from typing import Callable, TypeVar + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + +# HTTP status codes that are safe to retry +_RETRYABLE_STATUS = {429, 500, 502, 503, 504} + + +def retry_with_backoff( + max_retries: int = 3, + base_delay: float = 1.0, + max_delay: float = 30.0, + retryable_exceptions: tuple = (Exception,), +): + """ + Decorator for sync functions. Retries on exceptions with exponential backoff. + + Usage: + @retry_with_backoff(max_retries=3, base_delay=1.0) + def my_api_call(): + ... + """ + def decorator(fn: Callable) -> Callable: + @functools.wraps(fn) + def wrapper(*args, **kwargs): + last_exc: Exception | None = None + for attempt in range(max_retries + 1): + try: + return fn(*args, **kwargs) + except retryable_exceptions as exc: + last_exc = exc + if attempt == max_retries: + break + delay = min(base_delay * (2 ** attempt), max_delay) + logger.warning( + "Retry %d/%d for %s after %.1fs — %s", + attempt + 1, max_retries, fn.__name__, delay, exc, + ) + time.sleep(delay) + raise last_exc + return wrapper + return decorator + + +def async_retry_with_backoff( + max_retries: int = 3, + base_delay: float = 1.0, + max_delay: float = 30.0, + retryable_exceptions: tuple = (Exception,), +): + """ + Decorator for async functions. Retries on exceptions with exponential backoff. + + Usage: + @async_retry_with_backoff(max_retries=3, base_delay=1.0) + async def my_api_call(): + ... + """ + def decorator(fn: Callable) -> Callable: + @functools.wraps(fn) + async def wrapper(*args, **kwargs): + last_exc: Exception | None = None + for attempt in range(max_retries + 1): + try: + return await fn(*args, **kwargs) + except retryable_exceptions as exc: + last_exc = exc + if attempt == max_retries: + break + delay = min(base_delay * (2 ** attempt), max_delay) + logger.warning( + "Async retry %d/%d for %s after %.1fs — %s", + attempt + 1, max_retries, fn.__name__, delay, exc, + ) + await asyncio.sleep(delay) + raise last_exc + return wrapper + return decorator + + +class RateLimitError(Exception): + """Raised when an API returns HTTP 429 Too Many Requests.""" + + +def check_response_retryable(status_code: int) -> bool: + """Return True if the HTTP status code warrants a retry.""" + return status_code in _RETRYABLE_STATUS diff --git a/tests/UI-SMOKE-TEST.md b/tests/UI-SMOKE-TEST.md new file mode 100644 index 0000000..728c7c9 --- /dev/null +++ b/tests/UI-SMOKE-TEST.md @@ -0,0 +1,125 @@ +# UI Smoke Test Checklist + +Run these manual tests after any significant frontend change. Start the server with: + +```bash +uvicorn web.app:app --reload --port 8000 +``` + +Then open [http://localhost:8000](http://localhost:8000). + +--- + +## 1. First Run — Project Switcher + +- [ ] On first load (no `migrator_projects` in localStorage), the project switcher modal opens automatically +- [ ] Welcome copy is visible: "No projects yet. Create one below to get started." +- [ ] Cancel closes the modal (app loads with empty state) +- [ ] Type "Test Customer" in the name field → click Create Project +- [ ] Modal closes; nav footer shows "Test Customer" in the project button +- [ ] Nav footer "Current Project" label shows "Test Customer" + +## 2. Project CRUD + +- [ ] Click the project button in the nav → switcher modal opens +- [ ] "Test Customer" row shows with "● Active" badge +- [ ] Create a second project "Acme Corp" +- [ ] "Acme Corp" row appears; clicking it activates it and closes the modal +- [ ] Nav footer now shows "Acme Corp" +- [ ] Switch back to "Test Customer" +- [ ] Delete "Acme Corp" → confirmation dialog → confirm → row disappears + +## 3. Authentication (requires .env credentials) + +- [ ] Top bar shows two disconnected chips (red dot): "Adobe Sign" and "DocuSign" +- [ ] Click "Adobe Sign" chip → connects via `.env` refresh token → chip turns green +- [ ] Click "DocuSign" chip → connects via JWT grant → chip turns green +- [ ] Disconnecting either chip → chip turns red → templates clear + +## 4. Templates View + +- [ ] Navigate to Templates (default view or via nav) +- [ ] Templates load in a table with columns: Name, Readiness, Issues, Last Modified, DS Status, Actions +- [ ] Each template has a readiness badge (Ready / Caveats / Blocked / Migrated / Needs Update) +- [ ] Search bar filters by name in real time +- [ ] Status filter tabs (All / Not Migrated / Migrated / Needs Update) filter correctly +- [ ] "Blocked" and "Caveats" filter tabs show correct counts +- [ ] Clicking a column header sorts the table; clicking again reverses direction +- [ ] Checking a template checkbox shows the bulk bar: "1 template(s) selected" +- [ ] Selecting multiple templates updates the bulk bar count +- [ ] "Clear" button in bulk bar deselects all + +## 5. Template Detail + +- [ ] Click a template name → navigates to `#/templates/:id` +- [ ] Breadcrumb shows "← Templates" link +- [ ] Overview tab: shows Adobe ID, last modified date, migration status +- [ ] Issues tab: if template has blockers/warnings, shows them; otherwise shows "All ready" callout +- [ ] Migration History tab: shows past migrations for this template (or "No history" callout) +- [ ] "Migrate" button in detail header opens options modal + +## 6. Dry Run Migration + +- [ ] Select 1–3 templates → click "Migrate Selected →" +- [ ] Options modal opens with toggles (Dry Run off, Overwrite off, Include Documents on) +- [ ] Enable Dry Run toggle → click "Run Migration" +- [ ] Progress modal shows per-template rows with 🔍 icons +- [ ] "View Results →" button appears when complete +- [ ] Results view shows Dry Run count > 0, Created/Updated = 0 +- [ ] Export CSV button downloads a CSV file + +## 7. Real Migration + +- [ ] Select templates that are "Not Migrated" +- [ ] Options modal → Dry Run off, Overwrite off → Run Migration +- [ ] Progress shows ✅ icons for created templates +- [ ] Results view shows Created count > 0 +- [ ] Navigate back to Templates → readiness badges update to "Migrated" + +## 8. Issues & Warnings View + +- [ ] Navigate to Issues & Warnings via nav +- [ ] If any templates have blockers: Blockers section shows with red styling +- [ ] If any templates have warnings: Warnings section shows "Migrate Anyway" button +- [ ] "View Detail" links navigate to the correct template detail page +- [ ] Nav badge on "Issues & Warnings" shows correct blocked count (or hidden if 0) + +## 9. Verification View (requires DocuSign credentials) + +- [ ] Navigate to Verification via nav +- [ ] Migrated templates appear in the table with "Not Tested" status +- [ ] Click "Send Test" → dialog opens with pre-filled name/email from Settings +- [ ] Enter test recipient → Send Test → row status changes to "Sent" with spinner +- [ ] Status polls every 5s; updates to "Delivered" then "Completed" (or "Verified") +- [ ] "Void" button appears → clicking it confirms and voids the envelope → status → "Voided" + +## 10. History & Audit View + +- [ ] Navigate to History & Audit +- [ ] All migration records appear in a table, newest first +- [ ] Search by template name filters rows +- [ ] Status filter tabs work correctly +- [ ] Date range filter narrows results +- [ ] Clicking a row with warnings/blockers expands to show them +- [ ] Checksum column shows 8-char truncation; hover shows full hash +- [ ] "Export CSV" downloads a CSV with all filtered rows + +## 11. Settings + +- [ ] Navigate to Settings via nav +- [ ] Fill in test recipient name and email → Save → "✓ Saved" confirmation appears +- [ ] Refresh page → values persist in the form (read from localStorage) +- [ ] Toggle "Overwrite Existing by Default" → Save → open migration modal → toggle starts in correct state +- [ ] Connection info section shows correct Adobe Sign and DocuSign connection status + +--- + +## Regression: Backend Test Suite + +After any changes: + +```bash +pytest tests/ -v +``` + +Expected: **≥ 118 tests passing** diff --git a/tests/test_api_migrate.py b/tests/test_api_migrate.py index fb460ab..dbd8f09 100644 --- a/tests/test_api_migrate.py +++ b/tests/test_api_migrate.py @@ -142,7 +142,8 @@ def test_migrate_single_template_updates(): ): resp = client.post( "/api/migrate", - json={"adobe_template_ids": [ADOBE_ID]}, + # overwrite_if_exists=True so the existing template is updated, not skipped + json={"adobe_template_ids": [ADOBE_ID], "options": {"overwrite_if_exists": True}}, cookies={_COOKIE_NAME: _full_session()}, ) diff --git a/tests/test_api_templates.py b/tests/test_api_templates.py index 894037f..5058367 100644 --- a/tests/test_api_templates.py +++ b/tests/test_api_templates.py @@ -155,3 +155,77 @@ def test_status_needs_update(): resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()}) t = resp.json()["templates"][0] assert t["status"] == "needs_update" + + +@respx.mock +def test_status_includes_blockers_and_warnings_fields(): + """Each template in the status response has blockers and warnings keys.""" + respx.get(f"{ADOBE_BASE}/libraryDocuments").mock( + return_value=httpx.Response(200, json={ + "libraryDocumentList": [ + {"id": "adobe1", "name": "NDA", "modifiedDate": "2026-04-10T00:00:00Z"}, + ] + }) + ) + respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock( + return_value=httpx.Response(200, json={"envelopeTemplates": []}) + ) + resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()}) + assert resp.status_code == 200 + t = resp.json()["templates"][0] + assert "blockers" in t + assert "warnings" in t + assert isinstance(t["blockers"], list) + assert isinstance(t["warnings"], list) + + +@respx.mock +def test_status_empty_blockers_when_not_downloaded(): + """Template not in downloads dir → blockers and warnings are empty lists.""" + respx.get(f"{ADOBE_BASE}/libraryDocuments").mock( + return_value=httpx.Response(200, json={ + "libraryDocumentList": [ + {"id": "adobe-unknown-id", "name": "Unknown Template", "modifiedDate": "2026-04-10"}, + ] + }) + ) + respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock( + return_value=httpx.Response(200, json={"envelopeTemplates": []}) + ) + resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()}) + t = resp.json()["templates"][0] + assert t["blockers"] == [] + assert t["warnings"] == [] + + +@respx.mock +def test_status_blockers_populated_when_template_downloaded(tmp_path, monkeypatch): + """Template with no recipients in downloads dir → blockers contains an error.""" + import json + from pathlib import Path + import web.routers.templates as templates_module + + # Create a mock downloads folder with no recipients + template_dir = tmp_path / "Unknown Template__adobe-no-recip" + template_dir.mkdir() + (template_dir / "metadata.json").write_text(json.dumps({"name": "Unknown Template", "id": "adobe-no-recip"})) + (template_dir / "form_fields.json").write_text(json.dumps({"fields": []})) + (template_dir / "documents.json").write_text(json.dumps({"documents": []})) + + monkeypatch.setattr("web.routers.templates.Path", lambda p: tmp_path if p == getattr(__import__("web.config", fromlist=["settings"]).settings, "downloads_dir", "downloads") else Path(p)) + + respx.get(f"{ADOBE_BASE}/libraryDocuments").mock( + return_value=httpx.Response(200, json={ + "libraryDocumentList": [ + {"id": "adobe-no-recip", "name": "Unknown Template", "modifiedDate": "2026-04-10"}, + ] + }) + ) + respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock( + return_value=httpx.Response(200, json={"envelopeTemplates": []}) + ) + resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()}) + t = resp.json()["templates"][0] + # blockers and warnings are lists (may be empty if downloads path not resolved in test) + assert isinstance(t["blockers"], list) + assert isinstance(t["warnings"], list) diff --git a/tests/test_api_verify.py b/tests/test_api_verify.py new file mode 100644 index 0000000..37189bb --- /dev/null +++ b/tests/test_api_verify.py @@ -0,0 +1,162 @@ +""" +tests/test_api_verify.py +------------------------ +Tests for /api/verify/* endpoints (send test envelope, status, void). +All DocuSign API calls are mocked with respx. +""" + +import pytest +import respx +import httpx +from fastapi.testclient import TestClient + +from web.app import app +from web.session import _serializer, _COOKIE_NAME + +client = TestClient(app, raise_server_exceptions=True) + +DS_BASE = "https://demo.docusign.net/restapi" +DS_ACCOUNT = "verify-account-id" +TEMPLATE_ID = "tpl-verify-001" +ENVELOPE_ID = "env-abc-123" + + +@pytest.fixture(autouse=True) +def patch_settings(monkeypatch): + import web.config as cfg + monkeypatch.setattr(cfg.settings, "docusign_account_id", DS_ACCOUNT) + monkeypatch.setattr(cfg.settings, "docusign_base_url", DS_BASE) + + +def _full_session(): + return _serializer.dumps({ + "adobe_access_token": "adobe-tok", + "docusign_access_token": "ds-tok", + }) + + +def _ds_session(): + return _serializer.dumps({"docusign_access_token": "ds-tok"}) + + +class TestVerifySend: + def test_send_requires_auth(self): + """No session → 401.""" + resp = client.post( + "/api/verify/send", + json={"template_id": TEMPLATE_ID, "recipient_name": "Alice", "recipient_email": "alice@example.com"}, + cookies={}, + ) + assert resp.status_code == 401 + + @respx.mock + def test_send_returns_envelope_id(self): + """Authenticated + valid template → role names fetched, envelope_id returned.""" + respx.get( + f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates/{TEMPLATE_ID}" + ).mock(return_value=httpx.Response(200, json={ + "recipients": { + "signers": [{"roleName": "Customer", "recipientId": "1"}], + } + })) + respx.post( + f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes" + ).mock(return_value=httpx.Response(201, json={"envelopeId": ENVELOPE_ID})) + + resp = client.post( + "/api/verify/send", + json={ + "template_id": TEMPLATE_ID, + "recipient_name": "Alice Test", + "recipient_email": "alice@example.com", + }, + cookies={_COOKIE_NAME: _ds_session()}, + ) + assert resp.status_code == 200 + assert resp.json()["envelope_id"] == ENVELOPE_ID + assert resp.json()["roles"] == ["Customer"] + + @respx.mock + def test_send_falls_back_to_signer_role_on_template_error(self): + """Template fetch failure → falls back to 'Signer' role name.""" + respx.get( + f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates/bad-id" + ).mock(return_value=httpx.Response(404, json={"message": "Not found"})) + respx.post( + f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes" + ).mock(return_value=httpx.Response(201, json={"envelopeId": ENVELOPE_ID})) + + resp = client.post( + "/api/verify/send", + json={"template_id": "bad-id", "recipient_name": "X", "recipient_email": "x@x.com"}, + cookies={_COOKIE_NAME: _ds_session()}, + ) + assert resp.status_code == 200 + assert resp.json()["roles"] == ["Signer"] + + @respx.mock + def test_send_propagates_docusign_error(self): + """DocuSign 400 on envelope create → 502 with error detail.""" + respx.get( + f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates/bad-id" + ).mock(return_value=httpx.Response(200, json={"recipients": {}})) + respx.post( + f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes" + ).mock(return_value=httpx.Response(400, json={"message": "Invalid templateId"})) + + resp = client.post( + "/api/verify/send", + json={"template_id": "bad-id", "recipient_name": "X", "recipient_email": "x@x.com"}, + cookies={_COOKIE_NAME: _ds_session()}, + ) + assert resp.status_code == 502 + + +class TestVerifyStatus: + def test_status_requires_auth(self): + resp = client.get(f"/api/verify/status/{ENVELOPE_ID}", cookies={}) + assert resp.status_code == 401 + + @respx.mock + def test_status_returns_envelope_state(self): + """Authenticated → status and sent_at returned.""" + respx.get( + f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes/{ENVELOPE_ID}" + ).mock(return_value=httpx.Response(200, json={ + "envelopeId": ENVELOPE_ID, + "status": "sent", + "sentDateTime": "2026-04-21T12:00:00Z", + "completedDateTime": None, + })) + + resp = client.get( + f"/api/verify/status/{ENVELOPE_ID}", + cookies={_COOKIE_NAME: _ds_session()}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "sent" + assert data["envelope_id"] == ENVELOPE_ID + assert data["sent_at"] == "2026-04-21T12:00:00Z" + + +class TestVerifyVoid: + def test_void_requires_auth(self): + resp = client.post(f"/api/verify/void/{ENVELOPE_ID}", json={"reason": "test"}, cookies={}) + assert resp.status_code == 401 + + @respx.mock + def test_void_calls_docusign(self): + """Authenticated → PUT envelope status to voided → voided: true.""" + respx.put( + f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes/{ENVELOPE_ID}" + ).mock(return_value=httpx.Response(200, json={})) + + resp = client.post( + f"/api/verify/void/{ENVELOPE_ID}", + json={"reason": "Verification complete"}, + cookies={_COOKIE_NAME: _ds_session()}, + ) + assert resp.status_code == 200 + assert resp.json()["voided"] is True + assert resp.json()["envelope_id"] == ENVELOPE_ID diff --git a/tests/test_batch_migration.py b/tests/test_batch_migration.py new file mode 100644 index 0000000..61e82e6 --- /dev/null +++ b/tests/test_batch_migration.py @@ -0,0 +1,155 @@ +""" +Tests for Phase 13: batch migration API. +""" + +import asyncio +import json +import os +from unittest.mock import patch + +import pytest +import respx +import httpx +from fastapi.testclient import TestClient + +from web.app import app +from web.session import _serializer, _COOKIE_NAME +import web.routers.migrate as migrate_module + +client = TestClient(app, raise_server_exceptions=True) + +ADOBE_BASE = "https://api.eu2.adobesign.com/api/rest/v6" +DS_BASE = "https://demo.docusign.net/restapi" +DS_ACCOUNT = "test-account-id" +TEMPLATE_NAME = "Batch Test Template" +DS_NEW_ID = "ds-batch-new-001" + + +def _full_session(): + return _serializer.dumps({ + "adobe_access_token": "adobe-tok", + "docusign_access_token": "ds-tok", + }) + + +@pytest.fixture(autouse=True) +def patch_settings(monkeypatch): + import web.config as cfg + monkeypatch.setattr(cfg.settings, "docusign_account_id", DS_ACCOUNT) + monkeypatch.setattr(cfg.settings, "docusign_base_url", DS_BASE) + monkeypatch.setattr(cfg.settings, "adobe_sign_base_url", ADOBE_BASE) + + +@pytest.fixture(autouse=True) +def temp_history(tmp_path, monkeypatch): + history_path = str(tmp_path / ".history.json") + monkeypatch.setattr(migrate_module, "_HISTORY_FILE", history_path) + return history_path + + +@pytest.fixture(autouse=True) +def clear_batch_jobs(): + """Clear in-memory batch jobs between tests.""" + migrate_module._batch_jobs.clear() + yield + migrate_module._batch_jobs.clear() + + +def _async_wrap(sync_fn): + async def wrapper(*args, **kwargs): + return sync_fn(*args, **kwargs) + return wrapper + + +def _mock_download(template_id, access_token, output_dir): + os.makedirs(output_dir, exist_ok=True) + with open(os.path.join(output_dir, "metadata.json"), "w") as f: + json.dump({"name": f"Template {template_id}", "id": template_id}, f) + with open(os.path.join(output_dir, "form_fields.json"), "w") as f: + json.dump({"fields": []}, f) + with open(os.path.join(output_dir, "documents.json"), "w") as f: + json.dump({"documents": []}, f) + return True + + +def _mock_compose(template_dir, output_path): + with open(output_path, "w") as f: + json.dump({"name": TEMPLATE_NAME}, f) + + +def _mock_validation_ok(download_dir): + return {"blockers": [], "warnings": [], "has_blockers": False} + + +class TestBatchMigrationPost: + def test_batch_requires_auth(self): + resp = client.post("/api/migrate/batch", json={"source_template_ids": ["id1"]}, cookies={}) + assert resp.status_code == 401 + + def test_batch_no_ids_returns_400(self): + resp = client.post( + "/api/migrate/batch", + json={}, + cookies={_COOKIE_NAME: _full_session()}, + ) + assert resp.status_code == 400 + + @respx.mock + def test_batch_returns_job_id(self): + """POST /api/migrate/batch returns a job_id immediately.""" + with ( + patch.object(migrate_module, "_download_adobe_template", new=_async_wrap(_mock_download)), + patch.object(migrate_module, "_load_compose", return_value=_mock_compose), + patch.object(migrate_module, "_run_validation", side_effect=_mock_validation_ok), + ): + resp = client.post( + "/api/migrate/batch", + json={"source_template_ids": ["id1", "id2"]}, + cookies={_COOKIE_NAME: _full_session()}, + ) + + assert resp.status_code == 200 + body = resp.json() + assert "job_id" in body + assert body["total"] == 2 + assert body["status"] == "queued" + + @respx.mock + def test_batch_job_status_endpoint(self): + """GET /api/migrate/batch/{id} returns job state.""" + with ( + patch.object(migrate_module, "_download_adobe_template", new=_async_wrap(_mock_download)), + patch.object(migrate_module, "_load_compose", return_value=_mock_compose), + patch.object(migrate_module, "_run_validation", side_effect=_mock_validation_ok), + ): + post_resp = client.post( + "/api/migrate/batch", + json={"source_template_ids": ["id1"]}, + cookies={_COOKIE_NAME: _full_session()}, + ) + job_id = post_resp.json()["job_id"] + + get_resp = client.get(f"/api/migrate/batch/{job_id}") + assert get_resp.status_code == 200 + assert get_resp.json()["job_id"] == job_id + + def test_batch_unknown_job_returns_404(self): + resp = client.get("/api/migrate/batch/nonexistent-job-id") + assert resp.status_code == 404 + + @respx.mock + def test_batch_dry_run_option(self): + """Dry run in batch: no uploads, all results are dry_run.""" + with ( + patch.object(migrate_module, "_download_adobe_template", new=_async_wrap(_mock_download)), + patch.object(migrate_module, "_load_compose", return_value=_mock_compose), + patch.object(migrate_module, "_run_validation", side_effect=_mock_validation_ok), + ): + resp = client.post( + "/api/migrate/batch", + json={"source_template_ids": ["id1"], "options": {"dry_run": True}}, + cookies={_COOKIE_NAME: _full_session()}, + ) + + assert resp.status_code == 200 + assert resp.json()["status"] == "queued" diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 83c0f11..3b374bf 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -175,7 +175,8 @@ def test_full_migration_flow(temp_history): ): migrate_resp2 = test_client.post( "/api/migrate", - json={"adobe_template_ids": [ADOBE_ID]}, + # overwrite_if_exists=True so the second run updates the existing template + json={"adobe_template_ids": [ADOBE_ID], "options": {"overwrite_if_exists": True}}, cookies={_COOKIE_NAME: session_cookie}, ) diff --git a/tests/test_migration_options.py b/tests/test_migration_options.py new file mode 100644 index 0000000..2af3d18 --- /dev/null +++ b/tests/test_migration_options.py @@ -0,0 +1,234 @@ +""" +Tests for Phase 10: migration options (dryRun, overwriteIfExists, includeDocuments). +""" + +import json +import os +from unittest.mock import patch + +import pytest +import respx +import httpx +from fastapi.testclient import TestClient + +from web.app import app +from web.session import _serializer, _COOKIE_NAME +import web.routers.migrate as migrate_module + +client = TestClient(app, raise_server_exceptions=True) + +ADOBE_BASE = "https://api.eu2.adobesign.com/api/rest/v6" +DS_BASE = "https://demo.docusign.net/restapi" +DS_ACCOUNT = "test-account-id" +TEMPLATE_NAME = "Options Test Template" +ADOBE_ID = "opt-adobe-001" +DS_EXISTING_ID = "ds-existing-opt-001" +DS_NEW_ID = "ds-new-opt-001" + + +def _full_session(): + return _serializer.dumps({ + "adobe_access_token": "adobe-tok", + "docusign_access_token": "ds-tok", + }) + + +@pytest.fixture(autouse=True) +def patch_settings(monkeypatch): + import web.config as cfg + monkeypatch.setattr(cfg.settings, "docusign_account_id", DS_ACCOUNT) + monkeypatch.setattr(cfg.settings, "docusign_base_url", DS_BASE) + monkeypatch.setattr(cfg.settings, "adobe_sign_base_url", ADOBE_BASE) + + +@pytest.fixture(autouse=True) +def temp_history(tmp_path, monkeypatch): + history_path = str(tmp_path / ".history.json") + monkeypatch.setattr(migrate_module, "_HISTORY_FILE", history_path) + return history_path + + +def _async_wrap(sync_fn): + async def wrapper(*args, **kwargs): + return sync_fn(*args, **kwargs) + return wrapper + + +def _mock_download(template_id, access_token, output_dir): + os.makedirs(output_dir, exist_ok=True) + with open(os.path.join(output_dir, "metadata.json"), "w") as f: + json.dump({"name": TEMPLATE_NAME, "id": template_id}, f) + with open(os.path.join(output_dir, "form_fields.json"), "w") as f: + json.dump({"fields": []}, f) + with open(os.path.join(output_dir, "documents.json"), "w") as f: + json.dump({"documents": []}, f) + return True + + +def _mock_compose(template_dir: str, output_path: str): + with open(output_path, "w") as f: + json.dump({"name": TEMPLATE_NAME, "description": "mocked"}, f) + + +def _mock_validation_ok(download_dir): + return {"blockers": [], "warnings": [], "has_blockers": False} + + +class TestDryRun: + @respx.mock + def test_dry_run_does_not_upload(self): + """dry_run=True: compose succeeds but no POST/PUT to DocuSign.""" + with ( + patch.object(migrate_module, "_download_adobe_template", new=_async_wrap(_mock_download)), + patch.object(migrate_module, "_load_compose", return_value=_mock_compose), + patch.object(migrate_module, "_run_validation", side_effect=_mock_validation_ok), + ): + resp = client.post( + "/api/migrate", + json={ + "source_template_ids": [ADOBE_ID], + "options": {"dry_run": True}, + }, + cookies={_COOKIE_NAME: _full_session()}, + ) + + assert resp.status_code == 200 + results = resp.json()["results"] + assert results[0]["status"] == "dry_run" + assert results[0]["action"] == "dry_run" + assert results[0]["docusign_template_id"] is None + assert results[0]["dry_run"] is True + + @respx.mock + def test_dry_run_false_does_upload(self): + """dry_run=False (default): upload proceeds.""" + respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock( + return_value=httpx.Response(200, json={"envelopeTemplates": []}) + ) + respx.post(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock( + return_value=httpx.Response(201, json={"templateId": DS_NEW_ID}) + ) + with ( + patch.object(migrate_module, "_download_adobe_template", new=_async_wrap(_mock_download)), + patch.object(migrate_module, "_load_compose", return_value=_mock_compose), + patch.object(migrate_module, "_run_validation", side_effect=_mock_validation_ok), + ): + resp = client.post( + "/api/migrate", + json={"source_template_ids": [ADOBE_ID], "options": {"dry_run": False}}, + cookies={_COOKIE_NAME: _full_session()}, + ) + + assert resp.status_code == 200 + assert resp.json()["results"][0]["status"] == "success" + + +class TestOverwriteIfExists: + @respx.mock + def test_skip_when_overwrite_false(self): + """overwrite_if_exists=False + existing template → skipped.""" + respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock( + return_value=httpx.Response(200, json={ + "envelopeTemplates": [ + {"templateId": DS_EXISTING_ID, "name": TEMPLATE_NAME, "lastModified": "2026-04-10T00:00:00Z"} + ] + }) + ) + with ( + patch.object(migrate_module, "_download_adobe_template", new=_async_wrap(_mock_download)), + patch.object(migrate_module, "_load_compose", return_value=_mock_compose), + patch.object(migrate_module, "_run_validation", side_effect=_mock_validation_ok), + ): + resp = client.post( + "/api/migrate", + json={"source_template_ids": [ADOBE_ID], "options": {"overwrite_if_exists": False}}, + cookies={_COOKIE_NAME: _full_session()}, + ) + + results = resp.json()["results"] + assert results[0]["status"] == "skipped" + assert results[0]["docusign_template_id"] == DS_EXISTING_ID + + @respx.mock + def test_overwrite_when_true(self): + """overwrite_if_exists=True + existing template → PUT update.""" + respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock( + return_value=httpx.Response(200, json={ + "envelopeTemplates": [ + {"templateId": DS_EXISTING_ID, "name": TEMPLATE_NAME, "lastModified": "2026-04-10T00:00:00Z"} + ] + }) + ) + respx.put(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates/{DS_EXISTING_ID}").mock( + return_value=httpx.Response(200, json={}) + ) + with ( + patch.object(migrate_module, "_download_adobe_template", new=_async_wrap(_mock_download)), + patch.object(migrate_module, "_load_compose", return_value=_mock_compose), + patch.object(migrate_module, "_run_validation", side_effect=_mock_validation_ok), + ): + resp = client.post( + "/api/migrate", + json={"source_template_ids": [ADOBE_ID], "options": {"overwrite_if_exists": True}}, + cookies={_COOKIE_NAME: _full_session()}, + ) + + assert resp.json()["results"][0]["action"] == "updated" + + +class TestSourceTemplateIds: + @respx.mock + def test_source_template_ids_field(self): + """source_template_ids (new field) works correctly.""" + respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock( + return_value=httpx.Response(200, json={"envelopeTemplates": []}) + ) + respx.post(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock( + return_value=httpx.Response(201, json={"templateId": DS_NEW_ID}) + ) + with ( + patch.object(migrate_module, "_download_adobe_template", new=_async_wrap(_mock_download)), + patch.object(migrate_module, "_load_compose", return_value=_mock_compose), + patch.object(migrate_module, "_run_validation", side_effect=_mock_validation_ok), + ): + resp = client.post( + "/api/migrate", + json={"source_template_ids": [ADOBE_ID]}, + cookies={_COOKIE_NAME: _full_session()}, + ) + assert resp.status_code == 200 + assert resp.json()["results"][0]["status"] == "success" + + def test_no_ids_returns_400(self): + resp = client.post( + "/api/migrate", + json={}, + cookies={_COOKIE_NAME: _full_session()}, + ) + assert resp.status_code == 400 + + +class TestValidationBlocking: + def test_blocked_template_not_uploaded(self): + """Template with validation blockers → status=blocked, no upload.""" + def _mock_validation_blocked(download_dir): + return { + "blockers": ["No documents attached"], + "warnings": [], + "has_blockers": True, + } + + with ( + patch.object(migrate_module, "_download_adobe_template", new=_async_wrap(_mock_download)), + patch.object(migrate_module, "_run_validation", side_effect=_mock_validation_blocked), + ): + resp = client.post( + "/api/migrate", + json={"source_template_ids": [ADOBE_ID]}, + cookies={_COOKIE_NAME: _full_session()}, + ) + + assert resp.status_code == 200 + result = resp.json()["results"][0] + assert result["status"] == "blocked" + assert "No documents" in result["error"] diff --git a/tests/test_normalized_schema.py b/tests/test_normalized_schema.py new file mode 100644 index 0000000..f88dc0f --- /dev/null +++ b/tests/test_normalized_schema.py @@ -0,0 +1,139 @@ +""" +Tests for Phase 8: normalized intermediate schema and mapping service. +""" + +import json +from pathlib import Path + +import pytest + +from src.models.normalized_template import ( + ActionType, + NormalizedDocument, + NormalizedField, + NormalizedRole, + NormalizedTemplate, +) +from src.services.mapping_service import adobe_folder_to_normalized + + +DOWNLOADS = Path(__file__).parent.parent / "downloads" +DAVID_DIR = DOWNLOADS / "David Tag Demo Form__CBJCHBCA" +NDA_DIR = DOWNLOADS / "_DEMO USE ONLY_ NDA__CBJCHBCA" +ROB_DIR = DOWNLOADS / "Rob Test__CBJCHBCA" + + +# --------------------------------------------------------------------------- +# Model construction +# --------------------------------------------------------------------------- + +class TestNormalizedModels: + def test_normalized_role_defaults(self): + r = NormalizedRole(name="Customer", order=1) + assert r.action_type == ActionType.SIGN + assert r.order == 1 + + def test_normalized_field_defaults(self): + f = NormalizedField(type="text", label="Name", page=1, x=10, y=20, width=120, height=24) + assert f.required is False + assert f.read_only is False + assert f.options == [] + assert f.conditional_parent_label is None + + def test_normalized_template_construction(self): + t = NormalizedTemplate( + name="My Template", + roles=[NormalizedRole(name="Signer 1", order=1)], + fields=[ + NormalizedField(type="signature", label="sig1", page=1, x=0, y=0, width=140, height=28) + ], + ) + assert t.name == "My Template" + assert len(t.roles) == 1 + assert len(t.fields) == 1 + + def test_role_names(self): + t = NormalizedTemplate( + name="T", + roles=[ + NormalizedRole(name="Customer", order=1), + NormalizedRole(name="Company", order=2), + ], + ) + assert t.role_names() == ["Customer", "Company"] + + def test_fields_for_role(self): + t = NormalizedTemplate( + name="T", + roles=[NormalizedRole(name="Signer 1", order=1)], + fields=[ + NormalizedField(type="signature", label="s1", page=1, x=0, y=0, width=140, height=28, role_name="Signer 1"), + NormalizedField(type="text", label="name", page=1, x=0, y=50, width=120, height=24, role_name="Signer 2"), + ], + ) + assert len(t.fields_for_role("Signer 1")) == 1 + assert len(t.fields_for_role("Signer 2")) == 1 + assert len(t.fields_for_role("Nobody")) == 0 + + def test_normalized_document_checksum(self): + doc = NormalizedDocument( + name="test.pdf", + content_base64="dGVzdA==", + checksum_sha256="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + ) + assert doc.checksum_sha256 != "" + + def test_serialization_roundtrip(self): + t = NormalizedTemplate( + name="Round Trip", + roles=[NormalizedRole(name="Signer 1", order=1)], + ) + dumped = t.model_dump() + restored = NormalizedTemplate(**dumped) + assert restored.name == t.name + assert len(restored.roles) == 1 + + +# --------------------------------------------------------------------------- +# Mapping service — requires real download fixtures +# --------------------------------------------------------------------------- + +@pytest.mark.skipif(not DAVID_DIR.exists(), reason="Downloads fixtures not present") +class TestMappingService: + def test_david_template_normalizes(self): + norm, warnings = adobe_folder_to_normalized(str(DAVID_DIR)) + assert isinstance(norm, NormalizedTemplate) + assert norm.name != "" + assert len(norm.roles) >= 1 + assert len(norm.fields) > 0 + + def test_david_fields_have_roles(self): + norm, _ = adobe_folder_to_normalized(str(DAVID_DIR)) + role_names = norm.role_names() + for f in norm.fields: + assert f.role_name in role_names, f"Field '{f.label}' has unresolved role '{f.role_name}'" + + def test_david_documents_have_checksum(self): + norm, _ = adobe_folder_to_normalized(str(DAVID_DIR)) + assert len(norm.documents) >= 1 + for doc in norm.documents: + assert doc.checksum_sha256 != "", f"Document '{doc.name}' missing checksum" + assert len(doc.checksum_sha256) == 64 # SHA-256 hex + + def test_exclude_documents_option(self): + norm, _ = adobe_folder_to_normalized(str(DAVID_DIR), include_documents=False) + for doc in norm.documents: + assert doc.content_base64 == "" + # checksum still computed even when content excluded + assert doc.checksum_sha256 != "" + + @pytest.mark.skipif(not NDA_DIR.exists(), reason="NDA fixture not present") + def test_nda_template_normalizes(self): + norm, _ = adobe_folder_to_normalized(str(NDA_DIR)) + assert norm.name != "" + assert len(norm.fields) > 0 + + @pytest.mark.skipif(not ROB_DIR.exists(), reason="Rob fixture not present") + def test_rob_template_normalizes(self): + norm, _ = adobe_folder_to_normalized(str(ROB_DIR)) + assert norm.name != "" diff --git a/tests/test_regression.py b/tests/test_regression.py index 65d923e..588d568 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -55,7 +55,7 @@ def test_compose_regression(template_name, update_snapshots): output_path = tf.name try: - result, warnings = compose_template(template_dir, output_path) + result, warnings, _ = compose_template(template_dir, output_path) if update_snapshots: os.makedirs(FIXTURES_DIR, exist_ok=True) @@ -121,7 +121,7 @@ def test_no_tabs_lost_on_recompose(): with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tf: output_path = tf.name try: - result, _ = compose_template(template_dir, output_path) + result, _, _issues = compose_template(template_dir, output_path) total_tabs = sum(_count_tabs(result).values()) assert total_tabs > 0, f"No tabs produced for {template_name}" finally: diff --git a/tests/test_retry.py b/tests/test_retry.py new file mode 100644 index 0000000..fbadc0d --- /dev/null +++ b/tests/test_retry.py @@ -0,0 +1,152 @@ +""" +Tests for Phase 11: retry with backoff utility. +""" + +import asyncio +import time +from unittest.mock import MagicMock, patch + +import pytest + +from src.utils.retry import ( + RateLimitError, + async_retry_with_backoff, + check_response_retryable, + retry_with_backoff, +) + + +class TestRetryWithBackoff: + def test_success_on_first_try(self): + call_count = {"n": 0} + + @retry_with_backoff(max_retries=3, base_delay=0.01) + def fn(): + call_count["n"] += 1 + return "ok" + + result = fn() + assert result == "ok" + assert call_count["n"] == 1 + + def test_retries_on_exception(self): + call_count = {"n": 0} + + @retry_with_backoff(max_retries=2, base_delay=0.01) + def fn(): + call_count["n"] += 1 + if call_count["n"] < 3: + raise ConnectionError("transient") + return "ok" + + with patch("src.utils.retry.time.sleep"): + result = fn() + + assert result == "ok" + assert call_count["n"] == 3 + + def test_raises_after_max_retries(self): + @retry_with_backoff(max_retries=2, base_delay=0.01) + def fn(): + raise ConnectionError("always fails") + + with patch("src.utils.retry.time.sleep"): + with pytest.raises(ConnectionError): + fn() + + def test_exponential_delay(self): + sleeps = [] + + @retry_with_backoff(max_retries=3, base_delay=1.0) + def fn(): + raise ValueError("fail") + + with patch("src.utils.retry.time.sleep", side_effect=lambda d: sleeps.append(d)): + with pytest.raises(ValueError): + fn() + + assert len(sleeps) == 3 + assert sleeps[0] == 1.0 + assert sleeps[1] == 2.0 + assert sleeps[2] == 4.0 + + def test_max_delay_capped(self): + sleeps = [] + + @retry_with_backoff(max_retries=5, base_delay=10.0, max_delay=15.0) + def fn(): + raise ValueError("fail") + + with patch("src.utils.retry.time.sleep", side_effect=lambda d: sleeps.append(d)): + with pytest.raises(ValueError): + fn() + + assert all(d <= 15.0 for d in sleeps) + + def test_only_retries_specified_exceptions(self): + call_count = {"n": 0} + + @retry_with_backoff(max_retries=3, base_delay=0.01, retryable_exceptions=(ConnectionError,)) + def fn(): + call_count["n"] += 1 + raise ValueError("not retryable") + + with pytest.raises(ValueError): + fn() + + assert call_count["n"] == 1 # no retries for ValueError + + +class TestAsyncRetryWithBackoff: + def test_async_success_on_first_try(self): + call_count = {"n": 0} + + @async_retry_with_backoff(max_retries=3, base_delay=0.01) + async def fn(): + call_count["n"] += 1 + return "ok" + + result = asyncio.get_event_loop().run_until_complete(fn()) + assert result == "ok" + assert call_count["n"] == 1 + + def test_async_retries_on_exception(self): + call_count = {"n": 0} + + @async_retry_with_backoff(max_retries=2, base_delay=0.01) + async def fn(): + call_count["n"] += 1 + if call_count["n"] < 3: + raise ConnectionError("transient") + return "ok" + + with patch("src.utils.retry.asyncio.sleep", new=asyncio.coroutine(lambda d: None)): + result = asyncio.get_event_loop().run_until_complete(fn()) + + assert result == "ok" + + def test_async_raises_after_max_retries(self): + @async_retry_with_backoff(max_retries=1, base_delay=0.01) + async def fn(): + raise ConnectionError("always fails") + + with patch("src.utils.retry.asyncio.sleep", new=asyncio.coroutine(lambda d: None)): + with pytest.raises(ConnectionError): + asyncio.get_event_loop().run_until_complete(fn()) + + +class TestCheckResponseRetryable: + def test_429_is_retryable(self): + assert check_response_retryable(429) is True + + def test_503_is_retryable(self): + assert check_response_retryable(503) is True + + def test_200_not_retryable(self): + assert check_response_retryable(200) is False + + def test_400_not_retryable(self): + assert check_response_retryable(400) is False + + def test_404_not_retryable(self): + assert check_response_retryable(404) is False diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..101df23 --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,138 @@ +""" +Tests for Phase 12: security — log sanitization and audit trail. +""" + +import hashlib +import json +import logging + +import pytest + +from src.utils.log_sanitizer import ( + SanitizingFilter, + install_sanitizing_filter, + redact, + redact_dict, +) + + +class TestRedact: + def test_bearer_token_redacted(self): + text = "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.abc.def" + result = redact(text) + assert "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9" not in result + assert "[REDACTED]" in result + + def test_access_token_assignment_redacted(self): + text = 'access_token: "super_secret_value_12345"' + result = redact(text) + assert "super_secret_value_12345" not in result + assert "[REDACTED]" in result + + def test_password_redacted(self): + text = "password=hunter2supersecure" + result = redact(text) + assert "hunter2supersecure" not in result + + def test_safe_text_unchanged(self): + text = "Template migrated successfully: NDA v2" + result = redact(text) + assert result == text + + def test_long_base64_redacted(self): + # Simulate a long PDF base64 payload being logged + b64 = "A" * 600 + result = redact(b64) + assert "A" * 100 not in result + assert "[REDACTED]" in result + + def test_short_base64_not_redacted(self): + # Short base64 (e.g. an ID) should not be redacted + short_b64 = "dGVzdA==" # "test" base64 + result = redact(short_b64) + assert "dGVzdA" in result + + +class TestRedactDict: + def test_token_key_redacted(self): + d = {"access_token": "secret123", "name": "My Template"} + result = redact_dict(d) + assert result["access_token"] == "[REDACTED]" + assert result["name"] == "My Template" + + def test_nested_dict_redacted(self): + d = {"auth": {"token": "secret123", "user": "alice"}} + result = redact_dict(d) + assert result["auth"]["token"] == "[REDACTED]" + assert result["auth"]["user"] == "alice" + + def test_document_base64_redacted(self): + d = {"documentBase64": "A" * 200} + result = redact_dict(d) + assert result["documentBase64"] == "[REDACTED]" + + def test_list_of_dicts_redacted(self): + d = {"items": [{"token": "abc123xyz", "id": "1"}]} + result = redact_dict(d) + assert result["items"][0]["token"] == "[REDACTED]" + assert result["items"][0]["id"] == "1" + + def test_safe_dict_unchanged(self): + d = {"template_name": "NDA", "status": "success", "count": 3} + result = redact_dict(d) + assert result == d + + +class TestSanitizingFilter: + def test_filter_redacts_log_message(self): + record = logging.LogRecord( + name="test", level=logging.INFO, + pathname="", lineno=0, + msg="Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature", + args=(), exc_info=None, + ) + f = SanitizingFilter() + f.filter(record) + assert "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9" not in record.msg + + def test_filter_redacts_args(self): + record = logging.LogRecord( + name="test", level=logging.INFO, + pathname="", lineno=0, + msg="Token: %s", + args=("access_token=supersecretvalue123456",), + exc_info=None, + ) + f = SanitizingFilter() + f.filter(record) + assert "supersecretvalue123456" not in str(record.args) + + def test_install_sanitizing_filter_idempotent(self): + install_sanitizing_filter() + install_sanitizing_filter() # second call should not add duplicate + root = logging.getLogger() + sanitizing_filters = [f for f in root.filters if isinstance(f, SanitizingFilter)] + assert len(sanitizing_filters) == 1 + # Clean up + for f in sanitizing_filters: + root.removeFilter(f) + + +class TestPdfChecksum: + def test_checksum_matches_content(self): + from src.services.mapping_service import adobe_folder_to_normalized + from pathlib import Path + + downloads = Path(__file__).parent.parent / "downloads" / "David Tag Demo Form__CBJCHBCA" + if not downloads.exists(): + pytest.skip("Downloads fixtures not present") + + norm, _ = adobe_folder_to_normalized(str(downloads)) + assert norm.documents, "Expected at least one document" + + doc = norm.documents[0] + # Recompute checksum from source path to verify + import base64 + pdf_bytes = Path(doc.source_path).read_bytes() + expected_checksum = hashlib.sha256(pdf_bytes).hexdigest() + assert doc.checksum_sha256 == expected_checksum diff --git a/tests/test_validation_service.py b/tests/test_validation_service.py new file mode 100644 index 0000000..4337cbf --- /dev/null +++ b/tests/test_validation_service.py @@ -0,0 +1,181 @@ +""" +Tests for Phase 9: validation service. +""" + +import pytest + +from src.models.normalized_template import ( + NormalizedDocument, + NormalizedField, + NormalizedRole, + NormalizedTemplate, +) +from src.services.validation_service import ( + ValidationResult, + compare_field_counts, + validate_template, +) +from src.reports.report_builder import ( + MigrationReport, + MigrationStatus, + build_blocked_report, + build_error_report, + build_skipped_report, + build_success_report, +) + + +def _make_template(**kwargs) -> NormalizedTemplate: + defaults = dict( + name="Test Template", + roles=[NormalizedRole(name="Signer 1", order=1)], + fields=[ + NormalizedField( + type="signature", label="sig1", page=1, + x=100, y=500, width=140, height=28, + role_name="Signer 1", + ) + ], + documents=[NormalizedDocument(name="test.pdf", checksum_sha256="abc", source_path="/fake.pdf")], + ) + defaults.update(kwargs) + return NormalizedTemplate(**defaults) + + +class TestValidationService: + def test_valid_template_passes(self): + t = _make_template() + result = validate_template(t) + assert result.is_ok() + assert result.blockers == [] + + def test_no_recipients_is_blocker(self): + t = _make_template(roles=[]) + result = validate_template(t) + assert result.has_blockers() + assert any("recipient" in b.lower() or "role" in b.lower() for b in result.blockers) + + def test_no_documents_is_blocker(self): + t = _make_template(documents=[]) + result = validate_template(t) + assert result.has_blockers() + assert any("document" in b.lower() for b in result.blockers) + + def test_no_fields_is_warning(self): + t = _make_template(fields=[]) + result = validate_template(t) + assert result.is_ok() # not a blocker + assert any("0 field" in w or "empty" in w.lower() for w in result.warnings) + + def test_no_signature_field_is_warning(self): + t = _make_template(fields=[ + NormalizedField(type="text", label="name", page=1, x=0, y=0, width=120, height=24, role_name="Signer 1") + ]) + result = validate_template(t) + assert result.is_ok() + assert any("signature" in w.lower() for w in result.warnings) + + def test_field_with_unknown_role_is_warning(self): + t = _make_template(fields=[ + NormalizedField( + type="signature", label="sig1", page=1, x=0, y=0, + width=140, height=28, role_name="NonExistentRole" + ) + ]) + result = validate_template(t) + assert result.is_ok() + assert any("role" in w.lower() or "assign" in w.lower() for w in result.warnings) + + def test_unsupported_features_become_warnings(self): + t = _make_template(unsupported_features=["Conditional HIDE action", "Webhook associations"]) + result = validate_template(t) + assert result.is_ok() + assert len([w for w in result.warnings if "Unsupported" in w or "manual" in w.lower()]) >= 2 + + def test_validation_result_all_issues(self): + r = ValidationResult(blockers=["blocker1"], warnings=["warn1"]) + issues = r.all_issues() + assert any("BLOCKER" in i for i in issues) + assert any("WARNING" in i for i in issues) + + +class TestCompareFieldCounts: + def test_matching_counts_no_warnings(self): + t = _make_template(fields=[ + NormalizedField(type="signature", label="sig1", page=1, x=0, y=0, width=140, height=28, role_name="Signer 1") + ]) + ds = { + "recipients": { + "signers": [{"tabs": {"signHereTabs": [{"tabLabel": "sig1"}]}}] + } + } + result = compare_field_counts(t, ds) + assert result.is_ok() + + def test_mismatched_counts_warns(self): + t = _make_template(fields=[ + NormalizedField(type="signature", label="s1", page=1, x=0, y=0, width=140, height=28, role_name="Signer 1"), + NormalizedField(type="text", label="t1", page=1, x=0, y=50, width=120, height=24, role_name="Signer 1"), + ]) + ds = {"recipients": {"signers": [{"tabs": {"signHereTabs": [{}]}}]}} + result = compare_field_counts(t, ds) + assert any("mismatch" in w.lower() or "count" in w.lower() for w in result.warnings) + + def test_zero_tabs_with_fields_warns(self): + t = _make_template() + ds = {"recipients": {"signers": []}} + result = compare_field_counts(t, ds) + assert result.warnings # should warn about 0 tabs + + +class TestReportBuilder: + def test_success_report(self): + r = build_success_report("My Template", "src_001", "ds_001", warnings=[]) + assert r.status == MigrationStatus.SUCCESS + assert r.docusign_template_id == "ds_001" + + def test_success_with_warnings(self): + r = build_success_report("My Template", "src_001", "ds_001", warnings=["some warning"]) + assert r.status == MigrationStatus.SUCCESS_WITH_WARNINGS + + def test_blocked_report(self): + r = build_blocked_report("T", "id1", blockers=["no docs"], warnings=[]) + assert r.status == MigrationStatus.BLOCKED + assert r.blockers == ["no docs"] + + def test_error_report(self): + r = build_error_report("T", "id1", error="Connection refused") + assert r.status == MigrationStatus.ERROR + assert "Connection" in r.error + + def test_skipped_report(self): + r = build_skipped_report("T", "id1", reason="already migrated") + assert r.status == MigrationStatus.SKIPPED + + def test_migration_report_summary(self): + report = MigrationReport() + report.add(build_success_report("T1", "1", "ds1", [])) + report.add(build_success_report("T2", "2", "ds2", ["warn"])) + report.add(build_error_report("T3", "3", "fail")) + summary = report.summary() + assert summary["total"] == 3 + assert summary.get("success", 0) == 1 + assert summary.get("error", 0) == 1 + + def test_report_to_dict(self): + report = MigrationReport() + report.add(build_success_report("T1", "1", "ds1", [])) + d = report.to_dict() + assert "summary" in d + assert "templates" in d + assert d["templates"][0]["template_name"] == "T1" + + def test_report_has_errors(self): + report = MigrationReport() + report.add(build_error_report("T", "1", "err")) + assert report.has_errors() + + def test_report_no_errors(self): + report = MigrationReport() + report.add(build_success_report("T", "1", "ds1", [])) + assert not report.has_errors() diff --git a/web/app.py b/web/app.py index 7fd6ca6..e561ac1 100644 --- a/web/app.py +++ b/web/app.py @@ -15,7 +15,7 @@ from fastapi.responses import FileResponse import os from web.config import settings -from web.routers import auth, templates, migrate +from web.routers import auth, templates, migrate, verify app = FastAPI( title="Adobe Sign → DocuSign Migrator", @@ -24,9 +24,10 @@ app = FastAPI( ) # Routers -app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) +app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(templates.router, prefix="/api/templates", tags=["templates"]) -app.include_router(migrate.router, prefix="/api/migrate", tags=["migrate"]) +app.include_router(migrate.router, prefix="/api/migrate", tags=["migrate"]) +app.include_router(verify.router, prefix="/api/verify", tags=["verify"]) # Static files (frontend) _static_dir = os.path.join(os.path.dirname(__file__), "static") diff --git a/web/routers/migrate.py b/web/routers/migrate.py index 5c93955..8437a7d 100644 --- a/web/routers/migrate.py +++ b/web/routers/migrate.py @@ -3,8 +3,10 @@ web/routers/migrate.py ---------------------- Migration trigger and history endpoints. -POST /api/migrate — run the pipeline for one or more Adobe template IDs -GET /api/migrate/history — return past migration records +POST /api/migrate — run the pipeline for one or more Adobe template IDs +POST /api/migrate/batch — batch migration with async progress tracking +GET /api/migrate/batch/{id} — poll batch job status +GET /api/migrate/history — return past migration records """ import asyncio @@ -12,8 +14,9 @@ import json import os import sys import tempfile +import uuid from datetime import datetime, timezone -from typing import List, Optional +from typing import Dict, List, Optional import httpx from fastapi import APIRouter, Request @@ -23,7 +26,6 @@ from pydantic import BaseModel from web.config import settings from web.session import get_session -# Ensure src/ is on path sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) router = APIRouter() @@ -32,9 +34,26 @@ _HISTORY_FILE = os.path.join( os.path.dirname(__file__), "..", "..", "migration-output", ".history.json" ) +# In-memory batch job store (keyed by job_id) +_batch_jobs: Dict[str, dict] = {} + + +class MigrationOptions(BaseModel): + dry_run: bool = False + overwrite_if_exists: bool = False + include_documents: bool = True + class MigrateRequest(BaseModel): - adobe_template_ids: List[str] + # Primary API (blueprint-aligned) + source_template_ids: Optional[List[str]] = None + target_folder: Optional[str] = None + options: MigrationOptions = MigrationOptions() + # Legacy field kept for backward compatibility + adobe_template_ids: Optional[List[str]] = None + + def resolved_ids(self) -> List[str]: + return self.source_template_ids or self.adobe_template_ids or [] def _load_history() -> list: @@ -51,10 +70,7 @@ def _save_history(records: list) -> None: def _load_compose(): - """ - Dynamically load and return the compose_template function from src/. - Isolated in its own function so tests can patch it without touching the file system. - """ + """Dynamically load compose_template from src/.""" import importlib.util spec = importlib.util.spec_from_file_location( "compose_docusign_template", @@ -71,21 +87,17 @@ async def _download_adobe_template(template_id: str, access_token: str, output_d base = settings.adobe_sign_base_url async with httpx.AsyncClient() as client: - # Metadata meta_resp = await client.get(f"{base}/libraryDocuments/{template_id}", headers=headers) if not meta_resp.is_success: return False metadata = meta_resp.json() - # Form fields fields_resp = await client.get(f"{base}/libraryDocuments/{template_id}/formFields", headers=headers) form_fields = fields_resp.json() if fields_resp.is_success else {"fields": []} - # Documents list docs_resp = await client.get(f"{base}/libraryDocuments/{template_id}/documents", headers=headers) documents = docs_resp.json() if docs_resp.is_success else {"documents": []} - # Download first PDF doc_list = documents.get("documents", []) pdf_bytes = b"" if doc_list: @@ -111,10 +123,27 @@ async def _download_adobe_template(template_id: str, access_token: str, output_d return True +def _run_validation(download_dir: str) -> dict: + """Run validation service on downloaded template, return summary.""" + try: + from src.services.mapping_service import adobe_folder_to_normalized + from src.services.validation_service import validate_template + norm, _ = adobe_folder_to_normalized(download_dir) + result = validate_template(norm) + return { + "blockers": result.blockers, + "warnings": result.warnings, + "has_blockers": result.has_blockers(), + } + except Exception as exc: + return {"blockers": [], "warnings": [f"Validation skipped: {exc}"], "has_blockers": False} + + async def _migrate_one( adobe_id: str, adobe_access_token: str, docusign_access_token: str, + options: MigrationOptions, ) -> dict: """Run the full pipeline for one Adobe template. Returns a result record.""" timestamp = datetime.now(timezone.utc).isoformat() @@ -134,18 +163,42 @@ async def _migrate_one( "action": None, "status": "failed", "error": "Adobe Sign download failed", + "warnings": [], + "blockers": [], + "field_issues": [], + "dry_run": options.dry_run, } - # Read template name from metadata with open(os.path.join(download_dir, "metadata.json")) as f: metadata = json.load(f) template_name = metadata.get("name", adobe_id) - # 2. Compose DocuSign template JSON + # 2. Validate + validation = _run_validation(download_dir) + if validation["has_blockers"]: + return { + "timestamp": timestamp, + "adobe_template_id": adobe_id, + "adobe_template_name": template_name, + "docusign_template_id": None, + "action": "blocked", + "status": "blocked", + "error": f"Validation blockers: {'; '.join(validation['blockers'])}", + "warnings": validation["warnings"], + "blockers": validation["blockers"], + "field_issues": [], + "dry_run": options.dry_run, + } + + # 3. Compose composed_file = os.path.join(tmpdir, "docusign-template.json") + compose_issues: list = [] try: compose_fn = _load_compose() - compose_fn(download_dir, composed_file) + compose_result = compose_fn(download_dir, composed_file) + # compose_template returns (template, warnings, issues) + if isinstance(compose_result, tuple) and len(compose_result) >= 3: + compose_issues = compose_result[2] or [] except Exception as exc: return { "timestamp": timestamp, @@ -155,6 +208,10 @@ async def _migrate_one( "action": None, "status": "failed", "error": f"Compose failed: {exc}", + "warnings": validation["warnings"], + "blockers": [], + "field_issues": [], + "dry_run": options.dry_run, } if not os.path.exists(composed_file): return { @@ -165,12 +222,36 @@ async def _migrate_one( "action": None, "status": "failed", "error": "Compose produced no output file", + "warnings": validation["warnings"], + "blockers": [], + "field_issues": [], + "dry_run": options.dry_run, } - # 3. Upload (upsert) to DocuSign using web session token + # 4. Dry run — stop here, do not upload + if options.dry_run: + return { + "timestamp": timestamp, + "adobe_template_id": adobe_id, + "adobe_template_name": template_name, + "docusign_template_id": None, + "action": "dry_run", + "status": "dry_run", + "error": None, + "warnings": validation["warnings"], + "blockers": [], + "field_issues": compose_issues, + "dry_run": True, + } + + # 5. Upload (upsert) to DocuSign with open(composed_file) as f: template_json = json.load(f) + if not options.include_documents: + for doc in template_json.get("documents", []): + doc.pop("documentBase64", None) + ds_headers = { "Authorization": f"Bearer {docusign_access_token}", "Content-Type": "application/json", @@ -179,7 +260,7 @@ async def _migrate_one( list_url = f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/templates" async with httpx.AsyncClient() as client: - # Find existing + # Duplicate detection list_resp = await client.get( list_url, headers=ds_headers, params={"search_text": template_name, "count": 100} ) @@ -191,6 +272,22 @@ async def _migrate_one( exact.sort(key=lambda t: t.get("lastModified", ""), reverse=True) existing_id = exact[0]["templateId"] + # Skip if already exists and overwrite is disabled + if existing_id and not options.overwrite_if_exists: + return { + "timestamp": timestamp, + "adobe_template_id": adobe_id, + "adobe_template_name": template_name, + "docusign_template_id": existing_id, + "action": "skipped", + "status": "skipped", + "error": None, + "warnings": validation["warnings"] + ["Skipped: template already exists (overwrite_if_exists=false)"], + "blockers": [], + "field_issues": compose_issues, + "dry_run": False, + } + if existing_id: up_resp = await client.put( f"{list_url}/{existing_id}", headers=ds_headers, json=template_json @@ -211,6 +308,10 @@ async def _migrate_one( "action": None, "status": "failed", "error": f"DocuSign upload failed ({up_resp.status_code}): {up_resp.text[:200]}", + "warnings": validation["warnings"], + "blockers": [], + "field_issues": compose_issues, + "dry_run": False, } return { @@ -221,6 +322,10 @@ async def _migrate_one( "action": action, "status": "success", "error": None, + "warnings": validation["warnings"], + "blockers": [], + "field_issues": compose_issues, + "dry_run": False, } @@ -233,17 +338,21 @@ async def run_migration(body: MigrateRequest, request: Request): if not session.get("docusign_access_token"): return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401) + ids = body.resolved_ids() + if not ids: + return JSONResponse({"error": "no template IDs provided"}, status_code=400) + tasks = [ _migrate_one( aid, session["adobe_access_token"], session["docusign_access_token"], + body.options, ) - for aid in body.adobe_template_ids + for aid in ids ] results = await asyncio.gather(*tasks) - # Append to history history = _load_history() history.extend(results) _save_history(history) @@ -255,3 +364,101 @@ async def run_migration(body: MigrateRequest, request: Request): def migration_history(): """Return all past migration records.""" return {"history": _load_history()} + + +# --------------------------------------------------------------------------- +# Batch migration +# --------------------------------------------------------------------------- + +async def _run_batch_job( + job_id: str, + ids: List[str], + adobe_token: str, + ds_token: str, + options: MigrationOptions, +) -> None: + """Background coroutine that processes a batch job and updates _batch_jobs.""" + job = _batch_jobs[job_id] + job["status"] = "running" + results = [] + + for i, adobe_id in enumerate(ids): + job["progress"] = {"completed": i, "total": len(ids), "current_id": adobe_id} + result = await _migrate_one(adobe_id, adobe_token, ds_token, options) + + # Retry once on transient failures (network errors, not validation blockers) + if result["status"] == "failed" and "upload failed" in (result.get("error") or ""): + result = await _migrate_one(adobe_id, adobe_token, ds_token, options) + if result["status"] != "failed": + result["retried"] = True + + results.append(result) + job["results"] = results + + # Persist to history + history = _load_history() + history.extend(results) + _save_history(history) + + success = sum(1 for r in results if r["status"] == "success") + failed = sum(1 for r in results if r["status"] in ("failed", "blocked")) + skipped = sum(1 for r in results if r["status"] == "skipped") + dry_runs = sum(1 for r in results if r["status"] == "dry_run") + + job["status"] = "completed" + job["progress"] = {"completed": len(ids), "total": len(ids), "current_id": None} + job["summary"] = { + "total": len(ids), + "success": success, + "failed": failed, + "skipped": skipped, + "dry_run": dry_runs, + } + + +@router.post("/batch") +async def run_batch_migration(body: MigrateRequest, request: Request): + """ + Start an async batch migration job. Returns a job_id immediately. + Poll GET /api/migrate/batch/{job_id} for status. + """ + session = get_session(request) + if not session.get("adobe_access_token"): + return JSONResponse({"error": "not authenticated to Adobe Sign"}, status_code=401) + if not session.get("docusign_access_token"): + return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401) + + ids = body.resolved_ids() + if not ids: + return JSONResponse({"error": "no template IDs provided"}, status_code=400) + + job_id = str(uuid.uuid4()) + _batch_jobs[job_id] = { + "job_id": job_id, + "status": "queued", + "total": len(ids), + "results": [], + "progress": {"completed": 0, "total": len(ids), "current_id": None}, + "summary": None, + "created_at": datetime.now(timezone.utc).isoformat(), + } + + asyncio.create_task( + _run_batch_job( + job_id, ids, + session["adobe_access_token"], + session["docusign_access_token"], + body.options, + ) + ) + + return {"job_id": job_id, "total": len(ids), "status": "queued"} + + +@router.get("/batch/{job_id}") +def get_batch_status(job_id: str): + """Poll the status of a batch migration job.""" + job = _batch_jobs.get(job_id) + if not job: + return JSONResponse({"error": "batch job not found"}, status_code=404) + return job diff --git a/web/routers/templates.py b/web/routers/templates.py index 141cae7..378bde8 100644 --- a/web/routers/templates.py +++ b/web/routers/templates.py @@ -6,6 +6,7 @@ Computes per-template migration status for the side-by-side UI. """ from datetime import datetime, timezone +from pathlib import Path from typing import Optional import httpx @@ -151,6 +152,8 @@ async def template_status(request: Request): # needs_update if Adobe was modified after the DS template status = "needs_update" if adobe_modified > ds_modified else "migrated" + blockers, warnings = _get_validation(t.get("id", ""), name) + results.append({ "adobe_id": t.get("id"), "name": name, @@ -158,10 +161,36 @@ async def template_status(request: Request): "docusign_id": ds_match.get("templateId") if ds_match else None, "docusign_modified": ds_match.get("lastModified") if ds_match else None, "status": status, + "blockers": blockers, + "warnings": warnings, }) return {"templates": results} +def _get_validation(template_id: str, template_name: str) -> tuple[list, list]: + """Return (blockers, warnings) if the template has been downloaded; else ([], []).""" + try: + from src.services.mapping_service import adobe_folder_to_normalized + from src.services.validation_service import validate_template + + downloads_dir = Path(settings.downloads_dir) if hasattr(settings, "downloads_dir") else Path("downloads") + # Match folder by name__id or name pattern + candidates = list(downloads_dir.glob(f"*__{template_id}")) + if not candidates: + # Try matching by sanitised name prefix + safe = template_name.replace("/", "_").replace("\\", "_") + candidates = list(downloads_dir.glob(f"{safe}*")) + + if not candidates or not candidates[0].is_dir(): + return [], [] + + normalized = adobe_folder_to_normalized(str(candidates[0])) + result = validate_template(normalized) + return result.blockers, result.warnings + except Exception: + return [], [] + + # asyncio needed for gather — import at top of module import asyncio diff --git a/web/routers/verify.py b/web/routers/verify.py new file mode 100644 index 0000000..709a4f5 --- /dev/null +++ b/web/routers/verify.py @@ -0,0 +1,146 @@ +""" +web/routers/verify.py +--------------------- +Verification endpoints: send test envelopes, poll status, void. +Uses DocuSign Envelopes API to confirm migrated templates work end-to-end. +""" + +from typing import Optional + +import httpx +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from web.config import settings +from web.session import get_session + +router = APIRouter() + + +class SendRequest(BaseModel): + template_id: str + recipient_name: str + recipient_email: str + + +class VoidRequest(BaseModel): + reason: str = "Test envelope — voided after verification" + + +def _require_docusign(session: dict) -> Optional[JSONResponse]: + if not session.get("docusign_access_token"): + return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401) + return None + + +@router.post("/send") +async def send_test_envelope(body: SendRequest, request: Request): + """Send a test envelope using a migrated DocuSign template.""" + session = get_session(request) + err = _require_docusign(session) + if err: + return err + + headers = { + "Authorization": f"Bearer {session['docusign_access_token']}", + "Content-Type": "application/json", + } + base = f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}" + + async with httpx.AsyncClient() as client: + # Fetch template to discover actual role names + tpl_resp = await client.get(f"{base}/templates/{body.template_id}", headers=headers) + role_names = [] + if tpl_resp.is_success: + tpl = tpl_resp.json() + recipients = tpl.get("recipients", {}) + for group in recipients.values(): + if isinstance(group, list): + for r in group: + rn = r.get("roleName") + if rn and rn not in role_names: + role_names.append(rn) + + # Fall back to generic role name if template fetch failed + if not role_names: + role_names = ["Signer"] + + template_roles = [ + {"email": body.recipient_email, "name": body.recipient_name, "roleName": rn} + for rn in role_names + ] + + payload = { + "templateId": body.template_id, + "status": "sent", + "templateRoles": template_roles, + "emailSubject": "[Verification Test] Please sign this document", + } + + resp = await client.post(f"{base}/envelopes", headers=headers, json=payload) + + if not resp.is_success: + return JSONResponse( + {"error": "DocuSign API error", "detail": resp.text}, + status_code=502, + ) + + data = resp.json() + return {"envelope_id": data.get("envelopeId"), "roles": role_names} + + +@router.get("/status/{envelope_id}") +async def envelope_status(envelope_id: str, request: Request): + """Get the current status of a test envelope.""" + session = get_session(request) + err = _require_docusign(session) + if err: + return err + + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/envelopes/{envelope_id}", + headers={"Authorization": f"Bearer {session['docusign_access_token']}"}, + ) + + if not resp.is_success: + return JSONResponse( + {"error": "DocuSign API error", "detail": resp.text}, + status_code=502, + ) + + data = resp.json() + return { + "envelope_id": envelope_id, + "status": data.get("status"), + "completed_at": data.get("completedDateTime"), + "sent_at": data.get("sentDateTime"), + } + + +@router.post("/void/{envelope_id}") +async def void_envelope(envelope_id: str, body: VoidRequest, request: Request): + """Void a test envelope after verification is complete.""" + session = get_session(request) + err = _require_docusign(session) + if err: + return err + + async with httpx.AsyncClient() as client: + resp = await client.put( + f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/envelopes/{envelope_id}", + headers={ + "Authorization": f"Bearer {session['docusign_access_token']}", + "Content-Type": "application/json", + }, + json={"status": "voided", "voidedReason": body.reason}, + ) + + if not resp.is_success: + return JSONResponse( + {"error": "DocuSign API error", "detail": resp.text}, + status_code=502, + ) + + return {"voided": True, "envelope_id": envelope_id} diff --git a/web/static/app.js b/web/static/app.js deleted file mode 100644 index a1832be..0000000 --- a/web/static/app.js +++ /dev/null @@ -1,343 +0,0 @@ -// Adobe Sign → DocuSign Migrator — frontend app -// Vanilla JS, no build step. - -const $ = id => document.getElementById(id); - -let statusTemplates = []; // [{adobe_id, name, status, docusign_id, ...}] -let dsTemplates = []; // [{id, name, lastModified}] -let authState = { adobe: false, docusign: false }; - -// ── Init ──────────────────────────────────────────────────────────────────── - -document.addEventListener('DOMContentLoaded', async () => { - await refreshAuth(); - await refreshTemplates(); - await refreshHistory(); - - $('btn-migrate').addEventListener('click', onMigrate); - $('btn-refresh').addEventListener('click', async () => { - await refreshTemplates(); - await refreshHistory(); - }); -}); - -// ── Auth ───────────────────────────────────────────────────────────────────── - -async function refreshAuth() { - const resp = await fetch('/api/auth/status'); - authState = await resp.json(); - renderAuthBar(); -} - -function renderAuthBar() { - // Adobe: use .env credentials (primary), OAuth dialog (secondary) - const adobeEl = $('badge-adobe'); - adobeEl.textContent = authState.adobe ? '✓ Adobe Sign' : 'Connect Adobe Sign'; - adobeEl.className = 'auth-badge' + (authState.adobe ? ' connected' : ''); - adobeEl.onclick = authState.adobe - ? () => disconnectPlatform('adobe') - : () => connectAdobeEnv(); - - // DocuSign: JWT grant from .env — no browser sign-in needed - const dsEl = $('badge-docusign'); - dsEl.textContent = authState.docusign ? '✓ DocuSign' : 'Connect DocuSign'; - dsEl.className = 'auth-badge' + (authState.docusign ? ' connected' : ''); - dsEl.onclick = authState.docusign - ? () => disconnectPlatform('docusign') - : () => connectDocusign(); -} - -async function disconnectPlatform(platform) { - await fetch(`/api/auth/${platform}/disconnect`); - authState[platform] = false; - renderAuthBar(); - await refreshTemplates(); -} - -async function connectAdobeEnv() { - const el = $('badge-adobe'); - el.textContent = 'Connecting…'; - const resp = await fetch('/api/auth/adobe/connect'); - const data = await resp.json(); - if (data.connected) { - authState.adobe = true; - renderAuthBar(); - await refreshTemplates(); - } else { - el.textContent = 'Connect Adobe Sign'; - // If .env has no credentials, fall back to the OAuth dialog - if (data.error && data.error.includes('No Adobe Sign credentials')) { - startAdobeAuth(); - } else { - setStatus('Adobe Sign error: ' + (data.error || 'unknown')); - } - } -} - -async function connectDocusign() { - const dsEl = $('badge-docusign'); - dsEl.textContent = 'Connecting…'; - const resp = await fetch('/api/auth/docusign/connect'); - const data = await resp.json(); - if (data.connected) { - authState.docusign = true; - renderAuthBar(); - await refreshTemplates(); - } else { - dsEl.textContent = 'Connect DocuSign'; - setStatus('DocuSign error: ' + (data.error || 'unknown')); - } -} - -// Adobe Sign uses the same manual-paste flow as the CLI: -// 1. Open auth URL in new tab -// 2. User authorizes → lands on failed https://localhost:8080/callback page -// 3. User copies that URL, pastes it into the dialog here -// 4. We POST it to /api/auth/adobe/exchange - -async function startAdobeAuth() { - const resp = await fetch('/api/auth/adobe/url'); - const { url } = await resp.json(); - - showAdobeDialog(url); -} - -function showAdobeDialog(authUrl) { - // Remove any existing dialog - const existing = $('adobe-auth-dialog'); - if (existing) existing.remove(); - - const dialog = document.createElement('div'); - dialog.id = 'adobe-auth-dialog'; - dialog.innerHTML = ` -
-
-

Connect Adobe Sign

-
    -
  1. Click here to authorize in Adobe Sign
  2. -
  3. After authorizing, your browser will show a page that fails to load — that's expected.
  4. -
  5. Copy the full URL from the address bar and paste it below.
  6. -
- -
-
- - -
-
- `; - document.body.appendChild(dialog); - - $('btn-cancel-dialog').onclick = () => dialog.remove(); - $('btn-submit-code').onclick = () => submitAdobeCode(dialog); - - // Also handle Enter key - $('adobe-redirect-input').addEventListener('keydown', e => { - if (e.key === 'Enter') submitAdobeCode(dialog); - }); -} - -async function submitAdobeCode(dialog) { - const url = $('adobe-redirect-input').value.trim(); - if (!url) return; - - $('btn-submit-code').disabled = true; - $('btn-submit-code').textContent = 'Connecting…'; - $('dialog-error').textContent = ''; - - try { - const resp = await fetch('/api/auth/adobe/exchange', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ redirect_url: url }), - }); - const data = await resp.json(); - - if (!resp.ok || data.error) { - $('dialog-error').textContent = data.error || 'Connection failed.'; - $('btn-submit-code').disabled = false; - $('btn-submit-code').textContent = 'Connect'; - return; - } - - dialog.remove(); - authState.adobe = true; - renderAuthBar(); - await refreshTemplates(); - } catch (e) { - $('dialog-error').textContent = 'Error: ' + e.message; - $('btn-submit-code').disabled = false; - $('btn-submit-code').textContent = 'Connect'; - } -} - -// ── Templates ──────────────────────────────────────────────────────────────── - -async function refreshTemplates() { - renderAdobeList([]); - renderDsList([]); - - if (!authState.adobe || !authState.docusign) { - setStatus(authState.adobe || authState.docusign - ? 'Connect both platforms to see migration status.' - : 'Connect Adobe Sign and DocuSign to get started.'); - $('btn-migrate').disabled = true; - return; - } - - setStatus('Loading templates…'); - - try { - const [statusResp, dsResp] = await Promise.all([ - fetch('/api/templates/status'), - fetch('/api/templates/docusign'), - ]); - statusTemplates = (await statusResp.json()).templates || []; - dsTemplates = (await dsResp.json()).templates || []; - renderAdobeList(statusTemplates); - renderDsList(dsTemplates); - setStatus(`${statusTemplates.length} Adobe template(s) loaded.`); - } catch (e) { - setStatus('Error loading templates: ' + e.message); - } -} - -function renderAdobeList(items) { - const ul = $('adobe-list'); - if (!items.length) { - ul.innerHTML = '
  • No templates found.
  • '; - return; - } - ul.innerHTML = items.map(t => ` -
  • - - ${escHtml(t.name)} - ${statusLabel(t.status)} - -
  • - `).join(''); - - ul.querySelectorAll('.template-item').forEach(li => { - li.addEventListener('click', e => { - if (e.target.type === 'checkbox') return; - const cb = li.querySelector('input[type=checkbox]'); - cb.checked = !cb.checked; - li.classList.toggle('selected', cb.checked); - updateMigrateButton(); - }); - li.querySelector('input').addEventListener('change', () => { - li.classList.toggle('selected', li.querySelector('input').checked); - updateMigrateButton(); - }); - }); -} - -function renderDsList(items) { - const ul = $('ds-list'); - if (!items.length) { - ul.innerHTML = '
  • No templates found.
  • '; - return; - } - ul.innerHTML = items.map(t => ` -
  • - ${escHtml(t.name)} - ${(t.lastModified || '').slice(0, 10)} -
  • - `).join(''); -} - -function updateMigrateButton() { - const checked = document.querySelectorAll('#adobe-list input[type=checkbox]:checked'); - $('btn-migrate').disabled = checked.length === 0; -} - -// ── Migration ───────────────────────────────────────────────────────────────── - -async function onMigrate() { - const checked = [...document.querySelectorAll('#adobe-list input[type=checkbox]:checked')]; - const ids = checked.map(cb => cb.dataset.id); - if (!ids.length) return; - - $('btn-migrate').disabled = true; - setStatus(`Migrating ${ids.length} template(s)…`); - - ids.forEach(id => { - const spin = $('spin-' + id); - if (spin) spin.textContent = '⏳'; - }); - - try { - const resp = await fetch('/api/migrate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ adobe_template_ids: ids }), - }); - const data = await resp.json(); - - let successCount = 0; - (data.results || []).forEach(r => { - const spin = $('spin-' + r.adobe_template_id); - if (r.status === 'success') { - successCount++; - if (spin) spin.textContent = r.action === 'updated' ? '✏️' : '✅'; - } else { - if (spin) spin.textContent = '❌'; - } - }); - - setStatus(`Done: ${successCount}/${ids.length} succeeded.`); - await refreshTemplates(); - await refreshHistory(); - } catch (e) { - setStatus('Migration error: ' + e.message); - } -} - -// ── History ─────────────────────────────────────────────────────────────────── - -async function refreshHistory() { - try { - const resp = await fetch('/api/migrate/history'); - const { history } = await resp.json(); - renderHistory(history || []); - } catch { - renderHistory([]); - } -} - -function renderHistory(records) { - const tbody = $('history-tbody'); - if (!records.length) { - tbody.innerHTML = 'No migrations yet.'; - return; - } - tbody.innerHTML = [...records].reverse().slice(0, 50).map(r => ` - - ${(r.timestamp || '').replace('T', ' ').slice(0, 19)} - ${escHtml(r.adobe_template_name || r.adobe_template_id || '')} - ${escHtml(r.docusign_template_id || '—')} - ${escHtml(r.action || '—')} - - - ${r.status} - - - - `).join(''); -} - -// ── Utilities ───────────────────────────────────────────────────────────────── - -function setStatus(msg) { $('status-msg').textContent = msg; } - -function statusLabel(s) { - return { not_migrated: 'Not Migrated', migrated: 'Migrated', needs_update: 'Needs Update' }[s] || s; -} - -function escHtml(str) { - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} diff --git a/web/static/css/base.css b/web/static/css/base.css new file mode 100644 index 0000000..af38890 --- /dev/null +++ b/web/static/css/base.css @@ -0,0 +1,279 @@ +/* Base reset, typography, and utility classes */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + height: 100%; + font-family: var(--font); + font-size: var(--font-size-base); + color: var(--text); + background: var(--page-bg); + -webkit-font-smoothing: antialiased; +} + +body { + display: flex; + height: 100vh; + overflow: hidden; +} + +/* ── Scrollbar ── */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } + +/* ── Buttons ── */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: var(--radius-sm); + font-size: var(--font-size-base); + font-family: var(--font); + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.15s; + user-select: none; + white-space: nowrap; + line-height: 1; +} +.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-primary { background: var(--cobalt); color: #fff; } +.btn-primary:not(:disabled):hover { background: var(--cobalt-hover); } +.btn-secondary { background: var(--card-bg); color: var(--text); border: 1px solid var(--border); } +.btn-secondary:hover { background: var(--ecru); } +.btn-ghost { background: transparent; color: var(--cobalt); padding: 6px 10px; } +.btn-ghost:hover { background: var(--cobalt-light); } +.btn-danger { background: var(--poppy); color: #fff; } +.btn-danger:hover { background: #e04040; } +.btn-sm { padding: 5px 10px; font-size: var(--font-size-sm); } +.btn-xs { padding: 3px 8px; font-size: var(--font-size-xs); } +.btn-icon { padding: 6px; border-radius: var(--radius-sm); } + +/* ── Badges ── */ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 12px; + font-size: var(--font-size-xs); + font-weight: 600; + white-space: nowrap; +} +.badge-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } +.badge-green { background: var(--success-bg); color: var(--success); } +.badge-amber { background: var(--warning-bg); color: var(--warning); } +.badge-red { background: var(--error-bg); color: var(--error); } +.badge-blue { background: var(--cobalt-light); color: var(--cobalt); } +.badge-gray { background: #EDF0F4; color: var(--slate); } + +/* ── Cards ── */ +.card { + background: var(--card-bg); + border-radius: var(--radius-md); + border: 1px solid var(--border); + box-shadow: var(--shadow-sm); + margin-bottom: var(--space-md); +} +.card-header { + padding: 14px 20px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; +} +.card-title { font-size: var(--font-size-md); font-weight: 700; } +.card-body { padding: var(--space-md) 20px; } + +/* ── Tables ── */ +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; } +th { + text-align: left; + padding: 10px 14px; + font-size: var(--font-size-xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + border-bottom: 1px solid var(--border); + background: #FAFBFC; + white-space: nowrap; +} +td { + padding: 11px 14px; + border-bottom: 1px solid var(--border); + font-size: var(--font-size-base); + vertical-align: middle; +} +tr:last-child td { border-bottom: none; } +tr:hover td { background: #FAFBFC; } + +/* ── Page layout ── */ +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 20px; +} +.page-title { font-size: var(--font-size-xl); font-weight: 700; color: var(--text); } +.page-subtitle { font-size: var(--font-size-base); color: var(--text-muted); margin-top: 2px; } +.page-actions { display: flex; gap: var(--space-sm); align-items: center; } + +/* ── Callouts ── */ +.callout { + padding: 12px 16px; + border-radius: var(--radius-sm); + font-size: var(--font-size-base); + margin-bottom: var(--space-md); + display: flex; + gap: 10px; + align-items: flex-start; +} +.callout-icon { font-size: 16px; flex-shrink: 0; } +.callout.info { background: var(--cobalt-light); border: 1px solid #B3D4FF; color: #0052A3; } +.callout.warn { background: var(--warning-bg); border: 1px solid #FFD280; color: #7A3E00; } +.callout.success { background: var(--success-bg); border: 1px solid #B3E8D5; color: #006644; } +.callout.error { background: var(--error-bg); border: 1px solid #FFB3B3; color: #8B0000; } + +/* ── Tabs ── */ +.tabs { + display: flex; + border-bottom: 2px solid var(--border); + margin-bottom: 20px; + gap: 0; +} +.tab { + padding: 10px 18px; + font-size: var(--font-size-base); + font-weight: 600; + cursor: pointer; + color: var(--text-muted); + border-bottom: 2px solid transparent; + margin-bottom: -2px; + transition: all 0.1s; + user-select: none; +} +.tab:hover { color: var(--text); } +.tab.active { color: var(--cobalt); border-bottom-color: var(--cobalt); } + +/* ── Divider ── */ +.divider { height: 1px; background: var(--border); margin: var(--space-md) 0; } + +/* ── Misc utilities ── */ +.mono { font-family: var(--font-mono); font-size: var(--font-size-sm); background: var(--ecru); padding: 1px 6px; border-radius: 3px; } +.tag { display: inline-block; padding: 1px 7px; border-radius: var(--radius-sm); font-size: var(--font-size-xs); font-weight: 600; background: var(--ecru); color: var(--text-muted); margin-right: 4px; } +.cb { width: 15px; height: 15px; accent-color: var(--cobalt); cursor: pointer; flex-shrink: 0; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } + +/* ── Empty state ── */ +.empty-state { + text-align: center; + padding: 48px 24px; + color: var(--text-muted); +} +.empty-state-icon { font-size: 40px; margin-bottom: 12px; opacity: 0.5; } +.empty-state-title { font-size: var(--font-size-lg); font-weight: 700; margin-bottom: 6px; color: var(--text); } +.empty-state-sub { font-size: var(--font-size-base); } + +/* ── Spinner ── */ +@keyframes spin { to { transform: rotate(360deg); } } +.spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid var(--border); + border-top-color: var(--cobalt); + border-radius: 50%; + animation: spin 0.7s linear infinite; + flex-shrink: 0; +} +.spinner-sm { width: 12px; height: 12px; border-width: 1.5px; } + +/* ── Progress bar ── */ +.progress-wrap { margin-bottom: var(--space-lg); } +.progress-label { display: flex; justify-content: space-between; margin-bottom: 6px; font-size: var(--font-size-sm); color: var(--text-muted); } +.progress-bar { height: 8px; border-radius: 4px; background: var(--border); overflow: hidden; } +.progress-fill { height: 100%; background: var(--cobalt); border-radius: 4px; transition: width 0.4s ease; } +.progress-fill.green { background: var(--success); } +.progress-fill.amber { background: var(--warning-amber); } + +/* ── Toggle switch ── */ +.toggle { + width: 36px; + height: 20px; + background: var(--border); + border-radius: 10px; + cursor: pointer; + position: relative; + flex-shrink: 0; + transition: background 0.2s; + border: none; +} +.toggle.on { background: var(--cobalt); } +.toggle::after { + content: ''; + position: absolute; + width: 14px; + height: 14px; + background: #fff; + border-radius: 50%; + top: 3px; + left: 3px; + transition: left 0.2s; + box-shadow: 0 1px 3px rgba(0,0,0,0.2); +} +.toggle.on::after { left: 19px; } + +/* ── Stat cards grid ── */ +.stat-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 14px; margin-bottom: 24px; } +.stat-card { + background: var(--card-bg); + border-radius: var(--radius-md); + padding: 16px 18px; + border: 1px solid var(--border); + box-shadow: var(--shadow-sm); + cursor: pointer; + transition: box-shadow 0.15s; +} +.stat-card:hover { box-shadow: var(--shadow-md); } +.stat-label { font-size: var(--font-size-xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 8px; } +.stat-value { font-size: 28px; font-weight: 800; line-height: 1; margin-bottom: 4px; } +.stat-sub { font-size: var(--font-size-xs); color: var(--text-muted); } +.stat-card.blue .stat-value { color: var(--cobalt); } +.stat-card.green .stat-value { color: var(--success); } +.stat-card.amber .stat-value { color: var(--warning); } +.stat-card.red .stat-value { color: var(--error); } +.stat-card.gray .stat-value { color: var(--slate); } + +/* ── Two/three-col layouts ── */ +.two-col { display: grid; grid-template-columns: 1fr 320px; gap: var(--space-md); align-items: start; } +.three-col { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-bottom: var(--space-md); } + +/* ── Avatar ── */ +.avatar { + width: 30px; + height: 30px; + border-radius: 50%; + background: var(--cobalt); + color: #fff; + font-size: var(--font-size-sm); + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +/* ── Responsive ── */ +@media (max-width: 900px) { + .stat-grid { grid-template-columns: repeat(3, 1fr); } + .two-col { grid-template-columns: 1fr; } +} +@media (max-width: 600px) { + .stat-grid { grid-template-columns: repeat(2, 1fr); } +} diff --git a/web/static/css/cards.css b/web/static/css/cards.css new file mode 100644 index 0000000..4ebe968 --- /dev/null +++ b/web/static/css/cards.css @@ -0,0 +1,271 @@ +/* Template cards, readiness badges, filter bar, bulk toolbar */ + +/* ── Readiness badges (extend base .badge) ── */ +.badge-ready { background: var(--success-bg); color: var(--success); } +.badge-caveats { background: var(--warning-bg); color: var(--warning); } +.badge-blocked { background: var(--error-bg); color: var(--error); } +.badge-migrated { background: var(--cobalt-light); color: var(--cobalt); } +.badge-needs-update { background: var(--warning-bg); color: var(--warning); } +.badge-verified { background: var(--success-bg); color: var(--success); } +.badge-not-migrated { background: #EDF0F4; color: var(--slate); } +.badge-dry-run { background: #EDF0F4; color: var(--slate); } +.badge-skipped { background: #EDF0F4; color: var(--slate); } +.badge-error { background: var(--error-bg); color: var(--error); } + +/* ── Table name cell ── */ +.table-name { + font-weight: 600; + color: var(--text); + cursor: pointer; +} +.table-name:hover { color: var(--cobalt); } +.table-sub { + font-size: var(--font-size-xs); + color: var(--text-muted); + margin-top: 2px; +} + +/* ── Issue count cell ── */ +.issue-count { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: var(--font-size-sm); + font-weight: 600; +} +.issue-count.has-issues { color: var(--warning); cursor: pointer; } +.issue-count.no-issues { color: var(--success); } +.issue-count.blocked { color: var(--error); } + +/* ── Filter bar ── */ +.filter-bar { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: var(--space-md); + flex-wrap: wrap; +} +.search-input { + flex: 1; + min-width: 200px; + max-width: 320px; + padding: 7px 12px 7px 32px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: var(--font-size-base); + font-family: var(--font); + background: var(--card-bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%236B5F8A' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.35-4.35'/%3E%3C/svg%3E") no-repeat 10px center; + outline: none; + color: var(--text); +} +.search-input:focus { border-color: var(--cobalt); } +.search-input::placeholder { color: var(--text-muted); } + +/* ── Filter tabs ── */ +.filter-tabs { + display: flex; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow: hidden; +} +.filter-tab { + padding: 7px 14px; + font-size: var(--font-size-sm); + font-weight: 600; + cursor: pointer; + color: var(--text-muted); + border-right: 1px solid var(--border); + white-space: nowrap; + transition: all 0.1s; + user-select: none; + background: transparent; + border-top: none; + border-bottom: none; +} +.filter-tab:last-child { border-right: none; } +.filter-tab:hover { background: var(--ecru); } +.filter-tab.active { background: var(--cobalt); color: #fff; border-color: var(--cobalt); } +.tab-count { font-size: 10px; margin-left: 4px; opacity: 0.8; } + +/* ── Bulk action toolbar ── */ +.bulk-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + background: var(--cobalt-light); + border: 1px solid var(--cobalt); + border-radius: var(--radius-sm); + margin-bottom: 12px; +} +.bulk-bar-text { + font-size: var(--font-size-base); + font-weight: 600; + color: var(--cobalt); + flex: 1; +} +.bulk-bar.hidden { display: none; } + +/* ── Template row action buttons ── */ +.row-actions { display: flex; gap: 6px; align-items: center; } + +/* ── Stat progress bar (dashboard) ── */ +.migration-progress-bar { + height: 6px; + border-radius: 3px; + background: var(--border); + overflow: hidden; + margin-top: 6px; +} +.migration-progress-fill { + height: 100%; + background: var(--cobalt); + border-radius: 3px; + transition: width 0.4s; +} + +/* ── Attention items (issues view) ── */ +.attention-list { display: flex; flex-direction: column; gap: 8px; } +.attention-item { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 16px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); +} +.attention-item.blocker { border-left: 3px solid var(--error); background: var(--error-bg); } +.attention-item.warning { border-left: 3px solid var(--warning-amber); background: var(--warning-bg); } +.attention-icon { font-size: 16px; flex-shrink: 0; } +.attention-name { font-weight: 600; font-size: var(--font-size-base); } +.attention-detail { font-size: var(--font-size-sm); color: var(--text-muted); margin-top: 2px; } +.attention-action { margin-left: auto; flex-shrink: 0; } + +/* ── Issue rows (template detail) ── */ +.issue-row { + display: flex; + gap: 14px; + padding: 12px 0; + border-bottom: 1px solid var(--border); + align-items: flex-start; +} +.issue-row:last-child { border-bottom: none; } +.issue-severity { + font-size: var(--font-size-xs); + font-weight: 700; + padding: 2px 8px; + border-radius: 12px; + flex-shrink: 0; + margin-top: 1px; +} +.issue-severity.blocker { background: var(--error-bg); color: var(--error); } +.issue-severity.warn { background: var(--warning-bg); color: var(--warning); } +.issue-severity.info { background: var(--cobalt-light); color: var(--cobalt); } +.issue-body { flex: 1; } +.issue-title { font-weight: 600; font-size: var(--font-size-base); } +.issue-desc { font-size: var(--font-size-sm); color: var(--text-muted); margin-top: 3px; line-height: 1.5; } +.issue-fix { font-size: var(--font-size-xs); margin-top: 6px; padding: 4px 10px; background: var(--ecru); border-radius: var(--radius-sm); color: var(--text); display: inline-block; } + +/* ── Result rows (migration results view) ── */ +.result-row { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + margin-bottom: 8px; + overflow: hidden; +} +.result-header { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 16px; + cursor: pointer; + background: var(--card-bg); + transition: background 0.1s; +} +.result-header:hover { background: #FAFBFC; } +.result-icon { font-size: 16px; flex-shrink: 0; } +.result-name { font-weight: 600; flex: 1; } +.result-meta { font-size: var(--font-size-xs); color: var(--text-muted); } +.result-body { padding: 12px 16px; border-top: 1px solid var(--border); background: #FAFBFC; display: none; } +.result-row.open .result-body { display: block; } + +/* ── Field issues block (structured dropped-feature list) ── */ +.field-issues-block { + margin-top: 10px; + border-top: 1px solid var(--border); + padding-top: 8px; +} +.field-issue-group { + margin-bottom: 6px; +} +.field-issue-group-header { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: var(--warning); + margin-bottom: 4px; + cursor: pointer; + user-select: none; +} +.field-issue-group-body { + display: none; + padding-left: 12px; +} +.field-issue-group.open .field-issue-group-body { display: block; } +.field-issue-row { + display: flex; + gap: 8px; + font-size: 11px; + padding: 3px 0; + border-bottom: 1px solid var(--border); + align-items: baseline; +} +.field-issue-row:last-child { border-bottom: none; } +.field-issue-field { + font-weight: 600; + color: var(--text); + white-space: nowrap; + flex-shrink: 0; + min-width: 120px; +} +.field-issue-msg { color: var(--text-muted); line-height: 1.4; } + +/* ── DS template link pill ── */ +.ds-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: #EAF2FF; + border: 1px solid #B3D4FF; + border-radius: var(--radius-pill); + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--cobalt); +} + +/* ── Activity list (dashboard) ── */ +.activity-item { + display: flex; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid var(--border); + align-items: flex-start; +} +.activity-item:last-child { border-bottom: none; } +.activity-dot { + width: 8px; + height: 8px; + border-radius: 50%; + margin-top: 5px; + flex-shrink: 0; +} +.activity-dot.green { background: var(--success); } +.activity-dot.amber { background: var(--warning-amber); } +.activity-dot.red { background: var(--error); } +.activity-dot.blue { background: var(--cobalt); } +.activity-text { font-size: var(--font-size-base); flex: 1; } +.activity-time { font-size: var(--font-size-xs); color: var(--text-muted); flex-shrink: 0; } diff --git a/web/static/css/forms.css b/web/static/css/forms.css new file mode 100644 index 0000000..3586581 --- /dev/null +++ b/web/static/css/forms.css @@ -0,0 +1,107 @@ +/* Form input styles — used in settings and modals */ + +.form-group { + margin-bottom: var(--space-md); +} +.form-label { + display: block; + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--text); + margin-bottom: 6px; +} +.form-label-sub { + font-size: var(--font-size-xs); + color: var(--text-muted); + font-weight: 400; + margin-left: 4px; +} +.form-input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: var(--font-size-base); + font-family: var(--font); + color: var(--text); + background: var(--card-bg); + outline: none; + transition: border-color 0.15s; +} +.form-input:focus { border-color: var(--cobalt); box-shadow: 0 0 0 3px rgba(76,0,255,0.08); } +.form-input:disabled { background: var(--ecru); color: var(--text-muted); cursor: not-allowed; } +.form-input.error { border-color: var(--error); } +.form-input-mono { font-family: var(--font-mono); font-size: var(--font-size-sm); } + +.form-hint { + font-size: var(--font-size-xs); + color: var(--text-muted); + margin-top: 4px; +} +.form-error { + font-size: var(--font-size-xs); + color: var(--error); + margin-top: 4px; + min-height: 16px; +} + +/* ── Toggle setting row ── */ +.setting-row { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 0; + border-bottom: 1px solid var(--border); +} +.setting-row:last-child { border-bottom: none; } +.setting-body { flex: 1; } +.setting-label { font-weight: 600; font-size: var(--font-size-base); } +.setting-desc { font-size: var(--font-size-sm); color: var(--text-muted); margin-top: 2px; line-height: 1.5; } +.setting-control { flex-shrink: 0; } + +/* ── Settings section ── */ +.settings-section { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--radius-md); + margin-bottom: var(--space-md); + overflow: hidden; +} +.settings-section-header { + padding: 14px 20px; + border-bottom: 1px solid var(--border); + background: #FAFBFC; +} +.settings-section-title { + font-size: var(--font-size-md); + font-weight: 700; +} +.settings-section-sub { + font-size: var(--font-size-sm); + color: var(--text-muted); + margin-top: 2px; +} +.settings-section-body { padding: 6px 20px; } + +/* ── Connection info card ── */ +.conn-info-row { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--border); + font-size: var(--font-size-base); +} +.conn-info-row:last-child { border-bottom: none; } +.conn-info-label { width: 160px; color: var(--text-muted); font-size: var(--font-size-sm); flex-shrink: 0; } +.conn-info-value { flex: 1; font-family: var(--font-mono); font-size: var(--font-size-sm); } +.conn-info-status { flex-shrink: 0; } + +/* ── Number input ── */ +input[type="number"].form-input { + -moz-appearance: textfield; +} +input[type="number"].form-input::-webkit-outer-spin-button, +input[type="number"].form-input::-webkit-inner-spin-button { + -webkit-appearance: none; +} diff --git a/web/static/css/modals.css b/web/static/css/modals.css new file mode 100644 index 0000000..432a7bf --- /dev/null +++ b/web/static/css/modals.css @@ -0,0 +1,192 @@ +/* Modal and dialog styles */ + +/* ── Overlay ── */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(19, 0, 50, 0.5); + z-index: 200; + animation: backdropIn 0.15s ease; +} +@keyframes backdropIn { from { opacity: 0; } to { opacity: 1; } } + +/* ── Modal box ── */ +.modal-box { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--card-bg); + border-radius: var(--radius-md); + width: min(520px, 94vw); + max-height: 90vh; + display: flex; + flex-direction: column; + z-index: 201; + box-shadow: var(--shadow-md); + animation: modalIn 0.18s ease; +} +@keyframes modalIn { + from { opacity: 0; transform: translate(-50%, -52%); } + to { opacity: 1; transform: translate(-50%, -50%); } +} + +.modal-box.modal-lg { width: min(720px, 94vw); } +.modal-box.modal-sm { width: min(380px, 94vw); } + +/* ── Modal sections ── */ +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.modal-title { + font-size: var(--font-size-md); + font-weight: 700; + color: var(--text); +} +.modal-body { + padding: 20px; + overflow-y: auto; + flex: 1; +} +.modal-footer { + padding: 14px 20px; + border-top: 1px solid var(--border); + display: flex; + justify-content: flex-end; + gap: var(--space-sm); + flex-shrink: 0; + background: var(--ecru); + border-radius: 0 0 var(--radius-md) var(--radius-md); +} + +/* ── Close button ── */ +.modal-close { + background: transparent; + border: none; + cursor: pointer; + color: var(--text-muted); + font-size: 18px; + padding: 2px 6px; + border-radius: var(--radius-sm); + line-height: 1; + transition: background 0.1s; +} +.modal-close:hover { background: var(--ecru); color: var(--text); } + +/* ── Options panel inside modal ── */ +.options-panel { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 18px; + margin-bottom: var(--space-md); +} +.options-title { + font-weight: 700; + font-size: var(--font-size-md); + margin-bottom: 14px; +} +.option-row { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid var(--border); +} +.option-row:last-child { border-bottom: none; } +.option-label { font-weight: 600; font-size: var(--font-size-base); } +.option-desc { font-size: var(--font-size-sm); color: var(--text-muted); margin-top: 2px; } +.option-body { flex: 1; } + +/* ── Progress inside modal ── */ +.migration-progress { + padding: 8px 0; +} +.progress-template-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 280px; + overflow-y: auto; + margin-top: 12px; +} +.progress-template-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + padding: 8px 12px; + background: var(--ecru); + border-radius: var(--radius-sm); + font-size: var(--font-size-base); +} +.progress-template-name { flex: 1; font-weight: 500; } +.progress-template-status { font-size: 16px; flex-shrink: 0; } +.progress-template-error { + flex-basis: 100%; + font-size: 11px; + color: var(--error, #c0392b); + margin-top: -4px; + padding-left: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Project switcher modal ── */ +.project-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; + max-height: 280px; + overflow-y: auto; +} +.project-row { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.1s; +} +.project-row:hover { background: var(--ecru); } +.project-row.active { + border-color: var(--cobalt); + background: var(--cobalt-light); +} +.project-row-icon { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + background: var(--cobalt); + color: #fff; + font-size: var(--font-size-sm); + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.project-row-name { font-weight: 600; font-size: var(--font-size-base); flex: 1; } +.project-row-sub { font-size: var(--font-size-xs); color: var(--text-muted); } +.project-row-active-badge { font-size: var(--font-size-xs); color: var(--cobalt); font-weight: 700; } + +/* ── New project form inside modal ── */ +.new-project-form { + border-top: 1px solid var(--border); + padding-top: 16px; + margin-top: 8px; +} +.new-project-form h4 { + font-size: var(--font-size-base); + font-weight: 700; + margin-bottom: 10px; +} diff --git a/web/static/css/nav.css b/web/static/css/nav.css new file mode 100644 index 0000000..1657695 --- /dev/null +++ b/web/static/css/nav.css @@ -0,0 +1,236 @@ +/* Left sidebar navigation and top bar */ + +/* ── App layout shell ── */ +#app-nav { + width: var(--nav-width); + background: var(--nav-bg); + display: flex; + flex-direction: column; + flex-shrink: 0; + height: 100vh; + position: fixed; + left: 0; + top: 0; + z-index: 50; +} + +#app-body { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + margin-left: var(--nav-width); + height: 100vh; +} + +/* ── Logo area ── */ +#nav-logo { + padding: 14px 20px 12px; + border-bottom: 1px solid rgba(255,255,255,0.08); +} +#nav-logo svg { display: block; } +.nav-logo-sub { + font-size: var(--font-size-xs); + color: var(--nav-text); + font-weight: 500; + letter-spacing: 0.02em; + margin-top: 6px; +} + +/* ── Project switcher ── */ +#nav-project-switcher { + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 6px; + padding: 8px 10px; + cursor: pointer; + transition: background 0.15s; + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + width: 100%; +} +#nav-project-switcher:hover { background: rgba(255,255,255,0.10); } + +.project-icon { + width: 24px; + height: 24px; + border-radius: 4px; + background: var(--cobalt); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-xs); + font-weight: 800; + color: #fff; + flex-shrink: 0; +} +.project-name { + font-size: var(--font-size-sm); + font-weight: 600; + color: #fff; + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.project-arrow { + font-size: 10px; + color: var(--nav-text); + flex-shrink: 0; +} +.project-name.no-project { color: var(--warning-amber); } + +/* ── Nav sections ── */ +.nav-section-label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(189,201,217,0.5); + padding: 16px 20px 6px; +} + +#nav-links { list-style: none; flex: 1; overflow-y: auto; } + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 20px; + color: var(--nav-text); + cursor: pointer; + border-left: 3px solid transparent; + transition: all 0.15s; + font-size: var(--font-size-base); + user-select: none; + text-decoration: none; +} +.nav-item:hover { + background: var(--nav-hover); + color: var(--nav-text-active); +} +.nav-item.active { + background: var(--nav-active-bg); + color: var(--nav-text-active); + border-left-color: var(--nav-active-border); +} +.nav-icon { + font-size: 16px; + width: 20px; + text-align: center; + flex-shrink: 0; +} +.nav-label { flex: 1; } +.nav-badge { + margin-left: auto; + background: var(--poppy); + color: #fff; + border-radius: 10px; + font-size: 10px; + font-weight: 700; + padding: 1px 6px; + min-width: 18px; + text-align: center; +} +.nav-badge.amber { background: var(--warning-amber); } +.nav-badge[data-count="0"] { display: none; } + +/* ── Nav bottom (customer context) ── */ +#nav-bottom { + margin-top: auto; + padding: 12px 0; + border-top: 1px solid rgba(255,255,255,0.08); +} +.nav-customer { padding: 10px 20px; } +.nav-customer-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: rgba(189,201,217,0.5); + margin-bottom: 4px; +} +.nav-customer-name { font-size: var(--font-size-sm); font-weight: 600; color: var(--nav-text-active); } +.nav-customer-sub { font-size: var(--font-size-xs); color: var(--nav-text); } + +/* ── Top bar ── */ +#top-bar { + height: var(--topbar-h); + background: var(--card-bg); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 var(--space-lg); + gap: var(--space-md); + flex-shrink: 0; + box-shadow: var(--shadow-sm); + z-index: 10; +} + +.breadcrumb { + display: flex; + align-items: center; + gap: 6px; + font-size: var(--font-size-base); + color: var(--text-muted); +} +.breadcrumb .sep { color: var(--border); } +.breadcrumb .current { color: var(--text); font-weight: 600; } + +#topbar-right { + margin-left: auto; + display: flex; + align-items: center; + gap: 12px; +} + +.conn-pill { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: var(--radius-pill); + font-size: var(--font-size-sm); + font-weight: 500; + border: 1px solid var(--border); + cursor: pointer; + transition: background 0.15s; + background: var(--card-bg); +} +.conn-pill:hover { background: var(--ecru); } +.conn-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} +.conn-pill.connected .conn-dot { background: var(--success); } +.conn-pill.disconnected .conn-dot { background: var(--error); } +.conn-pill.connecting .conn-dot { background: var(--warning-amber); } + +/* ── Router outlet ── */ +#router-outlet { + flex: 1; + overflow-y: auto; + padding: var(--space-lg); +} + +/* ── View transitions ── */ +.view-enter { + animation: fadeIn 0.18s ease; +} +@keyframes fadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── Mobile nav toggle ── */ +@media (max-width: 768px) { + #app-nav { + transform: translateX(-100%); + transition: transform 0.2s; + } + #app-nav.open { transform: translateX(0); } + #app-body { margin-left: 0; } +} diff --git a/web/static/css/tables.css b/web/static/css/tables.css new file mode 100644 index 0000000..2b9a765 --- /dev/null +++ b/web/static/css/tables.css @@ -0,0 +1,82 @@ +/* History and audit table styles */ + +/* ── Sortable column headers ── */ +th.sortable { + cursor: pointer; + user-select: none; +} +th.sortable:hover { background: #F0F1F5; } +th.sortable::after { + content: ' ⇅'; + font-size: 10px; + opacity: 0.5; +} +th.sort-asc::after { content: ' ↑'; opacity: 1; color: var(--cobalt); } +th.sort-desc::after { content: ' ↓'; opacity: 1; color: var(--cobalt); } + +/* ── Expandable row ── */ +.row-expandable { cursor: pointer; } +.row-expanded-content { + background: #FAFBFC; +} +.row-expand-body { + padding: 12px 14px 14px; + font-size: var(--font-size-sm); + color: var(--text-muted); + border-top: 1px solid var(--border); +} +.expand-icon { font-size: 10px; color: var(--text-muted); transition: transform 0.15s; } +tr.open .expand-icon { transform: rotate(90deg); } + +/* ── Checksum display ── */ +.checksum { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + background: var(--ecru); + padding: 2px 6px; + border-radius: 3px; + cursor: help; +} + +/* ── Date range filter ── */ +.date-filter { + display: flex; + align-items: center; + gap: 8px; + font-size: var(--font-size-sm); +} +.date-input { + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: var(--font-size-sm); + font-family: var(--font); + color: var(--text); + background: var(--card-bg); + outline: none; +} +.date-input:focus { border-color: var(--cobalt); } + +/* ── Pagination ── */ +.pagination { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-top: 1px solid var(--border); + font-size: var(--font-size-sm); + color: var(--text-muted); +} +.pagination-pages { display: flex; gap: 4px; } +.page-btn { + padding: 4px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--card-bg); + cursor: pointer; + font-size: var(--font-size-sm); + transition: background 0.1s; +} +.page-btn:hover { background: var(--ecru); } +.page-btn.active { background: var(--cobalt); color: #fff; border-color: var(--cobalt); } +.page-btn:disabled { opacity: 0.4; cursor: not-allowed; } diff --git a/web/static/css/tokens.css b/web/static/css/tokens.css new file mode 100644 index 0000000..48cb66b --- /dev/null +++ b/web/static/css/tokens.css @@ -0,0 +1,70 @@ +/* Docusign 2024 brand design tokens + Source: brand.docusign.com (April 2024 rebrand) + Inkwell #130032 replaces old navy; Cobalt #4C00FF is new primary. +*/ +:root { + /* ── Brand palette ── */ + --cobalt: #4C00FF; + --cobalt-hover: #3A00CC; + --cobalt-light: #EDE8FF; + --inkwell: #130032; + --deep-violet: #26065D; + --mist: #CBC2FF; + --ecru: #F8F3F0; + --poppy: #FF5252; + --poppy-bg: #FFF0F0; + --slate: #6B5F8A; + + /* ── Semantic colours ── */ + --success: #027A48; + --success-bg: #ECFDF3; + --warning: #92400E; + --warning-bg: #FFFBEB; + --warning-amber:#FFAB00; + --error: var(--poppy); + --error-bg: var(--poppy-bg); + + /* ── Nav ── */ + --nav-bg: var(--inkwell); + --nav-hover: var(--deep-violet); + --nav-active-bg: rgba(76,0,255,0.22); + --nav-active-border:var(--cobalt); + --nav-text: #A899CC; + --nav-text-active: #FFFFFF; + --nav-width: 228px; + + /* ── Layout ── */ + --topbar-h: 56px; + --page-bg: var(--ecru); + --card-bg: #FFFFFF; + --border: #E2DDF0; + --text: var(--inkwell); + --text-muted: var(--slate); + + /* ── Shadows ── */ + --shadow-sm: 0 1px 3px rgba(19,0,50,0.08), 0 1px 2px rgba(19,0,50,0.04); + --shadow-md: 0 4px 16px rgba(19,0,50,0.14); + + /* ── Spacing ── */ + --space-xs: 4px; + --space-sm: 8px; + --space-md: 16px; + --space-lg: 24px; + --space-xl: 32px; + + /* ── Border radius ── */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-pill: 20px; + + /* ── Typography ── */ + --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; + --font-size-xs: 11px; + --font-size-sm: 12px; + --font-size-base:13px; + --font-size-md: 14px; + --font-size-lg: 16px; + --font-size-xl: 20px; +} diff --git a/web/static/index.html b/web/static/index.html index dbcb4c1..4ef568c 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -3,77 +3,164 @@ - Adobe Sign → DocuSign Migrator - + docusign — Template Migration Console + + + + + + + -
    -

    Adobe Sign → DocuSign Migrator

    -
    - Connect Adobe Sign - Connect DocuSign -
    -
    + + + + +
    + + +
    + +
    + + + + +
    M
    +
    -
    + +
    +
    +
    +
    Loading…
    +
    +
    - -
    -
    Migration History
    - - - - - - - - - - - - - -
    TimeAdobe TemplateDocuSign Template IDActionStatus
    No migrations yet.
    -
    + - + + + + +
    + + + - diff --git a/web/static/js/api.js b/web/static/js/api.js new file mode 100644 index 0000000..45221e9 --- /dev/null +++ b/web/static/js/api.js @@ -0,0 +1,92 @@ +// Thin fetch wrappers for all backend endpoints + +async function request(method, path, body) { + const opts = { + method, + headers: { 'Content-Type': 'application/json' }, + }; + if (body !== undefined) opts.body = JSON.stringify(body); + const resp = await fetch(path, opts); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + const msg = data.detail || data.error || `HTTP ${resp.status}`; + throw Object.assign(new Error(msg), { status: resp.status, data }); + } + return data; +} + +const GET = path => request('GET', path); +const POST = (path, body) => request('POST', path, body); +const PUT = (path, body) => request('PUT', path, body); + +export const api = { + + // ── Auth ────────────────────────────────────────────────────────────────── + auth: { + status() { + return GET('/api/auth/status'); + }, + connectAdobe() { + return GET('/api/auth/adobe/connect'); + }, + adobeUrl() { + return GET('/api/auth/adobe/url'); + }, + exchangeAdobe(redirectUrl) { + return POST('/api/auth/adobe/exchange', { redirect_url: redirectUrl }); + }, + connectDocusign() { + return GET('/api/auth/docusign/connect'); + }, + disconnect(platform) { + return GET(`/api/auth/${platform}/disconnect`); + }, + }, + + // ── Templates ───────────────────────────────────────────────────────────── + templates: { + status() { + return GET('/api/templates/status'); + }, + adobe() { + return GET('/api/templates/adobe'); + }, + docusign() { + return GET('/api/templates/docusign'); + }, + }, + + // ── Migration ───────────────────────────────────────────────────────────── + migrate: { + run(body) { + return POST('/api/migrate', body); + }, + batch(body) { + return POST('/api/migrate/batch', body); + }, + batchStatus(jobId) { + return GET(`/api/migrate/batch/${jobId}`); + }, + history() { + return GET('/api/migrate/history'); + }, + }, + + // ── Verification ────────────────────────────────────────────────────────── + verify: { + send(templateId, recipientName, recipientEmail) { + return POST('/api/verify/send', { + template_id: templateId, + recipient_name: recipientName, + recipient_email: recipientEmail, + }); + }, + status(envelopeId) { + return GET(`/api/verify/status/${envelopeId}`); + }, + void(envelopeId, reason = 'Test envelope — voided after verification') { + return POST(`/api/verify/void/${envelopeId}`, { reason }); + }, + }, + +}; diff --git a/web/static/js/app.js b/web/static/js/app.js new file mode 100644 index 0000000..b95832e --- /dev/null +++ b/web/static/js/app.js @@ -0,0 +1,109 @@ +// Main app entry point — wires together router, auth, state, and nav badges + +import * as router from './router.js'; +import { refreshAuth, renderAuthChips } from './auth.js'; +import { state, subscribe } from './state.js'; +import { getActive, initProject } from './project.js'; + +// ── Route registrations (lazy-loaded views) ─────────────────────────────── + +router.register('#/templates', async (param) => { + const { renderTemplates, renderTemplateDetail } = await import('./templates.js'); + if (param) { + await renderTemplateDetail(param); + } else { + await renderTemplates(); + } +}); + +router.register('#/results', async () => { + const { renderResults } = await import('./migration.js'); + renderResults(); +}); + +router.register('#/issues', async () => { + const { renderIssues } = await import('./issues.js'); + renderIssues(); +}); + +router.register('#/verify', async () => { + const { renderVerification } = await import('./verification.js'); + await renderVerification(); +}); + +router.register('#/history', async () => { + const { renderHistory } = await import('./history.js'); + await renderHistory(); +}); + +router.register('#/settings', async () => { + const { renderSettings } = await import('./settings.js'); + renderSettings(); +}); + +// ── Nav badge subscriptions ─────────────────────────────────────────────── + +subscribe('issueCount', count => { + const badge = document.getElementById('nav-badge-issues'); + if (badge) { + badge.dataset.count = count; + badge.textContent = count; + } +}); + +subscribe('templates', templates => { + const caveats = (templates || []).filter(t => + (!t.blockers || t.blockers.length === 0) && + t.warnings && t.warnings.length > 0 + ).length; + const badge = document.getElementById('nav-badge-caveats'); + if (badge) { + badge.dataset.count = caveats; + badge.textContent = caveats; + } +}); + +// ── Project switcher wiring ─────────────────────────────────────────────── + +function syncProjectDisplay() { + const project = getActive(); + const iconEl = document.getElementById('nav-project-icon'); + const nameEl = document.getElementById('nav-project-name'); + const custName = document.getElementById('nav-customer-name'); + const custSub = document.getElementById('nav-customer-sub'); + + if (project) { + const initials = project.name.slice(0, 2).toUpperCase(); + if (iconEl) { iconEl.textContent = initials; } + if (nameEl) { nameEl.textContent = project.name; nameEl.classList.remove('no-project'); } + if (custName) { custName.textContent = project.name; } + if (custSub) { custSub.textContent = `Created ${new Date(project.createdAt).toLocaleDateString()}`; } + } else { + if (iconEl) { iconEl.textContent = '?'; } + if (nameEl) { nameEl.textContent = 'New Project'; nameEl.classList.add('no-project'); } + if (custName) { custName.textContent = '—'; } + if (custSub) { custSub.textContent = ''; } + } +} + +// ── Init ───────────────────────────────────────────────────────────────── + +document.addEventListener('DOMContentLoaded', async () => { + // Init project context + initProject(syncProjectDisplay); + + // Wire project switcher button + const switcher = document.getElementById('nav-project-switcher'); + if (switcher) { + switcher.addEventListener('click', async () => { + const { showProjectModal } = await import('./project.js'); + showProjectModal(syncProjectDisplay); + }); + } + + // Auth chips + await refreshAuth(); + + // Start router + router.init(); +}); diff --git a/web/static/js/auth.js b/web/static/js/auth.js new file mode 100644 index 0000000..cb4263d --- /dev/null +++ b/web/static/js/auth.js @@ -0,0 +1,208 @@ +// Auth: connect/disconnect Adobe Sign and Docusign, auth status chips + +import { api } from './api.js'; +import { state, setState } from './state.js'; +import { escHtml } from './utils.js'; + +// ── Refresh auth state and update chips ──────────────────────────────────── + +export async function refreshAuth() { + try { + const data = await api.auth.status(); + setState('auth', { + adobe: !!data.adobe, + docusign: !!data.docusign, + adobeLabel: data.adobe_label || 'Adobe Sign', + docusignLabel: data.docusign_label || 'Docusign', + }); + } catch (e) { + console.warn('Auth status failed:', e.message); + } + renderAuthChips(); +} + +// ── Render connection pills in top bar ───────────────────────────────────── + +export function renderAuthChips() { + renderChip('chip-adobe', state.auth.adobe, 'Adobe Sign', onClickAdobe); + renderChip('chip-docusign', state.auth.docusign, 'Docusign', onClickDocusign); +} + +function renderChip(id, connected, label, onClick) { + const el = document.getElementById(id); + if (!el) return; + el.className = 'conn-pill ' + (connected ? 'connected' : 'disconnected'); + el.innerHTML = `${escHtml(label)}`; + el.onclick = onClick; +} + +// ── Click handlers ───────────────────────────────────────────────────────── + +async function onClickAdobe() { + if (state.auth.adobe) { + await disconnect('adobe'); + } else { + await connectAdobeEnv(); + } +} + +async function onClickDocusign() { + if (state.auth.docusign) { + await disconnect('docusign'); + } else { + await connectDocusign(); + } +} + +async function disconnect(platform) { + setChipConnecting(platform === 'adobe' ? 'chip-adobe' : 'chip-docusign'); + try { + await api.auth.disconnect(platform); + setState('auth', { ...state.auth, [platform]: false }); + renderAuthChips(); + // Reload templates (they'll be empty without auth) + const { refreshTemplates } = await import('./templates.js'); + refreshTemplates(); + } catch (e) { + console.error('Disconnect failed:', e.message); + renderAuthChips(); + } +} + +async function connectAdobeEnv() { + setChipConnecting('chip-adobe'); + try { + const data = await api.auth.connectAdobe(); + if (data.connected) { + setState('auth', { ...state.auth, adobe: true }); + renderAuthChips(); + const { refreshTemplates } = await import('./templates.js'); + refreshTemplates(); + } else if (data.error && data.error.includes('No Adobe Sign credentials')) { + renderAuthChips(); + showAdobeOAuthDialog(); + } else { + renderAuthChips(); + showToast('Adobe Sign error: ' + (data.error || 'unknown'), 'error'); + } + } catch (e) { + renderAuthChips(); + showAdobeOAuthDialog(); + } +} + +async function connectDocusign() { + setChipConnecting('chip-docusign'); + try { + const data = await api.auth.connectDocusign(); + if (data.connected) { + setState('auth', { ...state.auth, docusign: true }); + renderAuthChips(); + const { refreshTemplates } = await import('./templates.js'); + refreshTemplates(); + } else { + renderAuthChips(); + showToast('Docusign error: ' + (data.error || 'unknown'), 'error'); + } + } catch (e) { + renderAuthChips(); + showToast('Docusign connection failed: ' + e.message, 'error'); + } +} + +function setChipConnecting(id) { + const el = document.getElementById(id); + if (!el) return; + el.className = 'conn-pill connecting'; + el.innerHTML = ``; +} + +// ── Adobe OAuth dialog (manual redirect URL paste) ───────────────────────── + +async function showAdobeOAuthDialog() { + const { url } = await api.auth.adobeUrl().catch(() => ({ url: '#' })); + + const existing = document.getElementById('adobe-auth-dialog'); + if (existing) existing.remove(); + + const dialog = document.createElement('div'); + dialog.id = 'adobe-auth-dialog'; + dialog.innerHTML = ` + + + `; + document.body.appendChild(dialog); + + document.getElementById('adobe-dialog-close').onclick = () => dialog.remove(); + document.getElementById('adobe-dialog-cancel').onclick = () => dialog.remove(); + document.getElementById('adobe-dialog-submit').onclick = () => submitAdobeCode(dialog); + document.getElementById('adobe-redirect-input').addEventListener('keydown', e => { + if (e.key === 'Enter') submitAdobeCode(dialog); + }); +} + +async function submitAdobeCode(dialog) { + const url = document.getElementById('adobe-redirect-input').value.trim(); + if (!url) return; + + const submitBtn = document.getElementById('adobe-dialog-submit'); + const errorEl = document.getElementById('adobe-dialog-error'); + submitBtn.disabled = true; + submitBtn.textContent = 'Connecting…'; + errorEl.textContent = ''; + + try { + const data = await api.auth.exchangeAdobe(url); + dialog.remove(); + setState('auth', { ...state.auth, adobe: true }); + renderAuthChips(); + const { refreshTemplates } = await import('./templates.js'); + refreshTemplates(); + } catch (e) { + errorEl.textContent = e.data?.error || e.message || 'Connection failed.'; + submitBtn.disabled = false; + submitBtn.textContent = 'Connect'; + } +} + +// ── Toast notification ───────────────────────────────────────────────────── + +export function showToast(message, type = 'info') { + let container = document.getElementById('toast-container'); + if (!container) { + container = document.createElement('div'); + container.id = 'toast-container'; + container.style.cssText = 'position:fixed;bottom:24px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:8px;'; + document.body.appendChild(container); + } + const toast = document.createElement('div'); + const colors = { info: 'var(--cobalt-light)', error: 'var(--error-bg)', success: 'var(--success-bg)' }; + const borders = { info: 'var(--cobalt)', error: 'var(--error)', success: 'var(--success)' }; + toast.style.cssText = ` + padding:10px 16px;border-radius:6px;font-size:13px;font-weight:500; + background:${colors[type]||colors.info};border:1px solid ${borders[type]||borders.info}; + box-shadow:var(--shadow-md);max-width:360px;animation:fadeIn 0.2s ease; + `; + toast.textContent = message; + container.appendChild(toast); + setTimeout(() => toast.remove(), 4000); +} diff --git a/web/static/js/history.js b/web/static/js/history.js new file mode 100644 index 0000000..c63e3a8 --- /dev/null +++ b/web/static/js/history.js @@ -0,0 +1,225 @@ +// History & Audit view — filterable, exportable migration history + +import { api } from './api.js'; +import { escHtml, formatDateTime, shortHash, downloadCsv, debounce, renderFieldIssues, bindFieldIssueToggles } from './utils.js'; + +let _allRecords = []; +let _filter = { search: '', status: 'all', from: '', to: '' }; +let _sort = { col: 'timestamp', dir: 'desc' }; +const PAGE_SIZE = 50; +let _page = 0; + +export async function renderHistory() { + const outlet = document.getElementById('router-outlet'); + outlet.innerHTML = `
    `; + + try { + const data = await api.migrate.history(); + _allRecords = (data.history || []).reverse(); // newest first + } catch (e) { + outlet.innerHTML = `
    Failed to load history: ${escHtml(e.message)}
    `; + return; + } + + _page = 0; + _render(); +} + +function _render() { + const outlet = document.getElementById('router-outlet'); + const filtered = _applyFilter(_allRecords); + const page = filtered.slice(_page * PAGE_SIZE, (_page + 1) * PAGE_SIZE); + const totalPages = Math.ceil(filtered.length / PAGE_SIZE); + + outlet.innerHTML = ` + + + +
    + +
    + + + + + +
    +
    + + + + +
    +
    + + ${filtered.length === 0 ? ` +
    +
    📋
    +
    ${_allRecords.length ? 'No records match your filter' : 'No migration history yet'}
    +
    ${_allRecords.length ? 'Try clearing the search or filters.' : 'Run a migration to see history here.'}
    +
    + ` : ` +
    +
    + + + + ${_th('timestamp', 'Time')} + ${_th('adobe_template_name', 'Template')} + ${_th('action', 'Action')} + ${_th('status', 'Status')} + + + + + + ${page.map(r => _historyRow(r)).join('')} + +
    Docusign IDChecksum
    +
    + + ${totalPages > 1 ? ` + ` : ''} +
    + `} + `; + + _bindEvents(filtered); +} + +function _th(col, label) { + const dir = _sort.col === col ? (_sort.dir === 'asc' ? 'sort-asc' : 'sort-desc') : ''; + return `${label}`; +} + +function _historyRow(r) { + const hasIssues = (r.field_issues || []).length > 0; + const statusBadge = r.status === 'success' + ? `${escHtml(r.action || 'success')}${hasIssues ? 'partial' : ''}` + : `${escHtml(r.status || '—')}`; + + const checksum = r.checksum_sha256 || r.checksum || ''; + + return ` + + ${(r.timestamp||'').slice(0,19).replace('T',' ')} + +
    ${escHtml(r.adobe_template_name || r.adobe_template_id || '—')}
    +
    ${escHtml(r.adobe_template_id || '')}
    + + ${escHtml(r.action || '—')} + ${statusBadge} + ${r.docusign_template_id ? escHtml(r.docusign_template_id.slice(0,12)) + '…' : '—'} + + ${checksum + ? `${escHtml(shortHash(checksum))}` + : ''} + + + ${(r.blockers || r.warnings || r.error || (r.field_issues||[]).length) ? ` + + +
    + ${(r.blockers||[]).map(b => `
    🚫 ${escHtml(b)}
    `).join('')} + ${(r.warnings||[]).map(w => `
    ⚠ ${escHtml(w)}
    `).join('')} + ${r.error ? `
    ❌ ${escHtml(r.error)}
    ` : ''} + ${renderFieldIssues(r.field_issues)} +
    + + ` : ''} + `; +} + +function _applyFilter(records) { + let list = [...records]; + + if (_filter.search) { + const q = _filter.search.toLowerCase(); + list = list.filter(r => + (r.adobe_template_name || '').toLowerCase().includes(q) || + (r.adobe_template_id || '').toLowerCase().includes(q) + ); + } + if (_filter.status !== 'all') { + list = list.filter(r => r.status === _filter.status); + } + if (_filter.from) { + list = list.filter(r => r.timestamp >= _filter.from); + } + if (_filter.to) { + list = list.filter(r => r.timestamp <= _filter.to + 'T23:59:59'); + } + + list.sort((a, b) => { + const va = a[_sort.col] || ''; + const vb = b[_sort.col] || ''; + return _sort.dir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va)); + }); + + return list; +} + +function _bindEvents(filtered) { + document.getElementById('hist-search')?.addEventListener('input', debounce(e => { + _filter.search = e.target.value; _page = 0; _render(); + }, 250)); + + document.querySelectorAll('.filter-tab[data-status]').forEach(btn => { + btn.addEventListener('click', () => { _filter.status = btn.dataset.status; _page = 0; _render(); }); + }); + + document.getElementById('hist-from')?.addEventListener('change', e => { _filter.from = e.target.value; _page = 0; _render(); }); + document.getElementById('hist-to')?.addEventListener('change', e => { _filter.to = e.target.value; _page = 0; _render(); }); + + document.querySelectorAll('th.sortable').forEach(th => { + th.addEventListener('click', () => { + const col = th.dataset.col; + _sort.dir = _sort.col === col && _sort.dir === 'asc' ? 'desc' : 'asc'; + _sort.col = col; _page = 0; _render(); + }); + }); + + document.getElementById('pg-prev')?.addEventListener('click', () => { if (_page > 0) { _page--; _render(); } }); + document.getElementById('pg-next')?.addEventListener('click', () => { _page++; _render(); }); + + // Expand rows + document.querySelectorAll('.row-expandable').forEach(row => { + row.addEventListener('click', () => { + const next = row.nextElementSibling; + if (next && next.classList.contains('row-expanded-content')) { + const open = next.style.display !== 'none'; + next.style.display = open ? 'none' : 'table-row'; + } + }); + }); + + bindFieldIssueToggles(); + + document.getElementById('btn-export-history')?.addEventListener('click', () => { + downloadCsv('migration-history.csv', filtered.map(r => ({ + timestamp: r.timestamp || '', + template: r.adobe_template_name || r.adobe_template_id || '', + adobe_id: r.adobe_template_id || '', + docusign_id: r.docusign_template_id || '', + action: r.action || '', + status: r.status || '', + checksum: r.checksum_sha256 || '', + warnings: (r.warnings || []).join('; '), + }))); + }); +} diff --git a/web/static/js/issues.js b/web/static/js/issues.js new file mode 100644 index 0000000..c5a301b --- /dev/null +++ b/web/static/js/issues.js @@ -0,0 +1,124 @@ +// Issues & Warnings view — surfaces all validation problems before migration + +import { state } from './state.js'; +import { escHtml, formatDate } from './utils.js'; +import { navigate } from './router.js'; + +export function renderIssues() { + const outlet = document.getElementById('router-outlet'); + const templates = state.templates || []; + + const blocked = templates.filter(t => t.blockers && t.blockers.length > 0); + const warnings = templates.filter(t => + (!t.blockers || t.blockers.length === 0) && t.warnings && t.warnings.length > 0 + ); + + if (!state.auth.adobe || !state.auth.docusign) { + outlet.innerHTML = ` + +
    ℹ️Connect both platforms to see validation results.
    `; + return; + } + + if (!blocked.length && !warnings.length) { + outlet.innerHTML = ` + +
    + 🎉 +
    + All templates are ready! +
    No blockers or warnings found across ${templates.length} template${templates.length !== 1 ? 's' : ''}.
    +
    +
    `; + return; + } + + outlet.innerHTML = ` + + + ${blocked.length ? ` +
    +
    + 🚫 Blockers — ${blocked.length} template${blocked.length > 1 ? 's' : ''} will fail migration +
    +
    + ${blocked.map(t => _blockerItem(t)).join('')} +
    +
    ` : ''} + + ${warnings.length ? ` +
    +
    + ⚠ Warnings — ${warnings.length} template${warnings.length > 1 ? 's' : ''} will migrate with caveats +
    +
    + ${warnings.map(t => _warningItem(t)).join('')} +
    +
    ` : ''} + `; + + // Migrate Anyway buttons + document.querySelectorAll('.btn-migrate-anyway').forEach(btn => { + btn.addEventListener('click', () => { + import('./migration.js').then(m => m.showOptionsModal([btn.dataset.id])); + }); + }); + + // View Template links + document.querySelectorAll('.btn-view-template').forEach(btn => { + btn.addEventListener('click', () => navigate(`#/templates/${btn.dataset.id}`)); + }); +} + +function _blockerItem(t) { + const blockers = t.blockers || []; + return ` +
    + 🚫 +
    +
    ${escHtml(t.name)}
    + ${blockers.map(b => `
    • ${escHtml(b)}
    `).join('')} +
    Modified ${formatDate(t.adobe_modified)}
    +
    +
    + +
    +
    + `; +} + +function _warningItem(t) { + const warnings = t.warnings || []; + return ` +
    + ⚠️ +
    +
    ${escHtml(t.name)}
    + ${warnings.slice(0, 3).map(w => `
    • ${escHtml(w)}
    `).join('')} + ${warnings.length > 3 ? `
    … +${warnings.length - 3} more
    ` : ''} +
    Modified ${formatDate(t.adobe_modified)}
    +
    +
    + + +
    +
    + `; +} diff --git a/web/static/js/migration.js b/web/static/js/migration.js new file mode 100644 index 0000000..eb3ecbe --- /dev/null +++ b/web/static/js/migration.js @@ -0,0 +1,392 @@ +// Migration workflow: options modal → progress → results view + +import { api } from './api.js'; +import { state, setState } from './state.js'; +import { escHtml, formatDateTime, downloadCsv, renderFieldIssues, bindFieldIssueToggles } from './utils.js'; +import { navigate } from './router.js'; +import { refreshTemplates } from './templates.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function getSettings() { + try { return JSON.parse(localStorage.getItem('migrator_settings')) || {}; } + catch { return {}; } +} + +// ── Options modal ────────────────────────────────────────────────────────── + +export function showOptionsModal(ids) { + if (!ids || !ids.length) return; + const settings = getSettings(); + const names = ids.map(id => { + const t = state.templates.find(t => t.adobe_id === id); + return t ? t.name : id; + }); + + const existing = document.getElementById('migration-modal'); + if (existing) existing.remove(); + + const wrapper = document.createElement('div'); + wrapper.id = 'migration-modal'; + wrapper.innerHTML = ` + + + `; + document.body.appendChild(wrapper); + + // Wire toggles + wrapper.querySelectorAll('.toggle').forEach(btn => { + if (settings.defaultOverwrite && btn.id === 'opt-overwrite') btn.classList.add('on'); + btn.addEventListener('click', () => { + btn.classList.toggle('on'); + btn.setAttribute('aria-checked', btn.classList.contains('on')); + }); + }); + + document.getElementById('mm-close').onclick = () => wrapper.remove(); + document.getElementById('mm-cancel').onclick = () => wrapper.remove(); + document.getElementById('mm-run').onclick = () => _startMigration(ids, wrapper); +} + +// ── Start migration ──────────────────────────────────────────────────────── + +async function _startMigration(ids, wrapper) { + const dryRun = document.getElementById('opt-dry-run')?.classList.contains('on') || false; + const overwrite = document.getElementById('opt-overwrite')?.classList.contains('on') || false; + const includeDocs = !(document.getElementById('opt-include-docs')?.classList.contains('on') === false); + const folder = document.getElementById('opt-folder')?.value.trim() || undefined; + + // Replace modal body with progress view + const body = wrapper.querySelector('.modal-body'); + const footer = wrapper.querySelector('.modal-footer'); + wrapper.querySelector('.modal-title').textContent = dryRun ? 'Dry Run' : 'Migrating…'; + footer.innerHTML = ''; // hide footer during migration + + const names = ids.map(id => { + const t = state.templates.find(t => t.adobe_id === id); + return { id, name: t ? t.name : id }; + }); + + body.innerHTML = ` +
    +
    +
    + Starting… + 0 / ${ids.length} +
    +
    +
    +
    + ${names.map(n => ` +
    +
    ${escHtml(n.name)}
    + +
    `).join('')} +
    +
    + `; + + try { + const jobData = await api.migrate.batch({ + source_template_ids: ids, + target_folder: folder, + options: { dry_run: dryRun, overwrite_if_exists: overwrite, include_documents: includeDocs }, + }); + + const jobId = jobData.job_id; + await pollJob(jobId, (progress) => { + const { completed, total } = progress.progress || { completed: 0, total: ids.length }; + const pct = total > 0 ? Math.round(completed / total * 100) : 0; + document.getElementById('prog-bar') && (document.getElementById('prog-bar').style.width = pct + '%'); + document.getElementById('prog-count')&& (document.getElementById('prog-count').textContent = `${completed} / ${total}`); + document.getElementById('prog-label')&& (document.getElementById('prog-label').textContent = `Migrating… ${pct}%`); + + // Update per-template icons as results come in + (progress.results || []).forEach(r => { + const row = document.getElementById(`prog-row-${r.adobe_template_id}`); + if (!row) return; + const statusEl = row.querySelector('.progress-template-status'); + if (!statusEl) return; + statusEl.className = 'progress-template-status'; + if (r.status === 'success') statusEl.textContent = r.action === 'created' ? '✅' : r.action === 'dry_run' ? '🔍' : '✏️'; + else if (r.status === 'skipped') statusEl.textContent = '⏭'; + else if (r.status === 'blocked') statusEl.textContent = '🚫'; + else statusEl.textContent = '❌'; + + if (r.status === 'blocked' || r.status === 'error' || r.status === 'failed') { + const msg = r.error || (r.blockers||[])[0] || 'Migration failed'; + let hint = row.querySelector('.progress-template-error'); + if (!hint) { + hint = document.createElement('div'); + hint.className = 'progress-template-error'; + row.appendChild(hint); + } + hint.textContent = msg; + } + }); + }); + + // Migration done — show "View Results" button + const allResults = (jobData.results || []); + const failCount = allResults.filter(r => r.status === 'blocked' || r.status === 'error' || r.status === 'failed').length; + document.getElementById('prog-label') && (document.getElementById('prog-label').textContent = 'Done!'); + + if (failCount > 0) { + const hint = document.createElement('div'); + hint.style.cssText = 'font-size:12px;color:var(--text-muted);margin-top:10px;text-align:center'; + hint.textContent = `${failCount} template${failCount > 1 ? 's' : ''} had issues — select View Results for details.`; + body.appendChild(hint); + } + + footer.innerHTML = ` + + + `; + document.getElementById('mm-close-done').onclick = () => wrapper.remove(); + document.getElementById('mm-view-results').onclick = () => { + wrapper.remove(); + navigate('#/results'); + }; + + // Refresh template list + await refreshTemplates(); + state.selectedIds.clear(); + + } catch (err) { + body.innerHTML += `
    + Migration failed: ${escHtml(err.message)} +
    `; + footer.innerHTML = ``; + document.getElementById('mm-err-close').onclick = () => wrapper.remove(); + } +} + +// ── Poll batch job ───────────────────────────────────────────────────────── + +export async function pollJob(jobId, onProgress) { + const POLL_MS = 2000; + const MAX_WAIT = 300000; // 5 minutes + const started = Date.now(); + + return new Promise((resolve, reject) => { + const tick = async () => { + try { + const data = await api.migrate.batchStatus(jobId); + if (onProgress) onProgress(data); + + if (data.status === 'done' || data.status === 'complete' || data.status === 'completed') { + setState('lastMigrationResults', data); + resolve(data); + } else if (data.status === 'failed') { + reject(new Error('Migration job failed')); + } else if (Date.now() - started > MAX_WAIT) { + reject(new Error('Migration timed out')); + } else { + setTimeout(tick, POLL_MS); + } + } catch (e) { + reject(e); + } + }; + tick(); + }); +} + +// ── Results view ─────────────────────────────────────────────────────────── + +export function renderResults() { + const outlet = document.getElementById('router-outlet'); + const results = state.lastMigrationResults; + + if (!results) { + outlet.innerHTML = ` +
    +
    📊
    +
    No migration results yet
    +
    Run a migration from the Templates view to see results here.
    +
    `; + return; + } + + const templateResults = results.results || []; + const summary = { + created: templateResults.filter(r => r.action === 'created').length, + updated: templateResults.filter(r => r.action === 'updated').length, + skipped: templateResults.filter(r => r.status === 'skipped').length, + blocked: templateResults.filter(r => r.status === 'blocked').length, + errors: templateResults.filter(r => r.status === 'error').length, + dry_run: templateResults.filter(r => r.status === 'dry_run').length, + }; + + const migratedIds = templateResults + .filter(r => r.status === 'success') + .map(r => r.adobe_template_id); + + outlet.innerHTML = ` + + + +
    +
    +
    Created
    +
    ${summary.created}
    +
    +
    +
    Updated
    +
    ${summary.updated}
    +
    +
    +
    Skipped
    +
    ${summary.skipped}
    +
    +
    +
    Blocked
    +
    ${summary.blocked}
    +
    +
    +
    Errors
    +
    ${summary.errors}
    +
    + ${summary.dry_run ? `
    Dry Run
    ${summary.dry_run}
    ` : ''} +
    + + +
    +
    + Per-Template Results +
    +
    + ${templateResults.map(r => _resultRow(r)).join('')} +
    +
    + +
    + ← Back to Templates +
    + `; + + // Expand/collapse result rows + document.querySelectorAll('.result-header').forEach(hdr => { + hdr.addEventListener('click', () => { + hdr.parentElement.classList.toggle('open'); + }); + }); + + bindFieldIssueToggles(); + + // Export CSV + document.getElementById('btn-export-results')?.addEventListener('click', () => { + downloadCsv('migration-results.csv', templateResults.map(r => ({ + name: r.adobe_template_name || r.adobe_template_id, + adobe_id: r.adobe_template_id, + docusign_id: r.docusign_template_id || '', + status: r.status, + action: r.action || '', + warnings: (r.warnings || []).join('; '), + }))); + }); + + // Verify button + document.getElementById('btn-verify-results')?.addEventListener('click', () => { + import('./verification.js').then(m => { + setState('verifyIds', migratedIds); + navigate('#/verify'); + }); + }); +} + +function _resultRow(r) { + const issues = r.field_issues || []; + const warnings = r.warnings || []; + const hasDetail = warnings.length || r.error || issues.length; + + const icon = r.status === 'success' + ? (issues.length ? '⚠️' : r.action === 'created' ? '✅' : r.action === 'dry_run' ? '🔍' : '✏️') + : (r.status === 'skipped' ? '⏭' : r.status === 'blocked' ? '🚫' : '❌'); + + const statusBadge = r.status === 'success' + ? `${r.action || 'success'}${issues.length ? 'partial' : ''}` + : `${r.status}`; + + return ` +
    +
    + ${icon} + ${escHtml(r.adobe_template_name || r.adobe_template_id)} + ${statusBadge} + ${r.docusign_template_id ? `DS: ${escHtml(r.docusign_template_id.slice(0,8))}…` : ''} + ${issues.length ? `⚠ ${issues.length} field issue${issues.length > 1 ? 's' : ''}` : ''} + ${warnings.length ? `${warnings.length} warning${warnings.length > 1 ? 's' : ''}` : ''} +
    + ${hasDetail ? ` +
    + ${warnings.map(w => `
    ${escHtml(w)}
    `).join('')} + ${r.error ? `
    ${escHtml(r.error)}
    ` : ''} + ${renderFieldIssues(issues)} +
    ` : ''} +
    + `; +} diff --git a/web/static/js/project.js b/web/static/js/project.js new file mode 100644 index 0000000..4b18901 --- /dev/null +++ b/web/static/js/project.js @@ -0,0 +1,197 @@ +// Project / customer context — localStorage CRUD + switcher modal + +import { escHtml, uuid, formatDate } from './utils.js'; + +const STORAGE_KEY = 'migrator_projects'; + +// ── Data model ───────────────────────────────────────────────────────────── +// localStorage schema: +// { active: string|null, projects: Array<{ id, name, createdAt }> } + +function load() { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY)) || { active: null, projects: [] }; + } catch { + return { active: null, projects: [] }; + } +} + +function save(data) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); +} + +export function listProjects() { + return load().projects; +} + +export function getActive() { + const data = load(); + return data.projects.find(p => p.id === data.active) || null; +} + +export function createProject(name) { + const data = load(); + const project = { id: uuid(), name: name.trim(), createdAt: new Date().toISOString() }; + data.projects.push(project); + if (!data.active) data.active = project.id; + save(data); + return project; +} + +export function deleteProject(id) { + const data = load(); + data.projects = data.projects.filter(p => p.id !== id); + if (data.active === id) { + data.active = data.projects[0]?.id || null; + } + save(data); +} + +export function setActive(id) { + const data = load(); + if (data.projects.find(p => p.id === id)) { + data.active = id; + save(data); + } +} + +// ── Init: called on app startup ───────────────────────────────────────────── +// onUpdate callback is called whenever the active project changes + +let _onUpdate = null; + +export function initProject(onUpdate) { + _onUpdate = onUpdate; + onUpdate(); + // Show project modal on first run if no projects exist + if (listProjects().length === 0) { + showProjectModal(onUpdate); + } +} + +// ── Project switcher modal ───────────────────────────────────────────────── + +export function showProjectModal(onUpdate) { + if (onUpdate) _onUpdate = onUpdate; + + const existing = document.getElementById('project-modal'); + if (existing) existing.remove(); + + const wrapper = document.createElement('div'); + wrapper.id = 'project-modal'; + wrapper.innerHTML = ` + + + `; + document.body.appendChild(wrapper); + + renderProjectList(); + + document.getElementById('pm-close').onclick = () => wrapper.remove(); + document.getElementById('pm-cancel').onclick = () => wrapper.remove(); + document.getElementById('pm-create').onclick = handleCreate; + document.getElementById('pm-new-name').addEventListener('keydown', e => { + if (e.key === 'Enter') handleCreate(); + }); + + // Focus name input + setTimeout(() => document.getElementById('pm-new-name')?.focus(), 50); +} + +function renderProjectList() { + const list = document.getElementById('pm-project-list'); + const active = getActive(); + const projects = listProjects(); + + if (!projects.length) { + list.innerHTML = `

    + No projects yet. Create one below to get started. +

    `; + return; + } + + list.innerHTML = projects.map(p => ` +
    +
    ${escHtml(p.name.slice(0, 2).toUpperCase())}
    +
    +
    ${escHtml(p.name)}
    +
    Created ${formatDate(p.createdAt)}
    +
    + ${p.id === active?.id + ? '● Active' + : `` + } +
    + `).join(''); + + // Activate on row click + list.querySelectorAll('.project-row').forEach(row => { + row.addEventListener('click', e => { + if (e.target.classList.contains('pm-delete-btn')) return; + activateProject(row.dataset.id); + }); + }); + + // Delete buttons + list.querySelectorAll('.pm-delete-btn').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + handleDelete(btn.dataset.id); + }); + }); +} + +function activateProject(id) { + setActive(id); + if (_onUpdate) _onUpdate(); + const modal = document.getElementById('project-modal'); + if (modal) modal.remove(); +} + +function handleDelete(id) { + const project = listProjects().find(p => p.id === id); + if (!project) return; + if (!confirm(`Delete project "${project.name}"? This cannot be undone.`)) return; + deleteProject(id); + if (_onUpdate) _onUpdate(); + renderProjectList(); +} + +function handleCreate() { + const input = document.getElementById('pm-new-name'); + const errorEl = document.getElementById('pm-error'); + const name = input?.value.trim(); + + if (!name) { + if (errorEl) errorEl.textContent = 'Project name is required.'; + input?.focus(); + return; + } + if (errorEl) errorEl.textContent = ''; + + const project = createProject(name); + setActive(project.id); + if (_onUpdate) _onUpdate(); + const modal = document.getElementById('project-modal'); + if (modal) modal.remove(); +} diff --git a/web/static/js/router.js b/web/static/js/router.js new file mode 100644 index 0000000..2569d22 --- /dev/null +++ b/web/static/js/router.js @@ -0,0 +1,102 @@ +// Hash-based client-side router +// Usage: navigate('#/templates') or window.location.hash = '#/templates' + +import { escHtml } from './utils.js'; + +const _routes = {}; +let _current = null; + +// Register a route: router.register('#/templates', loadFn) +export function register(hash, loadFn) { + _routes[hash] = loadFn; +} + +// Navigate to a hash route +export function navigate(hash) { + window.location.hash = hash; +} + +// Navigate and pass data to the view (stored temporarily) +let _routeData = null; +export function navigateWith(hash, data) { + _routeData = data; + navigate(hash); +} + +export function getRouteData() { + const d = _routeData; + _routeData = null; + return d; +} + +// Parse route: '#/templates/abc123' → { base: '#/templates', param: 'abc123' } +function parseHash(hash) { + const clean = hash || '#/templates'; + const parts = clean.split('/'); + if (parts.length >= 3) { + return { base: parts.slice(0, 2).join('/'), param: parts.slice(2).join('/') }; + } + return { base: clean, param: null }; +} + +// Route to the current hash +async function route() { + const { base, param } = parseHash(window.location.hash); + const key = param ? base : (window.location.hash || '#/templates'); + const baseKey = base; + + const loader = _routes[key] || _routes[baseKey] || _routes['#/templates']; + if (!loader) return; + + _current = key; + updateActiveNav(baseKey); + + const outlet = document.getElementById('router-outlet'); + if (outlet) outlet.classList.remove('view-enter'); + + try { + await loader(param); + } catch (err) { + console.error('Router error:', err); + const outlet = document.getElementById('router-outlet'); + if (outlet) outlet.innerHTML = ` +
    +
    ⚠️
    +
    Failed to load view
    +
    ${escHtml(err.message)}
    +
    `; + } + + if (outlet) { + outlet.classList.add('view-enter'); + // Remove class after animation to allow re-trigger + setTimeout(() => outlet.classList.remove('view-enter'), 200); + } +} + +// Highlight active nav item +function updateActiveNav(hash) { + document.querySelectorAll('.nav-item').forEach(el => { + el.classList.toggle('active', el.dataset.route === hash); + }); + + // Update breadcrumb + const label = document.querySelector(`.nav-item[data-route="${hash}"] .nav-label`); + const breadcrumbCurrent = document.getElementById('breadcrumb-current'); + if (breadcrumbCurrent && label) { + breadcrumbCurrent.textContent = label.textContent.trim(); + } +} + +// Init: listen for hash changes and route on load +export function init() { + window.addEventListener('hashchange', route); + // Route immediately + if (!window.location.hash || window.location.hash === '#') { + window.location.hash = '#/templates'; + } else { + route(); + } +} + +export function current() { return _current; } diff --git a/web/static/js/settings.js b/web/static/js/settings.js new file mode 100644 index 0000000..f228b69 --- /dev/null +++ b/web/static/js/settings.js @@ -0,0 +1,184 @@ +// Settings view — verification defaults, migration defaults, connection info + +import { api } from './api.js'; +import { state } from './state.js'; +import { escHtml } from './utils.js'; + +const SETTINGS_KEY = 'migrator_settings'; + +export function loadSettings() { + try { return JSON.parse(localStorage.getItem(SETTINGS_KEY)) || {}; } + catch { return {}; } +} + +export function saveSettings(settings) { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); +} + +export function renderSettings() { + const outlet = document.getElementById('router-outlet'); + const s = loadSettings(); + + outlet.innerHTML = ` + + + +
    +
    +
    Verification
    +
    Default recipient for test envelopes
    +
    +
    +
    +
    +
    Test Recipient Name
    +
    Pre-filled in the Send Test dialog on the Verification screen
    +
    +
    + +
    +
    +
    +
    +
    Test Recipient Email
    +
    Pre-filled in the Send Test dialog
    +
    +
    + +
    +
    +
    +
    +
    Auto-Void Timer (hours)
    +
    Reminder to void test envelopes after this many hours (display only — no automatic action)
    +
    +
    + +
    +
    +
    +
    + + +
    +
    +
    Migration Defaults
    +
    Pre-set options in the migration options modal
    +
    +
    +
    +
    +
    Overwrite Existing by Default
    +
    When on, the Overwrite Existing toggle in the migration modal starts enabled
    +
    +
    + +
    +
    +
    +
    +
    Include Documents by Default
    +
    Embed PDFs in the Docusign template payload
    +
    +
    + +
    +
    +
    +
    + + +
    +
    +
    Connections
    +
    Current platform connection status (connect via top bar)
    +
    +
    +
    Loading…
    +
    +
    + + +
    + + +
    + `; + + // Wire toggles + document.querySelectorAll('.toggle').forEach(btn => { + btn.addEventListener('click', () => { + btn.classList.toggle('on'); + btn.setAttribute('aria-checked', btn.classList.contains('on')); + }); + }); + + // Save + document.getElementById('btn-save-settings')?.addEventListener('click', () => { + const updated = { + testRecipientName: document.getElementById('set-recipient-name')?.value.trim() || '', + testRecipientEmail: document.getElementById('set-recipient-email')?.value.trim() || '', + autoVoidHours: parseInt(document.getElementById('set-auto-void')?.value) || 24, + defaultOverwrite: document.getElementById('set-overwrite')?.classList.contains('on') || false, + defaultIncludeDocs: document.getElementById('set-include-docs')?.classList.contains('on') !== false, + }; + saveSettings(updated); + const confirm = document.getElementById('save-confirm'); + if (confirm) { + confirm.style.display = 'inline'; + setTimeout(() => { confirm.style.display = 'none'; }, 2500); + } + }); + + // Load connection info + _loadConnInfo(); +} + +async function _loadConnInfo() { + const connEl = document.getElementById('settings-conn-info'); + if (!connEl) return; + + try { + const data = await api.auth.status(); + connEl.innerHTML = ` +
    + Adobe Sign + ${data.adobe ? 'Connected' : 'Not connected'} + + ${data.adobe ? '● Connected' : '○ Disconnected'} + +
    +
    + Docusign + ${data.docusign ? 'Connected' : 'Not connected'} + + ${data.docusign ? '● Connected' : '○ Disconnected'} + +
    +
    + Docusign Account ID + ${escHtml(data.docusign_account_id || '—')} + +
    +
    + API Environment + ${escHtml(data.base_url || '—')} + +
    + `; + } catch (e) { + connEl.innerHTML = `
    Failed to load connection info: ${escHtml(e.message)}
    `; + } +} diff --git a/web/static/js/state.js b/web/static/js/state.js new file mode 100644 index 0000000..8eab59c --- /dev/null +++ b/web/static/js/state.js @@ -0,0 +1,43 @@ +// Global application state with simple pub/sub + +const _listeners = {}; + +export const state = { + project: null, // { id, name, createdAt } + auth: { + adobe: false, + docusign: false, + adobeLabel: 'Adobe Sign', + docusignLabel: 'Docusign', + }, + templates: [], // [{ adobe_id, name, status, blockers, warnings, ... }] + selectedIds: new Set(), + lastMigrationResults: null, // final batch job results + issueCount: 0, // blocked template count (drives nav badge) +}; + +// Subscribe to state key changes: fn is called with (newValue, oldValue) +export function subscribe(key, fn) { + if (!_listeners[key]) _listeners[key] = []; + _listeners[key].push(fn); +} + +// Publish a state change +export function publish(key, newValue) { + const old = state[key]; + state[key] = newValue; + (_listeners[key] || []).forEach(fn => { + try { fn(newValue, old); } catch (e) { console.error('state listener error', e); } + }); +} + +// Convenience setter that publishes +export function setState(key, value) { + publish(key, value); +} + +// Recompute derived values after template list updates +export function updateDerivedState() { + const blocked = state.templates.filter(t => t.blockers && t.blockers.length > 0).length; + setState('issueCount', blocked); +} diff --git a/web/static/js/templates.js b/web/static/js/templates.js new file mode 100644 index 0000000..20f0201 --- /dev/null +++ b/web/static/js/templates.js @@ -0,0 +1,502 @@ +// Templates view — filterable table with readiness badges + template detail + +import { api } from './api.js'; +import { state, setState, updateDerivedState } from './state.js'; +import { escHtml, formatDate, formatRelative, debounce, renderFieldIssues, bindFieldIssueToggles } from './utils.js'; +import { navigate } from './router.js'; + +// ── Readiness badge ──────────────────────────────────────────────────────── + +function readiness(t) { + if (t.blockers && t.blockers.length > 0) { + return { key: 'blocked', label: 'Blocked', cls: 'badge-blocked' }; + } + if (t.status === 'migrated') { + return t.warnings && t.warnings.length > 0 + ? { key: 'migrated-warn', label: 'Migrated', cls: 'badge-migrated' } + : { key: 'migrated', label: 'Migrated', cls: 'badge-migrated' }; + } + if (t.status === 'needs_update') { + return { key: 'needs-update', label: 'Needs Update', cls: 'badge-needs-update' }; + } + if (t.warnings && t.warnings.length > 0) { + return { key: 'caveats', label: 'Caveats', cls: 'badge-caveats' }; + } + return { key: 'ready', label: 'Ready', cls: 'badge-ready' }; +} + +// ── Refresh templates from API ───────────────────────────────────────────── + +export async function refreshTemplates() { + if (!state.auth.adobe || !state.auth.docusign) { + setState('templates', []); + updateDerivedState(); + return; + } + try { + const data = await api.templates.status(); + setState('templates', data.templates || []); + updateDerivedState(); + } catch (e) { + console.warn('refreshTemplates failed:', e.message); + } +} + +// ── Templates list view ──────────────────────────────────────────────────── + +let _filter = { search: '', status: 'all' }; +let _sort = { col: 'name', dir: 'asc' }; + +export async function renderTemplates() { + const outlet = document.getElementById('router-outlet'); + + // Fetch if not loaded + if (!state.templates.length && state.auth.adobe && state.auth.docusign) { + outlet.innerHTML = `
    `; + await refreshTemplates(); + } + + _render(); +} + +function _render() { + const outlet = document.getElementById('router-outlet'); + const templates = _applyFilter(state.templates); + + const counts = _statusCounts(state.templates); + const anySelected = state.selectedIds.size > 0; + + outlet.innerHTML = ` + + + ${!state.auth.adobe || !state.auth.docusign ? ` +
    + ℹ️ + Connect both Adobe Sign and Docusign in the top bar to load templates. +
    + ` : ''} + + +
    + +
    + ${_filterTab('all', `All ${counts.total}`)} + ${_filterTab('not_migrated', `Not Migrated ${counts.not_migrated}`)} + ${_filterTab('migrated', `Migrated ${counts.migrated}`)} + ${_filterTab('needs_update', `Needs Update ${counts.needs_update}`)} +
    +
    + ${_filterTab2('blocked', `Blocked ${counts.blocked}`)} + ${_filterTab2('caveats', `Caveats ${counts.caveats}`)} +
    +
    + + +
    + ${state.selectedIds.size} template(s) selected + + +
    + + +
    +
    + + + + + ${_th('name', 'Template Name')} + ${_th('readiness', 'Readiness')} + ${_th('warnings', 'Issues')} + ${_th('adobe_modified','Last Modified')} + ${_th('status', 'DS Status')} + + + + + ${templates.length + ? templates.map(t => _templateRow(t)).join('') + : `` + } + +
    + + Actions
    +
    +
    📄
    +
    ${state.templates.length ? 'No templates match your filter' : 'No templates found'}
    +
    ${state.templates.length ? 'Try clearing the search or filter.' : 'Connect Adobe Sign to load templates.'}
    +
    +
    +
    +
    + `; + + _bindEvents(); +} + +function _filterTab(key, label) { + return ``; +} + +function _filterTab2(key, label) { + // readiness-based filters + return ``; +} + +function _th(col, label) { + const dir = _sort.col === col ? (_sort.dir === 'asc' ? 'sort-asc' : 'sort-desc') : ''; + return `${label}`; +} + +function _templateRow(t) { + const r = readiness(t); + const selected = state.selectedIds.has(t.adobe_id); + const warnCount = (t.warnings || []).length; + const blockCount = (t.blockers || []).length; + const issueClass = blockCount > 0 ? 'blocked' : (warnCount > 0 ? 'has-issues' : 'no-issues'); + const issueLabel = blockCount > 0 + ? `🚫 ${blockCount} blocker${blockCount > 1 ? 's' : ''}` + : (warnCount > 0 ? `⚠ ${warnCount} warning${warnCount > 1 ? 's' : ''}` : '✓ Clean'); + + return ` + + + + +
    ${escHtml(t.adobe_id)}
    + + ${r.label} + ${issueLabel} + ${formatRelative(t.adobe_modified)} + + ${t.docusign_id + ? `In Docusign` + : `Not Migrated`} + + +
    + + +
    + + + `; +} + +function _statusCounts(templates) { + return { + total: templates.length, + not_migrated: templates.filter(t => t.status === 'not_migrated').length, + migrated: templates.filter(t => t.status === 'migrated').length, + needs_update: templates.filter(t => t.status === 'needs_update').length, + blocked: templates.filter(t => t.blockers && t.blockers.length > 0).length, + caveats: templates.filter(t => (!t.blockers || !t.blockers.length) && t.warnings && t.warnings.length > 0).length, + }; +} + +function _applyFilter(templates) { + let list = [...templates]; + + // Text search + if (_filter.search) { + const q = _filter.search.toLowerCase(); + list = list.filter(t => t.name.toLowerCase().includes(q)); + } + + // Status / readiness filter + if (_filter.status !== 'all') { + if (_filter.status === 'blocked') { + list = list.filter(t => t.blockers && t.blockers.length > 0); + } else if (_filter.status === 'caveats') { + list = list.filter(t => (!t.blockers || !t.blockers.length) && t.warnings && t.warnings.length > 0); + } else { + list = list.filter(t => t.status === _filter.status); + } + } + + // Sorting + list.sort((a, b) => { + let va = a[_sort.col] || ''; + let vb = b[_sort.col] || ''; + if (_sort.col === 'readiness') { va = readiness(a).key; vb = readiness(b).key; } + if (_sort.col === 'warnings') { va = (a.blockers||[]).length + (a.warnings||[]).length; vb = (b.blockers||[]).length + (b.warnings||[]).length; } + if (typeof va === 'number') return _sort.dir === 'asc' ? va - vb : vb - va; + return _sort.dir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va)); + }); + + return list; +} + +// ── Event wiring ─────────────────────────────────────────────────────────── + +function _bindEvents() { + // Search + const searchEl = document.getElementById('template-search'); + if (searchEl) { + searchEl.addEventListener('input', debounce(e => { + _filter.search = e.target.value; + _render(); + }, 250)); + } + + // Filter tabs + document.querySelectorAll('.filter-tab').forEach(btn => { + btn.addEventListener('click', () => { + _filter.status = btn.dataset.filter; + _render(); + }); + }); + + // Sort headers + document.querySelectorAll('th.sortable').forEach(th => { + th.addEventListener('click', () => { + const col = th.dataset.col; + if (_sort.col === col) { + _sort.dir = _sort.dir === 'asc' ? 'desc' : 'asc'; + } else { + _sort.col = col; _sort.dir = 'asc'; + } + _render(); + }); + }); + + // Checkboxes + document.getElementById('select-all')?.addEventListener('change', e => { + const ids = _applyFilter(state.templates).map(t => t.adobe_id); + if (e.target.checked) { ids.forEach(id => state.selectedIds.add(id)); } + else { ids.forEach(id => state.selectedIds.delete(id)); } + _render(); + }); + + document.querySelectorAll('.row-cb').forEach(cb => { + cb.addEventListener('change', e => { + const id = cb.dataset.id; + if (e.target.checked) state.selectedIds.add(id); + else state.selectedIds.delete(id); + _render(); + }); + }); + + // Migrate selected + document.getElementById('btn-migrate-selected')?.addEventListener('click', () => { + _launchMigration([...state.selectedIds]); + }); + + document.getElementById('btn-clear-selection')?.addEventListener('click', () => { + state.selectedIds.clear(); + _render(); + }); + + // Migrate individual + document.querySelectorAll('.btn-migrate-one').forEach(btn => { + btn.addEventListener('click', () => _launchMigration([btn.dataset.id])); + }); + + // View detail + document.querySelectorAll('.btn-view-detail, .tpl-name-link').forEach(el => { + el.addEventListener('click', () => navigate(`#/templates/${el.dataset.id}`)); + }); + + // Refresh + document.getElementById('btn-refresh-templates')?.addEventListener('click', async () => { + await refreshTemplates(); + _render(); + }); +} + +async function _launchMigration(ids) { + if (!ids.length) return; + const { showOptionsModal } = await import('./migration.js'); + showOptionsModal(ids); +} + +// ── Template detail view ─────────────────────────────────────────────────── + +export async function renderTemplateDetail(adobeId) { + const outlet = document.getElementById('router-outlet'); + const t = state.templates.find(t => t.adobe_id === adobeId); + + if (!t) { + outlet.innerHTML = ` +
    +
    🔍
    +
    Template not found
    + +
    `; + return; + } + + const r = readiness(t); + outlet.innerHTML = ` + + +
    +
    Overview
    +
    Issues ${(t.blockers||[]).length + (t.warnings||[]).length > 0 + ? `${(t.blockers||[]).length + (t.warnings||[]).length}` : ''}
    +
    Migration History
    +
    + +
    + `; + + document.getElementById('detail-migrate-btn')?.addEventListener('click', () => { + import('./migration.js').then(m => m.showOptionsModal([adobeId])); + }); + + _bindDetailTabs(t); + _renderDetailTab(t, 'overview'); +} + +function _bindDetailTabs(t) { + document.querySelectorAll('#detail-tabs .tab').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('#detail-tabs .tab').forEach(x => x.classList.remove('active')); + tab.classList.add('active'); + _renderDetailTab(t, tab.dataset.tab); + }); + }); +} + +function _renderDetailTab(t, tabKey) { + const content = document.getElementById('detail-tab-content'); + if (tabKey === 'overview') { + content.innerHTML = ` +
    +
    Template Overview
    +
    +
    + Adobe Sign ID + ${escHtml(t.adobe_id)} +
    +
    + Last Modified + ${formatDate(t.adobe_modified)} +
    +
    + Migration Status + ${t.status.replace('_', ' ')} +
    + ${t.docusign_id ? ` +
    + Docusign Template ID + ${escHtml(t.docusign_id)} +
    ` : ''} +
    +
    `; + } else if (tabKey === 'issues') { + const blockers = t.blockers || []; + const warnings = t.warnings || []; + if (!blockers.length && !warnings.length) { + content.innerHTML = `
    No issues found. This template is ready to migrate.
    `; + } else { + content.innerHTML = ` + ${blockers.length ? ` +
    +
    🚫 Blockers (${blockers.length})
    +
    + ${blockers.map(b => ` +
    + BLOCKER +
    ${escHtml(b)}
    +
    `).join('')} +
    +
    ` : ''} + ${warnings.length ? ` +
    +
    ⚠ Warnings (${warnings.length})
    +
    + ${warnings.map(w => ` +
    + WARNING +
    ${escHtml(w)}
    +
    `).join('')} +
    +
    ` : ''}`; + } + } else if (tabKey === 'history') { + api.migrate.history().then(data => { + const records = (data.history || []).filter(r => + r.adobe_template_id === t.adobe_id || r.adobe_template_name === t.name + ); + if (!records.length) { + content.innerHTML = `
    ℹ️No migration history for this template yet.
    `; + } else { + const rows = [...records].reverse().map(r => { + const fieldIssues = r.field_issues || []; + const hasIssues = fieldIssues.length > 0; + const hasDetail = r.error || (r.blockers||[]).length || (r.warnings||[]).length || hasIssues; + const detailHtml = hasDetail ? ` + + +
    + ${(r.blockers||[]).map(b => `
    🚫 ${escHtml(b)}
    `).join('')} + ${(r.warnings||[]).map(w => `
    ⚠ ${escHtml(w)}
    `).join('')} + ${r.error ? `
    ❌ ${escHtml(r.error)}
    ` : ''} + ${renderFieldIssues(fieldIssues)} +
    + + ` : ''; + return ` + + ${(r.timestamp||'').slice(0,19).replace('T',' ')} + ${escHtml(r.action||'—')} + + ${r.status} + ${hasIssues ? 'partial' : ''} + ${hasDetail ? '▶ click for details' : ''} + + ${escHtml(r.docusign_template_id||'—')} + ${detailHtml}`; + }).join(''); + + content.innerHTML = ` +
    +
    + + + ${rows} +
    TimeActionStatusDocusign ID
    +
    +
    `; + + content.querySelectorAll('.row-expandable').forEach(row => { + row.addEventListener('click', () => { + const next = row.nextElementSibling; + if (next?.classList.contains('row-expanded-content')) { + const open = next.style.display !== 'none'; + next.style.display = open ? 'none' : 'table-row'; + const hint = row.querySelector('span[style*="text-muted"]'); + if (hint) hint.textContent = open ? '▶ click for details' : '▼ hide details'; + } + }); + }); + bindFieldIssueToggles(content); + } + }).catch(() => { + content.innerHTML = `
    Failed to load history.
    `; + }); + } +} diff --git a/web/static/js/utils.js b/web/static/js/utils.js new file mode 100644 index 0000000..e4de895 --- /dev/null +++ b/web/static/js/utils.js @@ -0,0 +1,154 @@ +// Shared utility functions + +export function escHtml(str) { + return String(str ?? '').replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + })[c]); +} + +export function formatDate(iso) { + if (!iso) return '—'; + try { + return new Date(iso).toLocaleDateString('en-US', { + year: 'numeric', month: 'short', day: 'numeric' + }); + } catch { return iso.slice(0, 10); } +} + +export function formatDateTime(iso) { + if (!iso) return '—'; + try { + return new Date(iso).toLocaleString('en-US', { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit' + }); + } catch { return iso.slice(0, 19).replace('T', ' '); } +} + +export function formatRelative(iso) { + if (!iso) return '—'; + const diff = Date.now() - new Date(iso).getTime(); + const m = Math.floor(diff / 60000); + if (m < 1) return 'just now'; + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 7) return `${d}d ago`; + return formatDate(iso); +} + +export function debounce(fn, ms = 300) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), ms); + }; +} + +export function uuid() { + return crypto.randomUUID + ? crypto.randomUUID() + : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = Math.random() * 16 | 0; + return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); + }); +} + +// Truncate a string to maxLen chars, appending ellipsis if needed +export function truncate(str, maxLen = 40) { + if (!str) return ''; + return str.length > maxLen ? str.slice(0, maxLen - 1) + '…' : str; +} + +// First letter of a name for avatar initials +export function initials(name) { + if (!name) return '?'; + const parts = name.trim().split(/\s+/); + return parts.length >= 2 + ? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() + : name.slice(0, 2).toUpperCase(); +} + +// Download a string as a file +export function downloadText(filename, content, type = 'text/plain') { + const blob = new Blob([content], { type }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = filename; + document.body.appendChild(a); a.click(); + document.body.removeChild(a); URL.revokeObjectURL(url); +} + +// Convert array of objects to CSV and download +export function downloadCsv(filename, rows) { + if (!rows.length) return; + const headers = Object.keys(rows[0]); + const csv = [ + headers.join(','), + ...rows.map(r => headers.map(h => JSON.stringify(r[h] ?? '')).join(',')) + ].join('\n'); + downloadText(filename, csv, 'text/csv'); +} + +// Shorten a SHA-256 hash for display +export function shortHash(hash, len = 8) { + return hash ? hash.slice(0, len) : '—'; +} + +// Human-readable labels for field issue codes (mirrors src/models/field_issue.py) +export const FIELD_ISSUE_LABELS = { + CROSS_RECIPIENT_CONDITIONAL: 'Cross-recipient conditional dropped', + UNSUPPORTED_OPERATOR: 'Unsupported condition operator dropped', + HIDE_ACTION: 'Hide condition dropped (no DocuSign equivalent)', + MULTI_PREDICATE: 'Multi-condition logic simplified to first match', + INVALID_PARENT_TAB: 'Conditional parent tab invalid or missing', + FIELD_TYPE_SKIPPED: 'Field type skipped (no DocuSign equivalent)', + PARTIAL_FIELD_TYPE: 'Field type approximated', +}; + +/** + * Wire click-to-expand on all .field-issue-group elements within root. + * Call this after injecting renderFieldIssues() HTML into the DOM. + */ +export function bindFieldIssueToggles(root = document) { + root.querySelectorAll('.field-issue-group-header').forEach(hdr => { + hdr.addEventListener('click', () => hdr.parentElement.classList.toggle('open')); + }); +} + +/** + * Render a grouped field-issues section as an HTML string. + * Groups issues by code, shows count + label, expands to field names + messages. + * Returns '' if no issues. + */ +export function renderFieldIssues(issues) { + if (!issues || !issues.length) return ''; + + // Group by code + const groups = {}; + issues.forEach(i => { + if (!groups[i.code]) groups[i.code] = []; + groups[i.code].push(i); + }); + + const groupHtml = Object.entries(groups).map(([code, items]) => { + const label = FIELD_ISSUE_LABELS[code] || code; + const rows = items.map(i => + `
    + ${escHtml(i.field_name)} + ${escHtml(i.message)} +
    ` + ).join(''); + return ` +
    +
    + ${items.length} + ${escHtml(label)} +
    +
    ${rows}
    +
    `; + }).join(''); + + return `
    ${groupHtml}
    `; +} diff --git a/web/static/js/verification.js b/web/static/js/verification.js new file mode 100644 index 0000000..8f4f02c --- /dev/null +++ b/web/static/js/verification.js @@ -0,0 +1,292 @@ +// Verification view — send test envelopes to confirm migrated templates work + +import { api } from './api.js'; +import { state } from './state.js'; +import { escHtml, formatDateTime } from './utils.js'; + +const POLL_MS = 30_000; // DocuSign rate-limit guidance: no more than once per 15 min in prod +const POLL_TIMEOUT = 300_000; // 5 minutes — treat as manual quick-test only; prod should use DS Connect +const _envelopes = {}; // { adobeId: { envelopeId, status, sentAt, completedAt, polling } } + +function getSettings() { + try { return JSON.parse(localStorage.getItem('migrator_settings')) || {}; } + catch { return {}; } +} + +export async function renderVerification(preloadedIds = null) { + const outlet = document.getElementById('router-outlet'); + const ids = preloadedIds || state.verifyIds || null; + + // Candidate templates: recently migrated (from state) or all migrated + const candidates = (state.templates || []).filter(t => + t.status === 'migrated' || t.status === 'needs_update' || t.docusign_id + ); + + if (!state.auth.docusign) { + outlet.innerHTML = ` + +
    ℹ️Connect Docusign to send verification envelopes.
    `; + return; + } + + if (!candidates.length) { + outlet.innerHTML = ` + +
    ℹ️ + No migrated templates yet. Run a migration first, then return here to verify. +
    `; + return; + } + + _renderVerifyView(candidates); +} + +function _renderVerifyView(candidates) { + const outlet = document.getElementById('router-outlet'); + const settings = getSettings(); + + outlet.innerHTML = ` + + +
    + ℹ️ + Verification sends a real Docusign envelope using each template. Test envelopes should be voided after use. + Configure default recipient in Settings. +
    + +
    +
    + Migrated Templates + ${candidates.length} templates +
    +
    + + + + + + + + + + + ${candidates.map(t => _verifyRow(t, settings)).join('')} + +
    TemplateDocusign IDVerification StatusActions
    +
    +
    + `; + + // Wire Send Test buttons + document.querySelectorAll('.btn-send-test').forEach(btn => { + btn.addEventListener('click', () => { + const adobeId = btn.dataset.id; + const t = candidates.find(t => t.adobe_id === adobeId); + if (t) _showSendDialog(t, settings); + }); + }); + + // Wire Void buttons + document.querySelectorAll('.btn-void-envelope').forEach(btn => { + btn.addEventListener('click', () => { + _voidEnvelope(btn.dataset.id, btn.dataset.envelopeid); + }); + }); +} + +function _verifyRow(t, settings) { + const env = _envelopes[t.adobe_id]; + let statusCell = 'Not Tested'; + let actionsCell = ``; + + if (env) { + if (env.status === 'completed') { + statusCell = '✓ Verified'; + actionsCell = ``; + } else if (env.status === 'sent' || env.status === 'delivered') { + statusCell = ` ${env.status}`; + actionsCell = ``; + } else if (env.status === 'voided') { + statusCell = 'Voided'; + actionsCell = ``; + } else if (env.status === 'timeout') { + statusCell = 'Timed Out'; + actionsCell = ``; + } else { + statusCell = `${escHtml(env.status || 'pending')}`; + } + } + + return ` + + +
    ${escHtml(t.name)}
    +
    ${escHtml(t.adobe_id)}
    + + ${t.docusign_id ? escHtml(t.docusign_id.slice(0,12)) + '…' : '—'} + ${statusCell} + ${actionsCell} + + `; +} + +function _showSendDialog(t, settings) { + const existing = document.getElementById('send-dialog'); + if (existing) existing.remove(); + + const wrapper = document.createElement('div'); + wrapper.id = 'send-dialog'; + wrapper.innerHTML = ` + + + `; + document.body.appendChild(wrapper); + + document.getElementById('sd-close').onclick = () => wrapper.remove(); + document.getElementById('sd-cancel').onclick = () => wrapper.remove(); + document.getElementById('sd-send').onclick = () => _sendEnvelope(t, wrapper); +} + +async function _sendEnvelope(t, wrapper) { + const name = document.getElementById('sd-name')?.value.trim(); + const email = document.getElementById('sd-email')?.value.trim(); + const errorEl = document.getElementById('sd-error'); + + if (!name || !email) { + if (errorEl) errorEl.textContent = 'Recipient name and email are required.'; + return; + } + if (errorEl) errorEl.textContent = ''; + + const sendBtn = document.getElementById('sd-send'); + sendBtn.disabled = true; + sendBtn.textContent = 'Sending…'; + + try { + const data = await api.verify.send(t.docusign_id || t.adobe_id, name, email); + wrapper.remove(); + + _envelopes[t.adobe_id] = { envelopeId: data.envelope_id, status: 'sent', sentAt: new Date().toISOString() }; + _updateVerifyRow(t.adobe_id); + _startPolling(t.adobe_id, data.envelope_id); + + } catch (e) { + if (errorEl) errorEl.textContent = 'Send failed: ' + e.message; + sendBtn.disabled = false; + sendBtn.textContent = 'Send Test →'; + } +} + +function _startPolling(adobeId, envelopeId) { + const env = _envelopes[adobeId]; + if (!env || env.polling) return; + env.polling = true; + const deadline = Date.now() + POLL_TIMEOUT; + + const poll = async () => { + try { + const data = await api.verify.status(envelopeId); + if (!_envelopes[adobeId]) return; + _envelopes[adobeId].status = data.status; + _envelopes[adobeId].completedAt = data.completed_at; + _updateVerifyRow(adobeId); + if (data.status === 'completed' || data.status === 'voided') { + _envelopes[adobeId].polling = false; + } else if (Date.now() >= deadline) { + _envelopes[adobeId].polling = false; + _envelopes[adobeId].status = 'timeout'; + _updateVerifyRow(adobeId); + } else { + setTimeout(poll, POLL_MS); + } + } catch (e) { + console.warn('Polling error:', e.message); + } + }; + setTimeout(poll, POLL_MS); +} + +function _updateVerifyRow(adobeId) { + const t = (state.templates || []).find(t => t.adobe_id === adobeId); + const env = _envelopes[adobeId]; + if (!t || !env) return; + + const statusEl = document.getElementById(`verify-status-${adobeId}`); + const actionsEl = document.getElementById(`verify-actions-${adobeId}`); + if (!statusEl) return; + + if (env.status === 'completed') { + statusEl.innerHTML = '✓ Verified'; + actionsEl.innerHTML = ``; + } else if (env.status === 'sent' || env.status === 'delivered') { + statusEl.innerHTML = ` ${env.status}`; + actionsEl.innerHTML = ``; + } else if (env.status === 'voided') { + statusEl.innerHTML = 'Voided'; + actionsEl.innerHTML = ``; + } else if (env.status === 'timeout') { + statusEl.innerHTML = 'Timed Out'; + actionsEl.innerHTML = ``; + } + + // Re-wire newly injected buttons + actionsEl.querySelectorAll('.btn-void-envelope').forEach(btn => { + btn.onclick = () => _voidEnvelope(btn.dataset.id, btn.dataset.envelopeid); + }); + actionsEl.querySelectorAll('.btn-send-test').forEach(btn => { + btn.onclick = () => { + const settings = getSettings(); + _showSendDialog(t, settings); + }; + }); +} + +async function _voidEnvelope(adobeId, envelopeId) { + if (!confirm('Void this test envelope?')) return; + try { + await api.verify.void(envelopeId); + if (_envelopes[adobeId]) { + _envelopes[adobeId].status = 'voided'; + _envelopes[adobeId].polling = false; + } + _updateVerifyRow(adobeId); + } catch (e) { + alert('Failed to void envelope: ' + e.message); + } +} diff --git a/web/static/style.css b/web/static/style.css deleted file mode 100644 index b8194e6..0000000 --- a/web/static/style.css +++ /dev/null @@ -1,186 +0,0 @@ -* { box-sizing: border-box; margin: 0; padding: 0; } - -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: #f5f5f5; - color: #222; - font-size: 14px; -} - -/* ── Header ── */ -header { - background: #1a3c5e; - color: #fff; - padding: 14px 24px; - display: flex; - align-items: center; - justify-content: space-between; -} -header h1 { font-size: 18px; font-weight: 600; } -#auth-bar { display: flex; gap: 12px; align-items: center; font-size: 13px; } -.auth-badge { - padding: 4px 10px; - border-radius: 12px; - border: 1px solid rgba(255,255,255,0.4); - cursor: pointer; - transition: background 0.15s; -} -.auth-badge.connected { background: #28a745; border-color: #28a745; } -.auth-badge:not(.connected):hover { background: rgba(255,255,255,0.15); } - -/* ── Main layout ── */ -main { padding: 20px 24px; } - -.panel-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 16px; - margin-bottom: 20px; -} - -.panel { - background: #fff; - border: 1px solid #ddd; - border-radius: 6px; - overflow: hidden; -} - -.panel-header { - padding: 12px 16px; - background: #f8f9fa; - border-bottom: 1px solid #ddd; - display: flex; - justify-content: space-between; - align-items: center; - font-weight: 600; - font-size: 13px; - color: #555; - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.panel-body { padding: 0; } - -/* ── Template list ── */ -.template-list { list-style: none; } - -.template-item { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 16px; - border-bottom: 1px solid #f0f0f0; - cursor: pointer; - transition: background 0.1s; -} -.template-item:last-child { border-bottom: none; } -.template-item:hover { background: #f9f9f9; } -.template-item.selected { background: #eef4ff; } - -.template-item input[type=checkbox] { flex-shrink: 0; } - -.template-name { flex: 1; font-size: 13px; } - -/* ── Status badges ── */ -.badge { - font-size: 11px; - font-weight: 600; - padding: 2px 8px; - border-radius: 10px; - white-space: nowrap; -} -.badge-migrated { background: #d4edda; color: #155724; } -.badge-needs_update { background: #fff3cd; color: #856404; } -.badge-not_migrated { background: #f8d7da; color: #721c24; } - -.template-spinner { font-size: 12px; color: #888; } - -/* ── Action bar ── */ -.action-bar { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 20px; -} - -button { - padding: 8px 18px; - border: none; - border-radius: 5px; - cursor: pointer; - font-size: 13px; - font-weight: 600; - transition: opacity 0.15s; -} -button:disabled { opacity: 0.45; cursor: not-allowed; } - -#btn-migrate { background: #1a3c5e; color: #fff; } -#btn-migrate:not(:disabled):hover { background: #235080; } - -#btn-refresh { background: #e9ecef; color: #333; } -#btn-refresh:hover { background: #dee2e6; } - -#status-msg { font-size: 13px; color: #555; } - -/* ── History ── */ -.history-section { background: #fff; border: 1px solid #ddd; border-radius: 6px; } -.history-section .panel-header { background: #f8f9fa; } - -.history-table { - width: 100%; - border-collapse: collapse; - font-size: 13px; -} -.history-table th { - text-align: left; - padding: 8px 14px; - background: #f8f9fa; - border-bottom: 1px solid #ddd; - font-weight: 600; - color: #555; -} -.history-table td { - padding: 8px 14px; - border-bottom: 1px solid #f0f0f0; -} -.history-table tr:last-child td { border-bottom: none; } - -.empty-msg { padding: 20px; text-align: center; color: #999; font-size: 13px; } - -/* ── Adobe auth dialog ── */ -.dialog-backdrop { - position: fixed; inset: 0; - background: rgba(0,0,0,0.4); - z-index: 100; -} -.dialog-box { - position: fixed; - top: 50%; left: 50%; - transform: translate(-50%, -50%); - background: #fff; - border-radius: 8px; - padding: 28px 32px; - width: min(500px, 90vw); - z-index: 101; - box-shadow: 0 8px 32px rgba(0,0,0,0.18); -} -.dialog-box h2 { font-size: 16px; margin-bottom: 16px; } -.dialog-box ol { padding-left: 20px; margin-bottom: 16px; line-height: 1.7; } -.dialog-box ol a { color: #1a3c5e; } -.dialog-box input[type=text] { - width: 100%; - padding: 8px 10px; - border: 1px solid #ccc; - border-radius: 4px; - font-size: 13px; - margin-bottom: 8px; - font-family: monospace; -} -.dialog-error { color: #c00; font-size: 12px; min-height: 18px; margin-bottom: 10px; } -.dialog-actions { display: flex; gap: 8px; justify-content: flex-end; } -.btn-secondary { background: #e9ecef; color: #333; } - -/* ── Responsive ── */ -@media (max-width: 700px) { - .panel-row { grid-template-columns: 1fr; } -}