task-d.7.8
This commit is contained in:
@@ -1,11 +1,19 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { getSetupReadiness, getSetupTemplates, verifySetup } from './service';
|
||||
import {
|
||||
getCurrentSetupRun,
|
||||
getSetupReadiness,
|
||||
getSetupRunReport,
|
||||
getSetupTemplates,
|
||||
verifySetup
|
||||
} from './service';
|
||||
import type { SetupVerificationPayload } from './types';
|
||||
|
||||
export const setupKeys = {
|
||||
all: ['setup'] as const,
|
||||
readiness: () => [...setupKeys.all, 'readiness'] as const,
|
||||
templates: () => [...setupKeys.all, 'templates'] as const,
|
||||
run: () => [...setupKeys.all, 'run'] as const,
|
||||
report: () => [...setupKeys.all, 'run', 'report'] as const,
|
||||
verification: (payload: SetupVerificationPayload = {}) =>
|
||||
[...setupKeys.all, 'verification', payload] as const
|
||||
};
|
||||
@@ -22,6 +30,18 @@ export const setupTemplatesQueryOptions = () =>
|
||||
queryFn: () => getSetupTemplates()
|
||||
});
|
||||
|
||||
export const setupRunQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: setupKeys.run(),
|
||||
queryFn: () => getCurrentSetupRun()
|
||||
});
|
||||
|
||||
export const setupRunReportQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: setupKeys.report(),
|
||||
queryFn: () => getSetupRunReport()
|
||||
});
|
||||
|
||||
export const setupVerificationQueryOptions = (payload: SetupVerificationPayload = {}) =>
|
||||
queryOptions({
|
||||
queryKey: setupKeys.verification(payload),
|
||||
|
||||
@@ -2,7 +2,9 @@ import { ApiClientError, apiClient } from '@/lib/api-client';
|
||||
import type {
|
||||
CsvPreviewResult,
|
||||
ImportReport,
|
||||
SaveSetupStepPayload,
|
||||
SetupReadinessReport,
|
||||
SetupRunResponse,
|
||||
SetupTemplatesResponse,
|
||||
SetupVerificationPayload,
|
||||
SetupVerificationReport
|
||||
@@ -21,6 +23,35 @@ export async function getSetupTemplates(): Promise<SetupTemplatesResponse> {
|
||||
return apiClient<SetupTemplatesResponse>('/setup/templates');
|
||||
}
|
||||
|
||||
export async function getCurrentSetupRun(): Promise<SetupRunResponse> {
|
||||
return apiClient<SetupRunResponse>('/setup/run/current');
|
||||
}
|
||||
|
||||
export async function startSetupRun(): Promise<SetupRunResponse> {
|
||||
return apiClient<SetupRunResponse>('/setup/run/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode: 'production' })
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveSetupStep(payload: SaveSetupStepPayload): Promise<SetupRunResponse> {
|
||||
return apiClient<SetupRunResponse>('/setup/run/steps', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeSetupRun(report: Record<string, unknown>): Promise<SetupRunResponse> {
|
||||
return apiClient<SetupRunResponse>('/setup/run/complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ report })
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSetupRunReport(): Promise<SetupRunResponse> {
|
||||
return apiClient<SetupRunResponse>('/setup/run/report');
|
||||
}
|
||||
|
||||
export async function verifySetup(
|
||||
payload: SetupVerificationPayload = {}
|
||||
): Promise<SetupVerificationReport> {
|
||||
|
||||
@@ -55,6 +55,114 @@ export interface SetupTemplatesResponse {
|
||||
templates: SetupTemplateMetadataItem[];
|
||||
}
|
||||
|
||||
export type SetupRunStatus =
|
||||
| 'not_started'
|
||||
| 'readiness_checked'
|
||||
| 'foundation_installed'
|
||||
| 'organization_created'
|
||||
| 'administrator_created'
|
||||
| 'scope_configured'
|
||||
| 'sequences_configured'
|
||||
| 'approval_configured'
|
||||
| 'csv_validated'
|
||||
| 'csv_imported'
|
||||
| 'verified'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
|
||||
export type SetupStepStatus =
|
||||
| 'not_started'
|
||||
| 'running'
|
||||
| 'passed'
|
||||
| 'warning'
|
||||
| 'failed'
|
||||
| 'skipped'
|
||||
| 'stale';
|
||||
|
||||
export interface SetupRunDto {
|
||||
id: string;
|
||||
status: SetupRunStatus;
|
||||
mode: string;
|
||||
targetOrganizationId: string | null;
|
||||
startedBy: string;
|
||||
completedBy: string | null;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
lastError: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SetupRunStepDto {
|
||||
id: string;
|
||||
setupRunId: string;
|
||||
stepKey: string;
|
||||
status: SetupStepStatus;
|
||||
inputHash: string | null;
|
||||
outputJson: Record<string, unknown> | null;
|
||||
errorsJson: unknown[] | null;
|
||||
warningsJson: unknown[] | null;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SeedManifestItemDto {
|
||||
id: string;
|
||||
seedManifestId: string;
|
||||
itemKey: string;
|
||||
sourceFile: string;
|
||||
checksum: string;
|
||||
status: string;
|
||||
importedCount: number;
|
||||
updatedCount: number;
|
||||
skippedCount: number;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
reportJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SeedManifestDto {
|
||||
id: string;
|
||||
setupRunId: string | null;
|
||||
organizationId: string | null;
|
||||
name: string;
|
||||
version: string;
|
||||
type: string;
|
||||
checksum: string;
|
||||
status: string;
|
||||
source: string;
|
||||
appliedBy: string | null;
|
||||
appliedAt: string | null;
|
||||
reportJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SetupRunResponse {
|
||||
active: boolean;
|
||||
completed: boolean;
|
||||
run: SetupRunDto | null;
|
||||
steps: SetupRunStepDto[];
|
||||
manifests: SeedManifestDto[];
|
||||
manifestItems: SeedManifestItemDto[];
|
||||
}
|
||||
|
||||
export interface SaveSetupStepPayload {
|
||||
setupRunId?: string;
|
||||
stepKey: string;
|
||||
status: SetupStepStatus;
|
||||
inputHash?: string | null;
|
||||
output?: Record<string, unknown> | null;
|
||||
errors?: unknown[] | null;
|
||||
warnings?: unknown[] | null;
|
||||
}
|
||||
|
||||
export type {
|
||||
CsvPreviewError,
|
||||
CsvPreviewFileResult,
|
||||
|
||||
42
src/features/setup/api/use-setup-run.ts
Normal file
42
src/features/setup/api/use-setup-run.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { completeSetupRun, saveSetupStep, startSetupRun } from './service';
|
||||
import { setupKeys } from './queries';
|
||||
import type { SaveSetupStepPayload } from './types';
|
||||
|
||||
function useInvalidateSetupRun() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return () => {
|
||||
queryClient.invalidateQueries({ queryKey: setupKeys.run() });
|
||||
queryClient.invalidateQueries({ queryKey: setupKeys.report() });
|
||||
};
|
||||
}
|
||||
|
||||
export function useStartSetupRun() {
|
||||
const invalidate = useInvalidateSetupRun();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => startSetupRun(),
|
||||
onSuccess: invalidate
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveSetupStep() {
|
||||
const invalidate = useInvalidateSetupRun();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: SaveSetupStepPayload) => saveSetupStep(payload),
|
||||
onSuccess: invalidate
|
||||
});
|
||||
}
|
||||
|
||||
export function useCompleteSetupRun() {
|
||||
const invalidate = useInvalidateSetupRun();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (report: Record<string, unknown>) => completeSetupRun(report),
|
||||
onSuccess: invalidate
|
||||
});
|
||||
}
|
||||
@@ -11,8 +11,13 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCsvCommit, useCsvPreview } from '../api/use-csv-commit';
|
||||
import { useCompleteSetupRun, useSaveSetupStep, useStartSetupRun } from '../api/use-setup-run';
|
||||
import { useSetupVerification } from '../api/use-setup-verification';
|
||||
import { setupReadinessQueryOptions, setupTemplatesQueryOptions } from '../api/queries';
|
||||
import {
|
||||
setupReadinessQueryOptions,
|
||||
setupRunQueryOptions,
|
||||
setupTemplatesQueryOptions
|
||||
} from '../api/queries';
|
||||
import type {
|
||||
CsvPreviewError,
|
||||
CsvPreviewFileResult,
|
||||
@@ -22,6 +27,8 @@ import type {
|
||||
SetupCheckStatus,
|
||||
SetupReadinessReport,
|
||||
SetupReportSummary,
|
||||
SetupRunStepDto,
|
||||
SetupStepStatus,
|
||||
SetupTemplateMetadataItem,
|
||||
SetupVerificationReport
|
||||
} from '../api/types';
|
||||
@@ -72,6 +79,16 @@ function getPreviewStatus(preview: CsvPreviewResult | null): SetupCheckStatus |
|
||||
return 'PASS';
|
||||
}
|
||||
|
||||
function getStepStatusFromApiStatus(status: SetupCheckStatus): SetupStepStatus {
|
||||
if (status === 'PASS') return 'passed';
|
||||
if (status === 'WARNING') return 'warning';
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> {
|
||||
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function groupChecks(checks: SetupCheckResult[]): Array<[string, SetupCheckResult[]]> {
|
||||
const groups = new Map<string, SetupCheckResult[]>();
|
||||
checks.forEach((check) => {
|
||||
@@ -259,7 +276,13 @@ function PreviewFileTable({ files }: { files: CsvPreviewFileResult[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CommitReportPanel({ report }: { report: ImportReport | null }) {
|
||||
function CommitReportPanel({
|
||||
report,
|
||||
persistedStep
|
||||
}: {
|
||||
report: ImportReport | null;
|
||||
persistedStep?: SetupRunStepDto;
|
||||
}) {
|
||||
if (!report) return <EmptyState message='Commit has not run yet.' />;
|
||||
|
||||
return (
|
||||
@@ -271,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} />
|
||||
|
||||
143
src/features/setup/server/seed-manifest.service.ts
Normal file
143
src/features/setup/server/seed-manifest.service.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import 'server-only';
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import { seedManifestItems, seedManifests } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import type { ImportFileReport, ImportReport } from '@/features/setup/server/csv/import-report';
|
||||
import type { CsvPreviewFileInput } from './csv/types';
|
||||
import { getActiveSetupRunId } from './setup-state.service';
|
||||
|
||||
function createId(prefix: string) {
|
||||
return `${prefix}_${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
function sha256(content: string | Uint8Array) {
|
||||
return `sha256:${createHash('sha256').update(content).digest('hex')}`;
|
||||
}
|
||||
|
||||
function normalizeCsvBytes(bytes: Uint8Array) {
|
||||
return Buffer.from(bytes).toString('utf8').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
}
|
||||
|
||||
function buildManifestChecksum(input: {
|
||||
name: string;
|
||||
version: string;
|
||||
type: string;
|
||||
source: string;
|
||||
items: Array<{ itemKey: string; checksum: string }>;
|
||||
}) {
|
||||
return sha256(
|
||||
JSON.stringify({
|
||||
name: input.name,
|
||||
version: input.version,
|
||||
type: input.type,
|
||||
source: input.source,
|
||||
items: input.items.toSorted((a, b) => a.itemKey.localeCompare(b.itemKey))
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function toItemStatus(file: ImportFileReport): 'applied' | 'warning' | 'failed' | 'skipped' {
|
||||
if (file.status === 'FAIL') return 'failed';
|
||||
if (file.status === 'WARNING') return 'warning';
|
||||
if (file.imported === 0 && file.updated === 0 && file.skipped > 0) return 'skipped';
|
||||
return 'applied';
|
||||
}
|
||||
|
||||
export async function createCsvImportSeedManifest(input: {
|
||||
files: CsvPreviewFileInput[];
|
||||
report: ImportReport;
|
||||
actorId: string;
|
||||
organizationId?: string | null;
|
||||
}) {
|
||||
if (input.report.dryRun || input.report.status === 'FAIL') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const checksumByFile = new Map(
|
||||
input.files.map((file) => [file.fileName, sha256(normalizeCsvBytes(file.bytes))])
|
||||
);
|
||||
const reportFiles = input.report.files.filter((file) => !file.fileName.startsWith('__'));
|
||||
const itemChecksums = reportFiles.map((file) => ({
|
||||
itemKey: file.fileName,
|
||||
checksum: checksumByFile.get(file.fileName) ?? sha256(file.fileName)
|
||||
}));
|
||||
const manifestChecksum = buildManifestChecksum({
|
||||
name: 'crm-business-import',
|
||||
version: '1.0.0',
|
||||
type: 'business',
|
||||
source: 'csv',
|
||||
items: itemChecksums
|
||||
});
|
||||
const existingManifests = await db
|
||||
.select()
|
||||
.from(seedManifests)
|
||||
.where(eq(seedManifests.name, 'crm-business-import'))
|
||||
.orderBy(desc(seedManifests.createdAt));
|
||||
const hasSameChecksum = existingManifests.some(
|
||||
(manifest) => manifest.version === '1.0.0' && manifest.checksum === manifestChecksum
|
||||
);
|
||||
const hasChangedChecksum = existingManifests.some(
|
||||
(manifest) => manifest.version === '1.0.0' && manifest.checksum !== manifestChecksum
|
||||
);
|
||||
const status = hasSameChecksum ? 'skipped' : hasChangedChecksum ? 'warning' : 'applied';
|
||||
const timestamp = new Date();
|
||||
const setupRunId = await getActiveSetupRunId();
|
||||
const manifestId = createId('seedmf');
|
||||
|
||||
await db.insert(seedManifests).values({
|
||||
id: manifestId,
|
||||
setupRunId,
|
||||
organizationId: input.organizationId ?? null,
|
||||
name: 'crm-business-import',
|
||||
version: '1.0.0',
|
||||
type: 'business',
|
||||
checksum: manifestChecksum,
|
||||
status,
|
||||
source: 'csv',
|
||||
appliedBy: input.actorId,
|
||||
appliedAt: timestamp,
|
||||
reportJson: {
|
||||
status: input.report.status,
|
||||
summary: input.report.summary,
|
||||
previewHash: input.report.previewHash,
|
||||
committedHash: input.report.committedHash,
|
||||
warnings: input.report.warnings,
|
||||
errors: input.report.errors
|
||||
},
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
reportFiles.map((file) =>
|
||||
db.insert(seedManifestItems).values({
|
||||
id: createId('seedmi'),
|
||||
seedManifestId: manifestId,
|
||||
itemKey: file.fileName,
|
||||
sourceFile: file.fileName,
|
||||
checksum: checksumByFile.get(file.fileName) ?? sha256(file.fileName),
|
||||
status: status === 'skipped' ? 'skipped' : toItemStatus(file),
|
||||
importedCount: file.imported,
|
||||
updatedCount: file.updated,
|
||||
skippedCount: file.skipped,
|
||||
errorCount: file.errors.length,
|
||||
warningCount: file.warnings.length,
|
||||
reportJson: {
|
||||
template: file.template,
|
||||
status: file.status,
|
||||
imported: file.imported,
|
||||
updated: file.updated,
|
||||
skipped: file.skipped,
|
||||
errors: file.errors,
|
||||
warnings: file.warnings
|
||||
},
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return manifestId;
|
||||
}
|
||||
335
src/features/setup/server/setup-state.service.ts
Normal file
335
src/features/setup/server/setup-state.service.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import 'server-only';
|
||||
|
||||
import { and, desc, eq, ne } from 'drizzle-orm';
|
||||
import { setupRuns, setupRunSteps, seedManifests, seedManifestItems } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export type SetupRunStatus =
|
||||
| 'not_started'
|
||||
| 'readiness_checked'
|
||||
| 'foundation_installed'
|
||||
| 'organization_created'
|
||||
| 'administrator_created'
|
||||
| 'scope_configured'
|
||||
| 'sequences_configured'
|
||||
| 'approval_configured'
|
||||
| 'csv_validated'
|
||||
| 'csv_imported'
|
||||
| 'verified'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
|
||||
export type SetupStepStatus =
|
||||
| 'not_started'
|
||||
| 'running'
|
||||
| 'passed'
|
||||
| 'warning'
|
||||
| 'failed'
|
||||
| 'skipped'
|
||||
| 'stale';
|
||||
|
||||
export type SetupRunMode = 'production' | 'demo_uat' | 'dry_run';
|
||||
|
||||
export interface SaveSetupStepInput {
|
||||
setupRunId?: string;
|
||||
stepKey: string;
|
||||
status: SetupStepStatus;
|
||||
inputHash?: string | null;
|
||||
output?: Record<string, unknown> | null;
|
||||
errors?: unknown[] | null;
|
||||
warnings?: unknown[] | null;
|
||||
}
|
||||
|
||||
const ACTIVE_RUN_STATUSES: SetupRunStatus[] = [
|
||||
'not_started',
|
||||
'readiness_checked',
|
||||
'foundation_installed',
|
||||
'organization_created',
|
||||
'administrator_created',
|
||||
'scope_configured',
|
||||
'sequences_configured',
|
||||
'approval_configured',
|
||||
'csv_validated',
|
||||
'csv_imported',
|
||||
'verified',
|
||||
'failed'
|
||||
];
|
||||
|
||||
const STEP_TO_RUN_STATUS: Record<string, SetupRunStatus> = {
|
||||
readiness: 'readiness_checked',
|
||||
foundation: 'foundation_installed',
|
||||
organization: 'organization_created',
|
||||
administrator: 'administrator_created',
|
||||
scope: 'scope_configured',
|
||||
sequences: 'sequences_configured',
|
||||
approval: 'approval_configured',
|
||||
csv_preview: 'csv_validated',
|
||||
csv_commit: 'csv_imported',
|
||||
verification: 'verified'
|
||||
};
|
||||
|
||||
const STALE_DEPENDENCIES: Record<string, string[]> = {
|
||||
readiness: ['foundation', 'organization', 'administrator', 'scope', 'sequences', 'approval', 'csv_preview', 'csv_commit', 'verification'],
|
||||
organization: ['administrator', 'scope', 'sequences', 'approval', 'csv_preview', 'csv_commit', 'verification'],
|
||||
administrator: ['verification'],
|
||||
scope: ['sequences', 'approval', 'csv_preview', 'csv_commit', 'verification'],
|
||||
sequences: ['verification'],
|
||||
approval: ['csv_preview', 'csv_commit', 'verification'],
|
||||
csv_preview: ['csv_commit'],
|
||||
csv_commit: ['verification']
|
||||
};
|
||||
|
||||
function now() {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
function createId(prefix: string) {
|
||||
return `${prefix}_${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
function isActiveRun(status: string): status is SetupRunStatus {
|
||||
return ACTIVE_RUN_STATUSES.includes(status as SetupRunStatus);
|
||||
}
|
||||
|
||||
function sanitizeOutput(stepKey: string, output: Record<string, unknown> | null | undefined) {
|
||||
if (!output) return null;
|
||||
|
||||
if (stepKey === 'csv_preview') {
|
||||
const summary = output.summary ?? null;
|
||||
const generatedAt = output.generatedAt ?? null;
|
||||
const previewHash = output.previewHash ?? null;
|
||||
const files = Array.isArray(output.files)
|
||||
? output.files.map((file) => {
|
||||
if (!file || typeof file !== 'object') return file;
|
||||
const candidate = file as Record<string, unknown>;
|
||||
return {
|
||||
fileName: candidate.fileName,
|
||||
template: candidate.template,
|
||||
importOrder: candidate.importOrder,
|
||||
status: candidate.status,
|
||||
summary: candidate.summary,
|
||||
errors: candidate.errors,
|
||||
warnings: candidate.warnings
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
return { summary, generatedAt, previewHash, files };
|
||||
}
|
||||
|
||||
if (stepKey === 'csv_commit') {
|
||||
const files = Array.isArray(output.files)
|
||||
? output.files.map((file) => {
|
||||
if (!file || typeof file !== 'object') return file;
|
||||
const candidate = file as Record<string, unknown>;
|
||||
return {
|
||||
fileName: candidate.fileName,
|
||||
template: candidate.template,
|
||||
status: candidate.status,
|
||||
imported: candidate.imported,
|
||||
updated: candidate.updated,
|
||||
skipped: candidate.skipped,
|
||||
errors: candidate.errors,
|
||||
warnings: candidate.warnings
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
return {
|
||||
status: output.status,
|
||||
generatedAt: output.generatedAt,
|
||||
previewHash: output.previewHash,
|
||||
committedHash: output.committedHash,
|
||||
dryRun: output.dryRun,
|
||||
summary: output.summary,
|
||||
errors: output.errors,
|
||||
warnings: output.warnings,
|
||||
files
|
||||
};
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
async function getLatestRunByStatus(statuses: SetupRunStatus[]) {
|
||||
const runs = await db.select().from(setupRuns).orderBy(desc(setupRuns.startedAt));
|
||||
return runs.find((run) => statuses.includes(run.status as SetupRunStatus)) ?? null;
|
||||
}
|
||||
|
||||
export async function getCurrentSetupRun() {
|
||||
const activeRun = await getLatestRunByStatus(ACTIVE_RUN_STATUSES);
|
||||
const latestCompletedRun = activeRun ? null : await getLatestRunByStatus(['completed']);
|
||||
const run = activeRun ?? latestCompletedRun;
|
||||
|
||||
if (!run) {
|
||||
return {
|
||||
active: false,
|
||||
run: null,
|
||||
steps: [],
|
||||
manifests: [],
|
||||
manifestItems: [],
|
||||
completed: false
|
||||
};
|
||||
}
|
||||
|
||||
const [steps, manifests] = await Promise.all([
|
||||
db.select().from(setupRunSteps).where(eq(setupRunSteps.setupRunId, run.id)),
|
||||
db.select().from(seedManifests).where(eq(seedManifests.setupRunId, run.id))
|
||||
]);
|
||||
|
||||
const manifestItems =
|
||||
manifests.length === 0
|
||||
? []
|
||||
: await db.select().from(seedManifestItems).where(
|
||||
// D.7.8 keeps this simple: report endpoint can group all items by matching manifest ids in memory.
|
||||
ne(seedManifestItems.seedManifestId, '__none__')
|
||||
);
|
||||
|
||||
const manifestIds = new Set(manifests.map((manifest) => manifest.id));
|
||||
|
||||
return {
|
||||
active: isActiveRun(run.status),
|
||||
run,
|
||||
steps,
|
||||
manifests,
|
||||
manifestItems: manifestItems.filter((item) => manifestIds.has(item.seedManifestId)),
|
||||
completed: run.status === 'completed'
|
||||
};
|
||||
}
|
||||
|
||||
export async function startSetupRun(input: {
|
||||
actorId: string;
|
||||
mode?: SetupRunMode;
|
||||
targetOrganizationId?: string | null;
|
||||
}) {
|
||||
const current = await getCurrentSetupRun();
|
||||
|
||||
if (current.run && current.active) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const timestamp = now();
|
||||
const id = createId('setrun');
|
||||
|
||||
await db.insert(setupRuns).values({
|
||||
id,
|
||||
status: 'not_started',
|
||||
mode: input.mode ?? 'production',
|
||||
targetOrganizationId: input.targetOrganizationId ?? null,
|
||||
startedBy: input.actorId,
|
||||
startedAt: timestamp,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
});
|
||||
|
||||
return getCurrentSetupRun();
|
||||
}
|
||||
|
||||
export async function saveSetupStep(input: SaveSetupStepInput) {
|
||||
const current = input.setupRunId
|
||||
? { run: (await db.select().from(setupRuns).where(eq(setupRuns.id, input.setupRunId)))[0] ?? null }
|
||||
: await getCurrentSetupRun();
|
||||
|
||||
const run = current.run;
|
||||
if (!run) {
|
||||
throw new Error('Start a setup run before saving setup steps.');
|
||||
}
|
||||
|
||||
const existingStep = (
|
||||
await db
|
||||
.select()
|
||||
.from(setupRunSteps)
|
||||
.where(and(eq(setupRunSteps.setupRunId, run.id), eq(setupRunSteps.stepKey, input.stepKey)))
|
||||
)[0];
|
||||
const timestamp = now();
|
||||
const sanitizedOutput = sanitizeOutput(input.stepKey, input.output);
|
||||
const completedAt = input.status === 'running' || input.status === 'not_started' ? null : timestamp;
|
||||
const inputChanged =
|
||||
Boolean(existingStep) &&
|
||||
existingStep.inputHash !== null &&
|
||||
input.inputHash !== null &&
|
||||
existingStep.inputHash !== input.inputHash;
|
||||
|
||||
await db
|
||||
.insert(setupRunSteps)
|
||||
.values({
|
||||
id: existingStep?.id ?? createId('setstep'),
|
||||
setupRunId: run.id,
|
||||
stepKey: input.stepKey,
|
||||
status: input.status,
|
||||
inputHash: input.inputHash ?? null,
|
||||
outputJson: sanitizedOutput,
|
||||
errorsJson: input.errors ?? null,
|
||||
warningsJson: input.warnings ?? null,
|
||||
startedAt: existingStep?.startedAt ?? timestamp,
|
||||
completedAt,
|
||||
createdAt: existingStep?.createdAt ?? timestamp,
|
||||
updatedAt: timestamp
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [setupRunSteps.setupRunId, setupRunSteps.stepKey],
|
||||
set: {
|
||||
status: input.status,
|
||||
inputHash: input.inputHash ?? null,
|
||||
outputJson: sanitizedOutput,
|
||||
errorsJson: input.errors ?? null,
|
||||
warningsJson: input.warnings ?? null,
|
||||
completedAt,
|
||||
updatedAt: timestamp
|
||||
}
|
||||
});
|
||||
|
||||
if (inputChanged) {
|
||||
const staleSteps = STALE_DEPENDENCIES[input.stepKey] ?? [];
|
||||
await Promise.all(
|
||||
staleSteps.map((stepKey) =>
|
||||
db
|
||||
.update(setupRunSteps)
|
||||
.set({ status: 'stale', updatedAt: timestamp })
|
||||
.where(and(eq(setupRunSteps.setupRunId, run.id), eq(setupRunSteps.stepKey, stepKey)))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const nextRunStatus =
|
||||
input.status === 'failed' ? 'failed' : STEP_TO_RUN_STATUS[input.stepKey] ?? (run.status as SetupRunStatus);
|
||||
|
||||
await db.update(setupRuns).set({ status: nextRunStatus, updatedAt: timestamp }).where(eq(setupRuns.id, run.id));
|
||||
|
||||
return getCurrentSetupRun();
|
||||
}
|
||||
|
||||
export async function completeSetupRun(input: { actorId: string; setupRunId?: string; report?: Record<string, unknown> }) {
|
||||
const current = input.setupRunId
|
||||
? { run: (await db.select().from(setupRuns).where(eq(setupRuns.id, input.setupRunId)))[0] ?? null }
|
||||
: await getCurrentSetupRun();
|
||||
|
||||
const run = current.run;
|
||||
if (!run) {
|
||||
throw new Error('Start a setup run before completing setup.');
|
||||
}
|
||||
|
||||
const timestamp = now();
|
||||
await db
|
||||
.update(setupRuns)
|
||||
.set({
|
||||
status: 'completed',
|
||||
completedBy: input.actorId,
|
||||
completedAt: timestamp,
|
||||
metadata: input.report ?? null,
|
||||
updatedAt: timestamp
|
||||
})
|
||||
.where(eq(setupRuns.id, run.id));
|
||||
|
||||
return getCurrentSetupRun();
|
||||
}
|
||||
|
||||
export async function getSetupRunReport() {
|
||||
return getCurrentSetupRun();
|
||||
}
|
||||
|
||||
export async function getActiveSetupRunId() {
|
||||
const current = await getCurrentSetupRun();
|
||||
return current.active ? current.run?.id ?? null : null;
|
||||
}
|
||||
Reference in New Issue
Block a user