task-d.7.8
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
# Task D.7.8 Seed Manifest Setup State Persistence
|
||||
|
||||
Date: 2026-07-03
|
||||
Status: implemented
|
||||
|
||||
## Scope Delivered
|
||||
|
||||
Added setup persistence schema and migration:
|
||||
|
||||
- `setup_runs`
|
||||
- `setup_run_steps`
|
||||
- `seed_manifests`
|
||||
- `seed_manifest_items`
|
||||
- `drizzle/0004_setup_state_seed_manifest.sql`
|
||||
|
||||
Added setup state services:
|
||||
|
||||
- `src/features/setup/server/setup-state.service.ts`
|
||||
- `src/features/setup/server/seed-manifest.service.ts`
|
||||
|
||||
Added protected setup run APIs:
|
||||
|
||||
- `GET /api/setup/run/current`
|
||||
- `POST /api/setup/run/start`
|
||||
- `POST /api/setup/run/steps`
|
||||
- `POST /api/setup/run/complete`
|
||||
- `GET /api/setup/run/report`
|
||||
|
||||
Updated CSV commit integration:
|
||||
|
||||
- Successful non-dry-run CSV commits create seed manifest records.
|
||||
- Dry-run commits do not write seed manifest records.
|
||||
- Failed commits do not write seed manifest records.
|
||||
|
||||
Updated Setup Wizard UI:
|
||||
|
||||
- Loads current setup run on mount.
|
||||
- Shows resumable/completed setup run banner.
|
||||
- Shows persisted step statuses.
|
||||
- Saves readiness, CSV preview, CSV commit, and verification step outputs.
|
||||
- Marks dependent later steps stale when step input hash changes.
|
||||
- Shows persisted seed manifest summaries.
|
||||
- Adds complete setup action.
|
||||
- Wizard remains usable if persistence APIs fail, with a warning.
|
||||
|
||||
## Persistence Behavior
|
||||
|
||||
- Step re-runs update the same `setup_run_steps` row.
|
||||
- Latest step report replaces prior step report.
|
||||
- CSV preview persistence strips raw row values before saving.
|
||||
- CSV commit persistence strips row-level raw details before saving.
|
||||
- Seed manifest item records store filenames, checksums, counts, status, and safe reports.
|
||||
- No raw CSV contents, passwords, password hashes, SQL errors, storage credentials, or secrets are persisted.
|
||||
|
||||
## Staleness Rules Implemented
|
||||
|
||||
- `readiness` changes mark later setup steps stale.
|
||||
- `organization` changes mark administrator, scope, sequence, approval, CSV, and verification steps stale.
|
||||
- `scope` changes mark sequence, approval, CSV, and verification steps stale.
|
||||
- `sequences` changes mark verification stale.
|
||||
- `approval` changes mark CSV and verification stale.
|
||||
- `csv_preview` changes mark `csv_commit` stale.
|
||||
- `csv_commit` changes mark verification stale.
|
||||
|
||||
## Out Of Scope Preserved
|
||||
|
||||
- No destructive reset/reseed behavior.
|
||||
- No `RESET_STEP` implementation.
|
||||
- No installation profile plugin runtime.
|
||||
- No CSV parser changes.
|
||||
- No setup template changes.
|
||||
- No storage or attachment import.
|
||||
- No new CRM behavior.
|
||||
- No production setup lock enforcement beyond completed status display.
|
||||
|
||||
## Validation
|
||||
|
||||
- `npm run typecheck -- --pretty false` passed.
|
||||
- `npx oxlint src/features/setup src/app/api/setup src/app/dashboard/administration/setup src/db/schema.ts` passed.
|
||||
- `npm run db:migrate` passed and applied migrations successfully.
|
||||
|
||||
## Residual Notes
|
||||
|
||||
- `plans/task-d.7.8.md` was untracked during implementation and was not modified as part of this delivery.
|
||||
- The Drizzle migration was added manually to match the current repository migration style and journal.
|
||||
- D.7.7 remains responsible for scoped reset/reseed behavior.
|
||||
67
drizzle/0004_setup_state_seed_manifest.sql
Normal file
67
drizzle/0004_setup_state_seed_manifest.sql
Normal file
@@ -0,0 +1,67 @@
|
||||
CREATE TABLE IF NOT EXISTS "setup_runs" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"status" text DEFAULT 'not_started' NOT NULL,
|
||||
"mode" text DEFAULT 'production' NOT NULL,
|
||||
"target_organization_id" text,
|
||||
"started_by" text NOT NULL,
|
||||
"completed_by" text,
|
||||
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"completed_at" timestamp with time zone,
|
||||
"last_error" text,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "setup_run_steps" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"setup_run_id" text NOT NULL,
|
||||
"step_key" text NOT NULL,
|
||||
"status" text DEFAULT 'not_started' NOT NULL,
|
||||
"input_hash" text,
|
||||
"output_json" jsonb,
|
||||
"errors_json" jsonb,
|
||||
"warnings_json" jsonb,
|
||||
"started_at" timestamp with time zone,
|
||||
"completed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "setup_run_steps_run_step_idx" ON "setup_run_steps" ("setup_run_id","step_key");
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "seed_manifests" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"setup_run_id" text,
|
||||
"organization_id" text,
|
||||
"name" text NOT NULL,
|
||||
"version" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"checksum" text NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"source" text NOT NULL,
|
||||
"applied_by" text,
|
||||
"applied_at" timestamp with time zone,
|
||||
"report_json" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "seed_manifest_items" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"seed_manifest_id" text NOT NULL,
|
||||
"item_key" text NOT NULL,
|
||||
"source_file" text NOT NULL,
|
||||
"checksum" text NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"imported_count" integer DEFAULT 0 NOT NULL,
|
||||
"updated_count" integer DEFAULT 0 NOT NULL,
|
||||
"skipped_count" integer DEFAULT 0 NOT NULL,
|
||||
"error_count" integer DEFAULT 0 NOT NULL,
|
||||
"warning_count" integer DEFAULT 0 NOT NULL,
|
||||
"report_json" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "seed_manifest_items_manifest_item_idx" ON "seed_manifest_items" ("seed_manifest_id","item_key");
|
||||
@@ -29,6 +29,13 @@
|
||||
"when": 1782881325779,
|
||||
"tag": "0003_curious_wild_child",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1783094400000,
|
||||
"tag": "0004_setup_state_seed_manifest",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
681
plans/task-d.7.8.md
Normal file
681
plans/task-d.7.8.md
Normal file
@@ -0,0 +1,681 @@
|
||||
# Task D.7.8 – Seed Manifest and Setup State Persistence
|
||||
|
||||
## Objective
|
||||
|
||||
Add persistent setup run tracking and seed manifest lifecycle tracking for ALLA OS Initial Setup.
|
||||
|
||||
This task must make the Setup Wizard resumable, auditable, and able to persist setup reports across browser refreshes and future setup sessions.
|
||||
|
||||
D.7.8 should build on:
|
||||
|
||||
- D.7.2 Readiness and Verification APIs
|
||||
- D.7.3 CSV Template Pack
|
||||
- D.7.4 CSV Preview Validation Engine
|
||||
- D.7.5 CSV Commit Import Engine
|
||||
- D.7.6 Dashboard Setup Wizard UI
|
||||
|
||||
This task must not implement destructive reset/reseed behavior. Reset remains D.7.7.
|
||||
|
||||
---
|
||||
|
||||
## Review Before Implementation
|
||||
|
||||
Review:
|
||||
|
||||
- AGENTS.md
|
||||
- plans/task-d.7.md
|
||||
- plans/task-d.7.1.md
|
||||
- plans/task-d.7.2.md
|
||||
- plans/task-d.7.3.md
|
||||
- plans/task-d.7.4.md
|
||||
- plans/task-d.7.5.md
|
||||
- plans/task-d.7.6.md
|
||||
- docs/setup/setup-state-machine.md
|
||||
- docs/setup/seed-version-manifest.md
|
||||
- docs/setup/setup-wizard-blueprint.md
|
||||
- docs/setup/verification-matrix.md
|
||||
- docs/setup/reset-reseed-matrix.md
|
||||
- docs/setup/setup-plugin-architecture.md
|
||||
- src/db/schema.ts
|
||||
- src/features/setup/**
|
||||
- src/app/dashboard/administration/setup/page.tsx
|
||||
- src/app/api/setup/**
|
||||
- existing migration conventions under drizzle/
|
||||
|
||||
---
|
||||
|
||||
## Implementation Scope
|
||||
|
||||
Add setup persistence tables:
|
||||
|
||||
- setup_runs
|
||||
- setup_run_steps
|
||||
- seed_manifests
|
||||
- seed_manifest_items
|
||||
|
||||
Add migration through existing Drizzle workflow.
|
||||
|
||||
Add schema definitions to:
|
||||
|
||||
src/db/schema.ts
|
||||
|
||||
or appropriate split schema location if the repo already uses modular schema files.
|
||||
|
||||
Add setup persistence services:
|
||||
|
||||
src/features/setup/server/setup-state.service.ts
|
||||
src/features/setup/server/seed-manifest.service.ts
|
||||
|
||||
Add API routes:
|
||||
|
||||
GET /api/setup/run/current
|
||||
POST /api/setup/run/start
|
||||
POST /api/setup/run/step
|
||||
POST /api/setup/run/complete
|
||||
GET /api/setup/run/report
|
||||
|
||||
Update setup wizard UI to:
|
||||
|
||||
- load current setup run
|
||||
- resume prior step status
|
||||
- persist step outputs
|
||||
- show completed/pending/stale step status
|
||||
- persist finish report
|
||||
- show setup completed state
|
||||
|
||||
Update CSV commit flow to optionally record seed manifest and manifest items.
|
||||
|
||||
---
|
||||
|
||||
## Database Tables
|
||||
|
||||
### setup_runs
|
||||
|
||||
Purpose:
|
||||
|
||||
One row per setup attempt/session.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
id
|
||||
|
||||
status
|
||||
|
||||
mode
|
||||
|
||||
target_organization_id
|
||||
|
||||
started_by
|
||||
|
||||
completed_by
|
||||
|
||||
started_at
|
||||
|
||||
completed_at
|
||||
|
||||
last_error
|
||||
|
||||
metadata
|
||||
|
||||
created_at
|
||||
|
||||
updated_at
|
||||
|
||||
Status values:
|
||||
|
||||
not_started
|
||||
|
||||
readiness_checked
|
||||
|
||||
foundation_installed
|
||||
|
||||
organization_created
|
||||
|
||||
administrator_created
|
||||
|
||||
scope_configured
|
||||
|
||||
sequences_configured
|
||||
|
||||
approval_configured
|
||||
|
||||
csv_validated
|
||||
|
||||
csv_imported
|
||||
|
||||
verified
|
||||
|
||||
completed
|
||||
|
||||
failed
|
||||
|
||||
cancelled
|
||||
|
||||
Mode values:
|
||||
|
||||
production
|
||||
|
||||
demo_uat
|
||||
|
||||
dry_run
|
||||
|
||||
---
|
||||
|
||||
### setup_run_steps
|
||||
|
||||
Purpose:
|
||||
|
||||
Step-level state, input hash, output report, warning/error history.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
id
|
||||
|
||||
setup_run_id
|
||||
|
||||
step_key
|
||||
|
||||
status
|
||||
|
||||
input_hash
|
||||
|
||||
output_json
|
||||
|
||||
errors_json
|
||||
|
||||
warnings_json
|
||||
|
||||
started_at
|
||||
|
||||
completed_at
|
||||
|
||||
created_at
|
||||
|
||||
updated_at
|
||||
|
||||
Step status values:
|
||||
|
||||
not_started
|
||||
|
||||
running
|
||||
|
||||
passed
|
||||
|
||||
warning
|
||||
|
||||
failed
|
||||
|
||||
skipped
|
||||
|
||||
stale
|
||||
|
||||
---
|
||||
|
||||
### seed_manifests
|
||||
|
||||
Purpose:
|
||||
|
||||
Track seed/import package lifecycle.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
id
|
||||
|
||||
setup_run_id
|
||||
|
||||
organization_id
|
||||
|
||||
name
|
||||
|
||||
version
|
||||
|
||||
type
|
||||
|
||||
checksum
|
||||
|
||||
status
|
||||
|
||||
source
|
||||
|
||||
applied_by
|
||||
|
||||
applied_at
|
||||
|
||||
report_json
|
||||
|
||||
created_at
|
||||
|
||||
updated_at
|
||||
|
||||
Type values:
|
||||
|
||||
foundation
|
||||
|
||||
organization
|
||||
|
||||
business
|
||||
|
||||
demo_uat
|
||||
|
||||
setup_state
|
||||
|
||||
Status values:
|
||||
|
||||
pending
|
||||
|
||||
applied
|
||||
|
||||
skipped
|
||||
|
||||
warning
|
||||
|
||||
failed
|
||||
|
||||
rolled_back
|
||||
|
||||
---
|
||||
|
||||
### seed_manifest_items
|
||||
|
||||
Purpose:
|
||||
|
||||
Track per-file/per-seed item checksum and result.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
id
|
||||
|
||||
seed_manifest_id
|
||||
|
||||
item_key
|
||||
|
||||
source_file
|
||||
|
||||
checksum
|
||||
|
||||
status
|
||||
|
||||
imported_count
|
||||
|
||||
updated_count
|
||||
|
||||
skipped_count
|
||||
|
||||
error_count
|
||||
|
||||
warning_count
|
||||
|
||||
report_json
|
||||
|
||||
created_at
|
||||
|
||||
updated_at
|
||||
|
||||
---
|
||||
|
||||
## State Machine Rules
|
||||
|
||||
Use:
|
||||
|
||||
docs/setup/setup-state-machine.md
|
||||
|
||||
Required events:
|
||||
|
||||
CHECK_READINESS
|
||||
|
||||
INSTALL_FOUNDATION
|
||||
|
||||
CREATE_ORGANIZATION
|
||||
|
||||
CREATE_ADMIN
|
||||
|
||||
CONFIGURE_SCOPE
|
||||
|
||||
CONFIGURE_SEQUENCES
|
||||
|
||||
CONFIGURE_APPROVAL
|
||||
|
||||
VALIDATE_CSV
|
||||
|
||||
IMPORT_CSV
|
||||
|
||||
RUN_VERIFICATION
|
||||
|
||||
COMPLETE_SETUP
|
||||
|
||||
FAIL_STEP
|
||||
|
||||
CANCEL_SETUP
|
||||
|
||||
RESUME_SETUP
|
||||
|
||||
RESET_STEP
|
||||
|
||||
For D.7.8, implement only non-destructive events.
|
||||
|
||||
Do not implement RESET_STEP destructive behavior.
|
||||
|
||||
RESET_STEP can return:
|
||||
|
||||
not implemented until D.7.7
|
||||
|
||||
---
|
||||
|
||||
## Persistence Rules
|
||||
|
||||
Every setup step should persist:
|
||||
|
||||
- step key
|
||||
- status
|
||||
- input hash
|
||||
- output summary/report
|
||||
- warnings
|
||||
- errors
|
||||
- completed timestamp
|
||||
|
||||
If the same step is re-run:
|
||||
|
||||
- update same step row
|
||||
- keep latest report
|
||||
- mark dependent later steps as stale when needed
|
||||
|
||||
Staleness rules:
|
||||
|
||||
- readiness changed → all later steps stale
|
||||
- organization changed → admin, scope, sequence, approval, CSV, verification stale
|
||||
- scope changed → sequence, approval, CSV, verification stale
|
||||
- sequence changed → verification stale
|
||||
- approval changed → CSV and verification stale
|
||||
- CSV preview changed → commit stale
|
||||
- CSV commit changed → verification stale
|
||||
|
||||
---
|
||||
|
||||
## Setup Run Behavior
|
||||
|
||||
### Current Run
|
||||
|
||||
GET /api/setup/run/current
|
||||
|
||||
Returns:
|
||||
|
||||
- active setup run if exists
|
||||
- latest completed setup run if no active run
|
||||
- setup step summaries
|
||||
- manifest summaries
|
||||
- completion status
|
||||
|
||||
### Start Run
|
||||
|
||||
POST /api/setup/run/start
|
||||
|
||||
Creates new setup run.
|
||||
|
||||
Rules:
|
||||
|
||||
- super_admin only
|
||||
- if active run exists, return existing run unless `forceNew=true`
|
||||
- mode defaults to production
|
||||
- target organization optional until resolved
|
||||
|
||||
### Save Step
|
||||
|
||||
POST /api/setup/run/step
|
||||
|
||||
Persists step report.
|
||||
|
||||
Input:
|
||||
|
||||
- setupRunId
|
||||
- stepKey
|
||||
- status
|
||||
- inputHash
|
||||
- output
|
||||
- warnings
|
||||
- errors
|
||||
|
||||
Rules:
|
||||
|
||||
- validate step key
|
||||
- update run status based on state machine
|
||||
- mark downstream stale steps as needed
|
||||
|
||||
### Complete Run
|
||||
|
||||
POST /api/setup/run/complete
|
||||
|
||||
Rules:
|
||||
|
||||
- all required steps must be passed/warning acknowledged
|
||||
- blocking failure prevents completion
|
||||
- verification must be latest and not stale
|
||||
- writes completed_at and status completed
|
||||
- returns final report
|
||||
|
||||
### Report
|
||||
|
||||
GET /api/setup/run/report
|
||||
|
||||
Returns:
|
||||
|
||||
- setup run summary
|
||||
- steps
|
||||
- manifests
|
||||
- final readiness/verification/import summaries
|
||||
|
||||
---
|
||||
|
||||
## Seed Manifest Behavior
|
||||
|
||||
When CSV commit succeeds, create or update a manifest.
|
||||
|
||||
Recommended package:
|
||||
|
||||
crm-business-import
|
||||
|
||||
Manifest item per committed CSV file.
|
||||
|
||||
For each file:
|
||||
|
||||
- source_file
|
||||
- checksum
|
||||
- status
|
||||
- imported_count
|
||||
- updated_count
|
||||
- skipped_count
|
||||
- error_count
|
||||
- warning_count
|
||||
- report_json
|
||||
|
||||
Version:
|
||||
|
||||
1.0.0
|
||||
|
||||
Source:
|
||||
|
||||
csv
|
||||
|
||||
Type:
|
||||
|
||||
business
|
||||
|
||||
Checksum behavior:
|
||||
|
||||
- same version + same checksum = skipped/already applied
|
||||
- same version + changed checksum = warning unless force mode
|
||||
- new checksum after successful import creates new manifest revision or new manifest row depending on chosen implementation
|
||||
|
||||
If D.7.5 import is dryRun:
|
||||
|
||||
- do not write seed manifest
|
||||
- optionally persist setup step output only
|
||||
|
||||
---
|
||||
|
||||
## API Security
|
||||
|
||||
- all routes protected
|
||||
- require super_admin for D.7.8
|
||||
- never expose raw SQL errors
|
||||
- never expose passwords, password hashes, secrets, storage credentials
|
||||
- report only safe summaries
|
||||
- setup reports can include filenames and counts, not raw CSV contents
|
||||
|
||||
---
|
||||
|
||||
## UI Updates
|
||||
|
||||
Update:
|
||||
|
||||
src/features/setup/components/setup-wizard.tsx
|
||||
|
||||
Add:
|
||||
|
||||
- load current setup run on mount
|
||||
- resume banner
|
||||
- persisted step status
|
||||
- stale step badge
|
||||
- completed setup summary
|
||||
- save step result after readiness, preview, commit, verification
|
||||
- complete setup button on finish step
|
||||
- report panel from persisted data
|
||||
|
||||
Important:
|
||||
|
||||
The wizard should still work if persistence API fails, but must show a warning.
|
||||
|
||||
---
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Destructive reset/reseed
|
||||
- RESET_STEP implementation
|
||||
- installation profile engine
|
||||
- plugin runtime
|
||||
- CSV parser changes unless needed for checksum
|
||||
- setup template changes
|
||||
- storage import
|
||||
- attachment import
|
||||
- new CRM behavior
|
||||
- production setup lock enforcement beyond completed status display
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
Required schema/migration:
|
||||
|
||||
src/db/schema.ts
|
||||
|
||||
drizzle migration file
|
||||
|
||||
Required services:
|
||||
|
||||
src/features/setup/server/setup-state.service.ts
|
||||
src/features/setup/server/seed-manifest.service.ts
|
||||
|
||||
Required API routes:
|
||||
|
||||
src/app/api/setup/run/current/route.ts
|
||||
src/app/api/setup/run/start/route.ts
|
||||
src/app/api/setup/run/step/route.ts
|
||||
src/app/api/setup/run/complete/route.ts
|
||||
src/app/api/setup/run/report/route.ts
|
||||
|
||||
Required UI update:
|
||||
|
||||
src/features/setup/components/setup-wizard.tsx
|
||||
|
||||
Optional helpers:
|
||||
|
||||
src/features/setup/server/setup-state-machine.ts
|
||||
src/features/setup/server/setup-report.service.ts
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
✓ setup_runs table exists.
|
||||
|
||||
✓ setup_run_steps table exists.
|
||||
|
||||
✓ seed_manifests table exists.
|
||||
|
||||
✓ seed_manifest_items table exists.
|
||||
|
||||
✓ Drizzle migration generated.
|
||||
|
||||
✓ Current setup run API works.
|
||||
|
||||
✓ Start setup run API works.
|
||||
|
||||
✓ Save setup step API works.
|
||||
|
||||
✓ Complete setup run API works.
|
||||
|
||||
✓ Setup report API works.
|
||||
|
||||
✓ Wizard can resume current run after refresh.
|
||||
|
||||
✓ Wizard shows persisted step statuses.
|
||||
|
||||
✓ Wizard marks stale dependent steps after rerun.
|
||||
|
||||
✓ CSV commit creates seed manifest records when commit succeeds.
|
||||
|
||||
✓ dryRun commit does not create seed manifest records.
|
||||
|
||||
✓ Completed setup run stores final report.
|
||||
|
||||
✓ All APIs require super_admin.
|
||||
|
||||
✓ No destructive reset logic is implemented.
|
||||
|
||||
✓ No secrets or raw CSV contents are persisted.
|
||||
|
||||
✓ Typecheck passes or unrelated failures are documented.
|
||||
|
||||
---
|
||||
|
||||
## Suggested Verification
|
||||
|
||||
Run:
|
||||
|
||||
npm run db:generate
|
||||
|
||||
npm run db:migrate
|
||||
|
||||
npm run typecheck
|
||||
|
||||
Run scoped lint:
|
||||
|
||||
npx oxlint src/features/setup src/app/api/setup src/app/dashboard/administration/setup src/db/schema.ts
|
||||
|
||||
Manual test:
|
||||
|
||||
1. Open Setup Wizard.
|
||||
2. Start setup run.
|
||||
3. Run readiness.
|
||||
4. Refresh browser.
|
||||
5. Confirm readiness result persists.
|
||||
6. Upload CSV and preview.
|
||||
7. Refresh browser.
|
||||
8. Confirm preview step status persists without raw CSV content.
|
||||
9. Commit CSV.
|
||||
10. Confirm seed manifest and manifest item rows exist.
|
||||
11. Run verification.
|
||||
12. Complete setup.
|
||||
13. Refresh browser.
|
||||
14. Confirm completed setup summary appears.
|
||||
15. Try as non-super_admin.
|
||||
16. Confirm access denied.
|
||||
|
||||
---
|
||||
|
||||
## Follow-up
|
||||
|
||||
After D.7.8:
|
||||
|
||||
D.7.7 – Scoped Reset and Reseed Engine
|
||||
|
||||
D.7.9 – Installation Profile and Plugin Runtime Enhancement
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { commitSetupCsvImport } from '@/features/setup/server/csv-commit.service';
|
||||
import { createCsvImportSeedManifest } from '@/features/setup/server/seed-manifest.service';
|
||||
import { AuthError, requireSystemRole } from '@/lib/auth/session';
|
||||
|
||||
function isFile(value: FormDataEntryValue): value is File {
|
||||
@@ -40,6 +41,14 @@ export async function POST(request: NextRequest) {
|
||||
actorId: session.user.id
|
||||
});
|
||||
|
||||
if (!dryRun && report.status !== 'FAIL') {
|
||||
await createCsvImportSeedManifest({
|
||||
files,
|
||||
report,
|
||||
actorId: session.user.id
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(report, { status: report.status === 'FAIL' ? 400 : 200 });
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
|
||||
27
src/app/api/setup/run/complete/route.ts
Normal file
27
src/app/api/setup/run/complete/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { completeSetupRun } from '@/features/setup/server/setup-state.service';
|
||||
import { AuthError, requireSystemRole } from '@/lib/auth/session';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await requireSystemRole('super_admin');
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
setupRunId?: string;
|
||||
report?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
return NextResponse.json(
|
||||
await completeSetupRun({
|
||||
actorId: session.user.id,
|
||||
setupRunId: body.setupRunId,
|
||||
report: body.report
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to complete setup run' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
src/app/api/setup/run/current/route.ts
Normal file
16
src/app/api/setup/run/current/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getCurrentSetupRun } from '@/features/setup/server/setup-state.service';
|
||||
import { AuthError, requireSystemRole } from '@/lib/auth/session';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireSystemRole('super_admin');
|
||||
return NextResponse.json(await getCurrentSetupRun());
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to load setup run' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
src/app/api/setup/run/report/route.ts
Normal file
16
src/app/api/setup/run/report/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getSetupRunReport } from '@/features/setup/server/setup-state.service';
|
||||
import { AuthError, requireSystemRole } from '@/lib/auth/session';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireSystemRole('super_admin');
|
||||
return NextResponse.json(await getSetupRunReport());
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to load setup report' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
27
src/app/api/setup/run/start/route.ts
Normal file
27
src/app/api/setup/run/start/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { startSetupRun } from '@/features/setup/server/setup-state.service';
|
||||
import { AuthError, requireSystemRole } from '@/lib/auth/session';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await requireSystemRole('super_admin');
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
mode?: 'production' | 'demo_uat' | 'dry_run';
|
||||
targetOrganizationId?: string | null;
|
||||
};
|
||||
|
||||
return NextResponse.json(
|
||||
await startSetupRun({
|
||||
actorId: session.user.id,
|
||||
mode: body.mode,
|
||||
targetOrganizationId: body.targetOrganizationId ?? null
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to start setup run' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
53
src/app/api/setup/run/steps/route.ts
Normal file
53
src/app/api/setup/run/steps/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import {
|
||||
saveSetupStep,
|
||||
type SetupStepStatus
|
||||
} from '@/features/setup/server/setup-state.service';
|
||||
import { AuthError, requireSystemRole } from '@/lib/auth/session';
|
||||
|
||||
const allowedStatuses = new Set<SetupStepStatus>([
|
||||
'not_started',
|
||||
'running',
|
||||
'passed',
|
||||
'warning',
|
||||
'failed',
|
||||
'skipped',
|
||||
'stale'
|
||||
]);
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await requireSystemRole('super_admin');
|
||||
const body = (await request.json()) as {
|
||||
setupRunId?: string;
|
||||
stepKey?: string;
|
||||
status?: SetupStepStatus;
|
||||
inputHash?: string | null;
|
||||
output?: Record<string, unknown> | null;
|
||||
errors?: unknown[] | null;
|
||||
warnings?: unknown[] | null;
|
||||
};
|
||||
|
||||
if (!body.stepKey || !body.status || !allowedStatuses.has(body.status)) {
|
||||
return NextResponse.json({ message: 'Invalid setup step payload' }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
await saveSetupStep({
|
||||
setupRunId: body.setupRunId,
|
||||
stepKey: body.stepKey,
|
||||
status: body.status,
|
||||
inputHash: body.inputHash ?? null,
|
||||
output: body.output ?? null,
|
||||
errors: body.errors ?? null,
|
||||
warnings: body.warnings ?? null
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to save setup step' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,88 @@ export const memberships = pgTable('memberships', {
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const setupRuns = pgTable('setup_runs', {
|
||||
id: text('id').primaryKey(),
|
||||
status: text('status').default('not_started').notNull(),
|
||||
mode: text('mode').default('production').notNull(),
|
||||
targetOrganizationId: text('target_organization_id'),
|
||||
startedBy: text('started_by').notNull(),
|
||||
completedBy: text('completed_by'),
|
||||
startedAt: timestamp('started_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
lastError: text('last_error'),
|
||||
metadata: jsonb('metadata').$type<Record<string, unknown> | null>(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const setupRunSteps = pgTable(
|
||||
'setup_run_steps',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
setupRunId: text('setup_run_id').notNull(),
|
||||
stepKey: text('step_key').notNull(),
|
||||
status: text('status').default('not_started').notNull(),
|
||||
inputHash: text('input_hash'),
|
||||
outputJson: jsonb('output_json').$type<Record<string, unknown> | null>(),
|
||||
errorsJson: jsonb('errors_json').$type<unknown[] | null>(),
|
||||
warningsJson: jsonb('warnings_json').$type<unknown[] | null>(),
|
||||
startedAt: timestamp('started_at', { withTimezone: true }),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(table) => ({
|
||||
setupRunStepIdx: uniqueIndex('setup_run_steps_run_step_idx').on(
|
||||
table.setupRunId,
|
||||
table.stepKey
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
export const seedManifests = pgTable('seed_manifests', {
|
||||
id: text('id').primaryKey(),
|
||||
setupRunId: text('setup_run_id'),
|
||||
organizationId: text('organization_id'),
|
||||
name: text('name').notNull(),
|
||||
version: text('version').notNull(),
|
||||
type: text('type').notNull(),
|
||||
checksum: text('checksum').notNull(),
|
||||
status: text('status').default('pending').notNull(),
|
||||
source: text('source').notNull(),
|
||||
appliedBy: text('applied_by'),
|
||||
appliedAt: timestamp('applied_at', { withTimezone: true }),
|
||||
reportJson: jsonb('report_json').$type<Record<string, unknown> | null>(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const seedManifestItems = pgTable(
|
||||
'seed_manifest_items',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
seedManifestId: text('seed_manifest_id').notNull(),
|
||||
itemKey: text('item_key').notNull(),
|
||||
sourceFile: text('source_file').notNull(),
|
||||
checksum: text('checksum').notNull(),
|
||||
status: text('status').default('pending').notNull(),
|
||||
importedCount: integer('imported_count').default(0).notNull(),
|
||||
updatedCount: integer('updated_count').default(0).notNull(),
|
||||
skippedCount: integer('skipped_count').default(0).notNull(),
|
||||
errorCount: integer('error_count').default(0).notNull(),
|
||||
warningCount: integer('warning_count').default(0).notNull(),
|
||||
reportJson: jsonb('report_json').$type<Record<string, unknown> | null>(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(table) => ({
|
||||
seedManifestItemIdx: uniqueIndex('seed_manifest_items_manifest_item_idx').on(
|
||||
table.seedManifestId,
|
||||
table.itemKey
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
export const crmRoleProfiles = pgTable(
|
||||
'crm_role_profiles',
|
||||
{
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { getSetupReadiness, getSetupTemplates, verifySetup } from './service';
|
||||
import {
|
||||
getCurrentSetupRun,
|
||||
getSetupReadiness,
|
||||
getSetupRunReport,
|
||||
getSetupTemplates,
|
||||
verifySetup
|
||||
} from './service';
|
||||
import type { SetupVerificationPayload } from './types';
|
||||
|
||||
export const setupKeys = {
|
||||
all: ['setup'] as const,
|
||||
readiness: () => [...setupKeys.all, 'readiness'] as const,
|
||||
templates: () => [...setupKeys.all, 'templates'] as const,
|
||||
run: () => [...setupKeys.all, 'run'] as const,
|
||||
report: () => [...setupKeys.all, 'run', 'report'] as const,
|
||||
verification: (payload: SetupVerificationPayload = {}) =>
|
||||
[...setupKeys.all, 'verification', payload] as const
|
||||
};
|
||||
@@ -22,6 +30,18 @@ export const setupTemplatesQueryOptions = () =>
|
||||
queryFn: () => getSetupTemplates()
|
||||
});
|
||||
|
||||
export const setupRunQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: setupKeys.run(),
|
||||
queryFn: () => getCurrentSetupRun()
|
||||
});
|
||||
|
||||
export const setupRunReportQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: setupKeys.report(),
|
||||
queryFn: () => getSetupRunReport()
|
||||
});
|
||||
|
||||
export const setupVerificationQueryOptions = (payload: SetupVerificationPayload = {}) =>
|
||||
queryOptions({
|
||||
queryKey: setupKeys.verification(payload),
|
||||
|
||||
@@ -2,7 +2,9 @@ import { ApiClientError, apiClient } from '@/lib/api-client';
|
||||
import type {
|
||||
CsvPreviewResult,
|
||||
ImportReport,
|
||||
SaveSetupStepPayload,
|
||||
SetupReadinessReport,
|
||||
SetupRunResponse,
|
||||
SetupTemplatesResponse,
|
||||
SetupVerificationPayload,
|
||||
SetupVerificationReport
|
||||
@@ -21,6 +23,35 @@ export async function getSetupTemplates(): Promise<SetupTemplatesResponse> {
|
||||
return apiClient<SetupTemplatesResponse>('/setup/templates');
|
||||
}
|
||||
|
||||
export async function getCurrentSetupRun(): Promise<SetupRunResponse> {
|
||||
return apiClient<SetupRunResponse>('/setup/run/current');
|
||||
}
|
||||
|
||||
export async function startSetupRun(): Promise<SetupRunResponse> {
|
||||
return apiClient<SetupRunResponse>('/setup/run/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode: 'production' })
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveSetupStep(payload: SaveSetupStepPayload): Promise<SetupRunResponse> {
|
||||
return apiClient<SetupRunResponse>('/setup/run/steps', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeSetupRun(report: Record<string, unknown>): Promise<SetupRunResponse> {
|
||||
return apiClient<SetupRunResponse>('/setup/run/complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ report })
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSetupRunReport(): Promise<SetupRunResponse> {
|
||||
return apiClient<SetupRunResponse>('/setup/run/report');
|
||||
}
|
||||
|
||||
export async function verifySetup(
|
||||
payload: SetupVerificationPayload = {}
|
||||
): Promise<SetupVerificationReport> {
|
||||
|
||||
@@ -55,6 +55,114 @@ export interface SetupTemplatesResponse {
|
||||
templates: SetupTemplateMetadataItem[];
|
||||
}
|
||||
|
||||
export type SetupRunStatus =
|
||||
| 'not_started'
|
||||
| 'readiness_checked'
|
||||
| 'foundation_installed'
|
||||
| 'organization_created'
|
||||
| 'administrator_created'
|
||||
| 'scope_configured'
|
||||
| 'sequences_configured'
|
||||
| 'approval_configured'
|
||||
| 'csv_validated'
|
||||
| 'csv_imported'
|
||||
| 'verified'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
|
||||
export type SetupStepStatus =
|
||||
| 'not_started'
|
||||
| 'running'
|
||||
| 'passed'
|
||||
| 'warning'
|
||||
| 'failed'
|
||||
| 'skipped'
|
||||
| 'stale';
|
||||
|
||||
export interface SetupRunDto {
|
||||
id: string;
|
||||
status: SetupRunStatus;
|
||||
mode: string;
|
||||
targetOrganizationId: string | null;
|
||||
startedBy: string;
|
||||
completedBy: string | null;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
lastError: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SetupRunStepDto {
|
||||
id: string;
|
||||
setupRunId: string;
|
||||
stepKey: string;
|
||||
status: SetupStepStatus;
|
||||
inputHash: string | null;
|
||||
outputJson: Record<string, unknown> | null;
|
||||
errorsJson: unknown[] | null;
|
||||
warningsJson: unknown[] | null;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SeedManifestItemDto {
|
||||
id: string;
|
||||
seedManifestId: string;
|
||||
itemKey: string;
|
||||
sourceFile: string;
|
||||
checksum: string;
|
||||
status: string;
|
||||
importedCount: number;
|
||||
updatedCount: number;
|
||||
skippedCount: number;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
reportJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SeedManifestDto {
|
||||
id: string;
|
||||
setupRunId: string | null;
|
||||
organizationId: string | null;
|
||||
name: string;
|
||||
version: string;
|
||||
type: string;
|
||||
checksum: string;
|
||||
status: string;
|
||||
source: string;
|
||||
appliedBy: string | null;
|
||||
appliedAt: string | null;
|
||||
reportJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SetupRunResponse {
|
||||
active: boolean;
|
||||
completed: boolean;
|
||||
run: SetupRunDto | null;
|
||||
steps: SetupRunStepDto[];
|
||||
manifests: SeedManifestDto[];
|
||||
manifestItems: SeedManifestItemDto[];
|
||||
}
|
||||
|
||||
export interface SaveSetupStepPayload {
|
||||
setupRunId?: string;
|
||||
stepKey: string;
|
||||
status: SetupStepStatus;
|
||||
inputHash?: string | null;
|
||||
output?: Record<string, unknown> | null;
|
||||
errors?: unknown[] | null;
|
||||
warnings?: unknown[] | null;
|
||||
}
|
||||
|
||||
export type {
|
||||
CsvPreviewError,
|
||||
CsvPreviewFileResult,
|
||||
|
||||
42
src/features/setup/api/use-setup-run.ts
Normal file
42
src/features/setup/api/use-setup-run.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { completeSetupRun, saveSetupStep, startSetupRun } from './service';
|
||||
import { setupKeys } from './queries';
|
||||
import type { SaveSetupStepPayload } from './types';
|
||||
|
||||
function useInvalidateSetupRun() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return () => {
|
||||
queryClient.invalidateQueries({ queryKey: setupKeys.run() });
|
||||
queryClient.invalidateQueries({ queryKey: setupKeys.report() });
|
||||
};
|
||||
}
|
||||
|
||||
export function useStartSetupRun() {
|
||||
const invalidate = useInvalidateSetupRun();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => startSetupRun(),
|
||||
onSuccess: invalidate
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveSetupStep() {
|
||||
const invalidate = useInvalidateSetupRun();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: SaveSetupStepPayload) => saveSetupStep(payload),
|
||||
onSuccess: invalidate
|
||||
});
|
||||
}
|
||||
|
||||
export function useCompleteSetupRun() {
|
||||
const invalidate = useInvalidateSetupRun();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (report: Record<string, unknown>) => completeSetupRun(report),
|
||||
onSuccess: invalidate
|
||||
});
|
||||
}
|
||||
@@ -11,8 +11,13 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCsvCommit, useCsvPreview } from '../api/use-csv-commit';
|
||||
import { useCompleteSetupRun, useSaveSetupStep, useStartSetupRun } from '../api/use-setup-run';
|
||||
import { useSetupVerification } from '../api/use-setup-verification';
|
||||
import { setupReadinessQueryOptions, setupTemplatesQueryOptions } from '../api/queries';
|
||||
import {
|
||||
setupReadinessQueryOptions,
|
||||
setupRunQueryOptions,
|
||||
setupTemplatesQueryOptions
|
||||
} from '../api/queries';
|
||||
import type {
|
||||
CsvPreviewError,
|
||||
CsvPreviewFileResult,
|
||||
@@ -22,6 +27,8 @@ import type {
|
||||
SetupCheckStatus,
|
||||
SetupReadinessReport,
|
||||
SetupReportSummary,
|
||||
SetupRunStepDto,
|
||||
SetupStepStatus,
|
||||
SetupTemplateMetadataItem,
|
||||
SetupVerificationReport
|
||||
} from '../api/types';
|
||||
@@ -72,6 +79,16 @@ function getPreviewStatus(preview: CsvPreviewResult | null): SetupCheckStatus |
|
||||
return 'PASS';
|
||||
}
|
||||
|
||||
function getStepStatusFromApiStatus(status: SetupCheckStatus): SetupStepStatus {
|
||||
if (status === 'PASS') return 'passed';
|
||||
if (status === 'WARNING') return 'warning';
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> {
|
||||
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function groupChecks(checks: SetupCheckResult[]): Array<[string, SetupCheckResult[]]> {
|
||||
const groups = new Map<string, SetupCheckResult[]>();
|
||||
checks.forEach((check) => {
|
||||
@@ -259,7 +276,13 @@ function PreviewFileTable({ files }: { files: CsvPreviewFileResult[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CommitReportPanel({ report }: { report: ImportReport | null }) {
|
||||
function CommitReportPanel({
|
||||
report,
|
||||
persistedStep
|
||||
}: {
|
||||
report: ImportReport | null;
|
||||
persistedStep?: SetupRunStepDto;
|
||||
}) {
|
||||
if (!report) return <EmptyState message='Commit has not run yet.' />;
|
||||
|
||||
return (
|
||||
@@ -271,8 +294,11 @@ function CommitReportPanel({ report }: { report: ImportReport | null }) {
|
||||
{report.dryRun ? 'Dry run only' : 'Database commit'} generated {report.generatedAt}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<PersistedStepBadge step={persistedStep} />
|
||||
<StatusBadge status={report.status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-2 sm:grid-cols-5'>
|
||||
<Metric label='Imported' value={report.summary.imported} />
|
||||
<Metric label='Updated' value={report.summary.updated} />
|
||||
@@ -285,7 +311,13 @@ function CommitReportPanel({ report }: { report: ImportReport | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
function VerificationPanel({ report }: { report: SetupVerificationReport | null }) {
|
||||
function VerificationPanel({
|
||||
report,
|
||||
persistedStep
|
||||
}: {
|
||||
report: SetupVerificationReport | null;
|
||||
persistedStep?: SetupRunStepDto;
|
||||
}) {
|
||||
if (!report) return <EmptyState message='Verification has not run yet.' />;
|
||||
|
||||
return (
|
||||
@@ -295,8 +327,11 @@ function VerificationPanel({ report }: { report: SetupVerificationReport | null
|
||||
<div className='font-medium'>{report.message}</div>
|
||||
<div className='text-sm text-muted-foreground'>Organization: {report.organizationId ?? 'Not selected'}</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<PersistedStepBadge step={persistedStep} />
|
||||
<StatusBadge status={report.status} />
|
||||
</div>
|
||||
</div>
|
||||
<SummaryPills summary={report.summary} />
|
||||
<ReportCheckList checks={report.checks} />
|
||||
</div>
|
||||
@@ -325,6 +360,16 @@ function Stepper({ currentStep }: { currentStep: WizardStepKey }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PersistedStepBadge({ step }: { step?: SetupRunStepDto }) {
|
||||
if (!step) return <Badge variant='outline'>not_started</Badge>;
|
||||
|
||||
return (
|
||||
<Badge variant={step.status === 'failed' ? 'destructive' : 'secondary'}>
|
||||
{step.status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetupWizard() {
|
||||
const [currentStep, setCurrentStep] = useState<WizardStepKey>('readiness');
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
@@ -336,9 +381,18 @@ export function SetupWizard() {
|
||||
|
||||
const readinessQuery = useQuery(setupReadinessQueryOptions());
|
||||
const templatesQuery = useQuery(setupTemplatesQueryOptions());
|
||||
const setupRunQuery = useQuery(setupRunQueryOptions());
|
||||
const previewMutation = useCsvPreview();
|
||||
const commitMutation = useCsvCommit();
|
||||
const verificationMutation = useSetupVerification();
|
||||
const startRunMutation = useStartSetupRun();
|
||||
const saveStepMutation = useSaveSetupStep();
|
||||
const completeRunMutation = useCompleteSetupRun();
|
||||
|
||||
const persistedStepByKey = useMemo(() => {
|
||||
const entries = setupRunQuery.data?.steps.map((step) => [step.stepKey, step] as const) ?? [];
|
||||
return new Map(entries);
|
||||
}, [setupRunQuery.data?.steps]);
|
||||
|
||||
const fileFingerprint = useMemo(() => getFileFingerprint(files), [files]);
|
||||
const filesChangedAfterPreview = Boolean(preview) && fileFingerprint !== previewFingerprint;
|
||||
@@ -366,6 +420,36 @@ export function SetupWizard() {
|
||||
setWarningAcknowledged(false);
|
||||
}
|
||||
|
||||
async function saveStep(input: {
|
||||
stepKey: string;
|
||||
status: SetupStepStatus;
|
||||
inputHash?: string | null;
|
||||
output?: Record<string, unknown> | null;
|
||||
errors?: unknown[] | null;
|
||||
warnings?: unknown[] | null;
|
||||
}) {
|
||||
if (!setupRunQuery.data?.active || !setupRunQuery.data.run) return;
|
||||
|
||||
await saveStepMutation.mutateAsync({
|
||||
setupRunId: setupRunQuery.data.run.id,
|
||||
...input
|
||||
});
|
||||
}
|
||||
|
||||
async function runReadiness() {
|
||||
const result = await readinessQuery.refetch();
|
||||
if (!result.data) return;
|
||||
|
||||
await saveStep({
|
||||
stepKey: 'readiness',
|
||||
status: getStepStatusFromApiStatus(result.data.status),
|
||||
inputHash: `readiness:${result.data.status}:${result.data.summary.fail}:${result.data.summary.warning}`,
|
||||
output: toRecord(result.data),
|
||||
errors: result.data.checks.filter((check) => check.status === 'FAIL'),
|
||||
warnings: result.data.checks.filter((check) => check.status === 'WARNING')
|
||||
});
|
||||
}
|
||||
|
||||
async function runPreview() {
|
||||
const result = await previewMutation.mutateAsync(files);
|
||||
setPreview(result);
|
||||
@@ -373,6 +457,15 @@ export function SetupWizard() {
|
||||
setCommitReport(null);
|
||||
setWarningAcknowledged(false);
|
||||
setCurrentStep('preview');
|
||||
const status = getPreviewStatus(result) ?? 'FAIL';
|
||||
await saveStep({
|
||||
stepKey: 'csv_preview',
|
||||
status: getStepStatusFromApiStatus(status),
|
||||
inputHash: result.previewHash,
|
||||
output: toRecord(result),
|
||||
errors: result.files.flatMap((file) => file.errors),
|
||||
warnings: result.files.flatMap((file) => file.warnings)
|
||||
});
|
||||
}
|
||||
|
||||
async function runCommit() {
|
||||
@@ -383,14 +476,40 @@ export function SetupWizard() {
|
||||
dryRun
|
||||
});
|
||||
setCommitReport(report);
|
||||
await saveStep({
|
||||
stepKey: 'csv_commit',
|
||||
status: getStepStatusFromApiStatus(report.status),
|
||||
inputHash: report.committedHash,
|
||||
output: toRecord(report),
|
||||
errors: report.errors,
|
||||
warnings: report.warnings
|
||||
});
|
||||
}
|
||||
|
||||
async function runVerification() {
|
||||
const report = await verificationMutation.mutateAsync({});
|
||||
setCurrentStep('verification');
|
||||
await saveStep({
|
||||
stepKey: 'verification',
|
||||
status: getStepStatusFromApiStatus(report.status),
|
||||
inputHash: `verification:${report.status}:${report.summary.fail}:${report.summary.warning}`,
|
||||
output: toRecord(report),
|
||||
errors: report.checks.filter((check) => check.status === 'FAIL'),
|
||||
warnings: report.checks.filter((check) => check.status === 'WARNING')
|
||||
});
|
||||
return report;
|
||||
}
|
||||
|
||||
async function completeSetup() {
|
||||
await completeRunMutation.mutateAsync({
|
||||
readiness: readinessQuery.data?.status ?? persistedStepByKey.get('readiness')?.status ?? 'not_started',
|
||||
preview: previewStatus ?? persistedStepByKey.get('csv_preview')?.status ?? 'not_started',
|
||||
commit: commitReport?.status ?? persistedStepByKey.get('csv_commit')?.status ?? 'not_started',
|
||||
verification:
|
||||
verificationMutation.data?.status ?? persistedStepByKey.get('verification')?.status ?? 'not_started'
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<Stepper currentStep={currentStep} />
|
||||
@@ -404,6 +523,61 @@ export function SetupWizard() {
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Setup run</CardTitle>
|
||||
<CardDescription>Resume persisted setup progress across browser refreshes.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{setupRunQuery.isError ? (
|
||||
<Alert>
|
||||
<Icons.warning className='h-4 w-4' />
|
||||
<AlertTitle>Persistence unavailable</AlertTitle>
|
||||
<AlertDescription>
|
||||
The wizard can continue in this browser session, but setup progress could not be
|
||||
loaded.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{setupRunQuery.data?.run ? (
|
||||
<div className='grid gap-3 md:grid-cols-[1fr_auto]'>
|
||||
<div>
|
||||
<div className='font-medium'>
|
||||
{setupRunQuery.data.active ? 'Active setup run' : 'Latest completed setup run'}
|
||||
</div>
|
||||
<div className='text-sm text-muted-foreground'>
|
||||
Status: {setupRunQuery.data.run.status} · Started: {setupRunQuery.data.run.startedAt}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={setupRunQuery.data.completed ? 'default' : 'secondary'}>
|
||||
{setupRunQuery.data.completed ? 'completed' : 'resumable'}
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState message='No setup run has been started yet.' />
|
||||
)}
|
||||
<div className='grid gap-2 sm:grid-cols-4'>
|
||||
<Metric label='Readiness' value={persistedStepByKey.get('readiness')?.status ?? 'not_started'} />
|
||||
<Metric label='CSV Preview' value={persistedStepByKey.get('csv_preview')?.status ?? 'not_started'} />
|
||||
<Metric label='CSV Commit' value={persistedStepByKey.get('csv_commit')?.status ?? 'not_started'} />
|
||||
<Metric label='Verification' value={persistedStepByKey.get('verification')?.status ?? 'not_started'} />
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => startRunMutation.mutate()}
|
||||
disabled={startRunMutation.isPending || setupRunQuery.data?.active}
|
||||
>
|
||||
{startRunMutation.isPending ? (
|
||||
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Icons.check className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
Start setup run
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||||
@@ -414,15 +588,15 @@ export function SetupWizard() {
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => readinessQuery.refetch()}
|
||||
disabled={readinessQuery.isFetching}
|
||||
onClick={runReadiness}
|
||||
>
|
||||
{readinessQuery.isFetching ? (
|
||||
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Icons.checks className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
Re-run
|
||||
Re-run and save
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -435,7 +609,11 @@ export function SetupWizard() {
|
||||
) : null}
|
||||
{readinessQuery.isLoading ? <EmptyState message='Loading setup readiness checks...' /> : null}
|
||||
{readinessQuery.data ? (
|
||||
<ReadinessPanel report={readinessQuery.data} onContinue={() => setCurrentStep('templates')} />
|
||||
<ReadinessPanel
|
||||
report={readinessQuery.data}
|
||||
persistedStep={persistedStepByKey.get('readiness')}
|
||||
onContinue={() => setCurrentStep('templates')}
|
||||
/>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -532,8 +710,11 @@ export function SetupWizard() {
|
||||
<div className='font-medium'>Preview hash</div>
|
||||
<div className='break-all text-sm text-muted-foreground'>{preview.previewHash}</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<PersistedStepBadge step={persistedStepByKey.get('csv_preview')} />
|
||||
{previewStatus ? <StatusBadge status={previewStatus} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-2 sm:grid-cols-6'>
|
||||
<Metric label='Files' value={preview.summary.files} />
|
||||
<Metric label='Rows' value={preview.summary.rows} />
|
||||
@@ -599,7 +780,7 @@ export function SetupWizard() {
|
||||
{dryRun ? 'Run commit dry run' : 'Commit import'}
|
||||
</Button>
|
||||
{commitMutation.isError ? <ErrorAlert message={getErrorMessage(commitMutation.error)} /> : null}
|
||||
<CommitReportPanel report={commitReport} />
|
||||
<CommitReportPanel report={commitReport} persistedStep={persistedStepByKey.get('csv_commit')} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -623,7 +804,10 @@ export function SetupWizard() {
|
||||
<ErrorAlert message={getErrorMessage(verificationMutation.error)} />
|
||||
</>
|
||||
) : null}
|
||||
<VerificationPanel report={verificationMutation.data ?? null} />
|
||||
<VerificationPanel
|
||||
report={verificationMutation.data ?? null}
|
||||
persistedStep={persistedStepByKey.get('verification')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -639,8 +823,33 @@ export function SetupWizard() {
|
||||
<Metric label='Commit' value={commitReport?.status ?? 'Not run'} />
|
||||
<Metric label='Verification' value={verificationMutation.data?.status ?? 'Not run'} />
|
||||
</div>
|
||||
<Button type='button' variant='outline' onClick={() => setCurrentStep('finish')}>
|
||||
Mark current step complete
|
||||
{setupRunQuery.data?.manifests.length ? (
|
||||
<div className='rounded-md border p-3'>
|
||||
<div className='mb-2 text-sm font-medium'>Seed manifests</div>
|
||||
<div className='space-y-2'>
|
||||
{setupRunQuery.data.manifests.map((manifest) => (
|
||||
<div key={manifest.id} className='flex flex-wrap items-center justify-between gap-2 text-sm'>
|
||||
<span>
|
||||
{manifest.name}@{manifest.version}
|
||||
</span>
|
||||
<Badge variant='secondary'>{manifest.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={completeSetup}
|
||||
disabled={completeRunMutation.isPending || setupRunQuery.data?.completed}
|
||||
>
|
||||
{completeRunMutation.isPending ? (
|
||||
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Icons.checks className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
Complete setup
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -650,9 +859,11 @@ export function SetupWizard() {
|
||||
|
||||
function ReadinessPanel({
|
||||
report,
|
||||
persistedStep,
|
||||
onContinue
|
||||
}: {
|
||||
report: SetupReadinessReport;
|
||||
persistedStep?: SetupRunStepDto;
|
||||
onContinue: () => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -662,8 +873,11 @@ function ReadinessPanel({
|
||||
<div className='font-medium'>{report.message}</div>
|
||||
<div className='text-sm text-muted-foreground'>Checked at {report.time}</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<PersistedStepBadge step={persistedStep} />
|
||||
<StatusBadge status={report.status} />
|
||||
</div>
|
||||
</div>
|
||||
<SummaryPills summary={report.summary} />
|
||||
<ReportCheckList checks={report.checks} />
|
||||
<Button type='button' variant='outline' onClick={onContinue} disabled={report.summary.fail > 0}>
|
||||
|
||||
143
src/features/setup/server/seed-manifest.service.ts
Normal file
143
src/features/setup/server/seed-manifest.service.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import 'server-only';
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import { seedManifestItems, seedManifests } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import type { ImportFileReport, ImportReport } from '@/features/setup/server/csv/import-report';
|
||||
import type { CsvPreviewFileInput } from './csv/types';
|
||||
import { getActiveSetupRunId } from './setup-state.service';
|
||||
|
||||
function createId(prefix: string) {
|
||||
return `${prefix}_${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
function sha256(content: string | Uint8Array) {
|
||||
return `sha256:${createHash('sha256').update(content).digest('hex')}`;
|
||||
}
|
||||
|
||||
function normalizeCsvBytes(bytes: Uint8Array) {
|
||||
return Buffer.from(bytes).toString('utf8').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
}
|
||||
|
||||
function buildManifestChecksum(input: {
|
||||
name: string;
|
||||
version: string;
|
||||
type: string;
|
||||
source: string;
|
||||
items: Array<{ itemKey: string; checksum: string }>;
|
||||
}) {
|
||||
return sha256(
|
||||
JSON.stringify({
|
||||
name: input.name,
|
||||
version: input.version,
|
||||
type: input.type,
|
||||
source: input.source,
|
||||
items: input.items.toSorted((a, b) => a.itemKey.localeCompare(b.itemKey))
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function toItemStatus(file: ImportFileReport): 'applied' | 'warning' | 'failed' | 'skipped' {
|
||||
if (file.status === 'FAIL') return 'failed';
|
||||
if (file.status === 'WARNING') return 'warning';
|
||||
if (file.imported === 0 && file.updated === 0 && file.skipped > 0) return 'skipped';
|
||||
return 'applied';
|
||||
}
|
||||
|
||||
export async function createCsvImportSeedManifest(input: {
|
||||
files: CsvPreviewFileInput[];
|
||||
report: ImportReport;
|
||||
actorId: string;
|
||||
organizationId?: string | null;
|
||||
}) {
|
||||
if (input.report.dryRun || input.report.status === 'FAIL') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const checksumByFile = new Map(
|
||||
input.files.map((file) => [file.fileName, sha256(normalizeCsvBytes(file.bytes))])
|
||||
);
|
||||
const reportFiles = input.report.files.filter((file) => !file.fileName.startsWith('__'));
|
||||
const itemChecksums = reportFiles.map((file) => ({
|
||||
itemKey: file.fileName,
|
||||
checksum: checksumByFile.get(file.fileName) ?? sha256(file.fileName)
|
||||
}));
|
||||
const manifestChecksum = buildManifestChecksum({
|
||||
name: 'crm-business-import',
|
||||
version: '1.0.0',
|
||||
type: 'business',
|
||||
source: 'csv',
|
||||
items: itemChecksums
|
||||
});
|
||||
const existingManifests = await db
|
||||
.select()
|
||||
.from(seedManifests)
|
||||
.where(eq(seedManifests.name, 'crm-business-import'))
|
||||
.orderBy(desc(seedManifests.createdAt));
|
||||
const hasSameChecksum = existingManifests.some(
|
||||
(manifest) => manifest.version === '1.0.0' && manifest.checksum === manifestChecksum
|
||||
);
|
||||
const hasChangedChecksum = existingManifests.some(
|
||||
(manifest) => manifest.version === '1.0.0' && manifest.checksum !== manifestChecksum
|
||||
);
|
||||
const status = hasSameChecksum ? 'skipped' : hasChangedChecksum ? 'warning' : 'applied';
|
||||
const timestamp = new Date();
|
||||
const setupRunId = await getActiveSetupRunId();
|
||||
const manifestId = createId('seedmf');
|
||||
|
||||
await db.insert(seedManifests).values({
|
||||
id: manifestId,
|
||||
setupRunId,
|
||||
organizationId: input.organizationId ?? null,
|
||||
name: 'crm-business-import',
|
||||
version: '1.0.0',
|
||||
type: 'business',
|
||||
checksum: manifestChecksum,
|
||||
status,
|
||||
source: 'csv',
|
||||
appliedBy: input.actorId,
|
||||
appliedAt: timestamp,
|
||||
reportJson: {
|
||||
status: input.report.status,
|
||||
summary: input.report.summary,
|
||||
previewHash: input.report.previewHash,
|
||||
committedHash: input.report.committedHash,
|
||||
warnings: input.report.warnings,
|
||||
errors: input.report.errors
|
||||
},
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
reportFiles.map((file) =>
|
||||
db.insert(seedManifestItems).values({
|
||||
id: createId('seedmi'),
|
||||
seedManifestId: manifestId,
|
||||
itemKey: file.fileName,
|
||||
sourceFile: file.fileName,
|
||||
checksum: checksumByFile.get(file.fileName) ?? sha256(file.fileName),
|
||||
status: status === 'skipped' ? 'skipped' : toItemStatus(file),
|
||||
importedCount: file.imported,
|
||||
updatedCount: file.updated,
|
||||
skippedCount: file.skipped,
|
||||
errorCount: file.errors.length,
|
||||
warningCount: file.warnings.length,
|
||||
reportJson: {
|
||||
template: file.template,
|
||||
status: file.status,
|
||||
imported: file.imported,
|
||||
updated: file.updated,
|
||||
skipped: file.skipped,
|
||||
errors: file.errors,
|
||||
warnings: file.warnings
|
||||
},
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return manifestId;
|
||||
}
|
||||
335
src/features/setup/server/setup-state.service.ts
Normal file
335
src/features/setup/server/setup-state.service.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import 'server-only';
|
||||
|
||||
import { and, desc, eq, ne } from 'drizzle-orm';
|
||||
import { setupRuns, setupRunSteps, seedManifests, seedManifestItems } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export type SetupRunStatus =
|
||||
| 'not_started'
|
||||
| 'readiness_checked'
|
||||
| 'foundation_installed'
|
||||
| 'organization_created'
|
||||
| 'administrator_created'
|
||||
| 'scope_configured'
|
||||
| 'sequences_configured'
|
||||
| 'approval_configured'
|
||||
| 'csv_validated'
|
||||
| 'csv_imported'
|
||||
| 'verified'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
|
||||
export type SetupStepStatus =
|
||||
| 'not_started'
|
||||
| 'running'
|
||||
| 'passed'
|
||||
| 'warning'
|
||||
| 'failed'
|
||||
| 'skipped'
|
||||
| 'stale';
|
||||
|
||||
export type SetupRunMode = 'production' | 'demo_uat' | 'dry_run';
|
||||
|
||||
export interface SaveSetupStepInput {
|
||||
setupRunId?: string;
|
||||
stepKey: string;
|
||||
status: SetupStepStatus;
|
||||
inputHash?: string | null;
|
||||
output?: Record<string, unknown> | null;
|
||||
errors?: unknown[] | null;
|
||||
warnings?: unknown[] | null;
|
||||
}
|
||||
|
||||
const ACTIVE_RUN_STATUSES: SetupRunStatus[] = [
|
||||
'not_started',
|
||||
'readiness_checked',
|
||||
'foundation_installed',
|
||||
'organization_created',
|
||||
'administrator_created',
|
||||
'scope_configured',
|
||||
'sequences_configured',
|
||||
'approval_configured',
|
||||
'csv_validated',
|
||||
'csv_imported',
|
||||
'verified',
|
||||
'failed'
|
||||
];
|
||||
|
||||
const STEP_TO_RUN_STATUS: Record<string, SetupRunStatus> = {
|
||||
readiness: 'readiness_checked',
|
||||
foundation: 'foundation_installed',
|
||||
organization: 'organization_created',
|
||||
administrator: 'administrator_created',
|
||||
scope: 'scope_configured',
|
||||
sequences: 'sequences_configured',
|
||||
approval: 'approval_configured',
|
||||
csv_preview: 'csv_validated',
|
||||
csv_commit: 'csv_imported',
|
||||
verification: 'verified'
|
||||
};
|
||||
|
||||
const STALE_DEPENDENCIES: Record<string, string[]> = {
|
||||
readiness: ['foundation', 'organization', 'administrator', 'scope', 'sequences', 'approval', 'csv_preview', 'csv_commit', 'verification'],
|
||||
organization: ['administrator', 'scope', 'sequences', 'approval', 'csv_preview', 'csv_commit', 'verification'],
|
||||
administrator: ['verification'],
|
||||
scope: ['sequences', 'approval', 'csv_preview', 'csv_commit', 'verification'],
|
||||
sequences: ['verification'],
|
||||
approval: ['csv_preview', 'csv_commit', 'verification'],
|
||||
csv_preview: ['csv_commit'],
|
||||
csv_commit: ['verification']
|
||||
};
|
||||
|
||||
function now() {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
function createId(prefix: string) {
|
||||
return `${prefix}_${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
function isActiveRun(status: string): status is SetupRunStatus {
|
||||
return ACTIVE_RUN_STATUSES.includes(status as SetupRunStatus);
|
||||
}
|
||||
|
||||
function sanitizeOutput(stepKey: string, output: Record<string, unknown> | null | undefined) {
|
||||
if (!output) return null;
|
||||
|
||||
if (stepKey === 'csv_preview') {
|
||||
const summary = output.summary ?? null;
|
||||
const generatedAt = output.generatedAt ?? null;
|
||||
const previewHash = output.previewHash ?? null;
|
||||
const files = Array.isArray(output.files)
|
||||
? output.files.map((file) => {
|
||||
if (!file || typeof file !== 'object') return file;
|
||||
const candidate = file as Record<string, unknown>;
|
||||
return {
|
||||
fileName: candidate.fileName,
|
||||
template: candidate.template,
|
||||
importOrder: candidate.importOrder,
|
||||
status: candidate.status,
|
||||
summary: candidate.summary,
|
||||
errors: candidate.errors,
|
||||
warnings: candidate.warnings
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
return { summary, generatedAt, previewHash, files };
|
||||
}
|
||||
|
||||
if (stepKey === 'csv_commit') {
|
||||
const files = Array.isArray(output.files)
|
||||
? output.files.map((file) => {
|
||||
if (!file || typeof file !== 'object') return file;
|
||||
const candidate = file as Record<string, unknown>;
|
||||
return {
|
||||
fileName: candidate.fileName,
|
||||
template: candidate.template,
|
||||
status: candidate.status,
|
||||
imported: candidate.imported,
|
||||
updated: candidate.updated,
|
||||
skipped: candidate.skipped,
|
||||
errors: candidate.errors,
|
||||
warnings: candidate.warnings
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
return {
|
||||
status: output.status,
|
||||
generatedAt: output.generatedAt,
|
||||
previewHash: output.previewHash,
|
||||
committedHash: output.committedHash,
|
||||
dryRun: output.dryRun,
|
||||
summary: output.summary,
|
||||
errors: output.errors,
|
||||
warnings: output.warnings,
|
||||
files
|
||||
};
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
async function getLatestRunByStatus(statuses: SetupRunStatus[]) {
|
||||
const runs = await db.select().from(setupRuns).orderBy(desc(setupRuns.startedAt));
|
||||
return runs.find((run) => statuses.includes(run.status as SetupRunStatus)) ?? null;
|
||||
}
|
||||
|
||||
export async function getCurrentSetupRun() {
|
||||
const activeRun = await getLatestRunByStatus(ACTIVE_RUN_STATUSES);
|
||||
const latestCompletedRun = activeRun ? null : await getLatestRunByStatus(['completed']);
|
||||
const run = activeRun ?? latestCompletedRun;
|
||||
|
||||
if (!run) {
|
||||
return {
|
||||
active: false,
|
||||
run: null,
|
||||
steps: [],
|
||||
manifests: [],
|
||||
manifestItems: [],
|
||||
completed: false
|
||||
};
|
||||
}
|
||||
|
||||
const [steps, manifests] = await Promise.all([
|
||||
db.select().from(setupRunSteps).where(eq(setupRunSteps.setupRunId, run.id)),
|
||||
db.select().from(seedManifests).where(eq(seedManifests.setupRunId, run.id))
|
||||
]);
|
||||
|
||||
const manifestItems =
|
||||
manifests.length === 0
|
||||
? []
|
||||
: await db.select().from(seedManifestItems).where(
|
||||
// D.7.8 keeps this simple: report endpoint can group all items by matching manifest ids in memory.
|
||||
ne(seedManifestItems.seedManifestId, '__none__')
|
||||
);
|
||||
|
||||
const manifestIds = new Set(manifests.map((manifest) => manifest.id));
|
||||
|
||||
return {
|
||||
active: isActiveRun(run.status),
|
||||
run,
|
||||
steps,
|
||||
manifests,
|
||||
manifestItems: manifestItems.filter((item) => manifestIds.has(item.seedManifestId)),
|
||||
completed: run.status === 'completed'
|
||||
};
|
||||
}
|
||||
|
||||
export async function startSetupRun(input: {
|
||||
actorId: string;
|
||||
mode?: SetupRunMode;
|
||||
targetOrganizationId?: string | null;
|
||||
}) {
|
||||
const current = await getCurrentSetupRun();
|
||||
|
||||
if (current.run && current.active) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const timestamp = now();
|
||||
const id = createId('setrun');
|
||||
|
||||
await db.insert(setupRuns).values({
|
||||
id,
|
||||
status: 'not_started',
|
||||
mode: input.mode ?? 'production',
|
||||
targetOrganizationId: input.targetOrganizationId ?? null,
|
||||
startedBy: input.actorId,
|
||||
startedAt: timestamp,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
});
|
||||
|
||||
return getCurrentSetupRun();
|
||||
}
|
||||
|
||||
export async function saveSetupStep(input: SaveSetupStepInput) {
|
||||
const current = input.setupRunId
|
||||
? { run: (await db.select().from(setupRuns).where(eq(setupRuns.id, input.setupRunId)))[0] ?? null }
|
||||
: await getCurrentSetupRun();
|
||||
|
||||
const run = current.run;
|
||||
if (!run) {
|
||||
throw new Error('Start a setup run before saving setup steps.');
|
||||
}
|
||||
|
||||
const existingStep = (
|
||||
await db
|
||||
.select()
|
||||
.from(setupRunSteps)
|
||||
.where(and(eq(setupRunSteps.setupRunId, run.id), eq(setupRunSteps.stepKey, input.stepKey)))
|
||||
)[0];
|
||||
const timestamp = now();
|
||||
const sanitizedOutput = sanitizeOutput(input.stepKey, input.output);
|
||||
const completedAt = input.status === 'running' || input.status === 'not_started' ? null : timestamp;
|
||||
const inputChanged =
|
||||
Boolean(existingStep) &&
|
||||
existingStep.inputHash !== null &&
|
||||
input.inputHash !== null &&
|
||||
existingStep.inputHash !== input.inputHash;
|
||||
|
||||
await db
|
||||
.insert(setupRunSteps)
|
||||
.values({
|
||||
id: existingStep?.id ?? createId('setstep'),
|
||||
setupRunId: run.id,
|
||||
stepKey: input.stepKey,
|
||||
status: input.status,
|
||||
inputHash: input.inputHash ?? null,
|
||||
outputJson: sanitizedOutput,
|
||||
errorsJson: input.errors ?? null,
|
||||
warningsJson: input.warnings ?? null,
|
||||
startedAt: existingStep?.startedAt ?? timestamp,
|
||||
completedAt,
|
||||
createdAt: existingStep?.createdAt ?? timestamp,
|
||||
updatedAt: timestamp
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [setupRunSteps.setupRunId, setupRunSteps.stepKey],
|
||||
set: {
|
||||
status: input.status,
|
||||
inputHash: input.inputHash ?? null,
|
||||
outputJson: sanitizedOutput,
|
||||
errorsJson: input.errors ?? null,
|
||||
warningsJson: input.warnings ?? null,
|
||||
completedAt,
|
||||
updatedAt: timestamp
|
||||
}
|
||||
});
|
||||
|
||||
if (inputChanged) {
|
||||
const staleSteps = STALE_DEPENDENCIES[input.stepKey] ?? [];
|
||||
await Promise.all(
|
||||
staleSteps.map((stepKey) =>
|
||||
db
|
||||
.update(setupRunSteps)
|
||||
.set({ status: 'stale', updatedAt: timestamp })
|
||||
.where(and(eq(setupRunSteps.setupRunId, run.id), eq(setupRunSteps.stepKey, stepKey)))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const nextRunStatus =
|
||||
input.status === 'failed' ? 'failed' : STEP_TO_RUN_STATUS[input.stepKey] ?? (run.status as SetupRunStatus);
|
||||
|
||||
await db.update(setupRuns).set({ status: nextRunStatus, updatedAt: timestamp }).where(eq(setupRuns.id, run.id));
|
||||
|
||||
return getCurrentSetupRun();
|
||||
}
|
||||
|
||||
export async function completeSetupRun(input: { actorId: string; setupRunId?: string; report?: Record<string, unknown> }) {
|
||||
const current = input.setupRunId
|
||||
? { run: (await db.select().from(setupRuns).where(eq(setupRuns.id, input.setupRunId)))[0] ?? null }
|
||||
: await getCurrentSetupRun();
|
||||
|
||||
const run = current.run;
|
||||
if (!run) {
|
||||
throw new Error('Start a setup run before completing setup.');
|
||||
}
|
||||
|
||||
const timestamp = now();
|
||||
await db
|
||||
.update(setupRuns)
|
||||
.set({
|
||||
status: 'completed',
|
||||
completedBy: input.actorId,
|
||||
completedAt: timestamp,
|
||||
metadata: input.report ?? null,
|
||||
updatedAt: timestamp
|
||||
})
|
||||
.where(eq(setupRuns.id, run.id));
|
||||
|
||||
return getCurrentSetupRun();
|
||||
}
|
||||
|
||||
export async function getSetupRunReport() {
|
||||
return getCurrentSetupRun();
|
||||
}
|
||||
|
||||
export async function getActiveSetupRunId() {
|
||||
const current = await getCurrentSetupRun();
|
||||
return current.active ? current.run?.id ?? null : null;
|
||||
}
|
||||
Reference in New Issue
Block a user