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,
|
"when": 1782881325779,
|
||||||
"tag": "0003_curious_wild_child",
|
"tag": "0003_curious_wild_child",
|
||||||
"breakpoints": true
|
"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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { commitSetupCsvImport } from '@/features/setup/server/csv-commit.service';
|
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';
|
import { AuthError, requireSystemRole } from '@/lib/auth/session';
|
||||||
|
|
||||||
function isFile(value: FormDataEntryValue): value is File {
|
function isFile(value: FormDataEntryValue): value is File {
|
||||||
@@ -40,6 +41,14 @@ export async function POST(request: NextRequest) {
|
|||||||
actorId: session.user.id
|
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 });
|
return NextResponse.json(report, { status: report.status === 'FAIL' ? 400 : 200 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
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()
|
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(
|
export const crmRoleProfiles = pgTable(
|
||||||
'crm_role_profiles',
|
'crm_role_profiles',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { queryOptions } from '@tanstack/react-query';
|
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';
|
import type { SetupVerificationPayload } from './types';
|
||||||
|
|
||||||
export const setupKeys = {
|
export const setupKeys = {
|
||||||
all: ['setup'] as const,
|
all: ['setup'] as const,
|
||||||
readiness: () => [...setupKeys.all, 'readiness'] as const,
|
readiness: () => [...setupKeys.all, 'readiness'] as const,
|
||||||
templates: () => [...setupKeys.all, 'templates'] as const,
|
templates: () => [...setupKeys.all, 'templates'] as const,
|
||||||
|
run: () => [...setupKeys.all, 'run'] as const,
|
||||||
|
report: () => [...setupKeys.all, 'run', 'report'] as const,
|
||||||
verification: (payload: SetupVerificationPayload = {}) =>
|
verification: (payload: SetupVerificationPayload = {}) =>
|
||||||
[...setupKeys.all, 'verification', payload] as const
|
[...setupKeys.all, 'verification', payload] as const
|
||||||
};
|
};
|
||||||
@@ -22,6 +30,18 @@ export const setupTemplatesQueryOptions = () =>
|
|||||||
queryFn: () => getSetupTemplates()
|
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 = {}) =>
|
export const setupVerificationQueryOptions = (payload: SetupVerificationPayload = {}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: setupKeys.verification(payload),
|
queryKey: setupKeys.verification(payload),
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { ApiClientError, apiClient } from '@/lib/api-client';
|
|||||||
import type {
|
import type {
|
||||||
CsvPreviewResult,
|
CsvPreviewResult,
|
||||||
ImportReport,
|
ImportReport,
|
||||||
|
SaveSetupStepPayload,
|
||||||
SetupReadinessReport,
|
SetupReadinessReport,
|
||||||
|
SetupRunResponse,
|
||||||
SetupTemplatesResponse,
|
SetupTemplatesResponse,
|
||||||
SetupVerificationPayload,
|
SetupVerificationPayload,
|
||||||
SetupVerificationReport
|
SetupVerificationReport
|
||||||
@@ -21,6 +23,35 @@ export async function getSetupTemplates(): Promise<SetupTemplatesResponse> {
|
|||||||
return apiClient<SetupTemplatesResponse>('/setup/templates');
|
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(
|
export async function verifySetup(
|
||||||
payload: SetupVerificationPayload = {}
|
payload: SetupVerificationPayload = {}
|
||||||
): Promise<SetupVerificationReport> {
|
): Promise<SetupVerificationReport> {
|
||||||
|
|||||||
@@ -55,6 +55,114 @@ export interface SetupTemplatesResponse {
|
|||||||
templates: SetupTemplateMetadataItem[];
|
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 {
|
export type {
|
||||||
CsvPreviewError,
|
CsvPreviewError,
|
||||||
CsvPreviewFileResult,
|
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 { Input } from '@/components/ui/input';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useCsvCommit, useCsvPreview } from '../api/use-csv-commit';
|
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 { useSetupVerification } from '../api/use-setup-verification';
|
||||||
import { setupReadinessQueryOptions, setupTemplatesQueryOptions } from '../api/queries';
|
import {
|
||||||
|
setupReadinessQueryOptions,
|
||||||
|
setupRunQueryOptions,
|
||||||
|
setupTemplatesQueryOptions
|
||||||
|
} from '../api/queries';
|
||||||
import type {
|
import type {
|
||||||
CsvPreviewError,
|
CsvPreviewError,
|
||||||
CsvPreviewFileResult,
|
CsvPreviewFileResult,
|
||||||
@@ -22,6 +27,8 @@ import type {
|
|||||||
SetupCheckStatus,
|
SetupCheckStatus,
|
||||||
SetupReadinessReport,
|
SetupReadinessReport,
|
||||||
SetupReportSummary,
|
SetupReportSummary,
|
||||||
|
SetupRunStepDto,
|
||||||
|
SetupStepStatus,
|
||||||
SetupTemplateMetadataItem,
|
SetupTemplateMetadataItem,
|
||||||
SetupVerificationReport
|
SetupVerificationReport
|
||||||
} from '../api/types';
|
} from '../api/types';
|
||||||
@@ -72,6 +79,16 @@ function getPreviewStatus(preview: CsvPreviewResult | null): SetupCheckStatus |
|
|||||||
return 'PASS';
|
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[]]> {
|
function groupChecks(checks: SetupCheckResult[]): Array<[string, SetupCheckResult[]]> {
|
||||||
const groups = new Map<string, SetupCheckResult[]>();
|
const groups = new Map<string, SetupCheckResult[]>();
|
||||||
checks.forEach((check) => {
|
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.' />;
|
if (!report) return <EmptyState message='Commit has not run yet.' />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -271,7 +294,10 @@ function CommitReportPanel({ report }: { report: ImportReport | null }) {
|
|||||||
{report.dryRun ? 'Dry run only' : 'Database commit'} generated {report.generatedAt}
|
{report.dryRun ? 'Dry run only' : 'Database commit'} generated {report.generatedAt}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={report.status} />
|
<div className='flex items-center gap-2'>
|
||||||
|
<PersistedStepBadge step={persistedStep} />
|
||||||
|
<StatusBadge status={report.status} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='grid gap-2 sm:grid-cols-5'>
|
<div className='grid gap-2 sm:grid-cols-5'>
|
||||||
<Metric label='Imported' value={report.summary.imported} />
|
<Metric label='Imported' value={report.summary.imported} />
|
||||||
@@ -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.' />;
|
if (!report) return <EmptyState message='Verification has not run yet.' />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -295,7 +327,10 @@ function VerificationPanel({ report }: { report: SetupVerificationReport | null
|
|||||||
<div className='font-medium'>{report.message}</div>
|
<div className='font-medium'>{report.message}</div>
|
||||||
<div className='text-sm text-muted-foreground'>Organization: {report.organizationId ?? 'Not selected'}</div>
|
<div className='text-sm text-muted-foreground'>Organization: {report.organizationId ?? 'Not selected'}</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={report.status} />
|
<div className='flex items-center gap-2'>
|
||||||
|
<PersistedStepBadge step={persistedStep} />
|
||||||
|
<StatusBadge status={report.status} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SummaryPills summary={report.summary} />
|
<SummaryPills summary={report.summary} />
|
||||||
<ReportCheckList checks={report.checks} />
|
<ReportCheckList checks={report.checks} />
|
||||||
@@ -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() {
|
export function SetupWizard() {
|
||||||
const [currentStep, setCurrentStep] = useState<WizardStepKey>('readiness');
|
const [currentStep, setCurrentStep] = useState<WizardStepKey>('readiness');
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
@@ -336,9 +381,18 @@ export function SetupWizard() {
|
|||||||
|
|
||||||
const readinessQuery = useQuery(setupReadinessQueryOptions());
|
const readinessQuery = useQuery(setupReadinessQueryOptions());
|
||||||
const templatesQuery = useQuery(setupTemplatesQueryOptions());
|
const templatesQuery = useQuery(setupTemplatesQueryOptions());
|
||||||
|
const setupRunQuery = useQuery(setupRunQueryOptions());
|
||||||
const previewMutation = useCsvPreview();
|
const previewMutation = useCsvPreview();
|
||||||
const commitMutation = useCsvCommit();
|
const commitMutation = useCsvCommit();
|
||||||
const verificationMutation = useSetupVerification();
|
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 fileFingerprint = useMemo(() => getFileFingerprint(files), [files]);
|
||||||
const filesChangedAfterPreview = Boolean(preview) && fileFingerprint !== previewFingerprint;
|
const filesChangedAfterPreview = Boolean(preview) && fileFingerprint !== previewFingerprint;
|
||||||
@@ -366,6 +420,36 @@ export function SetupWizard() {
|
|||||||
setWarningAcknowledged(false);
|
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() {
|
async function runPreview() {
|
||||||
const result = await previewMutation.mutateAsync(files);
|
const result = await previewMutation.mutateAsync(files);
|
||||||
setPreview(result);
|
setPreview(result);
|
||||||
@@ -373,6 +457,15 @@ export function SetupWizard() {
|
|||||||
setCommitReport(null);
|
setCommitReport(null);
|
||||||
setWarningAcknowledged(false);
|
setWarningAcknowledged(false);
|
||||||
setCurrentStep('preview');
|
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() {
|
async function runCommit() {
|
||||||
@@ -383,14 +476,40 @@ export function SetupWizard() {
|
|||||||
dryRun
|
dryRun
|
||||||
});
|
});
|
||||||
setCommitReport(report);
|
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() {
|
async function runVerification() {
|
||||||
const report = await verificationMutation.mutateAsync({});
|
const report = await verificationMutation.mutateAsync({});
|
||||||
setCurrentStep('verification');
|
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;
|
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 (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<Stepper currentStep={currentStep} />
|
<Stepper currentStep={currentStep} />
|
||||||
@@ -404,6 +523,61 @@ export function SetupWizard() {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||||||
@@ -414,15 +588,15 @@ export function SetupWizard() {
|
|||||||
<Button
|
<Button
|
||||||
type='button'
|
type='button'
|
||||||
variant='outline'
|
variant='outline'
|
||||||
onClick={() => readinessQuery.refetch()}
|
|
||||||
disabled={readinessQuery.isFetching}
|
disabled={readinessQuery.isFetching}
|
||||||
|
onClick={runReadiness}
|
||||||
>
|
>
|
||||||
{readinessQuery.isFetching ? (
|
{readinessQuery.isFetching ? (
|
||||||
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
|
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
|
||||||
) : (
|
) : (
|
||||||
<Icons.checks className='mr-2 h-4 w-4' />
|
<Icons.checks className='mr-2 h-4 w-4' />
|
||||||
)}
|
)}
|
||||||
Re-run
|
Re-run and save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -435,7 +609,11 @@ export function SetupWizard() {
|
|||||||
) : null}
|
) : null}
|
||||||
{readinessQuery.isLoading ? <EmptyState message='Loading setup readiness checks...' /> : null}
|
{readinessQuery.isLoading ? <EmptyState message='Loading setup readiness checks...' /> : null}
|
||||||
{readinessQuery.data ? (
|
{readinessQuery.data ? (
|
||||||
<ReadinessPanel report={readinessQuery.data} onContinue={() => setCurrentStep('templates')} />
|
<ReadinessPanel
|
||||||
|
report={readinessQuery.data}
|
||||||
|
persistedStep={persistedStepByKey.get('readiness')}
|
||||||
|
onContinue={() => setCurrentStep('templates')}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -532,7 +710,10 @@ export function SetupWizard() {
|
|||||||
<div className='font-medium'>Preview hash</div>
|
<div className='font-medium'>Preview hash</div>
|
||||||
<div className='break-all text-sm text-muted-foreground'>{preview.previewHash}</div>
|
<div className='break-all text-sm text-muted-foreground'>{preview.previewHash}</div>
|
||||||
</div>
|
</div>
|
||||||
{previewStatus ? <StatusBadge status={previewStatus} /> : null}
|
<div className='flex items-center gap-2'>
|
||||||
|
<PersistedStepBadge step={persistedStepByKey.get('csv_preview')} />
|
||||||
|
{previewStatus ? <StatusBadge status={previewStatus} /> : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='grid gap-2 sm:grid-cols-6'>
|
<div className='grid gap-2 sm:grid-cols-6'>
|
||||||
<Metric label='Files' value={preview.summary.files} />
|
<Metric label='Files' value={preview.summary.files} />
|
||||||
@@ -599,7 +780,7 @@ export function SetupWizard() {
|
|||||||
{dryRun ? 'Run commit dry run' : 'Commit import'}
|
{dryRun ? 'Run commit dry run' : 'Commit import'}
|
||||||
</Button>
|
</Button>
|
||||||
{commitMutation.isError ? <ErrorAlert message={getErrorMessage(commitMutation.error)} /> : null}
|
{commitMutation.isError ? <ErrorAlert message={getErrorMessage(commitMutation.error)} /> : null}
|
||||||
<CommitReportPanel report={commitReport} />
|
<CommitReportPanel report={commitReport} persistedStep={persistedStepByKey.get('csv_commit')} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -623,7 +804,10 @@ export function SetupWizard() {
|
|||||||
<ErrorAlert message={getErrorMessage(verificationMutation.error)} />
|
<ErrorAlert message={getErrorMessage(verificationMutation.error)} />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<VerificationPanel report={verificationMutation.data ?? null} />
|
<VerificationPanel
|
||||||
|
report={verificationMutation.data ?? null}
|
||||||
|
persistedStep={persistedStepByKey.get('verification')}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -639,8 +823,33 @@ export function SetupWizard() {
|
|||||||
<Metric label='Commit' value={commitReport?.status ?? 'Not run'} />
|
<Metric label='Commit' value={commitReport?.status ?? 'Not run'} />
|
||||||
<Metric label='Verification' value={verificationMutation.data?.status ?? 'Not run'} />
|
<Metric label='Verification' value={verificationMutation.data?.status ?? 'Not run'} />
|
||||||
</div>
|
</div>
|
||||||
<Button type='button' variant='outline' onClick={() => setCurrentStep('finish')}>
|
{setupRunQuery.data?.manifests.length ? (
|
||||||
Mark current step complete
|
<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>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -650,9 +859,11 @@ export function SetupWizard() {
|
|||||||
|
|
||||||
function ReadinessPanel({
|
function ReadinessPanel({
|
||||||
report,
|
report,
|
||||||
|
persistedStep,
|
||||||
onContinue
|
onContinue
|
||||||
}: {
|
}: {
|
||||||
report: SetupReadinessReport;
|
report: SetupReadinessReport;
|
||||||
|
persistedStep?: SetupRunStepDto;
|
||||||
onContinue: () => void;
|
onContinue: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -662,7 +873,10 @@ function ReadinessPanel({
|
|||||||
<div className='font-medium'>{report.message}</div>
|
<div className='font-medium'>{report.message}</div>
|
||||||
<div className='text-sm text-muted-foreground'>Checked at {report.time}</div>
|
<div className='text-sm text-muted-foreground'>Checked at {report.time}</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={report.status} />
|
<div className='flex items-center gap-2'>
|
||||||
|
<PersistedStepBadge step={persistedStep} />
|
||||||
|
<StatusBadge status={report.status} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SummaryPills summary={report.summary} />
|
<SummaryPills summary={report.summary} />
|
||||||
<ReportCheckList checks={report.checks} />
|
<ReportCheckList checks={report.checks} />
|
||||||
|
|||||||
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