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

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