task-d.7.8

This commit is contained in:
phaichayon
2026-07-03 10:19:03 +07:00
parent 110c2f9e7f
commit 9b05743b1e
18 changed files with 1980 additions and 16 deletions

View File

@@ -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.

View 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");

View File

@@ -29,6 +29,13 @@
"when": 1782881325779,
"tag": "0003_curious_wild_child",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1783094400000,
"tag": "0004_setup_state_seed_manifest",
"breakpoints": true
}
]
}
}

681
plans/task-d.7.8.md Normal file
View 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

View File

@@ -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) {

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View File

@@ -57,6 +57,88 @@ export const memberships = pgTable('memberships', {
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
});
export const setupRuns = pgTable('setup_runs', {
id: text('id').primaryKey(),
status: text('status').default('not_started').notNull(),
mode: text('mode').default('production').notNull(),
targetOrganizationId: text('target_organization_id'),
startedBy: text('started_by').notNull(),
completedBy: text('completed_by'),
startedAt: timestamp('started_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
lastError: text('last_error'),
metadata: jsonb('metadata').$type<Record<string, unknown> | null>(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
});
export const setupRunSteps = pgTable(
'setup_run_steps',
{
id: text('id').primaryKey(),
setupRunId: text('setup_run_id').notNull(),
stepKey: text('step_key').notNull(),
status: text('status').default('not_started').notNull(),
inputHash: text('input_hash'),
outputJson: jsonb('output_json').$type<Record<string, unknown> | null>(),
errorsJson: jsonb('errors_json').$type<unknown[] | null>(),
warningsJson: jsonb('warnings_json').$type<unknown[] | null>(),
startedAt: timestamp('started_at', { withTimezone: true }),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
setupRunStepIdx: uniqueIndex('setup_run_steps_run_step_idx').on(
table.setupRunId,
table.stepKey
)
})
);
export const seedManifests = pgTable('seed_manifests', {
id: text('id').primaryKey(),
setupRunId: text('setup_run_id'),
organizationId: text('organization_id'),
name: text('name').notNull(),
version: text('version').notNull(),
type: text('type').notNull(),
checksum: text('checksum').notNull(),
status: text('status').default('pending').notNull(),
source: text('source').notNull(),
appliedBy: text('applied_by'),
appliedAt: timestamp('applied_at', { withTimezone: true }),
reportJson: jsonb('report_json').$type<Record<string, unknown> | null>(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
});
export const seedManifestItems = pgTable(
'seed_manifest_items',
{
id: text('id').primaryKey(),
seedManifestId: text('seed_manifest_id').notNull(),
itemKey: text('item_key').notNull(),
sourceFile: text('source_file').notNull(),
checksum: text('checksum').notNull(),
status: text('status').default('pending').notNull(),
importedCount: integer('imported_count').default(0).notNull(),
updatedCount: integer('updated_count').default(0).notNull(),
skippedCount: integer('skipped_count').default(0).notNull(),
errorCount: integer('error_count').default(0).notNull(),
warningCount: integer('warning_count').default(0).notNull(),
reportJson: jsonb('report_json').$type<Record<string, unknown> | null>(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
seedManifestItemIdx: uniqueIndex('seed_manifest_items_manifest_item_idx').on(
table.seedManifestId,
table.itemKey
)
})
);
export const crmRoleProfiles = pgTable(
'crm_role_profiles',
{

View File

@@ -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),

View File

@@ -2,7 +2,9 @@ import { ApiClientError, apiClient } from '@/lib/api-client';
import type {
CsvPreviewResult,
ImportReport,
SaveSetupStepPayload,
SetupReadinessReport,
SetupRunResponse,
SetupTemplatesResponse,
SetupVerificationPayload,
SetupVerificationReport
@@ -21,6 +23,35 @@ export async function getSetupTemplates(): Promise<SetupTemplatesResponse> {
return apiClient<SetupTemplatesResponse>('/setup/templates');
}
export async function getCurrentSetupRun(): Promise<SetupRunResponse> {
return apiClient<SetupRunResponse>('/setup/run/current');
}
export async function startSetupRun(): Promise<SetupRunResponse> {
return apiClient<SetupRunResponse>('/setup/run/start', {
method: 'POST',
body: JSON.stringify({ mode: 'production' })
});
}
export async function saveSetupStep(payload: SaveSetupStepPayload): Promise<SetupRunResponse> {
return apiClient<SetupRunResponse>('/setup/run/steps', {
method: 'POST',
body: JSON.stringify(payload)
});
}
export async function completeSetupRun(report: Record<string, unknown>): Promise<SetupRunResponse> {
return apiClient<SetupRunResponse>('/setup/run/complete', {
method: 'POST',
body: JSON.stringify({ report })
});
}
export async function getSetupRunReport(): Promise<SetupRunResponse> {
return apiClient<SetupRunResponse>('/setup/run/report');
}
export async function verifySetup(
payload: SetupVerificationPayload = {}
): Promise<SetupVerificationReport> {

View File

@@ -55,6 +55,114 @@ export interface SetupTemplatesResponse {
templates: SetupTemplateMetadataItem[];
}
export type SetupRunStatus =
| 'not_started'
| 'readiness_checked'
| 'foundation_installed'
| 'organization_created'
| 'administrator_created'
| 'scope_configured'
| 'sequences_configured'
| 'approval_configured'
| 'csv_validated'
| 'csv_imported'
| 'verified'
| 'completed'
| 'failed'
| 'cancelled';
export type SetupStepStatus =
| 'not_started'
| 'running'
| 'passed'
| 'warning'
| 'failed'
| 'skipped'
| 'stale';
export interface SetupRunDto {
id: string;
status: SetupRunStatus;
mode: string;
targetOrganizationId: string | null;
startedBy: string;
completedBy: string | null;
startedAt: string;
completedAt: string | null;
lastError: string | null;
metadata: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
}
export interface SetupRunStepDto {
id: string;
setupRunId: string;
stepKey: string;
status: SetupStepStatus;
inputHash: string | null;
outputJson: Record<string, unknown> | null;
errorsJson: unknown[] | null;
warningsJson: unknown[] | null;
startedAt: string | null;
completedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface SeedManifestItemDto {
id: string;
seedManifestId: string;
itemKey: string;
sourceFile: string;
checksum: string;
status: string;
importedCount: number;
updatedCount: number;
skippedCount: number;
errorCount: number;
warningCount: number;
reportJson: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
}
export interface SeedManifestDto {
id: string;
setupRunId: string | null;
organizationId: string | null;
name: string;
version: string;
type: string;
checksum: string;
status: string;
source: string;
appliedBy: string | null;
appliedAt: string | null;
reportJson: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
}
export interface SetupRunResponse {
active: boolean;
completed: boolean;
run: SetupRunDto | null;
steps: SetupRunStepDto[];
manifests: SeedManifestDto[];
manifestItems: SeedManifestItemDto[];
}
export interface SaveSetupStepPayload {
setupRunId?: string;
stepKey: string;
status: SetupStepStatus;
inputHash?: string | null;
output?: Record<string, unknown> | null;
errors?: unknown[] | null;
warnings?: unknown[] | null;
}
export type {
CsvPreviewError,
CsvPreviewFileResult,

View 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
});
}

View File

@@ -11,8 +11,13 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { useCsvCommit, useCsvPreview } from '../api/use-csv-commit';
import { useCompleteSetupRun, useSaveSetupStep, useStartSetupRun } from '../api/use-setup-run';
import { useSetupVerification } from '../api/use-setup-verification';
import { setupReadinessQueryOptions, setupTemplatesQueryOptions } from '../api/queries';
import {
setupReadinessQueryOptions,
setupRunQueryOptions,
setupTemplatesQueryOptions
} from '../api/queries';
import type {
CsvPreviewError,
CsvPreviewFileResult,
@@ -22,6 +27,8 @@ import type {
SetupCheckStatus,
SetupReadinessReport,
SetupReportSummary,
SetupRunStepDto,
SetupStepStatus,
SetupTemplateMetadataItem,
SetupVerificationReport
} from '../api/types';
@@ -72,6 +79,16 @@ function getPreviewStatus(preview: CsvPreviewResult | null): SetupCheckStatus |
return 'PASS';
}
function getStepStatusFromApiStatus(status: SetupCheckStatus): SetupStepStatus {
if (status === 'PASS') return 'passed';
if (status === 'WARNING') return 'warning';
return 'failed';
}
function toRecord(value: unknown): Record<string, unknown> {
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
}
function groupChecks(checks: SetupCheckResult[]): Array<[string, SetupCheckResult[]]> {
const groups = new Map<string, SetupCheckResult[]>();
checks.forEach((check) => {
@@ -259,7 +276,13 @@ function PreviewFileTable({ files }: { files: CsvPreviewFileResult[] }) {
);
}
function CommitReportPanel({ report }: { report: ImportReport | null }) {
function CommitReportPanel({
report,
persistedStep
}: {
report: ImportReport | null;
persistedStep?: SetupRunStepDto;
}) {
if (!report) return <EmptyState message='Commit has not run yet.' />;
return (
@@ -271,7 +294,10 @@ function CommitReportPanel({ report }: { report: ImportReport | null }) {
{report.dryRun ? 'Dry run only' : 'Database commit'} generated {report.generatedAt}
</div>
</div>
<StatusBadge status={report.status} />
<div className='flex items-center gap-2'>
<PersistedStepBadge step={persistedStep} />
<StatusBadge status={report.status} />
</div>
</div>
<div className='grid gap-2 sm:grid-cols-5'>
<Metric label='Imported' value={report.summary.imported} />
@@ -285,7 +311,13 @@ function CommitReportPanel({ report }: { report: ImportReport | null }) {
);
}
function VerificationPanel({ report }: { report: SetupVerificationReport | null }) {
function VerificationPanel({
report,
persistedStep
}: {
report: SetupVerificationReport | null;
persistedStep?: SetupRunStepDto;
}) {
if (!report) return <EmptyState message='Verification has not run yet.' />;
return (
@@ -295,7 +327,10 @@ function VerificationPanel({ report }: { report: SetupVerificationReport | null
<div className='font-medium'>{report.message}</div>
<div className='text-sm text-muted-foreground'>Organization: {report.organizationId ?? 'Not selected'}</div>
</div>
<StatusBadge status={report.status} />
<div className='flex items-center gap-2'>
<PersistedStepBadge step={persistedStep} />
<StatusBadge status={report.status} />
</div>
</div>
<SummaryPills summary={report.summary} />
<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() {
const [currentStep, setCurrentStep] = useState<WizardStepKey>('readiness');
const [files, setFiles] = useState<File[]>([]);
@@ -336,9 +381,18 @@ export function SetupWizard() {
const readinessQuery = useQuery(setupReadinessQueryOptions());
const templatesQuery = useQuery(setupTemplatesQueryOptions());
const setupRunQuery = useQuery(setupRunQueryOptions());
const previewMutation = useCsvPreview();
const commitMutation = useCsvCommit();
const verificationMutation = useSetupVerification();
const startRunMutation = useStartSetupRun();
const saveStepMutation = useSaveSetupStep();
const completeRunMutation = useCompleteSetupRun();
const persistedStepByKey = useMemo(() => {
const entries = setupRunQuery.data?.steps.map((step) => [step.stepKey, step] as const) ?? [];
return new Map(entries);
}, [setupRunQuery.data?.steps]);
const fileFingerprint = useMemo(() => getFileFingerprint(files), [files]);
const filesChangedAfterPreview = Boolean(preview) && fileFingerprint !== previewFingerprint;
@@ -366,6 +420,36 @@ export function SetupWizard() {
setWarningAcknowledged(false);
}
async function saveStep(input: {
stepKey: string;
status: SetupStepStatus;
inputHash?: string | null;
output?: Record<string, unknown> | null;
errors?: unknown[] | null;
warnings?: unknown[] | null;
}) {
if (!setupRunQuery.data?.active || !setupRunQuery.data.run) return;
await saveStepMutation.mutateAsync({
setupRunId: setupRunQuery.data.run.id,
...input
});
}
async function runReadiness() {
const result = await readinessQuery.refetch();
if (!result.data) return;
await saveStep({
stepKey: 'readiness',
status: getStepStatusFromApiStatus(result.data.status),
inputHash: `readiness:${result.data.status}:${result.data.summary.fail}:${result.data.summary.warning}`,
output: toRecord(result.data),
errors: result.data.checks.filter((check) => check.status === 'FAIL'),
warnings: result.data.checks.filter((check) => check.status === 'WARNING')
});
}
async function runPreview() {
const result = await previewMutation.mutateAsync(files);
setPreview(result);
@@ -373,6 +457,15 @@ export function SetupWizard() {
setCommitReport(null);
setWarningAcknowledged(false);
setCurrentStep('preview');
const status = getPreviewStatus(result) ?? 'FAIL';
await saveStep({
stepKey: 'csv_preview',
status: getStepStatusFromApiStatus(status),
inputHash: result.previewHash,
output: toRecord(result),
errors: result.files.flatMap((file) => file.errors),
warnings: result.files.flatMap((file) => file.warnings)
});
}
async function runCommit() {
@@ -383,14 +476,40 @@ export function SetupWizard() {
dryRun
});
setCommitReport(report);
await saveStep({
stepKey: 'csv_commit',
status: getStepStatusFromApiStatus(report.status),
inputHash: report.committedHash,
output: toRecord(report),
errors: report.errors,
warnings: report.warnings
});
}
async function runVerification() {
const report = await verificationMutation.mutateAsync({});
setCurrentStep('verification');
await saveStep({
stepKey: 'verification',
status: getStepStatusFromApiStatus(report.status),
inputHash: `verification:${report.status}:${report.summary.fail}:${report.summary.warning}`,
output: toRecord(report),
errors: report.checks.filter((check) => check.status === 'FAIL'),
warnings: report.checks.filter((check) => check.status === 'WARNING')
});
return report;
}
async function completeSetup() {
await completeRunMutation.mutateAsync({
readiness: readinessQuery.data?.status ?? persistedStepByKey.get('readiness')?.status ?? 'not_started',
preview: previewStatus ?? persistedStepByKey.get('csv_preview')?.status ?? 'not_started',
commit: commitReport?.status ?? persistedStepByKey.get('csv_commit')?.status ?? 'not_started',
verification:
verificationMutation.data?.status ?? persistedStepByKey.get('verification')?.status ?? 'not_started'
});
}
return (
<div className='space-y-6'>
<Stepper currentStep={currentStep} />
@@ -404,6 +523,61 @@ export function SetupWizard() {
</AlertDescription>
</Alert>
<Card>
<CardHeader>
<CardTitle>Setup run</CardTitle>
<CardDescription>Resume persisted setup progress across browser refreshes.</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{setupRunQuery.isError ? (
<Alert>
<Icons.warning className='h-4 w-4' />
<AlertTitle>Persistence unavailable</AlertTitle>
<AlertDescription>
The wizard can continue in this browser session, but setup progress could not be
loaded.
</AlertDescription>
</Alert>
) : null}
{setupRunQuery.data?.run ? (
<div className='grid gap-3 md:grid-cols-[1fr_auto]'>
<div>
<div className='font-medium'>
{setupRunQuery.data.active ? 'Active setup run' : 'Latest completed setup run'}
</div>
<div className='text-sm text-muted-foreground'>
Status: {setupRunQuery.data.run.status} · Started: {setupRunQuery.data.run.startedAt}
</div>
</div>
<Badge variant={setupRunQuery.data.completed ? 'default' : 'secondary'}>
{setupRunQuery.data.completed ? 'completed' : 'resumable'}
</Badge>
</div>
) : (
<EmptyState message='No setup run has been started yet.' />
)}
<div className='grid gap-2 sm:grid-cols-4'>
<Metric label='Readiness' value={persistedStepByKey.get('readiness')?.status ?? 'not_started'} />
<Metric label='CSV Preview' value={persistedStepByKey.get('csv_preview')?.status ?? 'not_started'} />
<Metric label='CSV Commit' value={persistedStepByKey.get('csv_commit')?.status ?? 'not_started'} />
<Metric label='Verification' value={persistedStepByKey.get('verification')?.status ?? 'not_started'} />
</div>
<Button
type='button'
variant='outline'
onClick={() => startRunMutation.mutate()}
disabled={startRunMutation.isPending || setupRunQuery.data?.active}
>
{startRunMutation.isPending ? (
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
) : (
<Icons.check className='mr-2 h-4 w-4' />
)}
Start setup run
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className='flex flex-wrap items-center justify-between gap-3'>
@@ -414,15 +588,15 @@ export function SetupWizard() {
<Button
type='button'
variant='outline'
onClick={() => readinessQuery.refetch()}
disabled={readinessQuery.isFetching}
onClick={runReadiness}
>
{readinessQuery.isFetching ? (
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
) : (
<Icons.checks className='mr-2 h-4 w-4' />
)}
Re-run
Re-run and save
</Button>
</div>
</CardHeader>
@@ -435,7 +609,11 @@ export function SetupWizard() {
) : null}
{readinessQuery.isLoading ? <EmptyState message='Loading setup readiness checks...' /> : null}
{readinessQuery.data ? (
<ReadinessPanel report={readinessQuery.data} onContinue={() => setCurrentStep('templates')} />
<ReadinessPanel
report={readinessQuery.data}
persistedStep={persistedStepByKey.get('readiness')}
onContinue={() => setCurrentStep('templates')}
/>
) : null}
</CardContent>
</Card>
@@ -532,7 +710,10 @@ export function SetupWizard() {
<div className='font-medium'>Preview hash</div>
<div className='break-all text-sm text-muted-foreground'>{preview.previewHash}</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 className='grid gap-2 sm:grid-cols-6'>
<Metric label='Files' value={preview.summary.files} />
@@ -599,7 +780,7 @@ export function SetupWizard() {
{dryRun ? 'Run commit dry run' : 'Commit import'}
</Button>
{commitMutation.isError ? <ErrorAlert message={getErrorMessage(commitMutation.error)} /> : null}
<CommitReportPanel report={commitReport} />
<CommitReportPanel report={commitReport} persistedStep={persistedStepByKey.get('csv_commit')} />
</CardContent>
</Card>
@@ -623,7 +804,10 @@ export function SetupWizard() {
<ErrorAlert message={getErrorMessage(verificationMutation.error)} />
</>
) : null}
<VerificationPanel report={verificationMutation.data ?? null} />
<VerificationPanel
report={verificationMutation.data ?? null}
persistedStep={persistedStepByKey.get('verification')}
/>
</CardContent>
</Card>
@@ -639,8 +823,33 @@ export function SetupWizard() {
<Metric label='Commit' value={commitReport?.status ?? 'Not run'} />
<Metric label='Verification' value={verificationMutation.data?.status ?? 'Not run'} />
</div>
<Button type='button' variant='outline' onClick={() => setCurrentStep('finish')}>
Mark current step complete
{setupRunQuery.data?.manifests.length ? (
<div className='rounded-md border p-3'>
<div className='mb-2 text-sm font-medium'>Seed manifests</div>
<div className='space-y-2'>
{setupRunQuery.data.manifests.map((manifest) => (
<div key={manifest.id} className='flex flex-wrap items-center justify-between gap-2 text-sm'>
<span>
{manifest.name}@{manifest.version}
</span>
<Badge variant='secondary'>{manifest.status}</Badge>
</div>
))}
</div>
</div>
) : null}
<Button
type='button'
variant='outline'
onClick={completeSetup}
disabled={completeRunMutation.isPending || setupRunQuery.data?.completed}
>
{completeRunMutation.isPending ? (
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
) : (
<Icons.checks className='mr-2 h-4 w-4' />
)}
Complete setup
</Button>
</CardContent>
</Card>
@@ -650,9 +859,11 @@ export function SetupWizard() {
function ReadinessPanel({
report,
persistedStep,
onContinue
}: {
report: SetupReadinessReport;
persistedStep?: SetupRunStepDto;
onContinue: () => void;
}) {
return (
@@ -662,7 +873,10 @@ function ReadinessPanel({
<div className='font-medium'>{report.message}</div>
<div className='text-sm text-muted-foreground'>Checked at {report.time}</div>
</div>
<StatusBadge status={report.status} />
<div className='flex items-center gap-2'>
<PersistedStepBadge step={persistedStep} />
<StatusBadge status={report.status} />
</div>
</div>
<SummaryPills summary={report.summary} />
<ReportCheckList checks={report.checks} />

View 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;
}

View 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;
}