From 9b05743b1ef4f051d49cc4f9fa3389ee962769c9 Mon Sep 17 00:00:00 2001 From: phaichayon Date: Fri, 3 Jul 2026 10:19:03 +0700 Subject: [PATCH] task-d.7.8 --- ...8-seed-manifest-setup-state-persistence.md | 86 +++ drizzle/0004_setup_state_seed_manifest.sql | 67 ++ drizzle/meta/_journal.json | 9 +- plans/task-d.7.8.md | 681 ++++++++++++++++++ src/app/api/setup/import/commit/route.ts | 9 + src/app/api/setup/run/complete/route.ts | 27 + src/app/api/setup/run/current/route.ts | 16 + src/app/api/setup/run/report/route.ts | 16 + src/app/api/setup/run/start/route.ts | 27 + src/app/api/setup/run/steps/route.ts | 53 ++ src/db/schema.ts | 82 +++ src/features/setup/api/queries.ts | 22 +- src/features/setup/api/service.ts | 31 + src/features/setup/api/types.ts | 108 +++ src/features/setup/api/use-setup-run.ts | 42 ++ .../setup/components/setup-wizard.tsx | 242 ++++++- .../setup/server/seed-manifest.service.ts | 143 ++++ .../setup/server/setup-state.service.ts | 335 +++++++++ 18 files changed, 1980 insertions(+), 16 deletions(-) create mode 100644 docs/implementation/task-d78-seed-manifest-setup-state-persistence.md create mode 100644 drizzle/0004_setup_state_seed_manifest.sql create mode 100644 plans/task-d.7.8.md create mode 100644 src/app/api/setup/run/complete/route.ts create mode 100644 src/app/api/setup/run/current/route.ts create mode 100644 src/app/api/setup/run/report/route.ts create mode 100644 src/app/api/setup/run/start/route.ts create mode 100644 src/app/api/setup/run/steps/route.ts create mode 100644 src/features/setup/api/use-setup-run.ts create mode 100644 src/features/setup/server/seed-manifest.service.ts create mode 100644 src/features/setup/server/setup-state.service.ts diff --git a/docs/implementation/task-d78-seed-manifest-setup-state-persistence.md b/docs/implementation/task-d78-seed-manifest-setup-state-persistence.md new file mode 100644 index 0000000..38538e2 --- /dev/null +++ b/docs/implementation/task-d78-seed-manifest-setup-state-persistence.md @@ -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. diff --git a/drizzle/0004_setup_state_seed_manifest.sql b/drizzle/0004_setup_state_seed_manifest.sql new file mode 100644 index 0000000..eeadb9a --- /dev/null +++ b/drizzle/0004_setup_state_seed_manifest.sql @@ -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"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index b7ee355..37401d2 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] -} \ No newline at end of file +} diff --git a/plans/task-d.7.8.md b/plans/task-d.7.8.md new file mode 100644 index 0000000..6390735 --- /dev/null +++ b/plans/task-d.7.8.md @@ -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 diff --git a/src/app/api/setup/import/commit/route.ts b/src/app/api/setup/import/commit/route.ts index 65656a7..85abd8c 100644 --- a/src/app/api/setup/import/commit/route.ts +++ b/src/app/api/setup/import/commit/route.ts @@ -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) { diff --git a/src/app/api/setup/run/complete/route.ts b/src/app/api/setup/run/complete/route.ts new file mode 100644 index 0000000..b719e31 --- /dev/null +++ b/src/app/api/setup/run/complete/route.ts @@ -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; + }; + + 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 }); + } +} diff --git a/src/app/api/setup/run/current/route.ts b/src/app/api/setup/run/current/route.ts new file mode 100644 index 0000000..29abc0d --- /dev/null +++ b/src/app/api/setup/run/current/route.ts @@ -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 }); + } +} diff --git a/src/app/api/setup/run/report/route.ts b/src/app/api/setup/run/report/route.ts new file mode 100644 index 0000000..683d32c --- /dev/null +++ b/src/app/api/setup/run/report/route.ts @@ -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 }); + } +} diff --git a/src/app/api/setup/run/start/route.ts b/src/app/api/setup/run/start/route.ts new file mode 100644 index 0000000..565804d --- /dev/null +++ b/src/app/api/setup/run/start/route.ts @@ -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 }); + } +} diff --git a/src/app/api/setup/run/steps/route.ts b/src/app/api/setup/run/steps/route.ts new file mode 100644 index 0000000..8201fa8 --- /dev/null +++ b/src/app/api/setup/run/steps/route.ts @@ -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([ + '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 | 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 }); + } +} diff --git a/src/db/schema.ts b/src/db/schema.ts index f691071..24a940f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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 | 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 | null>(), + errorsJson: jsonb('errors_json').$type(), + warningsJson: jsonb('warnings_json').$type(), + 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 | 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 | 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', { diff --git a/src/features/setup/api/queries.ts b/src/features/setup/api/queries.ts index dbdefd4..807c0cd 100644 --- a/src/features/setup/api/queries.ts +++ b/src/features/setup/api/queries.ts @@ -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), diff --git a/src/features/setup/api/service.ts b/src/features/setup/api/service.ts index b650019..e50ee95 100644 --- a/src/features/setup/api/service.ts +++ b/src/features/setup/api/service.ts @@ -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 { return apiClient('/setup/templates'); } +export async function getCurrentSetupRun(): Promise { + return apiClient('/setup/run/current'); +} + +export async function startSetupRun(): Promise { + return apiClient('/setup/run/start', { + method: 'POST', + body: JSON.stringify({ mode: 'production' }) + }); +} + +export async function saveSetupStep(payload: SaveSetupStepPayload): Promise { + return apiClient('/setup/run/steps', { + method: 'POST', + body: JSON.stringify(payload) + }); +} + +export async function completeSetupRun(report: Record): Promise { + return apiClient('/setup/run/complete', { + method: 'POST', + body: JSON.stringify({ report }) + }); +} + +export async function getSetupRunReport(): Promise { + return apiClient('/setup/run/report'); +} + export async function verifySetup( payload: SetupVerificationPayload = {} ): Promise { diff --git a/src/features/setup/api/types.ts b/src/features/setup/api/types.ts index c648c76..7ff71b7 100644 --- a/src/features/setup/api/types.ts +++ b/src/features/setup/api/types.ts @@ -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 | null; + createdAt: string; + updatedAt: string; +} + +export interface SetupRunStepDto { + id: string; + setupRunId: string; + stepKey: string; + status: SetupStepStatus; + inputHash: string | null; + outputJson: Record | 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 | 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 | 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 | null; + errors?: unknown[] | null; + warnings?: unknown[] | null; +} + export type { CsvPreviewError, CsvPreviewFileResult, diff --git a/src/features/setup/api/use-setup-run.ts b/src/features/setup/api/use-setup-run.ts new file mode 100644 index 0000000..1b4a234 --- /dev/null +++ b/src/features/setup/api/use-setup-run.ts @@ -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) => completeSetupRun(report), + onSuccess: invalidate + }); +} diff --git a/src/features/setup/components/setup-wizard.tsx b/src/features/setup/components/setup-wizard.tsx index 76fe77a..7bfc96e 100644 --- a/src/features/setup/components/setup-wizard.tsx +++ b/src/features/setup/components/setup-wizard.tsx @@ -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 { + return JSON.parse(JSON.stringify(value)) as Record; +} + function groupChecks(checks: SetupCheckResult[]): Array<[string, SetupCheckResult[]]> { const groups = new Map(); 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 ; return ( @@ -271,7 +294,10 @@ function CommitReportPanel({ report }: { report: ImportReport | null }) { {report.dryRun ? 'Dry run only' : 'Database commit'} generated {report.generatedAt} - +
+ + +
@@ -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 ; return ( @@ -295,7 +327,10 @@ function VerificationPanel({ report }: { report: SetupVerificationReport | null
{report.message}
Organization: {report.organizationId ?? 'Not selected'}
- +
+ + +
@@ -325,6 +360,16 @@ function Stepper({ currentStep }: { currentStep: WizardStepKey }) { ); } +function PersistedStepBadge({ step }: { step?: SetupRunStepDto }) { + if (!step) return not_started; + + return ( + + {step.status} + + ); +} + export function SetupWizard() { const [currentStep, setCurrentStep] = useState('readiness'); const [files, setFiles] = useState([]); @@ -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 | 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 (
@@ -404,6 +523,61 @@ export function SetupWizard() { + + + Setup run + Resume persisted setup progress across browser refreshes. + + + {setupRunQuery.isError ? ( + + + Persistence unavailable + + The wizard can continue in this browser session, but setup progress could not be + loaded. + + + ) : null} + {setupRunQuery.data?.run ? ( +
+
+
+ {setupRunQuery.data.active ? 'Active setup run' : 'Latest completed setup run'} +
+
+ Status: {setupRunQuery.data.run.status} · Started: {setupRunQuery.data.run.startedAt} +
+
+ + {setupRunQuery.data.completed ? 'completed' : 'resumable'} + +
+ ) : ( + + )} +
+ + + + +
+ +
+
+
@@ -414,15 +588,15 @@ export function SetupWizard() {
@@ -435,7 +609,11 @@ export function SetupWizard() { ) : null} {readinessQuery.isLoading ? : null} {readinessQuery.data ? ( - setCurrentStep('templates')} /> + setCurrentStep('templates')} + /> ) : null}
@@ -532,7 +710,10 @@ export function SetupWizard() {
Preview hash
{preview.previewHash}
- {previewStatus ? : null} +
+ + {previewStatus ? : null} +
@@ -599,7 +780,7 @@ export function SetupWizard() { {dryRun ? 'Run commit dry run' : 'Commit import'} {commitMutation.isError ? : null} - + @@ -623,7 +804,10 @@ export function SetupWizard() { ) : null} - + @@ -639,8 +823,33 @@ export function SetupWizard() {
- @@ -650,9 +859,11 @@ export function SetupWizard() { function ReadinessPanel({ report, + persistedStep, onContinue }: { report: SetupReadinessReport; + persistedStep?: SetupRunStepDto; onContinue: () => void; }) { return ( @@ -662,7 +873,10 @@ function ReadinessPanel({
{report.message}
Checked at {report.time}
- +
+ + +
diff --git a/src/features/setup/server/seed-manifest.service.ts b/src/features/setup/server/seed-manifest.service.ts new file mode 100644 index 0000000..4b15c2a --- /dev/null +++ b/src/features/setup/server/seed-manifest.service.ts @@ -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; +} diff --git a/src/features/setup/server/setup-state.service.ts b/src/features/setup/server/setup-state.service.ts new file mode 100644 index 0000000..ffb5ecd --- /dev/null +++ b/src/features/setup/server/setup-state.service.ts @@ -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 | 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 = { + 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 = { + 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 | 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; + 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; + 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 }) { + 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; +}