Compare commits
25 Commits
854d112372
...
683a64158e
| Author | SHA1 | Date |
|---|---|---|
|
|
683a64158e | |
|
|
15c50f05e3 | |
|
|
b2bbcac842 | |
|
|
53eb206d89 | |
|
|
4f9cb43ac8 | |
|
|
1c5b131f19 | |
|
|
5eee7e0ab4 | |
|
|
374d1d10d0 | |
|
|
5cf415d38a | |
|
|
e521fd8e58 | |
|
|
6f684f330f | |
|
|
e9f21b6c6d | |
|
|
0dcf7193e0 | |
|
|
17e478e996 | |
|
|
5bf2cc756a | |
|
|
11b646d3b7 | |
|
|
329edc39d2 | |
|
|
587104d520 | |
|
|
023c3928f3 | |
|
|
85f82eaabf | |
|
|
516af313a1 | |
|
|
89382537b1 | |
|
|
aa5ab4b653 | |
|
|
64b33357cf | |
|
|
342e8c3471 |
Binary file not shown.
261
PRODUCT-SPEC.md
261
PRODUCT-SPEC.md
|
|
@ -1,9 +1,9 @@
|
||||||
# Initial Product Spec (Draft)
|
# Product Specification
|
||||||
|
|
||||||
## Project: Adobe Sign to DocuSign Template Migrator
|
## Project: Adobe Sign to DocuSign Template Migrator
|
||||||
|
|
||||||
### Purpose
|
### 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
|
- Generate best-approximation DocuSign templates programmatically
|
||||||
- Handle all basic field types and recipient roles
|
- Handle all basic field types and recipient roles
|
||||||
- Detect and warn on features needing special/manual handling (complex logic, custom validations, non-mappable features)
|
- Detect and warn on features needing special/manual handling (complex logic, custom validations, non-mappable features)
|
||||||
|
- Produce a structured migration report with successes, warnings, and manual-fix items
|
||||||
### 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)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Out of Scope (MVP)
|
### Architecture
|
||||||
- Agreement instance migration (focus on templates only)
|
|
||||||
|
#### 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
|
- 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)*
|
||||||
|
|
|
||||||
184
README.md
184
README.md
|
|
@ -102,7 +102,8 @@ If multiple templates share the same name, the most recently modified one is use
|
||||||
|
|
||||||
## Web UI
|
## 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:**
|
**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.
|
Then open [http://localhost:8000](http://localhost:8000) in your browser.
|
||||||
|
|
||||||
**Using the UI:**
|
### Navigation
|
||||||
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.
|
| Screen | Path | Purpose |
|
||||||
3. Your Adobe Sign templates appear on the left with status badges:
|
|---|---|---|
|
||||||
- **Not Migrated** (red) — no matching DocuSign template yet
|
| Templates | `#/templates` | Filterable table with readiness badges; bulk migration |
|
||||||
- **Migrated** (green) — a DocuSign template with the same name exists and is up to date
|
| Migration Results | `#/results` | Summary + per-template results from last migration |
|
||||||
- **Needs Update** (yellow) — the Adobe template was modified after the last migration
|
| Issues & Warnings | `#/issues` | All templates with blockers or warnings |
|
||||||
4. Check one or more templates and click **Migrate Selected**.
|
| Verification | `#/verify` | Send test envelopes; confirm templates work end-to-end |
|
||||||
5. Migration results appear inline; the history table at the bottom logs all past runs.
|
| 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)
|
**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
|
## Running tests
|
||||||
|
|
||||||
```bash
|
```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 -v # compose regression only
|
||||||
pytest tests/test_regression.py --update-snapshots # regenerate snapshots after intentional changes
|
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
|
## Project structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
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_auth.py # One-time OAuth flow for Adobe Sign (CLI)
|
||||||
adobe_api.py # Adobe Sign API client (auto token refresh)
|
adobe_api.py # Adobe Sign API client (auto token refresh)
|
||||||
download_templates.py # List and download templates from Adobe Sign
|
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
|
docusign_auth.py # DocuSign JWT auth + one-time consent flow
|
||||||
upload_docusign_template.py # Upsert upload: PUT if exists, POST if not
|
upload_docusign_template.py # Upsert upload: PUT if exists, POST if not
|
||||||
migrate_template.py # End-to-end CLI runner (download → convert → upload)
|
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/
|
web/
|
||||||
app.py # FastAPI entrypoint (uvicorn web.app:app)
|
app.py # FastAPI entrypoint (uvicorn web.app:app)
|
||||||
config.py # Environment-based settings
|
config.py # Environment-based settings
|
||||||
session.py # Signed cookie session helpers
|
session.py # Signed cookie session helpers
|
||||||
routers/
|
routers/
|
||||||
auth.py # Adobe Sign + DocuSign OAuth endpoints
|
auth.py # Adobe Sign + Docusign OAuth endpoints
|
||||||
templates.py # Template listing + migration status API
|
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/
|
static/
|
||||||
index.html # Web UI (side-by-side browser + migrate flow)
|
index.html # SPA shell (left nav, router outlet, top bar)
|
||||||
app.js # Vanilla JS frontend
|
css/
|
||||||
style.css # Styles + status badge colours
|
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/
|
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_upload_upsert.py # Upsert logic unit tests
|
||||||
test_api_health.py # Health endpoint
|
test_api_health.py # Health endpoint
|
||||||
test_api_auth.py # OAuth endpoint tests
|
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_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_e2e.py # Full pipeline end-to-end test
|
||||||
test_regression.py # Compose output vs snapshots
|
test_regression.py # Compose output vs snapshots
|
||||||
fixtures/expected/ # Regression snapshots (3 real templates)
|
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)
|
downloads/ # Downloaded Adobe Sign templates (gitignored)
|
||||||
migration-output/ # Converted DocuSign template JSONs + history
|
migration-output/ # Converted DocuSign template JSONs + history
|
||||||
sample-templates/ # JSON fixtures for offline testing
|
|
||||||
|
|
||||||
field-mapping.md # Field type mapping table + edge case log
|
field-mapping.md # Field type mapping table + edge case log
|
||||||
CLAUDE.md # Claude Code instructions for this project
|
PRODUCT-SPEC.md # Full product specification (blueprint-aligned)
|
||||||
docs/IMPLEMENTATION-PLAN.md # Feature design and test specifications
|
|
||||||
docs/agent-harness/
|
docs/agent-harness/
|
||||||
EXECUTION-BOARD.md # Living kanban board
|
EXECUTION-BOARD.md # Living kanban board
|
||||||
AGENT-INSTRUCTIONS.md # Definition of done + conventions
|
|
||||||
requirements.txt # Python dependencies
|
requirements.txt # Python dependencies
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
<body>
|
||||||
|
<nav id="app-nav"> <!-- left sidebar, 220px, Inkwell bg -->
|
||||||
|
<div id="nav-logo">…</div> <!-- docusign SVG logo, white wordmark -->
|
||||||
|
<ul id="nav-links">…</ul> <!-- 7 nav links with icons -->
|
||||||
|
<div id="nav-project">…</div> <!-- project switcher footer -->
|
||||||
|
</nav>
|
||||||
|
<div id="app-body">
|
||||||
|
<header id="top-bar">…</header> <!-- breadcrumb + auth chips -->
|
||||||
|
<main id="router-outlet">…</main> <!-- views injected here -->
|
||||||
|
</div>
|
||||||
|
<!-- modal containers -->
|
||||||
|
<div id="modal-overlay" hidden>…</div>
|
||||||
|
</body>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Execution Board (Living Kanban)
|
# 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
|
## Gitea
|
||||||
|
|
||||||
- [x] Committed and pushed all changes (2026-04-17)
|
- [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) 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-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-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
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 `conditionalAction` → DocuSign `conditionalParentLabel` + `conditionalParentValue` on the dependent tab.
|
||||||
|
|
||||||
| Adobe Sign | DocuSign | Notes |
|
| Adobe Sign | DocuSign | Outcome | Notes |
|
||||||
|-----------------------------------|---------------------------------|-------|
|
|-----------------------------------|---------------------------------|---------|-------|
|
||||||
| `predicates[].fieldName` | `conditionalParentLabel` | For radio groups, matches the group name |
|
| `predicates[].fieldName` | `conditionalParentLabel` | Mapped | For radio groups, matches the group name |
|
||||||
| `predicates[].value` | `conditionalParentValue` | The value the trigger must equal to reveal the tab |
|
| `predicates[].value` | `conditionalParentValue` | Mapped | The value the trigger must equal to reveal the tab |
|
||||||
| `action: SHOW` | Supported | Tab is hidden until condition is met |
|
| `action: SHOW` | Supported | Mapped | Tab is hidden until condition is met |
|
||||||
| `action: HIDE` | **Not supported** | No DocuSign equivalent — condition skipped, field always shown |
|
| `action: HIDE` | **Not supported** | Dropped | No DocuSign equivalent — field always shown. `HIDE_ACTION` issue emitted. |
|
||||||
| `operator: EQUALS` | Supported | Only operator DocuSign supports |
|
| `operator: EQUALS` | Supported | Mapped | Only operator DocuSign supports |
|
||||||
| Other operators | **Not supported** | Condition skipped, warning logged |
|
| Other operators (NOT_EQUALS, etc.)| **Not supported** | Dropped | Condition skipped. `UNSUPPORTED_OPERATOR` issue emitted. |
|
||||||
| Multiple predicates (ANY/ALL) | **Partial** — first EQUALS only | Warning logged; remaining predicates ignored |
|
| 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
|
## Known Gaps
|
||||||
|
|
||||||
- **Conditional HIDE**: Adobe Sign can conditionally hide a field. DocuSign only supports
|
- **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.
|
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.
|
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.
|
- **Multi-predicate conditions**: Adobe Sign supports ANY/ALL of multiple predicates.
|
||||||
DocuSign only supports a single parent condition per tab. Only the first EQUALS
|
DocuSign only supports a single parent condition per tab. Only the first EQUALS
|
||||||
predicate is mapped; complex conditions require manual rework.
|
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.
|
- **DocuSign formula fields**: No Adobe Sign equivalent — flag for manual rewrite.
|
||||||
- **Advanced field validation**: Adobe regex/custom script validation is not mapped;
|
- **Advanced field validation**: Adobe regex/custom script validation is not mapped;
|
||||||
best-effort via standard DocuSign validation types only.
|
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.
|
DocuSign `radioGroupTabs` entry with per-location radio button coordinates.
|
||||||
- **Stamp tab account feature**: `stampTabs` requires the stamp/hanko feature to be
|
- **Stamp tab account feature**: `stampTabs` requires the stamp/hanko feature to be
|
||||||
enabled on the DocuSign account. Verify before migrating templates that contain
|
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
|
## Field Issue Codes
|
||||||
- Add conditional logic/rule mapping table
|
|
||||||
- Document field mask and default value transforms
|
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.
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,19 @@ Conditional logic:
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from pathlib import Path
|
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"
|
DOCUMENT_ID = "1"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -154,7 +164,14 @@ def _sized_tabs(locations: list, label: str, extra: dict | None = None) -> list:
|
||||||
# Conditional logic
|
# 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
|
Apply DocuSign conditionalParentLabel / conditionalParentValue to tabs based
|
||||||
on an Adobe Sign conditionalAction.
|
on an Adobe Sign conditionalAction.
|
||||||
|
|
@ -169,6 +186,8 @@ def _apply_conditional_to_tabs(tabs: dict, field: dict, warnings: list) -> dict:
|
||||||
Mapping limitations:
|
Mapping limitations:
|
||||||
- Only SHOW action is supported. DocuSign has no native HIDE — condition skipped.
|
- Only SHOW action is supported. DocuSign has no native HIDE — condition skipped.
|
||||||
- Only EQUALS operator is supported. Others are 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;
|
- Only one predicate is mapped. Multi-predicate ANY/ALL logic is not supported;
|
||||||
the first EQUALS predicate is used and a warning is logged.
|
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")
|
action = ca.get("action", "SHOW")
|
||||||
|
|
||||||
if action != "SHOW":
|
if action != "SHOW":
|
||||||
warnings.append(
|
msg = (
|
||||||
f"Conditional '{label}': action={action} is not supported in DocuSign "
|
f"Field '{label}' has a HIDE condition which DocuSign does not support — "
|
||||||
f"(only SHOW is supported) — condition skipped"
|
f"condition dropped. The field will always be visible."
|
||||||
)
|
)
|
||||||
|
warnings.append(msg)
|
||||||
|
issues.append(FieldIssue(HIDE_ACTION, label, msg).to_dict())
|
||||||
return tabs
|
return tabs
|
||||||
|
|
||||||
predicate = next((p for p in predicates if p.get("operator") == "EQUALS"), None)
|
predicate = next((p for p in predicates if p.get("operator") == "EQUALS"), None)
|
||||||
if not predicate:
|
if not predicate:
|
||||||
warnings.append(
|
ops = [p.get("operator") for p in predicates]
|
||||||
f"Conditional '{label}': no EQUALS predicate found "
|
msg = (
|
||||||
f"(operators: {[p.get('operator') for p in predicates]}) — condition skipped"
|
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
|
return tabs
|
||||||
|
|
||||||
if len(predicates) > 1:
|
parent_field_name = predicate["fieldName"]
|
||||||
warnings.append(
|
|
||||||
f"Conditional '{label}': {len(predicates)} predicates with "
|
# Cross-recipient check: DocuSign does not support conditionals across recipients
|
||||||
f"anyOrAll={ca.get('anyOrAll')} — only first EQUALS predicate mapped, "
|
if field_assignee is not None and current_assignee:
|
||||||
f"remaining conditions ignored"
|
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"]
|
parent_value = predicate["value"]
|
||||||
|
|
||||||
for tab_list in tabs.values():
|
for tab_list in tabs.values():
|
||||||
for tab in tab_list:
|
for tab in tab_list:
|
||||||
tab["conditionalParentLabel"] = parent_label
|
tab["conditionalParentLabel"] = parent_field_name
|
||||||
tab["conditionalParentValue"] = parent_value
|
tab["conditionalParentValue"] = parent_value
|
||||||
|
|
||||||
return tabs
|
return tabs
|
||||||
|
|
@ -220,11 +260,12 @@ def _apply_conditional_to_tabs(tabs: dict, field: dict, warnings: list) -> dict:
|
||||||
# Tab builder
|
# 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.
|
Convert one Adobe Sign field into the correct DocuSign tabs structure.
|
||||||
Returns a dict of tab-group keys, e.g. {"textTabs": [...]}.
|
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", "")
|
input_type = field.get("inputType", "")
|
||||||
label = field.get("name", "unnamed")
|
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 input_type == "TEXT_FIELD":
|
||||||
if content_type == "SIGNATURE_DATE":
|
if content_type == "SIGNATURE_DATE":
|
||||||
# Auto-populated with the signing date
|
|
||||||
return {"dateSignedTabs": _sized_tabs(locations, label)}
|
return {"dateSignedTabs": _sized_tabs(locations, label)}
|
||||||
elif content_type == "SIGNER_NAME":
|
elif content_type == "SIGNER_NAME":
|
||||||
# Auto-populated with the signer's full name
|
|
||||||
return {"fullNameTabs": _sized_tabs(locations, label)}
|
return {"fullNameTabs": _sized_tabs(locations, label)}
|
||||||
elif content_type == "SIGNER_EMAIL":
|
elif content_type == "SIGNER_EMAIL":
|
||||||
# Auto-populated with the signer's email address
|
|
||||||
return {"emailAddressTabs": _sized_tabs(locations, label)}
|
return {"emailAddressTabs": _sized_tabs(locations, label)}
|
||||||
elif content_type in ("COMPANY", "SIGNER_COMPANY"):
|
elif content_type in ("COMPANY", "SIGNER_COMPANY"):
|
||||||
# Auto-populated with the signer's company
|
|
||||||
return {"companyTabs": _sized_tabs(locations, label)}
|
return {"companyTabs": _sized_tabs(locations, label)}
|
||||||
elif content_type in ("TITLE", "SIGNER_TITLE"):
|
elif content_type in ("TITLE", "SIGNER_TITLE"):
|
||||||
# Auto-populated with the signer's title
|
|
||||||
return {"titleTabs": _sized_tabs(locations, label)}
|
return {"titleTabs": _sized_tabs(locations, label)}
|
||||||
elif content_type == "DATA" and validation == "DATE":
|
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})}
|
return {"dateTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})}
|
||||||
elif content_type == "DATA" and validation == "NUMBER":
|
elif content_type == "DATA" and validation == "NUMBER":
|
||||||
return {"numberTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})}
|
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})}
|
return {"textTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})}
|
||||||
|
|
||||||
elif input_type == "SIGNATURE":
|
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":
|
if content_type == "SIGNER_INITIALS":
|
||||||
return {"initialHereTabs": [_make_base_tab(loc, label) for loc in locations]}
|
return {"initialHereTabs": [_make_base_tab(loc, label) for loc in locations]}
|
||||||
else:
|
else:
|
||||||
return {"signHereTabs": [_make_base_tab(loc, label) for loc in locations]}
|
return {"signHereTabs": [_make_base_tab(loc, label) for loc in locations]}
|
||||||
|
|
||||||
elif input_type == "BLOCK" and content_type == "SIGNATURE_BLOCK":
|
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]}
|
return {"signHereTabs": [_make_base_tab(loc, label) for loc in locations]}
|
||||||
|
|
||||||
elif input_type == "DATE":
|
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})}
|
return {"listTabs": _sized_tabs(locations, label, {"required": required_str, "listItems": list_items})}
|
||||||
|
|
||||||
elif input_type == "RADIO":
|
elif input_type == "RADIO":
|
||||||
# Each location is one radio button within the group — not tab merging
|
|
||||||
options = field.get("hiddenOptions") or []
|
options = field.get("hiddenOptions") or []
|
||||||
radios = []
|
radios = []
|
||||||
for i, loc in enumerate(locations):
|
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}]}
|
return {"radioGroupTabs": [{"groupName": label, "documentId": DOCUMENT_ID, "radios": radios}]}
|
||||||
|
|
||||||
elif input_type == "FILE_CHOOSER":
|
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"})
|
tab = _make_base_tab(locations[0], label, {"optional": "true" if not field.get("required") else "false"})
|
||||||
return {"signerAttachmentTabs": [tab]}
|
return {"signerAttachmentTabs": [tab]}
|
||||||
|
|
||||||
elif input_type == "INLINE_IMAGE":
|
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 {}
|
return {}
|
||||||
|
|
||||||
elif input_type == "STAMP":
|
elif input_type == "STAMP":
|
||||||
# DocuSign stampTabs — signer uploads or selects a hanko/seal stamp image.
|
msg = (
|
||||||
# Requires the stamp feature to be enabled on the DocuSign account.
|
f"Field '{label}' is a STAMP — mapped to stampTabs. "
|
||||||
warnings.append(f"STAMP '{label}' → stampTabs (verify stamp feature is enabled on your DocuSign account)")
|
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]}
|
return {"stampTabs": [_make_base_tab(loc, label) for loc in locations]}
|
||||||
|
|
||||||
elif input_type == "PARTICIPATION_STAMP":
|
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 {}
|
return {}
|
||||||
|
|
||||||
else:
|
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 {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -325,11 +370,55 @@ def merge_tabs(acc: dict, new: dict) -> dict:
|
||||||
return acc
|
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
|
# 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.
|
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
|
output_path: where to write the resulting DocuSign template JSON
|
||||||
|
|
||||||
Returns:
|
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)
|
template_dir = Path(template_dir)
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
|
issues: list[dict] = []
|
||||||
|
|
||||||
# Load source files
|
# Load source files
|
||||||
metadata = json.loads((template_dir / "metadata.json").read_text())
|
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": {},
|
"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
|
# Assign tabs to the correct signer
|
||||||
for field in fields:
|
for field in fields:
|
||||||
assignee = field.get("assignee") or f"recipient{max(field.get('signerIndex', 0), 0)}"
|
assignee = field.get("assignee") or f"recipient{max(field.get('signerIndex', 0), 0)}"
|
||||||
idx = assignee_to_index(assignee, recipients)
|
idx = assignee_to_index(assignee, recipients)
|
||||||
if idx >= len(signers):
|
if idx >= len(signers):
|
||||||
idx = 0
|
idx = 0
|
||||||
tabs = build_tabs_for_field(field, warnings)
|
tabs = build_tabs_for_field(field, warnings, issues)
|
||||||
tabs = _apply_conditional_to_tabs(tabs, field, warnings)
|
tabs = _apply_conditional_to_tabs(tabs, field, warnings, issues, assignee, field_assignee)
|
||||||
signers[idx]["tabs"] = merge_tabs(signers[idx]["tabs"], tabs)
|
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 = {
|
template = {
|
||||||
"name": metadata.get("name", template_dir.name),
|
"name": metadata.get("name", template_dir.name),
|
||||||
"description": f"Migrated from Adobe Sign — original owner: {metadata.get('ownerEmail', '')}",
|
"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:
|
with open(output_path, "w") as f:
|
||||||
json.dump(template, f, indent=2)
|
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"
|
output_path = Path(__file__).parent.parent / "migration-output" / template_dir.name / "docusign-template.json"
|
||||||
print(f"\n--- {template_dir.name} ---")
|
print(f"\n--- {template_dir.name} ---")
|
||||||
try:
|
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}")
|
print(f" Written: {output_path}")
|
||||||
for w in warnings:
|
for w in warnings:
|
||||||
print(f" WARNING: {w}")
|
print(f" WARNING: {w}")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -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}"],
|
||||||
|
)
|
||||||
|
|
@ -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/<template-name>/ 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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 <token>"
|
||||||
|
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())
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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**
|
||||||
|
|
@ -142,7 +142,8 @@ def test_migrate_single_template_updates():
|
||||||
):
|
):
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/api/migrate",
|
"/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()},
|
cookies={_COOKIE_NAME: _full_session()},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -155,3 +155,77 @@ def test_status_needs_update():
|
||||||
resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()})
|
resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()})
|
||||||
t = resp.json()["templates"][0]
|
t = resp.json()["templates"][0]
|
||||||
assert t["status"] == "needs_update"
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -175,7 +175,8 @@ def test_full_migration_flow(temp_history):
|
||||||
):
|
):
|
||||||
migrate_resp2 = test_client.post(
|
migrate_resp2 = test_client.post(
|
||||||
"/api/migrate",
|
"/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},
|
cookies={_COOKIE_NAME: session_cookie},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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 != ""
|
||||||
|
|
@ -55,7 +55,7 @@ def test_compose_regression(template_name, update_snapshots):
|
||||||
output_path = tf.name
|
output_path = tf.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result, warnings = compose_template(template_dir, output_path)
|
result, warnings, _ = compose_template(template_dir, output_path)
|
||||||
|
|
||||||
if update_snapshots:
|
if update_snapshots:
|
||||||
os.makedirs(FIXTURES_DIR, exist_ok=True)
|
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:
|
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tf:
|
||||||
output_path = tf.name
|
output_path = tf.name
|
||||||
try:
|
try:
|
||||||
result, _ = compose_template(template_dir, output_path)
|
result, _, _issues = compose_template(template_dir, output_path)
|
||||||
total_tabs = sum(_count_tabs(result).values())
|
total_tabs = sum(_count_tabs(result).values())
|
||||||
assert total_tabs > 0, f"No tabs produced for {template_name}"
|
assert total_tabs > 0, f"No tabs produced for {template_name}"
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -15,7 +15,7 @@ from fastapi.responses import FileResponse
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from web.config import settings
|
from web.config import settings
|
||||||
from web.routers import auth, templates, migrate
|
from web.routers import auth, templates, migrate, verify
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Adobe Sign → DocuSign Migrator",
|
title="Adobe Sign → DocuSign Migrator",
|
||||||
|
|
@ -24,9 +24,10 @@ app = FastAPI(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Routers
|
# 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(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 files (frontend)
|
||||||
_static_dir = os.path.join(os.path.dirname(__file__), "static")
|
_static_dir = os.path.join(os.path.dirname(__file__), "static")
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ web/routers/migrate.py
|
||||||
----------------------
|
----------------------
|
||||||
Migration trigger and history endpoints.
|
Migration trigger and history endpoints.
|
||||||
|
|
||||||
POST /api/migrate — run the pipeline for one or more Adobe template IDs
|
POST /api/migrate — run the pipeline for one or more Adobe template IDs
|
||||||
GET /api/migrate/history — return past migration records
|
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
|
import asyncio
|
||||||
|
|
@ -12,8 +14,9 @@ import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
|
|
@ -23,7 +26,6 @@ from pydantic import BaseModel
|
||||||
from web.config import settings
|
from web.config import settings
|
||||||
from web.session import get_session
|
from web.session import get_session
|
||||||
|
|
||||||
# Ensure src/ is on path
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -32,9 +34,26 @@ _HISTORY_FILE = os.path.join(
|
||||||
os.path.dirname(__file__), "..", "..", "migration-output", ".history.json"
|
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):
|
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:
|
def _load_history() -> list:
|
||||||
|
|
@ -51,10 +70,7 @@ def _save_history(records: list) -> None:
|
||||||
|
|
||||||
|
|
||||||
def _load_compose():
|
def _load_compose():
|
||||||
"""
|
"""Dynamically load compose_template from src/."""
|
||||||
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.
|
|
||||||
"""
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
spec = importlib.util.spec_from_file_location(
|
spec = importlib.util.spec_from_file_location(
|
||||||
"compose_docusign_template",
|
"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
|
base = settings.adobe_sign_base_url
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
# Metadata
|
|
||||||
meta_resp = await client.get(f"{base}/libraryDocuments/{template_id}", headers=headers)
|
meta_resp = await client.get(f"{base}/libraryDocuments/{template_id}", headers=headers)
|
||||||
if not meta_resp.is_success:
|
if not meta_resp.is_success:
|
||||||
return False
|
return False
|
||||||
metadata = meta_resp.json()
|
metadata = meta_resp.json()
|
||||||
|
|
||||||
# Form fields
|
|
||||||
fields_resp = await client.get(f"{base}/libraryDocuments/{template_id}/formFields", headers=headers)
|
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": []}
|
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)
|
docs_resp = await client.get(f"{base}/libraryDocuments/{template_id}/documents", headers=headers)
|
||||||
documents = docs_resp.json() if docs_resp.is_success else {"documents": []}
|
documents = docs_resp.json() if docs_resp.is_success else {"documents": []}
|
||||||
|
|
||||||
# Download first PDF
|
|
||||||
doc_list = documents.get("documents", [])
|
doc_list = documents.get("documents", [])
|
||||||
pdf_bytes = b""
|
pdf_bytes = b""
|
||||||
if doc_list:
|
if doc_list:
|
||||||
|
|
@ -111,10 +123,27 @@ async def _download_adobe_template(template_id: str, access_token: str, output_d
|
||||||
return True
|
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(
|
async def _migrate_one(
|
||||||
adobe_id: str,
|
adobe_id: str,
|
||||||
adobe_access_token: str,
|
adobe_access_token: str,
|
||||||
docusign_access_token: str,
|
docusign_access_token: str,
|
||||||
|
options: MigrationOptions,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Run the full pipeline for one Adobe template. Returns a result record."""
|
"""Run the full pipeline for one Adobe template. Returns a result record."""
|
||||||
timestamp = datetime.now(timezone.utc).isoformat()
|
timestamp = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
@ -134,18 +163,42 @@ async def _migrate_one(
|
||||||
"action": None,
|
"action": None,
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"error": "Adobe Sign download 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:
|
with open(os.path.join(download_dir, "metadata.json")) as f:
|
||||||
metadata = json.load(f)
|
metadata = json.load(f)
|
||||||
template_name = metadata.get("name", adobe_id)
|
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")
|
composed_file = os.path.join(tmpdir, "docusign-template.json")
|
||||||
|
compose_issues: list = []
|
||||||
try:
|
try:
|
||||||
compose_fn = _load_compose()
|
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:
|
except Exception as exc:
|
||||||
return {
|
return {
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
|
|
@ -155,6 +208,10 @@ async def _migrate_one(
|
||||||
"action": None,
|
"action": None,
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"error": f"Compose failed: {exc}",
|
"error": f"Compose failed: {exc}",
|
||||||
|
"warnings": validation["warnings"],
|
||||||
|
"blockers": [],
|
||||||
|
"field_issues": [],
|
||||||
|
"dry_run": options.dry_run,
|
||||||
}
|
}
|
||||||
if not os.path.exists(composed_file):
|
if not os.path.exists(composed_file):
|
||||||
return {
|
return {
|
||||||
|
|
@ -165,12 +222,36 @@ async def _migrate_one(
|
||||||
"action": None,
|
"action": None,
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"error": "Compose produced no output file",
|
"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:
|
with open(composed_file) as f:
|
||||||
template_json = json.load(f)
|
template_json = json.load(f)
|
||||||
|
|
||||||
|
if not options.include_documents:
|
||||||
|
for doc in template_json.get("documents", []):
|
||||||
|
doc.pop("documentBase64", None)
|
||||||
|
|
||||||
ds_headers = {
|
ds_headers = {
|
||||||
"Authorization": f"Bearer {docusign_access_token}",
|
"Authorization": f"Bearer {docusign_access_token}",
|
||||||
"Content-Type": "application/json",
|
"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"
|
list_url = f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/templates"
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
# Find existing
|
# Duplicate detection
|
||||||
list_resp = await client.get(
|
list_resp = await client.get(
|
||||||
list_url, headers=ds_headers, params={"search_text": template_name, "count": 100}
|
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)
|
exact.sort(key=lambda t: t.get("lastModified", ""), reverse=True)
|
||||||
existing_id = exact[0]["templateId"]
|
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:
|
if existing_id:
|
||||||
up_resp = await client.put(
|
up_resp = await client.put(
|
||||||
f"{list_url}/{existing_id}", headers=ds_headers, json=template_json
|
f"{list_url}/{existing_id}", headers=ds_headers, json=template_json
|
||||||
|
|
@ -211,6 +308,10 @@ async def _migrate_one(
|
||||||
"action": None,
|
"action": None,
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"error": f"DocuSign upload failed ({up_resp.status_code}): {up_resp.text[:200]}",
|
"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 {
|
return {
|
||||||
|
|
@ -221,6 +322,10 @@ async def _migrate_one(
|
||||||
"action": action,
|
"action": action,
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"error": None,
|
"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"):
|
if not session.get("docusign_access_token"):
|
||||||
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
|
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 = [
|
tasks = [
|
||||||
_migrate_one(
|
_migrate_one(
|
||||||
aid,
|
aid,
|
||||||
session["adobe_access_token"],
|
session["adobe_access_token"],
|
||||||
session["docusign_access_token"],
|
session["docusign_access_token"],
|
||||||
|
body.options,
|
||||||
)
|
)
|
||||||
for aid in body.adobe_template_ids
|
for aid in ids
|
||||||
]
|
]
|
||||||
results = await asyncio.gather(*tasks)
|
results = await asyncio.gather(*tasks)
|
||||||
|
|
||||||
# Append to history
|
|
||||||
history = _load_history()
|
history = _load_history()
|
||||||
history.extend(results)
|
history.extend(results)
|
||||||
_save_history(history)
|
_save_history(history)
|
||||||
|
|
@ -255,3 +364,101 @@ async def run_migration(body: MigrateRequest, request: Request):
|
||||||
def migration_history():
|
def migration_history():
|
||||||
"""Return all past migration records."""
|
"""Return all past migration records."""
|
||||||
return {"history": _load_history()}
|
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
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ Computes per-template migration status for the side-by-side UI.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -151,6 +152,8 @@ async def template_status(request: Request):
|
||||||
# needs_update if Adobe was modified after the DS template
|
# needs_update if Adobe was modified after the DS template
|
||||||
status = "needs_update" if adobe_modified > ds_modified else "migrated"
|
status = "needs_update" if adobe_modified > ds_modified else "migrated"
|
||||||
|
|
||||||
|
blockers, warnings = _get_validation(t.get("id", ""), name)
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
"adobe_id": t.get("id"),
|
"adobe_id": t.get("id"),
|
||||||
"name": name,
|
"name": name,
|
||||||
|
|
@ -158,10 +161,36 @@ async def template_status(request: Request):
|
||||||
"docusign_id": ds_match.get("templateId") if ds_match else None,
|
"docusign_id": ds_match.get("templateId") if ds_match else None,
|
||||||
"docusign_modified": ds_match.get("lastModified") if ds_match else None,
|
"docusign_modified": ds_match.get("lastModified") if ds_match else None,
|
||||||
"status": status,
|
"status": status,
|
||||||
|
"blockers": blockers,
|
||||||
|
"warnings": warnings,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {"templates": results}
|
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
|
# asyncio needed for gather — import at top of module
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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 = `
|
|
||||||
<div class="dialog-backdrop"></div>
|
|
||||||
<div class="dialog-box">
|
|
||||||
<h2>Connect Adobe Sign</h2>
|
|
||||||
<ol>
|
|
||||||
<li><a href="${escHtml(authUrl)}" target="_blank" rel="noopener" id="adobe-auth-link">Click here to authorize in Adobe Sign</a></li>
|
|
||||||
<li>After authorizing, your browser will show a page that fails to load — that's expected.</li>
|
|
||||||
<li>Copy the full URL from the address bar and paste it below.</li>
|
|
||||||
</ol>
|
|
||||||
<input type="text" id="adobe-redirect-input" placeholder="https://localhost:8080/callback?code=…" />
|
|
||||||
<div class="dialog-error" id="dialog-error"></div>
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<button id="btn-submit-code">Connect</button>
|
|
||||||
<button id="btn-cancel-dialog" class="btn-secondary">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
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 = '<li class="empty-msg">No templates found.</li>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ul.innerHTML = items.map(t => `
|
|
||||||
<li class="template-item" data-id="${t.adobe_id}">
|
|
||||||
<input type="checkbox" data-id="${t.adobe_id}" />
|
|
||||||
<span class="template-name">${escHtml(t.name)}</span>
|
|
||||||
<span class="badge badge-${t.status}">${statusLabel(t.status)}</span>
|
|
||||||
<span class="template-spinner" id="spin-${t.adobe_id}"></span>
|
|
||||||
</li>
|
|
||||||
`).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 = '<li class="empty-msg">No templates found.</li>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ul.innerHTML = items.map(t => `
|
|
||||||
<li class="template-item">
|
|
||||||
<span class="template-name">${escHtml(t.name)}</span>
|
|
||||||
<span style="font-size:11px;color:#999">${(t.lastModified || '').slice(0, 10)}</span>
|
|
||||||
</li>
|
|
||||||
`).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 = '<tr><td colspan="5" class="empty-msg">No migrations yet.</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tbody.innerHTML = [...records].reverse().slice(0, 50).map(r => `
|
|
||||||
<tr>
|
|
||||||
<td>${(r.timestamp || '').replace('T', ' ').slice(0, 19)}</td>
|
|
||||||
<td>${escHtml(r.adobe_template_name || r.adobe_template_id || '')}</td>
|
|
||||||
<td>${escHtml(r.docusign_template_id || '—')}</td>
|
|
||||||
<td>${escHtml(r.action || '—')}</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge ${r.status === 'success' ? 'badge-migrated' : 'badge-not_migrated'}">
|
|
||||||
${r.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).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, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
@ -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); }
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -3,77 +3,164 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Adobe Sign → DocuSign Migrator</title>
|
<title>docusign — Template Migration Console</title>
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
<link rel="stylesheet" href="/static/css/tokens.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/base.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/nav.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/cards.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/modals.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/tables.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/forms.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<header>
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
<h1>Adobe Sign → DocuSign Migrator</h1>
|
LEFT NAVIGATION
|
||||||
<div id="auth-bar">
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
<span id="badge-adobe" class="auth-badge">Connect Adobe Sign</span>
|
<nav id="app-nav">
|
||||||
<span id="badge-docusign" class="auth-badge">Connect DocuSign</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
<!-- Logo + project switcher -->
|
||||||
|
<div id="nav-logo">
|
||||||
|
<svg viewBox="0 0 1200 241.4" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style="width:148px;height:auto;display:block;">
|
||||||
|
<style>.st0{fill:#4C00FF;}.st1{fill:#FF5252;}</style>
|
||||||
|
<g fill="#FFFFFF">
|
||||||
|
<g>
|
||||||
|
<path d="M1169.2,109.7v78.7h-28.9v-73.5c0-17.9-7.7-27.9-22.7-27.9s-24.9,10.5-27.7,28.1c-0.8,4.2-1,10.7-1,24.4v48.8H1060v-125h25.6c0.1,1.1,0.7,12.3,0.7,13c0,0.9,1.1,1.4,1.8,0.8c10.6-8.4,22.3-16.2,38.6-16.2C1153.5,60.9,1169.2,79,1169.2,109.7z"/>
|
||||||
|
<path d="M1013.4,63.4l-0.9,14.3c-0.1,0.9-1.2,1.4-1.8,0.8c-3.5-3.3-16.4-17.5-38.3-17.5c-31.4,0-54.5,27.1-54.5,63.9l0,0c0,37.3,22.9,64.5,54.5,64.5c21.1,0,34-13.7,36.4-16.7c0.7-0.8,2-0.3,2,0.7c-0.3,3.8-0.8,13.3-4,21.4c-4,10.2-13,19.7-31.1,19.7c-14.9,0-28.1-5.7-40.6-17.9L920,217.3c13.7,15.5,35.3,24.2,58.8,24.2c37.8,0,60.5-25.9,60.5-68.2V63.4H1013.4z M978.6,163.2c-18.7,0-31.9-16.2-31.9-38.3S959.9,87,978.6,87c18.7,0,31.9,15.7,31.9,37.9C1010.4,147.1,997.2,163.2,978.6,163.2z"/>
|
||||||
|
<path d="M857.5,151.3c0,23.7-19.9,39.6-49.1,39.6c-22.9,0-43.3-8.9-55.5-21.6l0,0l0,0l9.5-22.6c9.2,8.3,24,20.2,45.1,20.2c14.7,0,23.2-6.5,23.2-14.7c0-9.5-11.7-12-25.7-14.7c-19.9-4.2-46.3-11-46.3-38.1c0-22.7,18.4-38.3,45.6-38.3c20.9,0,38.9,8,51.3,18.4l-14.2,19.9c-12-9.5-24.6-14.2-37.1-14.2s-18.7,5.2-18.7,12.7c0,10.5,13.5,13.2,23.4,15.2C833.9,117.9,857.5,125.4,857.5,151.3z"/>
|
||||||
|
<path d="M434.9,60.9c-35.3,0-60.7,27.4-60.7,65s25.4,65,60.7,65s60.8-27.4,60.8-65S470.3,60.9,434.9,60.9z M434.9,164.7c-18.7,0-31.9-15.9-31.9-38.9c0-22.9,12.9-38.9,31.9-38.9c18.9,0,31.9,15.9,31.9,38.9S453.6,164.7,434.9,164.7z"/>
|
||||||
|
<path d="M505.9,125.9c0-37.1,25.4-65,59.3-65c26.9,0,46.6,13.5,55.8,38.9l-25.6,9.7c-7-15.7-16.2-22.4-30.1-22.4c-17.4,0-30.4,16.4-30.4,38.9c0,22.4,12.9,38.9,30.4,38.9c14,0,23.1-6.7,30.1-22.4l25.6,9.7c-9.2,25.4-28.9,38.9-55.8,38.9C531.3,190.9,505.9,163,505.9,125.9z"/>
|
||||||
|
<path d="M351.4,5.3c-0.5,0-1.1,0.1-1.6,0.4l-18.8,10c-0.4,0.2-0.6,0.6-0.6,1v59.5c0,1-1.2,1.4-1.9,0.8c-2.8-2.4-9.3-8.5-18.3-12.7c-4.7-2.2-11.6-3.4-17.9-3.4c-31.6,0-54.5,27.4-54.5,65s22.9,65,54.5,65c16.6,0,29.1-8.7,36.7-16.5c0.5-0.5,0.8-0.8,1.3-1.3c0.7-0.7,1.9-0.3,1.9,0.7l1,14.6h26.1V6.1c0-0.4-0.3-0.8-0.8-0.8C358.5,5.3,351.4,5.3,351.4,5.3z M298.5,164.7c-18.9,0-31.9-15.9-31.9-38.9S279.9,87,298.5,87c18.7,0,31.9,15.9,31.9,38.9C330.4,148.8,317.5,164.7,298.5,164.7z"/>
|
||||||
|
<path d="M891.5,63.8l-18.1,9.6c-0.4,0.2-0.6,0.6-0.6,1v114h28.9V64.1c0-0.4-0.3-0.8-0.8-0.8h-7.8C892.5,63.4,892,63.5,891.5,63.8z"/>
|
||||||
|
<path d="M887.2,43.1c9.6,0,17.4-7.8,17.4-17.4s-7.8-17.4-17.4-17.4c-9.6,0-17.4,7.8-17.4,17.4S877.6,43.1,887.2,43.1z"/>
|
||||||
|
<path d="M742.5,63.3v67.9c0,51.5-28.8,59.6-54.5,59.6s-54.5-8.2-54.5-59.6V63.3h28.8v75.1c0,7.3,1.8,26.3,25.7,26.3s25.7-18.9,25.7-26.3V63.3H742.5z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#FFFFFF">
|
||||||
|
<path d="M1185.7,175.6v1.8h-4.1v10.9h-2v-10.9h-4.1v-1.8H1185.7z M1200,188.3h-2v-10l-3.9,7.5h-1.1l-3.9-7.4v9.9h-2v-12.7h2.6l3.8,7.3l3.8-7.3h2.6L1200,188.3z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path class="st0" d="M139.5,139.5V189c0,2.6-2.1,4.7-4.7,4.7H4.7c-2.6,0-4.7-2.1-4.7-4.7V59c0-2.6,2.1-4.7,4.7-4.7h49.4v80.5c0,2.6,2.1,4.7,4.7,4.7H139.5z"/>
|
||||||
|
<path class="st1" d="M193.7,69.7c0,41.6-24.3,69.7-54.2,69.8V87.1c0-1.5-0.6-3-1.7-4l-27.2-27.2c-1.1-1.1-2.5-1.7-4-1.7H54.2V4.8c0-2.6,2.1-4.7,4.7-4.7h73.3C167,0,193.7,28,193.7,69.7z"/>
|
||||||
|
<path fill="#FFFFFF" d="M137.8,83c1.1,1.1,1.7,2.5,1.7,4v52.4H58.9c-2.6,0-4.7-2.1-4.7-4.7V54.2h52.4c1.5,0,3,0.6,4,1.7L137.8,83z"/>
|
||||||
|
</svg>
|
||||||
|
<div class="nav-logo-sub">Template Migration Console</div>
|
||||||
|
|
||||||
<!-- Action bar -->
|
<!-- Project switcher button -->
|
||||||
<div class="action-bar">
|
<button id="nav-project-switcher" aria-label="Switch project">
|
||||||
<button id="btn-migrate" disabled>Migrate Selected</button>
|
<div class="project-icon" id="nav-project-icon">?</div>
|
||||||
<button id="btn-refresh">↻ Refresh</button>
|
<div class="project-name no-project" id="nav-project-name">New Project</div>
|
||||||
<span id="status-msg">Loading…</span>
|
<div class="project-arrow">⇅</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Side-by-side panels -->
|
<!-- Nav links -->
|
||||||
<div class="panel-row">
|
<ul id="nav-links">
|
||||||
|
<li class="nav-section-label">Migration</li>
|
||||||
|
<li>
|
||||||
|
<a class="nav-item" data-route="#/templates" href="#/templates">
|
||||||
|
<span class="nav-icon">☰</span>
|
||||||
|
<span class="nav-label">Templates</span>
|
||||||
|
<span class="nav-badge amber" id="nav-badge-caveats" data-count="0">0</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="nav-item" data-route="#/results" href="#/results">
|
||||||
|
<span class="nav-icon">⬡</span>
|
||||||
|
<span class="nav-label">Migration Results</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="nav-item" data-route="#/issues" href="#/issues">
|
||||||
|
<span class="nav-icon">⚠</span>
|
||||||
|
<span class="nav-label">Issues & Warnings</span>
|
||||||
|
<span class="nav-badge" id="nav-badge-issues" data-count="0">0</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
<div class="panel">
|
<li class="nav-section-label">Post-Migration</li>
|
||||||
<div class="panel-header">
|
<li>
|
||||||
<span>Adobe Sign Templates</span>
|
<a class="nav-item" data-route="#/verify" href="#/verify">
|
||||||
<span style="font-weight:400;font-size:12px;color:#888">Select to migrate →</span>
|
<span class="nav-icon">✓</span>
|
||||||
</div>
|
<span class="nav-label">Verification</span>
|
||||||
<div class="panel-body">
|
</a>
|
||||||
<ul class="template-list" id="adobe-list">
|
</li>
|
||||||
<li class="empty-msg">Loading…</li>
|
<li>
|
||||||
</ul>
|
<a class="nav-item" data-route="#/history" href="#/history">
|
||||||
</div>
|
<span class="nav-icon">◷</span>
|
||||||
|
<span class="nav-label">History & Audit</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-section-label">Admin</li>
|
||||||
|
<li>
|
||||||
|
<a class="nav-item" data-route="#/settings" href="#/settings">
|
||||||
|
<span class="nav-icon">⚙</span>
|
||||||
|
<span class="nav-label">Settings</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Bottom: customer context -->
|
||||||
|
<div id="nav-bottom">
|
||||||
|
<div class="nav-customer">
|
||||||
|
<div class="nav-customer-label">Current Project</div>
|
||||||
|
<div class="nav-customer-name" id="nav-customer-name">—</div>
|
||||||
|
<div class="nav-customer-sub" id="nav-customer-sub"></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
</nav>
|
||||||
<div class="panel-header">
|
|
||||||
<span>DocuSign Templates</span>
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
</div>
|
MAIN CONTENT AREA
|
||||||
<div class="panel-body">
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
<ul class="template-list" id="ds-list">
|
<div id="app-body">
|
||||||
<li class="empty-msg">Loading…</li>
|
|
||||||
</ul>
|
<!-- Top bar -->
|
||||||
</div>
|
<header id="top-bar">
|
||||||
|
<nav class="breadcrumb" aria-label="breadcrumb">
|
||||||
|
<span>Migration Console</span>
|
||||||
|
<span class="sep">›</span>
|
||||||
|
<span class="current" id="breadcrumb-current">Templates</span>
|
||||||
|
</nav>
|
||||||
|
<div id="topbar-right">
|
||||||
|
<!-- Auth connection chips -->
|
||||||
|
<button id="chip-adobe" class="conn-pill disconnected" aria-label="Adobe Sign connection">
|
||||||
|
<span class="conn-dot"></span>Adobe Sign
|
||||||
|
</button>
|
||||||
|
<button id="chip-docusign" class="conn-pill disconnected" aria-label="Docusign connection">
|
||||||
|
<span class="conn-dot"></span>Docusign
|
||||||
|
</button>
|
||||||
|
<!-- User avatar -->
|
||||||
|
<div class="avatar" title="Logged in" aria-label="User">M</div>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
</div>
|
<!-- Router outlet — views injected here -->
|
||||||
|
<main id="router-outlet">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">⏳</div>
|
||||||
|
<div class="empty-state-title">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
<!-- Migration history -->
|
</div>
|
||||||
<div class="history-section">
|
|
||||||
<div class="panel-header">Migration History</div>
|
|
||||||
<table class="history-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Time</th>
|
|
||||||
<th>Adobe Template</th>
|
|
||||||
<th>DocuSign Template ID</th>
|
|
||||||
<th>Action</th>
|
|
||||||
<th>Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="history-tbody">
|
|
||||||
<tr><td colspan="5" class="empty-msg">No migrations yet.</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</main>
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
MODAL OVERLAY (shared, managed by modals.js)
|
||||||
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<div id="modal-root"></div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
TOAST CONTAINER (managed by auth.js)
|
||||||
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<div id="toast-container"></div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
APP ENTRY POINT
|
||||||
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<script type="module" src="/static/js/app.js"></script>
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
|
@ -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 = `<span class="conn-dot"></span>${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 = `<span class="conn-dot"></span><span class="spinner spinner-sm"></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 = `
|
||||||
|
<div class="modal-backdrop"></div>
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title">Connect Adobe Sign</span>
|
||||||
|
<button class="btn btn-ghost btn-icon" id="adobe-dialog-close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<ol style="padding-left:18px;line-height:1.8;margin-bottom:14px;font-size:13px">
|
||||||
|
<li><a href="${escHtml(url)}" target="_blank" rel="noopener" style="color:var(--cobalt)">Click here to authorize in Adobe Sign ↗</a></li>
|
||||||
|
<li>After authorizing, your browser will show a page that fails to load — that's expected.</li>
|
||||||
|
<li>Copy the full URL from the address bar and paste it below.</li>
|
||||||
|
</ol>
|
||||||
|
<input type="text" id="adobe-redirect-input" class="form-input"
|
||||||
|
placeholder="https://localhost:8080/callback?code=…" />
|
||||||
|
<div id="adobe-dialog-error" style="color:var(--error);font-size:12px;min-height:18px;margin-top:6px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="adobe-dialog-cancel">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="adobe-dialog-submit">Connect</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -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 = `<div class="empty-state"><div class="spinner"></div></div>`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.migrate.history();
|
||||||
|
_allRecords = (data.history || []).reverse(); // newest first
|
||||||
|
} catch (e) {
|
||||||
|
outlet.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load history: ${escHtml(e.message)}</div>`;
|
||||||
|
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 = `
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="page-title">History & Audit</div>
|
||||||
|
<div class="page-subtitle">${_allRecords.length} total migration records</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-actions">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="btn-export-history">⬇ Export CSV</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter bar -->
|
||||||
|
<div class="filter-bar">
|
||||||
|
<input type="search" class="search-input" id="hist-search"
|
||||||
|
placeholder="Search by template name…" value="${escHtml(_filter.search)}" />
|
||||||
|
<div class="filter-tabs">
|
||||||
|
<button class="filter-tab ${_filter.status === 'all' ? 'active' : ''}" data-status="all">All</button>
|
||||||
|
<button class="filter-tab ${_filter.status === 'success' ? 'active' : ''}" data-status="success">Success</button>
|
||||||
|
<button class="filter-tab ${_filter.status === 'error' ? 'active' : ''}" data-status="error">Errors</button>
|
||||||
|
<button class="filter-tab ${_filter.status === 'dry_run' ? 'active' : ''}" data-status="dry_run">Dry Run</button>
|
||||||
|
<button class="filter-tab ${_filter.status === 'skipped' ? 'active' : ''}" data-status="skipped">Skipped</button>
|
||||||
|
</div>
|
||||||
|
<div class="date-filter">
|
||||||
|
<label style="font-size:11px;color:var(--text-muted)">From:</label>
|
||||||
|
<input type="date" class="date-input" id="hist-from" value="${_filter.from}" />
|
||||||
|
<label style="font-size:11px;color:var(--text-muted)">To:</label>
|
||||||
|
<input type="date" class="date-input" id="hist-to" value="${_filter.to}" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${filtered.length === 0 ? `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">📋</div>
|
||||||
|
<div class="empty-state-title">${_allRecords.length ? 'No records match your filter' : 'No migration history yet'}</div>
|
||||||
|
<div class="empty-state-sub">${_allRecords.length ? 'Try clearing the search or filters.' : 'Run a migration to see history here.'}</div>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
${_th('timestamp', 'Time')}
|
||||||
|
${_th('adobe_template_name', 'Template')}
|
||||||
|
${_th('action', 'Action')}
|
||||||
|
${_th('status', 'Status')}
|
||||||
|
<th>Docusign ID</th>
|
||||||
|
<th>Checksum</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${page.map(r => _historyRow(r)).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${totalPages > 1 ? `
|
||||||
|
<div class="pagination">
|
||||||
|
<span>Showing ${_page * PAGE_SIZE + 1}–${Math.min((_page + 1) * PAGE_SIZE, filtered.length)} of ${filtered.length}</span>
|
||||||
|
<div class="pagination-pages">
|
||||||
|
<button class="page-btn" id="pg-prev" ${_page === 0 ? 'disabled' : ''}>‹ Prev</button>
|
||||||
|
<button class="page-btn" id="pg-next" ${_page >= totalPages - 1 ? 'disabled' : ''}>Next ›</button>
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
_bindEvents(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _th(col, label) {
|
||||||
|
const dir = _sort.col === col ? (_sort.dir === 'asc' ? 'sort-asc' : 'sort-desc') : '';
|
||||||
|
return `<th class="sortable ${dir}" data-col="${col}">${label}</th>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _historyRow(r) {
|
||||||
|
const hasIssues = (r.field_issues || []).length > 0;
|
||||||
|
const statusBadge = r.status === 'success'
|
||||||
|
? `<span class="badge badge-green">${escHtml(r.action || 'success')}</span>${hasIssues ? '<span class="badge badge-amber">partial</span>' : ''}`
|
||||||
|
: `<span class="badge badge-${r.status === 'skipped' ? 'gray' : r.status === 'dry_run' ? 'gray' : 'red'}">${escHtml(r.status || '—')}</span>`;
|
||||||
|
|
||||||
|
const checksum = r.checksum_sha256 || r.checksum || '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr class="row-expandable" data-expanded="false">
|
||||||
|
<td style="white-space:nowrap;font-size:12px">${(r.timestamp||'').slice(0,19).replace('T',' ')}</td>
|
||||||
|
<td>
|
||||||
|
<div class="table-name">${escHtml(r.adobe_template_name || r.adobe_template_id || '—')}</div>
|
||||||
|
<div class="table-sub">${escHtml(r.adobe_template_id || '')}</div>
|
||||||
|
</td>
|
||||||
|
<td>${escHtml(r.action || '—')}</td>
|
||||||
|
<td>${statusBadge}</td>
|
||||||
|
<td class="mono" style="font-size:11px">${r.docusign_template_id ? escHtml(r.docusign_template_id.slice(0,12)) + '…' : '—'}</td>
|
||||||
|
<td>
|
||||||
|
${checksum
|
||||||
|
? `<span class="checksum" title="${escHtml(checksum)}">${escHtml(shortHash(checksum))}</span>`
|
||||||
|
: '<span style="color:var(--text-muted)">—</span>'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
${(r.blockers || r.warnings || r.error || (r.field_issues||[]).length) ? `
|
||||||
|
<tr class="row-expanded-content" style="display:none">
|
||||||
|
<td colspan="6">
|
||||||
|
<div class="row-expand-body">
|
||||||
|
${(r.blockers||[]).map(b => `<div style="color:var(--error);font-size:12px">🚫 ${escHtml(b)}</div>`).join('')}
|
||||||
|
${(r.warnings||[]).map(w => `<div style="color:var(--warning);font-size:12px">⚠ ${escHtml(w)}</div>`).join('')}
|
||||||
|
${r.error ? `<div style="color:var(--error);font-size:12px">❌ ${escHtml(r.error)}</div>` : ''}
|
||||||
|
${renderFieldIssues(r.field_issues)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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('; '),
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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 = `
|
||||||
|
<div class="page-header"><div><div class="page-title">Issues & Warnings</div></div></div>
|
||||||
|
<div class="callout info"><span class="callout-icon">ℹ️</span>Connect both platforms to see validation results.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!blocked.length && !warnings.length) {
|
||||||
|
outlet.innerHTML = `
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="page-title">Issues & Warnings</div>
|
||||||
|
<div class="page-subtitle">${templates.length} templates analyzed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="callout success" style="font-size:14px">
|
||||||
|
<span class="callout-icon">🎉</span>
|
||||||
|
<div>
|
||||||
|
<strong>All templates are ready!</strong>
|
||||||
|
<div style="margin-top:4px">No blockers or warnings found across ${templates.length} template${templates.length !== 1 ? 's' : ''}.</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
outlet.innerHTML = `
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="page-title">Issues & Warnings</div>
|
||||||
|
<div class="page-subtitle">${templates.length} templates analyzed —
|
||||||
|
${blocked.length ? `<span style="color:var(--error);font-weight:600">${blocked.length} blocked</span>` : ''}
|
||||||
|
${blocked.length && warnings.length ? ', ' : ''}
|
||||||
|
${warnings.length ? `<span style="color:var(--warning);font-weight:600">${warnings.length} with warnings</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-actions">
|
||||||
|
<a href="#/templates" class="btn btn-secondary btn-sm">← All Templates</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${blocked.length ? `
|
||||||
|
<div style="margin-bottom:24px">
|
||||||
|
<div style="font-size:14px;font-weight:700;color:var(--error);margin-bottom:10px">
|
||||||
|
🚫 Blockers — ${blocked.length} template${blocked.length > 1 ? 's' : ''} will fail migration
|
||||||
|
</div>
|
||||||
|
<div class="attention-list">
|
||||||
|
${blocked.map(t => _blockerItem(t)).join('')}
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
|
|
||||||
|
${warnings.length ? `
|
||||||
|
<div>
|
||||||
|
<div style="font-size:14px;font-weight:700;color:var(--warning);margin-bottom:10px">
|
||||||
|
⚠ Warnings — ${warnings.length} template${warnings.length > 1 ? 's' : ''} will migrate with caveats
|
||||||
|
</div>
|
||||||
|
<div class="attention-list">
|
||||||
|
${warnings.map(t => _warningItem(t)).join('')}
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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 `
|
||||||
|
<div class="attention-item blocker">
|
||||||
|
<span class="attention-icon">🚫</span>
|
||||||
|
<div style="flex:1">
|
||||||
|
<div class="attention-name">${escHtml(t.name)}</div>
|
||||||
|
${blockers.map(b => `<div class="attention-detail">• ${escHtml(b)}</div>`).join('')}
|
||||||
|
<div style="margin-top:6px;font-size:11px;color:var(--text-muted)">Modified ${formatDate(t.adobe_modified)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="attention-action" style="display:flex;flex-direction:column;gap:6px;align-items:flex-end">
|
||||||
|
<button class="btn btn-secondary btn-xs btn-view-template" data-id="${escHtml(t.adobe_id)}">View Detail</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _warningItem(t) {
|
||||||
|
const warnings = t.warnings || [];
|
||||||
|
return `
|
||||||
|
<div class="attention-item warning">
|
||||||
|
<span class="attention-icon">⚠️</span>
|
||||||
|
<div style="flex:1">
|
||||||
|
<div class="attention-name">${escHtml(t.name)}</div>
|
||||||
|
${warnings.slice(0, 3).map(w => `<div class="attention-detail">• ${escHtml(w)}</div>`).join('')}
|
||||||
|
${warnings.length > 3 ? `<div class="attention-detail" style="color:var(--text-muted)">… +${warnings.length - 3} more</div>` : ''}
|
||||||
|
<div style="margin-top:6px;font-size:11px;color:var(--text-muted)">Modified ${formatDate(t.adobe_modified)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="attention-action" style="display:flex;flex-direction:column;gap:6px;align-items:flex-end">
|
||||||
|
<button class="btn btn-primary btn-xs btn-migrate-anyway" data-id="${escHtml(t.adobe_id)}">Migrate Anyway</button>
|
||||||
|
<button class="btn btn-secondary btn-xs btn-view-template" data-id="${escHtml(t.adobe_id)}">View Detail</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
@ -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 = `
|
||||||
|
<div class="modal-backdrop"></div>
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title">Migration Options</span>
|
||||||
|
<button class="modal-close" id="mm-close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="callout info" style="margin-bottom:16px">
|
||||||
|
<span class="callout-icon">📋</span>
|
||||||
|
<span>Migrating <strong>${ids.length}</strong> template${ids.length > 1 ? 's' : ''}:
|
||||||
|
${names.slice(0, 3).map(n => `<em>${escHtml(n)}</em>`).join(', ')}${names.length > 3 ? `… +${names.length - 3} more` : ''}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="options-panel">
|
||||||
|
<div class="options-title">Options</div>
|
||||||
|
|
||||||
|
<div class="option-row">
|
||||||
|
<button class="toggle" id="opt-dry-run" role="switch" aria-checked="false"></button>
|
||||||
|
<div class="option-body">
|
||||||
|
<div class="option-label">Dry Run</div>
|
||||||
|
<div class="option-desc">Validate and preview without creating Docusign templates</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option-row">
|
||||||
|
<button class="toggle" id="opt-overwrite" role="switch"
|
||||||
|
aria-checked="${settings.defaultOverwrite ? 'true' : 'false'}"
|
||||||
|
class="toggle ${settings.defaultOverwrite ? 'on' : ''}"></button>
|
||||||
|
<div class="option-body">
|
||||||
|
<div class="option-label">Overwrite Existing</div>
|
||||||
|
<div class="option-desc">Update Docusign templates that already exist (default: skip)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option-row">
|
||||||
|
<button class="toggle on" id="opt-include-docs" role="switch" aria-checked="true"></button>
|
||||||
|
<div class="option-body">
|
||||||
|
<div class="option-label">Include Documents</div>
|
||||||
|
<div class="option-desc">Embed PDFs in the Docusign template payload</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option-row" style="align-items:center">
|
||||||
|
<div class="option-body">
|
||||||
|
<div class="option-label">Target Folder <span class="form-label-sub">(optional)</span></div>
|
||||||
|
<input type="text" class="form-input" id="opt-folder"
|
||||||
|
placeholder="e.g. Migrated Templates" style="margin-top:6px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="mm-cancel">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="mm-run">Run Migration →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<div class="migration-progress">
|
||||||
|
<div class="progress-wrap">
|
||||||
|
<div class="progress-label">
|
||||||
|
<span id="prog-label">Starting…</span>
|
||||||
|
<span id="prog-count">0 / ${ids.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar"><div class="progress-fill" id="prog-bar" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-template-list" id="prog-list">
|
||||||
|
${names.map(n => `
|
||||||
|
<div class="progress-template-row" id="prog-row-${escHtml(n.id)}">
|
||||||
|
<div class="progress-template-name">${escHtml(n.name)}</div>
|
||||||
|
<span class="progress-template-status spinner spinner-sm"></span>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<button class="btn btn-secondary" id="mm-close-done">Close</button>
|
||||||
|
<button class="btn btn-primary" id="mm-view-results">View Results →</button>
|
||||||
|
`;
|
||||||
|
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 += `<div class="callout error" style="margin-top:12px">
|
||||||
|
<span class="callout-icon">❌</span> Migration failed: ${escHtml(err.message)}
|
||||||
|
</div>`;
|
||||||
|
footer.innerHTML = `<button class="btn btn-secondary" id="mm-err-close">Close</button>`;
|
||||||
|
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 = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">📊</div>
|
||||||
|
<div class="empty-state-title">No migration results yet</div>
|
||||||
|
<div class="empty-state-sub">Run a migration from the <a href="#/templates" style="color:var(--cobalt)">Templates</a> view to see results here.</div>
|
||||||
|
</div>`;
|
||||||
|
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 = `
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="page-title">Migration Results</div>
|
||||||
|
<div class="page-subtitle">${formatDateTime(results.completed_at || new Date().toISOString())}</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-actions">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="btn-export-results">⬇ Export CSV</button>
|
||||||
|
${migratedIds.length ? `<button class="btn btn-primary btn-sm" id="btn-verify-results">Verify Templates →</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary stat cards -->
|
||||||
|
<div class="stat-grid" style="grid-template-columns:repeat(${summary.dry_run ? 6 : 5},1fr)">
|
||||||
|
<div class="stat-card green">
|
||||||
|
<div class="stat-label">Created</div>
|
||||||
|
<div class="stat-value">${summary.created}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card blue">
|
||||||
|
<div class="stat-label">Updated</div>
|
||||||
|
<div class="stat-value">${summary.updated}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card gray">
|
||||||
|
<div class="stat-label">Skipped</div>
|
||||||
|
<div class="stat-value">${summary.skipped}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card red">
|
||||||
|
<div class="stat-label">Blocked</div>
|
||||||
|
<div class="stat-value">${summary.blocked}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card red">
|
||||||
|
<div class="stat-label">Errors</div>
|
||||||
|
<div class="stat-value">${summary.errors}</div>
|
||||||
|
</div>
|
||||||
|
${summary.dry_run ? `<div class="stat-card gray"><div class="stat-label">Dry Run</div><div class="stat-value">${summary.dry_run}</div></div>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Per-template results -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">Per-Template Results</span>
|
||||||
|
</div>
|
||||||
|
<div id="results-list" style="padding:8px 16px">
|
||||||
|
${templateResults.map(r => _resultRow(r)).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:8px;margin-top:8px">
|
||||||
|
<a href="#/templates" class="btn btn-secondary btn-sm">← Back to Templates</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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'
|
||||||
|
? `<span class="badge badge-green">${r.action || 'success'}</span>${issues.length ? '<span class="badge badge-amber">partial</span>' : ''}`
|
||||||
|
: `<span class="badge badge-${r.status === 'skipped' ? 'gray' : 'red'}">${r.status}</span>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="result-row">
|
||||||
|
<div class="result-header">
|
||||||
|
<span class="result-icon">${icon}</span>
|
||||||
|
<span class="result-name">${escHtml(r.adobe_template_name || r.adobe_template_id)}</span>
|
||||||
|
${statusBadge}
|
||||||
|
${r.docusign_template_id ? `<span class="ds-pill" title="${escHtml(r.docusign_template_id)}">DS: ${escHtml(r.docusign_template_id.slice(0,8))}…</span>` : ''}
|
||||||
|
${issues.length ? `<span class="result-meta">⚠ ${issues.length} field issue${issues.length > 1 ? 's' : ''}</span>` : ''}
|
||||||
|
${warnings.length ? `<span class="result-meta">${warnings.length} warning${warnings.length > 1 ? 's' : ''}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${hasDetail ? `
|
||||||
|
<div class="result-body">
|
||||||
|
${warnings.map(w => `<div class="result-warn-item"><span class="ri">⚠</span>${escHtml(w)}</div>`).join('')}
|
||||||
|
${r.error ? `<div class="result-warn-item" style="color:var(--error)"><span class="ri">❌</span>${escHtml(r.error)}</div>` : ''}
|
||||||
|
${renderFieldIssues(issues)}
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
@ -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 = `
|
||||||
|
<div class="modal-backdrop"></div>
|
||||||
|
<div class="modal-box modal-sm">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title">Switch Project</span>
|
||||||
|
<button class="modal-close" id="pm-close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="padding-bottom:0">
|
||||||
|
<div class="project-list" id="pm-project-list"></div>
|
||||||
|
<div class="new-project-form">
|
||||||
|
<h4>New Project</h4>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" class="form-input" id="pm-new-name"
|
||||||
|
placeholder="Customer name (e.g. Acme Corp)" maxlength="60" />
|
||||||
|
<div class="form-error" id="pm-error"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="pm-cancel">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="pm-create">Create Project</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = `<p style="font-size:13px;color:var(--text-muted);margin-bottom:8px">
|
||||||
|
No projects yet. Create one below to get started.
|
||||||
|
</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = projects.map(p => `
|
||||||
|
<div class="project-row ${p.id === active?.id ? 'active' : ''}" data-id="${escHtml(p.id)}">
|
||||||
|
<div class="project-row-icon">${escHtml(p.name.slice(0, 2).toUpperCase())}</div>
|
||||||
|
<div style="flex:1">
|
||||||
|
<div class="project-row-name">${escHtml(p.name)}</div>
|
||||||
|
<div class="project-row-sub">Created ${formatDate(p.createdAt)}</div>
|
||||||
|
</div>
|
||||||
|
${p.id === active?.id
|
||||||
|
? '<span class="project-row-active-badge">● Active</span>'
|
||||||
|
: `<button class="btn btn-secondary btn-xs pm-delete-btn" data-id="${escHtml(p.id)}" title="Delete project">✕</button>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`).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();
|
||||||
|
}
|
||||||
|
|
@ -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 = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">⚠️</div>
|
||||||
|
<div class="empty-state-title">Failed to load view</div>
|
||||||
|
<div class="empty-state-sub">${escHtml(err.message)}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
|
@ -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 = `
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="page-title">Settings</div>
|
||||||
|
<div class="page-subtitle">Configure verification defaults and migration behavior</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Verification defaults -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section-header">
|
||||||
|
<div class="settings-section-title">Verification</div>
|
||||||
|
<div class="settings-section-sub">Default recipient for test envelopes</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section-body">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-body">
|
||||||
|
<div class="setting-label">Test Recipient Name</div>
|
||||||
|
<div class="setting-desc">Pre-filled in the Send Test dialog on the Verification screen</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control" style="min-width:240px">
|
||||||
|
<input type="text" class="form-input" id="set-recipient-name"
|
||||||
|
value="${escHtml(s.testRecipientName || '')}"
|
||||||
|
placeholder="e.g. Test User" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-body">
|
||||||
|
<div class="setting-label">Test Recipient Email</div>
|
||||||
|
<div class="setting-desc">Pre-filled in the Send Test dialog</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control" style="min-width:240px">
|
||||||
|
<input type="email" class="form-input" id="set-recipient-email"
|
||||||
|
value="${escHtml(s.testRecipientEmail || '')}"
|
||||||
|
placeholder="e.g. test@example.com" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-body">
|
||||||
|
<div class="setting-label">Auto-Void Timer (hours)</div>
|
||||||
|
<div class="setting-desc">Reminder to void test envelopes after this many hours (display only — no automatic action)</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<input type="number" class="form-input" id="set-auto-void"
|
||||||
|
value="${s.autoVoidHours ?? 24}" min="1" max="168" style="width:80px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Migration defaults -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section-header">
|
||||||
|
<div class="settings-section-title">Migration Defaults</div>
|
||||||
|
<div class="settings-section-sub">Pre-set options in the migration options modal</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section-body">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-body">
|
||||||
|
<div class="setting-label">Overwrite Existing by Default</div>
|
||||||
|
<div class="setting-desc">When on, the Overwrite Existing toggle in the migration modal starts enabled</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<button class="toggle ${s.defaultOverwrite ? 'on' : ''}" id="set-overwrite"
|
||||||
|
role="switch" aria-checked="${!!s.defaultOverwrite}"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-body">
|
||||||
|
<div class="setting-label">Include Documents by Default</div>
|
||||||
|
<div class="setting-desc">Embed PDFs in the Docusign template payload</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<button class="toggle ${s.defaultIncludeDocs !== false ? 'on' : ''}" id="set-include-docs"
|
||||||
|
role="switch" aria-checked="${s.defaultIncludeDocs !== false}"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connection info (read-only) -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section-header">
|
||||||
|
<div class="settings-section-title">Connections</div>
|
||||||
|
<div class="settings-section-sub">Current platform connection status (connect via top bar)</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section-body" id="settings-conn-info">
|
||||||
|
<div style="padding:8px 0;font-size:13px;color:var(--text-muted)">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save -->
|
||||||
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
|
<button class="btn btn-primary" id="btn-save-settings">Save Settings</button>
|
||||||
|
<span id="save-confirm" style="font-size:13px;color:var(--success);display:none">✓ Saved</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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 = `
|
||||||
|
<div class="conn-info-row">
|
||||||
|
<span class="conn-info-label">Adobe Sign</span>
|
||||||
|
<span class="conn-info-value">${data.adobe ? 'Connected' : 'Not connected'}</span>
|
||||||
|
<span class="conn-info-status">
|
||||||
|
<span class="badge ${data.adobe ? 'badge-green' : 'badge-gray'}">${data.adobe ? '● Connected' : '○ Disconnected'}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="conn-info-row">
|
||||||
|
<span class="conn-info-label">Docusign</span>
|
||||||
|
<span class="conn-info-value">${data.docusign ? 'Connected' : 'Not connected'}</span>
|
||||||
|
<span class="conn-info-status">
|
||||||
|
<span class="badge ${data.docusign ? 'badge-green' : 'badge-gray'}">${data.docusign ? '● Connected' : '○ Disconnected'}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="conn-info-row">
|
||||||
|
<span class="conn-info-label">Docusign Account ID</span>
|
||||||
|
<span class="conn-info-value mono">${escHtml(data.docusign_account_id || '—')}</span>
|
||||||
|
<span class="conn-info-status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="conn-info-row">
|
||||||
|
<span class="conn-info-label">API Environment</span>
|
||||||
|
<span class="conn-info-value mono">${escHtml(data.base_url || '—')}</span>
|
||||||
|
<span class="conn-info-status"></span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (e) {
|
||||||
|
connEl.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load connection info: ${escHtml(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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 = `<div class="empty-state"><div class="spinner"></div></div>`;
|
||||||
|
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 = `
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="page-title">Templates</div>
|
||||||
|
<div class="page-subtitle">${state.templates.length} Adobe Sign templates</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-actions">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="btn-refresh-templates">↻ Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${!state.auth.adobe || !state.auth.docusign ? `
|
||||||
|
<div class="callout info">
|
||||||
|
<span class="callout-icon">ℹ️</span>
|
||||||
|
Connect both Adobe Sign and Docusign in the top bar to load templates.
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<!-- Filter bar -->
|
||||||
|
<div class="filter-bar">
|
||||||
|
<input type="search" class="search-input" id="template-search"
|
||||||
|
placeholder="Search templates…" value="${escHtml(_filter.search)}" />
|
||||||
|
<div class="filter-tabs">
|
||||||
|
${_filterTab('all', `All <span class="tab-count">${counts.total}</span>`)}
|
||||||
|
${_filterTab('not_migrated', `Not Migrated <span class="tab-count">${counts.not_migrated}</span>`)}
|
||||||
|
${_filterTab('migrated', `Migrated <span class="tab-count">${counts.migrated}</span>`)}
|
||||||
|
${_filterTab('needs_update', `Needs Update <span class="tab-count">${counts.needs_update}</span>`)}
|
||||||
|
</div>
|
||||||
|
<div class="filter-tabs">
|
||||||
|
${_filterTab2('blocked', `Blocked <span class="tab-count">${counts.blocked}</span>`)}
|
||||||
|
${_filterTab2('caveats', `Caveats <span class="tab-count">${counts.caveats}</span>`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk action toolbar -->
|
||||||
|
<div class="bulk-bar ${anySelected ? '' : 'hidden'}" id="bulk-bar">
|
||||||
|
<span class="bulk-bar-text">${state.selectedIds.size} template(s) selected</span>
|
||||||
|
<button class="btn btn-primary btn-sm" id="btn-migrate-selected">Migrate Selected →</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" id="btn-clear-selection">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Templates table -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="templates-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:34px">
|
||||||
|
<input type="checkbox" class="cb" id="select-all" title="Select all" />
|
||||||
|
</th>
|
||||||
|
${_th('name', 'Template Name')}
|
||||||
|
${_th('readiness', 'Readiness')}
|
||||||
|
${_th('warnings', 'Issues')}
|
||||||
|
${_th('adobe_modified','Last Modified')}
|
||||||
|
${_th('status', 'DS Status')}
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${templates.length
|
||||||
|
? templates.map(t => _templateRow(t)).join('')
|
||||||
|
: `<tr><td colspan="7">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">📄</div>
|
||||||
|
<div class="empty-state-title">${state.templates.length ? 'No templates match your filter' : 'No templates found'}</div>
|
||||||
|
<div class="empty-state-sub">${state.templates.length ? 'Try clearing the search or filter.' : 'Connect Adobe Sign to load templates.'}</div>
|
||||||
|
</div>
|
||||||
|
</td></tr>`
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
_bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _filterTab(key, label) {
|
||||||
|
return `<button class="filter-tab ${_filter.status === key ? 'active' : ''}"
|
||||||
|
data-filter="${key}">${label}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _filterTab2(key, label) {
|
||||||
|
// readiness-based filters
|
||||||
|
return `<button class="filter-tab ${_filter.status === key ? 'active' : ''}"
|
||||||
|
data-filter="${key}">${label}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _th(col, label) {
|
||||||
|
const dir = _sort.col === col ? (_sort.dir === 'asc' ? 'sort-asc' : 'sort-desc') : '';
|
||||||
|
return `<th class="sortable ${dir}" data-col="${col}">${label}</th>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `
|
||||||
|
<tr class="${selected ? 'row-selected' : ''}" data-id="${escHtml(t.adobe_id)}">
|
||||||
|
<td><input type="checkbox" class="cb row-cb" data-id="${escHtml(t.adobe_id)}" ${selected ? 'checked' : ''} /></td>
|
||||||
|
<td>
|
||||||
|
<div class="table-name tpl-name-link" data-id="${escHtml(t.adobe_id)}">${escHtml(t.name)}</div>
|
||||||
|
<div class="table-sub">${escHtml(t.adobe_id)}</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="badge ${r.cls}">${r.label}</span></td>
|
||||||
|
<td><span class="issue-count ${issueClass}">${issueLabel}</span></td>
|
||||||
|
<td>${formatRelative(t.adobe_modified)}</td>
|
||||||
|
<td>
|
||||||
|
${t.docusign_id
|
||||||
|
? `<span class="badge badge-blue" title="${escHtml(t.docusign_id)}">In Docusign</span>`
|
||||||
|
: `<span class="badge badge-gray">Not Migrated</span>`}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button class="btn btn-ghost btn-xs btn-migrate-one" data-id="${escHtml(t.adobe_id)}" title="Migrate this template">→ Migrate</button>
|
||||||
|
<button class="btn btn-ghost btn-xs btn-view-detail" data-id="${escHtml(t.adobe_id)}" title="View details">Detail</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">🔍</div>
|
||||||
|
<div class="empty-state-title">Template not found</div>
|
||||||
|
<div class="empty-state-sub"><a href="#/templates" class="btn btn-ghost btn-sm">← Back to Templates</a></div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = readiness(t);
|
||||||
|
outlet.innerHTML = `
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<a href="#/templates" style="font-size:12px;color:var(--cobalt);text-decoration:none">← Templates</a>
|
||||||
|
<div class="page-title" style="margin-top:4px">${escHtml(t.name)}</div>
|
||||||
|
<div class="page-subtitle">Adobe ID: <span class="mono">${escHtml(t.adobe_id)}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="page-actions">
|
||||||
|
<span class="badge ${r.cls}">${r.label}</span>
|
||||||
|
<button class="btn btn-primary btn-sm" id="detail-migrate-btn">Migrate</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tabs" id="detail-tabs">
|
||||||
|
<div class="tab active" data-tab="overview">Overview</div>
|
||||||
|
<div class="tab" data-tab="issues">Issues ${(t.blockers||[]).length + (t.warnings||[]).length > 0
|
||||||
|
? `<span class="nav-badge" style="position:static;display:inline">${(t.blockers||[]).length + (t.warnings||[]).length}</span>` : ''}</div>
|
||||||
|
<div class="tab" data-tab="history">Migration History</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="detail-tab-content"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span class="card-title">Template Overview</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="conn-info-row">
|
||||||
|
<span class="conn-info-label">Adobe Sign ID</span>
|
||||||
|
<span class="conn-info-value">${escHtml(t.adobe_id)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="conn-info-row">
|
||||||
|
<span class="conn-info-label">Last Modified</span>
|
||||||
|
<span class="conn-info-value">${formatDate(t.adobe_modified)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="conn-info-row">
|
||||||
|
<span class="conn-info-label">Migration Status</span>
|
||||||
|
<span class="conn-info-value"><span class="badge badge-${t.status === 'migrated' ? 'migrated' : t.status === 'needs_update' ? 'amber' : 'gray'}">${t.status.replace('_', ' ')}</span></span>
|
||||||
|
</div>
|
||||||
|
${t.docusign_id ? `
|
||||||
|
<div class="conn-info-row">
|
||||||
|
<span class="conn-info-label">Docusign Template ID</span>
|
||||||
|
<span class="conn-info-value mono">${escHtml(t.docusign_id)}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} else if (tabKey === 'issues') {
|
||||||
|
const blockers = t.blockers || [];
|
||||||
|
const warnings = t.warnings || [];
|
||||||
|
if (!blockers.length && !warnings.length) {
|
||||||
|
content.innerHTML = `<div class="callout success"><span class="callout-icon">✓</span>No issues found. This template is ready to migrate.</div>`;
|
||||||
|
} else {
|
||||||
|
content.innerHTML = `
|
||||||
|
${blockers.length ? `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span class="card-title" style="color:var(--error)">🚫 Blockers (${blockers.length})</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
${blockers.map(b => `
|
||||||
|
<div class="issue-row">
|
||||||
|
<span class="issue-severity blocker">BLOCKER</span>
|
||||||
|
<div class="issue-body"><div class="issue-title">${escHtml(b)}</div></div>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
|
${warnings.length ? `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span class="card-title" style="color:var(--warning)">⚠ Warnings (${warnings.length})</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
${warnings.map(w => `
|
||||||
|
<div class="issue-row">
|
||||||
|
<span class="issue-severity warn">WARNING</span>
|
||||||
|
<div class="issue-body"><div class="issue-title">${escHtml(w)}</div></div>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>` : ''}`;
|
||||||
|
}
|
||||||
|
} 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 = `<div class="callout info"><span class="callout-icon">ℹ️</span>No migration history for this template yet.</div>`;
|
||||||
|
} 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 ? `
|
||||||
|
<tr class="row-expanded-content" style="display:none">
|
||||||
|
<td colspan="4">
|
||||||
|
<div class="row-expand-body">
|
||||||
|
${(r.blockers||[]).map(b => `<div style="color:var(--error);font-size:12px">🚫 ${escHtml(b)}</div>`).join('')}
|
||||||
|
${(r.warnings||[]).map(w => `<div style="color:var(--warning);font-size:12px">⚠ ${escHtml(w)}</div>`).join('')}
|
||||||
|
${r.error ? `<div style="color:var(--error);font-size:12px">❌ ${escHtml(r.error)}</div>` : ''}
|
||||||
|
${renderFieldIssues(fieldIssues)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>` : '';
|
||||||
|
return `
|
||||||
|
<tr class="${hasDetail ? 'row-expandable' : ''}" style="${hasDetail ? 'cursor:pointer' : ''}">
|
||||||
|
<td>${(r.timestamp||'').slice(0,19).replace('T',' ')}</td>
|
||||||
|
<td>${escHtml(r.action||'—')}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge ${r.status==='success'?'badge-green':'badge-red'}">${r.status}</span>
|
||||||
|
${hasIssues ? '<span class="badge badge-amber" style="font-size:10px">partial</span>' : ''}
|
||||||
|
${hasDetail ? '<span style="font-size:10px;color:var(--text-muted);margin-left:4px">▶ click for details</span>' : ''}
|
||||||
|
</td>
|
||||||
|
<td class="mono">${escHtml(r.docusign_template_id||'—')}</td>
|
||||||
|
</tr>${detailHtml}`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Time</th><th>Action</th><th>Status</th><th>Docusign ID</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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 = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load history.</div>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 =>
|
||||||
|
`<div class="field-issue-row">
|
||||||
|
<span class="field-issue-field">${escHtml(i.field_name)}</span>
|
||||||
|
<span class="field-issue-msg">${escHtml(i.message)}</span>
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
return `
|
||||||
|
<div class="field-issue-group">
|
||||||
|
<div class="field-issue-group-header">
|
||||||
|
<span class="badge badge-amber" style="font-size:10px">${items.length}</span>
|
||||||
|
${escHtml(label)}
|
||||||
|
</div>
|
||||||
|
<div class="field-issue-group-body">${rows}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `<div class="field-issues-block">${groupHtml}</div>`;
|
||||||
|
}
|
||||||
|
|
@ -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 = `
|
||||||
|
<div class="page-header"><div><div class="page-title">Verification</div></div></div>
|
||||||
|
<div class="callout info"><span class="callout-icon">ℹ️</span>Connect Docusign to send verification envelopes.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!candidates.length) {
|
||||||
|
outlet.innerHTML = `
|
||||||
|
<div class="page-header"><div><div class="page-title">Verification</div></div></div>
|
||||||
|
<div class="callout info"><span class="callout-icon">ℹ️</span>
|
||||||
|
No migrated templates yet. Run a migration first, then return here to verify.
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderVerifyView(candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderVerifyView(candidates) {
|
||||||
|
const outlet = document.getElementById('router-outlet');
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
outlet.innerHTML = `
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="page-title">Verification</div>
|
||||||
|
<div class="page-subtitle">Send test envelopes to confirm templates work end-to-end</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="callout info" style="margin-bottom:20px">
|
||||||
|
<span class="callout-icon">ℹ️</span>
|
||||||
|
Verification sends a real Docusign envelope using each template. Test envelopes should be voided after use.
|
||||||
|
Configure default recipient in <a href="#/settings" style="color:var(--cobalt)">Settings</a>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">Migrated Templates</span>
|
||||||
|
<span style="font-size:12px;color:var(--text-muted)">${candidates.length} templates</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="verify-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Template</th>
|
||||||
|
<th>Docusign ID</th>
|
||||||
|
<th>Verification Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${candidates.map(t => _verifyRow(t, settings)).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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 = '<span class="badge badge-gray">Not Tested</span>';
|
||||||
|
let actionsCell = `<button class="btn btn-primary btn-sm btn-send-test" data-id="${escHtml(t.adobe_id)}">Send Test</button>`;
|
||||||
|
|
||||||
|
if (env) {
|
||||||
|
if (env.status === 'completed') {
|
||||||
|
statusCell = '<span class="badge badge-green">✓ Verified</span>';
|
||||||
|
actionsCell = `<button class="btn btn-secondary btn-xs btn-void-envelope"
|
||||||
|
data-id="${escHtml(t.adobe_id)}" data-envelopeid="${escHtml(env.envelopeId)}">Void</button>`;
|
||||||
|
} else if (env.status === 'sent' || env.status === 'delivered') {
|
||||||
|
statusCell = `<span class="badge badge-blue"><span class="spinner spinner-sm"></span> ${env.status}</span>`;
|
||||||
|
actionsCell = `<button class="btn btn-secondary btn-xs btn-void-envelope"
|
||||||
|
data-id="${escHtml(t.adobe_id)}" data-envelopeid="${escHtml(env.envelopeId)}">Void</button>`;
|
||||||
|
} else if (env.status === 'voided') {
|
||||||
|
statusCell = '<span class="badge badge-gray">Voided</span>';
|
||||||
|
actionsCell = `<button class="btn btn-primary btn-sm btn-send-test" data-id="${escHtml(t.adobe_id)}">Send Again</button>`;
|
||||||
|
} else if (env.status === 'timeout') {
|
||||||
|
statusCell = '<span class="badge badge-amber">Timed Out</span>';
|
||||||
|
actionsCell = `<button class="btn btn-primary btn-sm btn-send-test" data-id="${escHtml(t.adobe_id)}">Send Again</button>`;
|
||||||
|
} else {
|
||||||
|
statusCell = `<span class="badge badge-amber">${escHtml(env.status || 'pending')}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr id="verify-row-${escHtml(t.adobe_id)}">
|
||||||
|
<td>
|
||||||
|
<div class="table-name">${escHtml(t.name)}</div>
|
||||||
|
<div class="table-sub">${escHtml(t.adobe_id)}</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono" style="font-size:11px">${t.docusign_id ? escHtml(t.docusign_id.slice(0,12)) + '…' : '—'}</td>
|
||||||
|
<td id="verify-status-${escHtml(t.adobe_id)}">${statusCell}</td>
|
||||||
|
<td id="verify-actions-${escHtml(t.adobe_id)}">${actionsCell}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="modal-backdrop"></div>
|
||||||
|
<div class="modal-box modal-sm">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title">Send Test Envelope</span>
|
||||||
|
<button class="modal-close" id="sd-close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div style="font-size:13px;color:var(--text-muted);margin-bottom:14px">
|
||||||
|
Template: <strong>${escHtml(t.name)}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="sd-name">Recipient Name</label>
|
||||||
|
<input type="text" class="form-input" id="sd-name"
|
||||||
|
value="${escHtml(settings.testRecipientName || '')}"
|
||||||
|
placeholder="Test Recipient" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="sd-email">Recipient Email</label>
|
||||||
|
<input type="email" class="form-input" id="sd-email"
|
||||||
|
value="${escHtml(settings.testRecipientEmail || '')}"
|
||||||
|
placeholder="test@example.com" />
|
||||||
|
<div class="form-error" id="sd-error"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="sd-cancel">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="sd-send">Send Test →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = '<span class="badge badge-green">✓ Verified</span>';
|
||||||
|
actionsEl.innerHTML = `<button class="btn btn-secondary btn-xs btn-void-envelope"
|
||||||
|
data-id="${escHtml(adobeId)}" data-envelopeid="${escHtml(env.envelopeId)}">Void</button>`;
|
||||||
|
} else if (env.status === 'sent' || env.status === 'delivered') {
|
||||||
|
statusEl.innerHTML = `<span class="badge badge-blue"><span class="spinner spinner-sm"></span> ${env.status}</span>`;
|
||||||
|
actionsEl.innerHTML = `<button class="btn btn-secondary btn-xs btn-void-envelope"
|
||||||
|
data-id="${escHtml(adobeId)}" data-envelopeid="${escHtml(env.envelopeId)}">Void</button>`;
|
||||||
|
} else if (env.status === 'voided') {
|
||||||
|
statusEl.innerHTML = '<span class="badge badge-gray">Voided</span>';
|
||||||
|
actionsEl.innerHTML = `<button class="btn btn-primary btn-sm btn-send-test" data-id="${escHtml(adobeId)}">Send Again</button>`;
|
||||||
|
} else if (env.status === 'timeout') {
|
||||||
|
statusEl.innerHTML = '<span class="badge badge-amber">Timed Out</span>';
|
||||||
|
actionsEl.innerHTML = `<button class="btn btn-primary btn-sm btn-send-test" data-id="${escHtml(adobeId)}">Send Again</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue