csv setupdata
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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 ?? {}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
183
src/features/setup/server/setup-import-source.service.ts
Normal file
183
src/features/setup/server/setup-import-source.service.ts
Normal 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 };
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user