csv setupdata

This commit is contained in:
phaichayon
2026-07-06 15:50:47 +07:00
parent 944dbc031d
commit d93f25e1ce
37 changed files with 1782 additions and 623 deletions

View File

@@ -1,6 +1,8 @@
import { queryOptions } from '@tanstack/react-query';
import {
getBootstrapSetupStatus,
getCurrentBootstrapSetupRun,
getCurrentSetupRun,
getSetupProfiles,
getSetupReadiness,
@@ -16,10 +18,11 @@ export const setupKeys = {
templates: (profileId?: string) => [...setupKeys.all, 'templates', profileId ?? 'crm_demo'] as const,
profiles: (profileId?: string) => [...setupKeys.all, 'profiles', profileId ?? 'crm_demo'] as const,
bootstrapStatus: () => [...setupKeys.all, 'bootstrap', 'status'] as const,
bootstrapRun: (bootstrapToken?: string) =>
[...setupKeys.all, 'bootstrap', 'run', bootstrapToken ? 'token-provided' : 'no-token'] as const,
run: () => [...setupKeys.all, 'run'] as const,
report: () => [...setupKeys.all, 'run', 'report'] as const,
verification: (payload: SetupVerificationPayload = {}) =>
[...setupKeys.all, 'verification', payload] as const
verification: (payload: SetupVerificationPayload = {}) => [...setupKeys.all, 'verification', payload] as const
};
export const setupReadinessQueryOptions = (profileId?: string) =>
@@ -52,6 +55,12 @@ export const setupRunQueryOptions = () =>
queryFn: () => getCurrentSetupRun()
});
export const bootstrapSetupRunQueryOptions = (bootstrapToken?: string) =>
queryOptions({
queryKey: setupKeys.bootstrapRun(bootstrapToken),
queryFn: () => getCurrentBootstrapSetupRun(bootstrapToken)
});
export const setupRunReportQueryOptions = () =>
queryOptions({
queryKey: setupKeys.report(),

View File

@@ -1,7 +1,8 @@
import { ApiClientError, apiClient } from '@/lib/api-client';
import type {
CsvPreviewResult,
BootstrapSetupStatusResponse,
CompleteBootstrapSetupPayload,
CsvPreviewResult,
ImportReport,
SaveSetupStepPayload,
SetupProfilesResponse,
@@ -23,60 +24,6 @@ function withProfile(endpoint: string, profileId?: string) {
return `${endpoint}${separator}profile=${encodeURIComponent(profileId)}`;
}
export async function getSetupReadiness(profileId?: string): Promise<SetupReadinessReport> {
return apiClient<SetupReadinessReport>(withProfile('/setup/readiness', profileId));
}
export async function getSetupTemplates(profileId?: string): Promise<SetupTemplatesResponse> {
return apiClient<SetupTemplatesResponse>(withProfile('/setup/templates', profileId));
}
export async function getSetupProfiles(profileId?: string): Promise<SetupProfilesResponse> {
return apiClient<SetupProfilesResponse>(withProfile('/setup/profiles', profileId));
}
export async function getBootstrapSetupStatus(): Promise<BootstrapSetupStatusResponse> {
return apiClient<BootstrapSetupStatusResponse>('/setup/bootstrap/status');
}
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> {
return apiClient<SetupVerificationReport>('/setup/verify', {
method: 'POST',
body: JSON.stringify(payload)
});
}
async function readUploadResponse<T>(response: Response): Promise<T> {
if (response.ok) {
return response.json() as Promise<T>;
@@ -98,6 +45,70 @@ function buildCsvFormData(files: File[]): FormData {
return formData;
}
function bootstrapHeaders(bootstrapToken?: string) {
return bootstrapToken ? { 'x-bootstrap-setup-token': bootstrapToken } : undefined;
}
export async function getSetupReadiness(profileId?: string): Promise<SetupReadinessReport> {
return apiClient<SetupReadinessReport>(withProfile('/setup/readiness', profileId));
}
export async function getSetupTemplates(profileId?: string): Promise<SetupTemplatesResponse> {
return apiClient<SetupTemplatesResponse>(withProfile('/setup/templates', profileId));
}
export async function getSetupProfiles(profileId?: string): Promise<SetupProfilesResponse> {
return apiClient<SetupProfilesResponse>(withProfile('/setup/profiles', profileId));
}
export async function getBootstrapSetupStatus(): Promise<BootstrapSetupStatusResponse> {
return apiClient<BootstrapSetupStatusResponse>('/setup/bootstrap/status');
}
export async function getCurrentSetupRun(): Promise<SetupRunResponse> {
return apiClient<SetupRunResponse>('/setup/run/current');
}
export async function getCurrentBootstrapSetupRun(bootstrapToken?: string): Promise<SetupRunResponse> {
return readUploadResponse<SetupRunResponse>(
await fetch('/api/setup/bootstrap/run/current', {
headers: bootstrapHeaders(bootstrapToken)
})
);
}
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 getSetupRunReport(): Promise<SetupRunResponse> {
return apiClient<SetupRunResponse>('/setup/run/report');
}
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 verifySetup(payload: SetupVerificationPayload): Promise<SetupVerificationReport> {
return apiClient<SetupVerificationReport>('/setup/verify', {
method: 'POST',
body: JSON.stringify(payload)
});
}
export async function previewSetupCsvFiles(files: File[]): Promise<CsvPreviewResult> {
return readUploadResponse<CsvPreviewResult>(
await fetch('/api/setup/import/preview', {
@@ -107,6 +118,16 @@ export async function previewSetupCsvFiles(files: File[]): Promise<CsvPreviewRes
);
}
export async function previewConfiguredSetupCsvFiles(): Promise<CsvPreviewResult> {
return readUploadResponse<CsvPreviewResult>(
await fetch('/api/setup/import/preview', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ source: 'configured-directory' })
})
);
}
export async function previewBootstrapCsvStep(input: {
stepKey: string;
file: File;
@@ -124,6 +145,24 @@ export async function previewBootstrapCsvStep(input: {
);
}
export async function previewConfiguredBootstrapCsv(input: {
bootstrapToken?: string;
} = {}): Promise<CsvPreviewResult> {
return readUploadResponse<CsvPreviewResult>(
await fetch('/api/setup/bootstrap/preview', {
method: 'POST',
headers: {
'content-type': 'application/json',
...bootstrapHeaders(input.bootstrapToken)
},
body: JSON.stringify({
source: 'configured-directory',
bootstrapToken: input.bootstrapToken
})
})
);
}
export async function commitSetupCsvFiles(input: {
files: File[];
previewHash: string;
@@ -141,6 +180,23 @@ export async function commitSetupCsvFiles(input: {
);
}
export async function commitConfiguredSetupCsvFiles(input: {
previewHash: string;
dryRun?: boolean;
}): Promise<ImportReport> {
return readUploadResponse<ImportReport>(
await fetch('/api/setup/import/commit', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
source: 'configured-directory',
previewHash: input.previewHash,
dryRun: Boolean(input.dryRun)
})
})
);
}
export async function commitBootstrapCsvStep(input: {
stepKey: string;
file: File;
@@ -162,9 +218,34 @@ export async function commitBootstrapCsvStep(input: {
);
}
export async function completeBootstrapSetup(report: Record<string, unknown>): Promise<SetupRunResponse> {
export async function commitConfiguredBootstrapCsv(input: {
previewHash: string;
dryRun?: boolean;
bootstrapToken?: string;
}): Promise<ImportReport> {
return readUploadResponse<ImportReport>(
await fetch('/api/setup/bootstrap/commit', {
method: 'POST',
headers: {
'content-type': 'application/json',
...bootstrapHeaders(input.bootstrapToken)
},
body: JSON.stringify({
source: 'configured-directory',
previewHash: input.previewHash,
dryRun: Boolean(input.dryRun),
bootstrapToken: input.bootstrapToken
})
})
);
}
export async function completeBootstrapSetup(input: CompleteBootstrapSetupPayload): Promise<SetupRunResponse> {
return apiClient<SetupRunResponse>('/setup/bootstrap/complete', {
method: 'POST',
body: JSON.stringify({ report })
body: JSON.stringify({
bootstrapToken: input.bootstrapToken,
report: input.report ?? {}
})
});
}

View File

@@ -1,4 +1,4 @@
export type SetupCheckStatus = 'PASS' | 'WARNING' | 'FAIL';
export type SetupCheckStatus = "PASS" | "WARNING" | "FAIL";
export interface SetupCheckResult {
id: string;
@@ -89,6 +89,34 @@ export interface SetupTemplateMetadataItem {
visible?: boolean;
}
export interface SetupImportSourceFileDto {
fileName: string;
kind: 'required' | 'optional';
bytes: number;
modifiedAt: string;
checksum: string;
headers: string[];
}
export interface SetupImportIgnoredFileDto {
fileName: string;
reason: string;
}
export interface SetupImportSourceDto {
envKey: string;
exampleValue: string;
configuredValue: string | null;
configured: boolean;
resolvedPath: string | null;
exists: boolean;
files: SetupImportSourceFileDto[];
missingRequiredFiles: string[];
ignoredFiles: SetupImportIgnoredFileDto[];
errors: string[];
warnings: string[];
}
export interface SetupTemplatesResponse {
success: boolean;
time: string;
@@ -119,6 +147,7 @@ export interface BootstrapSetupStatusResponse {
requiredFiles: string[];
optionalFiles: string[];
requiredSteps: BootstrapRequiredStepDto[];
importSource: SetupImportSourceDto;
}
export interface SetupProfilesResponse {
@@ -137,29 +166,29 @@ export interface SetupProfilesResponse {
}
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';
| "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';
| "not_started"
| "running"
| "passed"
| "warning"
| "failed"
| "skipped"
| "stale";
export interface SetupRunDto {
id: string;
@@ -244,18 +273,23 @@ export interface SaveSetupStepPayload {
warnings?: unknown[] | null;
}
export interface CompleteBootstrapSetupPayload {
report?: Record<string, unknown>;
bootstrapToken?: string;
}
export type {
CsvPreviewError,
CsvPreviewFileResult,
CsvPreviewRow,
CsvPreviewResult,
CsvPreviewStatus,
CsvPreviewWarning
} from '@/features/setup/server/csv/types';
CsvPreviewWarning,
} from "@/features/setup/server/csv/types";
export type {
ImportFileReport,
ImportReport,
ImportRowReport,
ImportStatus
} from '@/features/setup/server/csv/import-report';
ImportStatus,
} from "@/features/setup/server/csv/import-report";

View File

@@ -1,10 +1,15 @@
'use client';
import { useMutation } from '@tanstack/react-query';
import {
commitBootstrapCsvStep,
commitConfiguredBootstrapCsv,
commitConfiguredSetupCsvFiles,
commitSetupCsvFiles,
previewBootstrapCsvStep,
previewConfiguredBootstrapCsv,
previewConfiguredSetupCsvFiles,
previewSetupCsvFiles
} from './service';
@@ -16,15 +21,25 @@ export function useCsvPreview() {
export function useCsvCommit() {
return useMutation({
mutationFn: (input: { files: File[]; previewHash: string; dryRun?: boolean }) =>
commitSetupCsvFiles(input)
mutationFn: (input: { files: File[]; previewHash: string; dryRun?: boolean }) => commitSetupCsvFiles(input)
});
}
export function useConfiguredCsvPreview() {
return useMutation({
mutationFn: () => previewConfiguredSetupCsvFiles()
});
}
export function useConfiguredCsvCommit() {
return useMutation({
mutationFn: (input: { previewHash: string; dryRun?: boolean }) => commitConfiguredSetupCsvFiles(input)
});
}
export function useBootstrapCsvPreview() {
return useMutation({
mutationFn: (input: { stepKey: string; file: File; bootstrapToken?: string }) =>
previewBootstrapCsvStep(input)
mutationFn: (input: { stepKey: string; file: File; bootstrapToken?: string }) => previewBootstrapCsvStep(input)
});
}
@@ -39,3 +54,16 @@ export function useBootstrapCsvCommit() {
}) => commitBootstrapCsvStep(input)
});
}
export function useConfiguredBootstrapCsvPreview() {
return useMutation({
mutationFn: (input: { bootstrapToken?: string }) => previewConfiguredBootstrapCsv(input)
});
}
export function useConfiguredBootstrapCsvCommit() {
return useMutation({
mutationFn: (input: { previewHash: string; dryRun?: boolean; bootstrapToken?: string }) =>
commitConfiguredBootstrapCsv(input)
});
}

View File

@@ -1,16 +1,19 @@
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { completeBootstrapSetup, completeSetupRun, saveSetupStep, startSetupRun } from './service';
import { setupKeys } from './queries';
import type { SaveSetupStepPayload } from './types';
import type { CompleteBootstrapSetupPayload, SaveSetupStepPayload } from './types';
function useInvalidateSetupRun() {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries({ queryKey: setupKeys.run() });
queryClient.invalidateQueries({ queryKey: [...setupKeys.all, 'bootstrap'] });
queryClient.invalidateQueries({ queryKey: setupKeys.report() });
queryClient.invalidateQueries({ queryKey: setupKeys.bootstrapStatus() });
};
}
@@ -45,7 +48,7 @@ export function useCompleteBootstrapSetup() {
const invalidate = useInvalidateSetupRun();
return useMutation({
mutationFn: (report: Record<string, unknown>) => completeBootstrapSetup(report),
mutationFn: (input: CompleteBootstrapSetupPayload) => completeBootstrapSetup(input),
onSuccess: invalidate
});
}

View File

@@ -2,6 +2,8 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Icons } from '@/components/icons';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
@@ -10,9 +12,15 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { useBootstrapCsvCommit, useBootstrapCsvPreview } from '../api/use-csv-commit';
import { useCompleteBootstrapSetup } from '../api/use-setup-run';
import {
useConfiguredBootstrapCsvCommit,
useConfiguredBootstrapCsvPreview,
useConfiguredCsvCommit,
useConfiguredCsvPreview
} from '../api/use-csv-commit';
import { useCompleteBootstrapSetup, useCompleteSetupRun } from '../api/use-setup-run';
import {
bootstrapSetupRunQueryOptions,
bootstrapSetupStatusQueryOptions,
setupRunQueryOptions,
setupTemplatesQueryOptions
@@ -24,18 +32,14 @@ import type {
CsvPreviewResult,
ImportReport,
SetupCheckStatus,
SetupImportSourceDto,
SetupImportSourceFileDto,
SetupRunStepDto,
SetupStepStatus,
SetupTemplateMetadataItem
} from '../api/types';
const BUSINESS_IMPORT_FILES = [
'customers.csv',
'contacts.csv',
'leads.csv',
'opportunities.csv',
'quotations.csv'
];
const SAMPLE_ROW_LIMIT = 3;
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
@@ -45,9 +49,7 @@ function getErrorMessage(error: unknown): string {
function getPreviewStatus(preview: CsvPreviewResult | null): SetupCheckStatus | null {
if (!preview) return null;
if (preview.summary.error > 0 || preview.files.some((file) => file.status === 'FAIL')) return 'FAIL';
if (preview.summary.warning > 0 || preview.files.some((file) => file.status === 'WARNING')) {
return 'WARNING';
}
if (preview.summary.warning > 0 || preview.files.some((file) => file.status === 'WARNING')) return 'WARNING';
return 'PASS';
}
@@ -55,19 +57,14 @@ function isStepComplete(step?: SetupRunStepDto): boolean {
return step?.status === 'passed' || step?.status === 'warning';
}
function setupStatusFromReport(report: ImportReport): SetupStepStatus {
if (report.status === 'FAIL') return 'failed';
if (report.status === 'WARNING') return 'warning';
return 'passed';
function statusBadgeVariant(status: SetupStepStatus | SetupCheckStatus | 'found' | 'missing' | 'not_started') {
if (status === 'failed' || status === 'FAIL' || status === 'missing') return 'destructive';
if (status === 'warning' || status === 'WARNING' || status === 'stale') return 'secondary';
if (status === 'passed' || status === 'PASS' || status === 'found') return 'default';
return 'outline';
}
function statusBadgeVariant(status: SetupStepStatus | SetupCheckStatus | 'locked' | 'not_started') {
if (status === 'FAIL' || status === 'failed') return 'destructive';
if (status === 'PASS' || status === 'passed') return 'default';
return 'secondary';
}
function StepStatusBadge({ status }: { status: SetupStepStatus | SetupCheckStatus | 'locked' | 'not_started' }) {
function StepStatusBadge({ status }: { status: SetupStepStatus | SetupCheckStatus | 'found' | 'missing' | 'not_started' }) {
return <Badge variant={statusBadgeVariant(status)}>{status}</Badge>;
}
@@ -114,57 +111,13 @@ function CsvErrors({ errors }: { errors: CsvPreviewError[] }) {
);
}
function PreviewFilePanel({ file }: { file: CsvPreviewFileResult }) {
function LoadingAlert({ phase }: { phase: string }) {
return (
<div className='rounded-md border p-3'>
<div className='mb-3 flex flex-wrap items-center justify-between gap-2'>
<div>
<div className='font-medium'>{file.fileName}</div>
<div className='text-sm text-muted-foreground'>Template: {file.template}</div>
</div>
<StepStatusBadge status={file.status} />
</div>
<div className='grid gap-2 sm:grid-cols-5'>
<Metric label='Rows' value={file.summary.rows} />
<Metric label='Create' value={file.summary.create} />
<Metric label='Update' value={file.summary.update} />
<Metric label='Warning' value={file.summary.warning} />
<Metric label='Error' value={file.summary.error} />
</div>
<div className='mt-3'>
<CsvErrors errors={[...file.errors, ...file.warnings]} />
</div>
</div>
);
}
function ImportReportPanel({ report }: { report: ImportReport | null }) {
if (!report) {
return (
<div className='rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground'>
Import has not run for this step yet.
</div>
);
}
return (
<div className='space-y-3'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div>
<div className='font-medium'>Import result</div>
<div className='break-all text-sm text-muted-foreground'>Committed hash: {report.committedHash}</div>
</div>
<StepStatusBadge status={report.status} />
</div>
<div className='grid gap-2 sm:grid-cols-5'>
<Metric label='Imported' value={report.summary.imported} />
<Metric label='Updated' value={report.summary.updated} />
<Metric label='Skipped' value={report.summary.skipped} />
<Metric label='Warnings' value={report.summary.warnings} />
<Metric label='Errors' value={report.summary.errors} />
</div>
<CsvErrors errors={[...report.errors, ...report.warnings]} />
</div>
<Alert>
<Icons.spinner className='h-4 w-4 animate-spin' />
<AlertTitle>Working</AlertTitle>
<AlertDescription>{phase}</AlertDescription>
</Alert>
);
}
@@ -172,169 +125,419 @@ function Stepper({
steps,
currentStepKey,
stepByKey,
fileByName,
previewByFileName,
onSelect
}: {
steps: BootstrapRequiredStepDto[];
currentStepKey: string;
stepByKey: Map<string, SetupRunStepDto>;
fileByName: Map<string, SetupImportSourceFileDto>;
previewByFileName: Map<string, CsvPreviewFileResult>;
onSelect: (stepKey: string) => void;
}) {
return (
<Card className='self-start'>
<CardHeader>
<CardTitle>System Required</CardTitle>
<CardDescription>Import required setup CSV files in dependency order.</CardDescription>
<CardDescription>Click a row to inspect the CSV file detail.</CardDescription>
</CardHeader>
<CardContent className='space-y-2'>
{steps.map((step, index) => {
const persisted = stepByKey.get(step.stepKey);
const locked = step.dependsOn.some((dependency) => !isStepComplete(stepByKey.get(dependency)));
const sourceFile = fileByName.get(step.fileName.toLowerCase());
const previewFile = previewByFileName.get(step.fileName.toLowerCase());
const selected = currentStepKey === step.stepKey;
return (
<button
key={step.stepKey}
type='button'
onClick={() => onSelect(step.stepKey)}
className={cn(
'flex min-h-14 w-full items-center gap-3 rounded-md border px-3 py-2 text-left text-sm transition-colors',
currentStepKey === step.stepKey && 'border-primary bg-primary/10',
locked && 'cursor-not-allowed opacity-60'
'flex min-h-16 w-full items-center gap-3 rounded-md border px-3 py-2 text-left text-sm transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
selected && 'border-primary bg-primary/10'
)}
disabled={locked}
>
<span className='flex size-8 shrink-0 items-center justify-center rounded-full border bg-background text-xs font-semibold'>
{index + 1}
</span>
<span className='min-w-0 flex-1'>
<span className='block truncate font-medium'>{step.title}</span>
<span className='block truncate text-xs text-muted-foreground'>{step.fileName}</span>
<span className='flex items-center gap-2'>
{sourceFile ? (
<Icons.circleCheck className='size-4 shrink-0 text-primary' />
) : (
<Icons.alertCircle className='size-4 shrink-0 text-destructive' />
)}
<span className='truncate font-medium'>{step.title}</span>
</span>
<span className='mt-1 block truncate text-xs text-muted-foreground'>{step.fileName}</span>
</span>
<StepStatusBadge status={locked ? 'locked' : (persisted?.status ?? 'not_started')} />
<div className='flex shrink-0 flex-col items-end gap-1'>
<StepStatusBadge status={sourceFile ? 'found' : 'missing'} />
<StepStatusBadge status={previewFile?.status ?? persisted?.status ?? 'not_started'} />
</div>
</button>
);
})}
<div className='pt-4'>
<div className='mb-2 text-xs font-medium uppercase text-muted-foreground'>Optional Business Data</div>
<div className='space-y-1'>
{BUSINESS_IMPORT_FILES.map((fileName) => (
<div
key={fileName}
className='flex min-h-9 items-center justify-between rounded-md bg-muted/30 px-3 text-sm text-muted-foreground'
>
<span>{fileName.replace('.csv', '')}</span>
<Badge variant='outline'>later</Badge>
</div>
))}
</div>
</div>
</CardContent>
</Card>
);
}
export function SetupWizard() {
function SourcePanel({ source }: { source?: SetupImportSourceDto }) {
if (!source) {
return (
<Card>
<CardHeader>
<CardTitle>Configured Import Folder</CardTitle>
<CardDescription>Loading setup import source.</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div>
<CardTitle>Configured Import Folder</CardTitle>
<CardDescription>
Configure CSV source with {source.envKey} in .env, then restart the server.
</CardDescription>
</div>
<Badge variant={source.errors.length > 0 ? 'destructive' : source.warnings.length > 0 ? 'secondary' : 'default'}>
{source.errors.length > 0 ? 'Not ready' : source.warnings.length > 0 ? 'Warning' : 'Ready'}
</Badge>
</div>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid gap-3 md:grid-cols-2'>
<div className='rounded-md border p-3'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Env key</div>
<div className='mt-1 font-medium'>{source.envKey}</div>
</div>
<div className='rounded-md border p-3'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Configured value</div>
<div className='mt-1 break-all font-medium'>{source.configuredValue ?? 'Not configured'}</div>
</div>
<div className='rounded-md border p-3 md:col-span-2'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Resolved path</div>
<div className='mt-1 break-all text-sm'>{source.resolvedPath ?? 'Unavailable'}</div>
</div>
</div>
{source.errors.length > 0 ? (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Import folder requires configuration</AlertTitle>
<AlertDescription>
<div className='space-y-2'>
{source.errors.map((error) => (
<div key={error}>{error}</div>
))}
<code className='block rounded-md bg-background px-3 py-2 text-xs'>
{source.envKey}={source.exampleValue}
</code>
</div>
</AlertDescription>
</Alert>
) : null}
</CardContent>
</Card>
);
}
function SelectedFileDetail({
step,
sourceFile,
template,
previewFile
}: {
step: BootstrapRequiredStepDto;
sourceFile?: SetupImportSourceFileDto;
template?: SetupTemplateMetadataItem;
previewFile?: CsvPreviewFileResult;
}) {
const sampleRows = previewFile?.rows.slice(0, SAMPLE_ROW_LIMIT) ?? [];
const sampleColumns = sampleRows[0] ? Object.keys(sampleRows[0].values) : [];
return (
<Card>
<CardHeader>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div>
<CardTitle>{step.title}</CardTitle>
<CardDescription>{step.fileName}</CardDescription>
</div>
<StepStatusBadge status={sourceFile ? 'found' : 'missing'} />
</div>
</CardHeader>
<CardContent className='space-y-4'>
{!sourceFile ? (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>File missing</AlertTitle>
<AlertDescription>Add {step.fileName} to the configured setup import folder.</AlertDescription>
</Alert>
) : (
<>
<div className='grid gap-2 sm:grid-cols-3'>
<Metric label='Bytes' value={sourceFile.bytes.toLocaleString()} />
<Metric label='Kind' value={sourceFile.kind} />
<Metric label='Preview' value={previewFile?.status ?? 'not_run'} />
</div>
<div className='rounded-md border p-3 text-sm'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Modified</div>
<div className='mt-1'>{sourceFile.modifiedAt}</div>
<div className='mt-3 text-xs font-medium uppercase text-muted-foreground'>Checksum</div>
<div className='mt-1 break-all'>{sourceFile.checksum}</div>
</div>
<div className='rounded-md border p-3'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Columns</div>
<div className='mt-2 flex flex-wrap gap-2'>
{sourceFile.headers.map((header) => (
<Badge key={header} variant='outline'>
{header}
</Badge>
))}
</div>
</div>
</>
)}
<div className='rounded-md border p-3'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Required headers</div>
<div className='mt-2 flex flex-wrap gap-2'>
{(template?.requiredColumns ?? []).map((column) => (
<Badge key={column} variant='secondary'>
{column}
</Badge>
))}
</div>
</div>
{previewFile ? (
<div className='space-y-3'>
<div className='grid gap-2 sm:grid-cols-7'>
<Metric label='Rows' value={previewFile.summary.rows} />
<Metric label='Valid' value={previewFile.summary.valid} />
<Metric label='Create' value={previewFile.summary.create} />
<Metric label='Update' value={previewFile.summary.update} />
<Metric label='Skip' value={previewFile.summary.skip} />
<Metric label='Warning' value={previewFile.summary.warning} />
<Metric label='Error' value={previewFile.summary.error} />
</div>
{sampleRows.length > 0 ? (
<div className='overflow-x-auto rounded-md border'>
<table className='w-full min-w-[720px] text-sm'>
<thead className='bg-muted/50 text-left'>
<tr>
<th className='px-3 py-2 font-medium'>Row</th>
<th className='px-3 py-2 font-medium'>Action</th>
{sampleColumns.map((column) => (
<th key={column} className='px-3 py-2 font-medium'>
{column}
</th>
))}
</tr>
</thead>
<tbody>
{sampleRows.map((row) => (
<tr key={row.row} className='border-t'>
<td className='px-3 py-2'>{row.row}</td>
<td className='px-3 py-2'>{row.action}</td>
{sampleColumns.map((column) => (
<td key={`${row.row}-${column}`} className='max-w-[220px] truncate px-3 py-2'>
{row.values[column] || '-'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : null}
<CsvErrors errors={[...previewFile.errors, ...previewFile.warnings]} />
</div>
) : (
<div className='rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground'>
Click Execute Import Seed Data to preview and import the configured CSV files.
</div>
)}
</CardContent>
</Card>
);
}
function ImportReportPanel({ report }: { report: ImportReport | null }) {
if (!report) return null;
return (
<Alert variant={report.status === 'FAIL' ? 'destructive' : 'default'}>
{report.status === 'FAIL' ? <Icons.alertCircle className='h-4 w-4' /> : <Icons.circleCheck className='h-4 w-4' />}
<AlertTitle>{report.status === 'FAIL' ? 'Import failed and was rolled back' : 'Import completed successfully'}</AlertTitle>
<AlertDescription>
Imported {report.summary.imported}, updated {report.summary.updated}, skipped {report.summary.skipped}, warnings{' '}
{report.summary.warnings}, errors {report.summary.errors}.
</AlertDescription>
</Alert>
);
}
interface SetupWizardProps {
mode?: 'admin' | 'public';
tokenRequired?: boolean;
}
export function SetupWizard({ mode = 'admin', tokenRequired = false }: SetupWizardProps) {
const [currentStepKey, setCurrentStepKey] = useState('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [preview, setPreview] = useState<CsvPreviewResult | null>(null);
const [importReport, setImportReport] = useState<ImportReport | null>(null);
const [warningAcknowledged, setWarningAcknowledged] = useState(false);
const [bootstrapToken, setBootstrapToken] = useState('');
const [workingMessage, setWorkingMessage] = useState<string | null>(null);
const bootstrapTokenReady = mode !== 'public' || !tokenRequired || bootstrapToken.trim().length > 0;
const bootstrapStatusQuery = useQuery(bootstrapSetupStatusQueryOptions());
const templatesQuery = useQuery(setupTemplatesQueryOptions('production'));
const setupRunQuery = useQuery(setupRunQueryOptions());
const previewMutation = useBootstrapCsvPreview();
const commitMutation = useBootstrapCsvCommit();
const completeMutation = useCompleteBootstrapSetup();
const adminSetupRunQuery = useQuery({ ...setupRunQueryOptions(), enabled: mode === 'admin' });
const publicSetupRunQuery = useQuery({
...bootstrapSetupRunQueryOptions(bootstrapToken.trim() || undefined),
enabled: mode === 'public' && bootstrapTokenReady
});
const setupRunQuery = mode === 'public' ? publicSetupRunQuery : adminSetupRunQuery;
const adminPreviewMutation = useConfiguredCsvPreview();
const adminCommitMutation = useConfiguredCsvCommit();
const bootstrapPreviewMutation = useConfiguredBootstrapCsvPreview();
const bootstrapCommitMutation = useConfiguredBootstrapCsvCommit();
const completeBootstrapMutation = useCompleteBootstrapSetup();
const completeAdminMutation = useCompleteSetupRun();
const previewMutation = mode === 'public' ? bootstrapPreviewMutation : adminPreviewMutation;
const commitMutation = mode === 'public' ? bootstrapCommitMutation : adminCommitMutation;
const completeMutation = mode === 'public' ? completeBootstrapMutation : completeAdminMutation;
const steps = bootstrapStatusQuery.data?.requiredSteps ?? [];
const importSource = bootstrapStatusQuery.data?.importSource;
const sourceReady = Boolean(importSource && importSource.errors.length === 0);
const previewStatus = getPreviewStatus(preview);
const isWorking = previewMutation.isPending || commitMutation.isPending || completeMutation.isPending || Boolean(workingMessage);
const stepByKey = useMemo(() => {
const entries = setupRunQuery.data?.steps.map((step) => [step.stepKey, step] as const) ?? [];
return new Map(entries);
}, [setupRunQuery.data?.steps]);
const fileByName = useMemo(() => {
const entries = importSource?.files.map((file) => [file.fileName.toLowerCase(), file] as const) ?? [];
return new Map(entries);
}, [importSource?.files]);
const previewByFileName = useMemo(() => {
const entries = preview?.files.map((file) => [file.fileName.toLowerCase(), file] as const) ?? [];
return new Map(entries);
}, [preview?.files]);
const templateByFile = useMemo(() => {
const entries = templatesQuery.data?.templates.map((template) => [template.file, template] as const) ?? [];
return new Map(entries);
}, [templatesQuery.data?.templates]);
const currentStep = steps.find((step) => step.stepKey === currentStepKey) ?? steps[0] ?? null;
const currentTemplate = currentStep ? templateByFile.get(currentStep.fileName) : null;
const currentPersistedStep = currentStep ? stepByKey.get(currentStep.stepKey) : undefined;
const previewStatus = getPreviewStatus(preview);
const dependenciesComplete =
currentStep?.dependsOn.every((dependency) => isStepComplete(stepByKey.get(dependency))) ?? false;
const allRequiredComplete = steps.every((step) => isStepComplete(stepByKey.get(step.stepKey)));
const selectedFileMatches = selectedFile?.name.toLowerCase() === currentStep?.fileName.toLowerCase();
const importBlocked =
!currentStep ||
!selectedFile ||
!preview ||
!preview.previewHash ||
!selectedFileMatches ||
previewStatus === 'FAIL' ||
(previewStatus === 'WARNING' && !warningAcknowledged);
const selectedSourceFile = currentStep ? fileByName.get(currentStep.fileName.toLowerCase()) : undefined;
const selectedPreviewFile = currentStep ? previewByFileName.get(currentStep.fileName.toLowerCase()) : undefined;
const selectedTemplate = currentStep ? templateByFile.get(currentStep.fileName) : undefined;
const persistedComplete = steps.length > 0 && steps.every((step) => isStepComplete(stepByKey.get(step.stepKey)));
const importSucceeded = importReport?.status === 'PASS' || importReport?.status === 'WARNING';
const allRequiredComplete = persistedComplete || importSucceeded;
const submitDisabled = !bootstrapTokenReady || !sourceReady || isWorking;
function selectStep(stepKey: string) {
setCurrentStepKey(stepKey);
setSelectedFile(null);
setPreview(null);
setImportReport(null);
setWarningAcknowledged(false);
}
async function runPreviewOnly() {
const result =
mode === 'public'
? await bootstrapPreviewMutation.mutateAsync({ bootstrapToken: bootstrapToken.trim() || undefined })
: await adminPreviewMutation.mutateAsync();
function handleFile(fileList: FileList | null) {
setSelectedFile(fileList?.[0] ?? null);
setPreview(null);
setImportReport(null);
setWarningAcknowledged(false);
}
async function runPreview() {
if (!currentStep || !selectedFile) return;
const result = await previewMutation.mutateAsync({
stepKey: currentStep.stepKey,
file: selectedFile
});
setPreview(result);
setImportReport(null);
await setupRunQuery.refetch();
await Promise.all([setupRunQuery.refetch(), bootstrapStatusQuery.refetch()]);
return result;
}
async function runImport() {
if (!currentStep || !selectedFile || !preview?.previewHash) return;
const result = await commitMutation.mutateAsync({
stepKey: currentStep.stepKey,
file: selectedFile,
previewHash: preview.previewHash
});
setImportReport(result);
await setupRunQuery.refetch();
async function executeImportSeedData() {
setWorkingMessage('Previewing setup CSV files from the configured folder...');
setImportReport(null);
if (setupStatusFromReport(result) !== 'failed') {
const currentIndex = steps.findIndex((step) => step.stepKey === currentStep.stepKey);
const nextStep = steps[currentIndex + 1];
if (nextStep) selectStep(nextStep.stepKey);
try {
const previewResult = await runPreviewOnly();
const status = getPreviewStatus(previewResult);
if (status === 'FAIL') {
toast.error('Import blocked because preview has errors. No data was imported.');
return;
}
if (status === 'WARNING' && !warningAcknowledged) {
toast.error('Acknowledge preview warnings before importing seed data.');
return;
}
setWorkingMessage('Importing seed data in a transaction. Failed imports roll back automatically...');
const result =
mode === 'public'
? await bootstrapCommitMutation.mutateAsync({
previewHash: previewResult.previewHash,
bootstrapToken: bootstrapToken.trim() || undefined
})
: await adminCommitMutation.mutateAsync({ previewHash: previewResult.previewHash });
setImportReport(result);
await Promise.all([setupRunQuery.refetch(), bootstrapStatusQuery.refetch()]);
if (result.status === 'FAIL') {
toast.error('Import failed. Database changes were rolled back.');
return;
}
toast.success('Import seed data completed successfully.');
} catch (error) {
toast.error(`${getErrorMessage(error)} Database changes were rolled back.`);
} finally {
setWorkingMessage(null);
}
}
async function completeSetup() {
await completeMutation.mutateAsync({
source: 'step-by-step-bootstrap',
if (mode === 'public') {
await completeBootstrapMutation.mutateAsync({
bootstrapToken: bootstrapToken.trim() || undefined,
report: {
source: 'configured-directory-bootstrap',
requiredStepKeys: steps.map((step) => step.stepKey)
}
});
window.location.assign('/auth/sign-in');
return;
}
await completeAdminMutation.mutateAsync({
source: 'configured-directory-admin',
requiredStepKeys: steps.map((step) => step.stepKey)
});
window.location.assign('/auth/sign-in');
}
if (bootstrapStatusQuery.isLoading || templatesQuery.isLoading || setupRunQuery.isLoading) {
return (
<div className='rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground'>
Loading first-run setup wizard...
Loading setup import preview...
</div>
);
}
if (bootstrapStatusQuery.isError) {
return <ErrorAlert message={getErrorMessage(bootstrapStatusQuery.error)} />;
}
if (!currentStep) {
return (
<Alert>
@@ -346,213 +549,108 @@ export function SetupWizard() {
}
return (
<div className='grid gap-6 lg:grid-cols-[360px_1fr]'>
<Stepper steps={steps} currentStepKey={currentStep.stepKey} stepByKey={stepByKey} onSelect={selectStep} />
<div className='space-y-6'>
<Alert>
<Icons.info className='h-4 w-4' />
<AlertTitle>Import order decision</AlertTitle>
<AlertDescription>
Users are imported before organizations because organizations.csv resolves created_by_email
against existing users.
</AlertDescription>
</Alert>
{bootstrapStatusQuery.data?.initialized ? (
<Alert>
<Icons.circleCheck className='h-4 w-4' />
<AlertTitle>Setup already initialized</AlertTitle>
<AlertDescription>Public bootstrap setup is blocked after completion.</AlertDescription>
</Alert>
) : null}
<Card>
<CardHeader>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div>
<CardTitle>{currentStep.title}</CardTitle>
<CardDescription>{currentStep.description}</CardDescription>
</div>
<StepStatusBadge status={currentPersistedStep?.status ?? 'not_started'} />
</div>
</CardHeader>
<CardContent className='space-y-5'>
{!dependenciesComplete ? (
<Alert variant='destructive'>
<Icons.lock className='h-4 w-4' />
<AlertTitle>Step locked</AlertTitle>
<AlertDescription>
Complete dependencies first: {currentStep.dependsOn.join(', ')}.
</AlertDescription>
</Alert>
) : null}
<div className='grid gap-3 md:grid-cols-2'>
<div className='rounded-md border p-3'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Required filename</div>
<div className='mt-1 font-medium'>{currentStep.fileName}</div>
</div>
<div className='rounded-md border p-3'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Purpose</div>
<div className='mt-1 text-sm'>{currentTemplate?.description ?? currentStep.description}</div>
</div>
</div>
<TemplateColumns template={currentTemplate ?? undefined} />
<div className='space-y-2'>
<div className='text-sm font-medium'>Upload</div>
<Input
type='file'
accept='.csv,text/csv'
disabled={!dependenciesComplete}
onChange={(event) => handleFile(event.target.files)}
/>
{selectedFile ? (
<div className='flex flex-wrap items-center justify-between gap-2 rounded-md bg-muted/30 px-3 py-2 text-sm'>
<span>{selectedFile.name}</span>
<span className='text-muted-foreground'>{selectedFile.size} bytes</span>
</div>
) : null}
{selectedFile && !selectedFileMatches ? (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Wrong file selected</AlertTitle>
<AlertDescription>Upload {currentStep.fileName} for this step.</AlertDescription>
</Alert>
) : null}
</div>
<div className='flex flex-wrap gap-2'>
<Button
type='button'
variant='outline'
onClick={runPreview}
disabled={!dependenciesComplete || !selectedFile || !selectedFileMatches || previewMutation.isPending}
>
{previewMutation.isPending ? (
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
) : (
<Icons.upload className='mr-2 h-4 w-4' />
)}
Preview
</Button>
<Button type='button' onClick={runImport} disabled={importBlocked || commitMutation.isPending}>
{commitMutation.isPending ? (
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
) : (
<Icons.check className='mr-2 h-4 w-4' />
)}
Run Import
</Button>
</div>
{previewMutation.isError ? <ErrorAlert message={getErrorMessage(previewMutation.error)} /> : null}
{commitMutation.isError ? <ErrorAlert message={getErrorMessage(commitMutation.error)} /> : null}
<div className='space-y-3'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='font-medium'>Preview result</div>
{previewStatus ? <StepStatusBadge status={previewStatus} /> : null}
</div>
{preview ? (
<>
<div className='grid gap-2 sm:grid-cols-6'>
<Metric label='Files' value={preview.summary.files} />
<Metric label='Rows' value={preview.summary.rows} />
<Metric label='Create' value={preview.summary.create} />
<Metric label='Update' value={preview.summary.update} />
<Metric label='Warning' value={preview.summary.warning} />
<Metric label='Error' value={preview.summary.error} />
</div>
{previewStatus === 'WARNING' ? (
<div className='flex items-center gap-2 rounded-md border p-3 text-sm'>
<Checkbox
id='setup-preview-warning-acknowledgement'
checked={warningAcknowledged}
onCheckedChange={(checked) => setWarningAcknowledged(checked === true)}
/>
<label htmlFor='setup-preview-warning-acknowledgement'>
Acknowledge warnings and allow import.
</label>
</div>
) : null}
{preview.files.map((file) => (
<PreviewFilePanel key={file.fileName} file={file} />
))}
</>
) : (
<div className='rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground'>
Run Preview to validate this CSV before import.
</div>
)}
</div>
<ImportReportPanel report={importReport} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Final Verification</CardTitle>
<CardDescription>Setup can complete only after all system-required steps pass.</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid gap-2 sm:grid-cols-3'>
<Metric
label='Required'
value={`${steps.filter((step) => isStepComplete(stepByKey.get(step.stepKey))).length}/${steps.length}`}
/>
<Metric label='Status' value={allRequiredComplete ? 'Ready' : 'Incomplete'} />
<Metric label='Result' value={setupRunQuery.data?.run?.status ?? 'not_started'} />
</div>
<Button
type='button'
onClick={completeSetup}
disabled={!allRequiredComplete || completeMutation.isPending}
>
{completeMutation.isPending ? (
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
) : (
<Icons.checks className='mr-2 h-4 w-4' />
)}
Mark setup completed
</Button>
{completeMutation.isError ? <ErrorAlert message={getErrorMessage(completeMutation.error)} /> : null}
</CardContent>
</Card>
</div>
</div>
);
}
function TemplateColumns({ template }: { template?: SetupTemplateMetadataItem }) {
if (!template) {
return (
<div className='rounded-md border border-dashed p-4 text-sm text-muted-foreground'>
Template metadata is not available for this file.
</div>
);
}
return (
<div className='space-y-3 rounded-md border p-3'>
<div>
<div className='text-xs font-medium uppercase text-muted-foreground'>Required headers</div>
<div className='mt-2 flex flex-wrap gap-2'>
{template.requiredColumns.map((column) => (
<Badge key={column} variant='secondary'>
{column}
</Badge>
))}
<div className='space-y-6'>
<div className='flex flex-wrap items-start justify-between gap-3 rounded-lg border bg-background p-4'>
<div>
<div className='text-lg font-semibold'>Setup Import Form</div>
<div className='text-sm text-muted-foreground'>
Execute import seed data from the configured folder. Failed imports roll back in the commit transaction.
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button
type='button'
variant='outline'
onClick={runPreviewOnly}
disabled={!bootstrapTokenReady || !sourceReady || isWorking}
>
{previewMutation.isPending ? <Icons.spinner className='mr-2 h-4 w-4 animate-spin' /> : <Icons.page className='mr-2 h-4 w-4' />}
Refresh Preview
</Button>
<Button type='button' onClick={executeImportSeedData} disabled={submitDisabled}>
{isWorking ? <Icons.spinner className='mr-2 h-4 w-4 animate-spin' /> : <Icons.check className='mr-2 h-4 w-4' />}
Execute Import Seed Data
</Button>
</div>
</div>
<div>
<div className='text-xs font-medium uppercase text-muted-foreground'>Sample template</div>
<code className='mt-2 block overflow-x-auto rounded-md bg-muted px-3 py-2 text-xs'>
setup/templates/{template.file}
</code>
{workingMessage ? <LoadingAlert phase={workingMessage} /> : null}
{importReport ? <ImportReportPanel report={importReport} /> : null}
{previewStatus === 'WARNING' ? (
<div className='flex items-center gap-2 rounded-md border p-3 text-sm'>
<Checkbox
id='setup-preview-warning-acknowledgement'
checked={warningAcknowledged}
onCheckedChange={(checked) => setWarningAcknowledged(checked === true)}
/>
<label htmlFor='setup-preview-warning-acknowledgement'>Acknowledge warnings and allow import.</label>
</div>
) : null}
{mode === 'public' && tokenRequired ? (
<Card>
<CardHeader>
<CardTitle>Bootstrap Token</CardTitle>
<CardDescription>Enter the setup token before running protected bootstrap steps.</CardDescription>
</CardHeader>
<CardContent>
<Input
type='password'
value={bootstrapToken}
onChange={(event) => setBootstrapToken(event.target.value)}
placeholder='Bootstrap setup token'
aria-label='Bootstrap setup token'
/>
</CardContent>
</Card>
) : null}
<div className='grid gap-6 lg:grid-cols-[360px_1fr]'>
<Stepper
steps={steps}
currentStepKey={currentStep.stepKey}
stepByKey={stepByKey}
fileByName={fileByName}
previewByFileName={previewByFileName}
onSelect={setCurrentStepKey}
/>
<div className='space-y-6'>
<SourcePanel source={importSource} />
<SelectedFileDetail
step={currentStep}
sourceFile={selectedSourceFile}
template={selectedTemplate}
previewFile={selectedPreviewFile}
/>
<Card>
<CardHeader>
<CardTitle>Final Verification</CardTitle>
<CardDescription>Setup can complete after system-required import steps pass.</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid gap-2 sm:grid-cols-3'>
<Metric
label='Required'
value={`${steps.filter((step) => isStepComplete(stepByKey.get(step.stepKey))).length}/${steps.length}`}
/>
<Metric label='Status' value={allRequiredComplete ? 'Ready' : 'Incomplete'} />
<Metric label='Result' value={setupRunQuery.data?.run?.status ?? 'not_started'} />
</div>
<Button type='button' onClick={completeSetup} disabled={!allRequiredComplete || completeMutation.isPending}>
{completeMutation.isPending ? (
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
) : (
<Icons.checks className='mr-2 h-4 w-4' />
)}
Mark setup completed
</Button>
{completeMutation.isError ? <ErrorAlert message={getErrorMessage(completeMutation.error)} /> : null}
</CardContent>
</Card>
</div>
</div>
</div>
);

View File

@@ -1,7 +1,7 @@
import 'server-only';
import { hash } from 'bcryptjs';
import { randomUUID } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import { and, eq, isNull, sql } from 'drizzle-orm';
import {
crmApprovalMatrices,
@@ -34,6 +34,25 @@ import { normalizeEmail, normalizeLower, normalizeOrganizationCode, pipeList } f
type ImportTx = typeof db;
function deterministicId(input: string): string {
const digest = createHash('sha256').update(input).digest('hex');
const part3 = `5${digest.slice(13, 16)}`;
const part4 = `a${digest.slice(17, 20)}`;
return [
digest.slice(0, 8),
digest.slice(8, 12),
part3,
part4,
digest.slice(20, 32)
].join('-');
}
function deterministicOrganizationId(organizationCode: string): string {
const normalized = normalizeOrganizationCode(organizationCode).toLowerCase();
return deterministicId(`organization:${normalized}`);
}
export class CsvCommitExecutionError extends Error {
files: ImportFileReport[];
@@ -105,11 +124,14 @@ async function findUserId(tx: ImportTx, email: string): Promise<string | null> {
async function findOrganization(tx: ImportTx, organizationCode: string): Promise<{ id: string; slug: string } | null> {
if (!organizationCode) return null;
const normalized = normalizeOrganizationCode(organizationCode);
const deterministicId = deterministicOrganizationId(normalized);
const row = await first(
tx
.select({ id: organizations.id, slug: organizations.slug })
.from(organizations)
.where(sql`lower(${organizations.slug}) = ${normalized.toLowerCase()} or ${organizations.id} = ${normalized}`)
.where(
sql`lower(${organizations.slug}) = ${normalized.toLowerCase()} or ${organizations.id} = ${deterministicId} or ${organizations.id} = ${normalized}`
)
.limit(1)
);
return row ?? null;
@@ -306,7 +328,15 @@ async function upsertOrganization(tx: ImportTx, planRow: CsvCommitPlanRow): Prom
const values = planRow.row.values;
const slug = normalizeLower(values.slug);
const createdBy = await resolveUserId(tx, values.created_by_email);
const existing = await first(tx.select({ id: organizations.id }).from(organizations).where(eq(organizations.slug, slug)).limit(1));
const id = deterministicOrganizationId(values.organization_code);
const legacyId = normalizeOrganizationCode(values.organization_code);
const existing = await first(
tx
.select({ id: organizations.id })
.from(organizations)
.where(sql`${organizations.id} = ${id} or ${organizations.id} = ${legacyId} or ${organizations.slug} = ${slug}`)
.limit(1)
);
const updateValues = {
name: values.organization_name,
imageUrl: emptyToNull(values.logo_url),
@@ -318,7 +348,6 @@ async function upsertOrganization(tx: ImportTx, planRow: CsvCommitPlanRow): Prom
await tx.update(organizations).set(updateValues).where(eq(organizations.id, existing.id));
return { action: 'update', entityId: existing.id };
}
const id = normalizeOrganizationCode(values.organization_code);
await tx.insert(organizations).values({ id, slug, ...updateValues });
return { action: 'create', entityId: id };
}

View File

@@ -5,6 +5,7 @@ import { memberships, organizations, setupRuns, users } from '@/db/schema';
import { db } from '@/lib/db';
import type { ImportReport } from './csv/import-report';
import type { CsvPreviewFileInput, CsvPreviewResult } from './csv/types';
import { readConfiguredSetupImportSource, type SetupImportSourceReport } from './setup-import-source.service';
import type { SetupStepStatus } from './setup-state.service';
export const BOOTSTRAP_ACTOR_ID = 'bootstrap-setup';
@@ -137,6 +138,66 @@ export class BootstrapSetupError extends Error {
}
}
const SENSITIVE_COLUMN_PATTERN = /(password|password_hash|token|secret)/i;
function maskSensitiveValues(values: Record<string, string>) {
return Object.fromEntries(
Object.entries(values).map(([key, value]) => [
key,
SENSITIVE_COLUMN_PATTERN.test(key) && value ? '********' : value
])
);
}
export function sanitizeCsvPreviewForResponse(preview: CsvPreviewResult): CsvPreviewResult {
return {
...preview,
files: preview.files.map((file) => ({
...file,
rows: file.rows.slice(0, 5).map((row) => ({
...row,
values: maskSensitiveValues(row.values)
}))
}))
};
}
export function sanitizeCsvPreviewForPersistence(preview: CsvPreviewResult) {
return {
summary: preview.summary,
generatedAt: preview.generatedAt,
previewHash: preview.previewHash,
files: preview.files.map((file) => ({
fileName: file.fileName,
template: file.template,
importOrder: file.importOrder,
status: file.status,
summary: file.summary,
errors: file.errors,
warnings: file.warnings
}))
};
}
export function sanitizeCsvPreviewFileForPersistence(preview: CsvPreviewResult, fileName: string) {
const file = preview.files.find((candidate) => candidate.fileName.toLowerCase() === fileName.toLowerCase());
if (!file) return sanitizeCsvPreviewForPersistence(preview);
return {
summary: file.summary,
generatedAt: preview.generatedAt,
previewHash: preview.previewHash,
file: {
fileName: file.fileName,
template: file.template,
importOrder: file.importOrder,
status: file.status,
summary: file.summary,
errors: file.errors,
warnings: file.warnings
}
};
}
export interface BootstrapSetupStatus {
initialized: boolean;
tokenRequired: boolean;
@@ -149,6 +210,7 @@ export interface BootstrapSetupStatus {
requiredFiles: string[];
optionalFiles: string[];
requiredSteps: typeof BOOTSTRAP_REQUIRED_STEPS;
importSource: SetupImportSourceReport;
}
async function hasRows<T>(query: Promise<Array<T & { value: number }>>) {
@@ -161,7 +223,7 @@ async function hasRows<T>(query: Promise<Array<T & { value: number }>>) {
}
export async function getBootstrapSetupStatus(): Promise<BootstrapSetupStatus> {
const [completedSetupRun, superAdminUser, organization, adminMembership] = await Promise.all([
const [completedSetupRun, superAdminUser, organization, adminMembership, importSource] = await Promise.all([
hasRows(
db
.select({ value: count() })
@@ -180,7 +242,11 @@ export async function getBootstrapSetupStatus(): Promise<BootstrapSetupStatus> {
.select({ value: count() })
.from(memberships)
.where(eq(memberships.role, 'admin'))
)
),
readConfiguredSetupImportSource({
requiredFiles: BOOTSTRAP_REQUIRED_TEMPLATE_FILES,
optionalFiles: BOOTSTRAP_OPTIONAL_TEMPLATE_FILES
}).then((result) => result.report)
]);
const checks = {
@@ -196,7 +262,8 @@ export async function getBootstrapSetupStatus(): Promise<BootstrapSetupStatus> {
checks,
requiredFiles: [...BOOTSTRAP_REQUIRED_TEMPLATE_FILES],
optionalFiles: [...BOOTSTRAP_OPTIONAL_TEMPLATE_FILES],
requiredSteps: [...BOOTSTRAP_REQUIRED_STEPS]
requiredSteps: [...BOOTSTRAP_REQUIRED_STEPS],
importSource
};
}

View File

@@ -0,0 +1,183 @@
import 'server-only';
import { createHash } from 'node:crypto';
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { getCsvTemplateRegistry } from './csv/template-registry';
import type { CsvPreviewFileInput } from './csv/types';
const SETUP_IMPORT_ENV_KEY = 'SETUP_IMPORT_DIR';
const DEFAULT_SETUP_IMPORT_DIR = './setup/ALLA-ONVALLA';
export interface SetupImportSourceFile {
fileName: string;
kind: 'required' | 'optional';
bytes: number;
modifiedAt: string;
checksum: string;
headers: string[];
}
export interface SetupImportIgnoredFile {
fileName: string;
reason: string;
}
export interface SetupImportSourceReport {
envKey: typeof SETUP_IMPORT_ENV_KEY;
exampleValue: typeof DEFAULT_SETUP_IMPORT_DIR;
configuredValue: string | null;
configured: boolean;
resolvedPath: string | null;
exists: boolean;
files: SetupImportSourceFile[];
missingRequiredFiles: string[];
ignoredFiles: SetupImportIgnoredFile[];
errors: string[];
warnings: string[];
}
export interface SetupImportSourceReadResult {
report: SetupImportSourceReport;
files: CsvPreviewFileInput[];
}
function sha256(bytes: Uint8Array) {
return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
}
function readCsvHeaders(bytes: Uint8Array) {
const text = new TextDecoder('utf-8', { fatal: false })
.decode(bytes)
.replace(/^\uFEFF/, '');
const [header = ''] = text.split(/\r?\n/, 1);
return header.split(',').map((column) => column.trim()).filter(Boolean);
}
function resolveConfiguredPath(configuredValue: string) {
return path.isAbsolute(configuredValue)
? path.normalize(configuredValue)
: path.resolve(process.cwd(), configuredValue);
}
function createBaseReport(configuredValue: string | null): SetupImportSourceReport {
return {
envKey: SETUP_IMPORT_ENV_KEY,
exampleValue: DEFAULT_SETUP_IMPORT_DIR,
configuredValue,
configured: Boolean(configuredValue),
resolvedPath: configuredValue ? resolveConfiguredPath(configuredValue) : null,
exists: false,
files: [],
missingRequiredFiles: [],
ignoredFiles: [],
errors: [],
warnings: []
};
}
export async function readConfiguredSetupImportSource(input: {
requiredFiles: readonly string[];
optionalFiles: readonly string[];
}): Promise<SetupImportSourceReadResult> {
const configuredValue = process.env[SETUP_IMPORT_ENV_KEY]?.trim() || null;
const report = createBaseReport(configuredValue);
if (!configuredValue || !report.resolvedPath) {
report.errors.push(
`Add ${SETUP_IMPORT_ENV_KEY}=${DEFAULT_SETUP_IMPORT_DIR} to .env and restart the server.`
);
report.missingRequiredFiles = [...input.requiredFiles];
return { report, files: [] };
}
const registry = await getCsvTemplateRegistry();
const supportedFiles = new Map(
registry.templates
.filter((template) => template.supported)
.map((template) => [template.file.toLowerCase(), template])
);
const requiredFiles = new Set(input.requiredFiles.map((fileName) => fileName.toLowerCase()));
const discovered = new Set<string>();
const files: CsvPreviewFileInput[] = [];
try {
const directoryStat = await stat(report.resolvedPath);
if (!directoryStat.isDirectory()) {
report.errors.push(`${report.resolvedPath} is not a directory.`);
report.missingRequiredFiles = [...input.requiredFiles];
return { report, files: [] };
}
report.exists = true;
const entries = await readdir(report.resolvedPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
const fileName = path.basename(entry.name);
const normalized = fileName.toLowerCase();
const template = supportedFiles.get(normalized);
if (!template) {
report.ignoredFiles.push({
fileName,
reason: 'File is not a registered supported setup CSV template.'
});
continue;
}
const filePath = path.join(report.resolvedPath, fileName);
const [fileStat, bytes] = await Promise.all([stat(filePath), readFile(filePath)]);
const byteArray = new Uint8Array(bytes);
const kind = requiredFiles.has(normalized) ? 'required' : 'optional';
discovered.add(normalized);
files.push({ fileName: template.file, bytes: byteArray });
report.files.push({
fileName: template.file,
kind,
bytes: byteArray.byteLength,
modifiedAt: fileStat.mtime.toISOString(),
checksum: sha256(byteArray),
headers: readCsvHeaders(byteArray)
});
}
} catch (error) {
report.errors.push(
error instanceof Error
? `Unable to read ${report.resolvedPath}: ${error.message}`
: `Unable to read ${report.resolvedPath}.`
);
}
report.missingRequiredFiles = input.requiredFiles.filter(
(fileName) => !discovered.has(fileName.toLowerCase())
);
if (report.missingRequiredFiles.length > 0) {
report.errors.push(
`Missing required setup CSV files: ${report.missingRequiredFiles.join(', ')}.`
);
}
if (report.ignoredFiles.length > 0) {
report.warnings.push('Some files in the setup import directory are ignored.');
}
files.sort((left, right) => {
const leftTemplate = supportedFiles.get(left.fileName.toLowerCase());
const rightTemplate = supportedFiles.get(right.fileName.toLowerCase());
return (leftTemplate?.importOrder ?? 0) - (rightTemplate?.importOrder ?? 0);
});
report.files.sort((left, right) => {
const leftTemplate = supportedFiles.get(left.fileName.toLowerCase());
const rightTemplate = supportedFiles.get(right.fileName.toLowerCase());
return (leftTemplate?.importOrder ?? 0) - (rightTemplate?.importOrder ?? 0);
});
return { report, files };
}

View File

@@ -39,6 +39,7 @@ export interface SaveSetupStepInput {
output?: Record<string, unknown> | null;
errors?: unknown[] | null;
warnings?: unknown[] | null;
skipStaleInvalidation?: boolean;
}
const ACTIVE_RUN_STATUSES: SetupRunStatus[] = [
@@ -319,7 +320,7 @@ export async function saveSetupStep(input: SaveSetupStepInput) {
}
});
if (inputChanged) {
if (inputChanged && !input.skipStaleInvalidation) {
const staleSteps = STALE_DEPENDENCIES[input.stepKey] ?? [];
await Promise.all(
staleSteps.map((stepKey) =>