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,23 +1,33 @@
import { redirect } from 'next/navigation';
import { BootstrapSetup } from '@/features/setup/components/bootstrap-setup';
import { getBootstrapSetupStatus } from '@/features/setup/server/setup-bootstrap.service';
import { redirect } from "next/navigation";
import { SetupWizard } from "@/features/setup/components/setup-wizard";
import { getBootstrapSetupStatus } from "@/features/setup/server/setup-bootstrap.service";
export const metadata = {
title: 'ALLA OS Setup'
title: "ALLA OS Setup",
};
export default async function BootstrapSetupPage() {
const status = await getBootstrapSetupStatus();
if (status.initialized) {
redirect('/auth/sign-in');
redirect("/auth/sign-in");
}
return (
<BootstrapSetup
tokenRequired={status.tokenRequired}
requiredFiles={status.requiredFiles}
optionalFiles={status.optionalFiles}
/>
<main className="min-h-screen bg-background py-8 px-4">
<div className="flex w-full flex-col gap-6">
<div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-normal">
ALLA OS Setup
</h1>
<p className="max-w-3xl text-sm text-muted-foreground">
Preview setup CSV files from SETUP_IMPORT_DIR, run the import, then
complete setup.
</p>
</div>
<SetupWizard mode="public" tokenRequired={status.tokenRequired} />
</div>
</main>
);
}

View File

@@ -1,15 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { commitSetupCsvImport } from '@/features/setup/server/csv-commit.service';
import { createCsvImportSeedManifest } from '@/features/setup/server/seed-manifest.service';
import type { ImportReport } from '@/features/setup/server/csv/import-report';
import type { CsvPreviewFileInput } from '@/features/setup/server/csv/types';
import {
assertBootstrapStepFiles,
assertBootstrapSetupAvailable,
assertBootstrapStepFiles,
assertBootstrapToken,
BOOTSTRAP_ACTOR_ID,
BOOTSTRAP_OPTIONAL_TEMPLATE_FILES,
BOOTSTRAP_REQUIRED_STEPS,
BOOTSTRAP_REQUIRED_TEMPLATE_FILES,
BootstrapSetupError,
setupStepStatusFromImportReport,
setupStepStatusFromReportStatus,
toSerializableRecord
} from '@/features/setup/server/setup-bootstrap.service';
import { readConfiguredSetupImportSource } from '@/features/setup/server/setup-import-source.service';
import { saveSetupStep, startSetupRun } from '@/features/setup/server/setup-state.service';
function isFile(value: FormDataEntryValue): value is File {
@@ -22,58 +30,156 @@ function isFile(value: FormDataEntryValue): value is File {
);
}
function parseBoolean(value: FormDataEntryValue | null): boolean {
function parseBoolean(value: FormDataEntryValue | string | boolean | undefined | null): boolean {
if (typeof value === 'boolean') return value;
if (typeof value !== 'string') return false;
return value.trim().toLowerCase() === 'true';
}
async function readMultipartFiles(formData: FormData): Promise<CsvPreviewFileInput[]> {
const uploadedFiles = Array.from(formData.values()).filter(isFile);
return Promise.all(
uploadedFiles.map(async (file) => ({
fileName: file.name,
bytes: new Uint8Array(await file.arrayBuffer())
}))
);
}
async function saveConfiguredCommitSteps(report: ImportReport) {
await Promise.all(
BOOTSTRAP_REQUIRED_STEPS.map((step) => {
const file = report.files.find(
(candidate) => candidate.fileName.toLowerCase() === step.fileName.toLowerCase()
);
if (!file) return Promise.resolve();
return saveSetupStep({
stepKey: step.stepKey,
status: setupStepStatusFromReportStatus(file.status),
inputHash: report.committedHash,
output: toSerializableRecord({
status: file.status,
generatedAt: report.generatedAt,
previewHash: report.previewHash,
committedHash: report.committedHash,
dryRun: report.dryRun,
file: {
fileName: file.fileName,
template: file.template,
status: file.status,
imported: file.imported,
updated: file.updated,
skipped: file.skipped,
errors: file.errors,
warnings: file.warnings
}
}),
errors: file.errors,
warnings: file.warnings,
skipStaleInvalidation: true
});
})
);
}
async function commitConfiguredDirectory(request: NextRequest) {
const body = (await request.json().catch(() => ({}))) as {
bootstrapToken?: string;
source?: string;
previewHash?: string;
dryRun?: boolean | string;
};
assertBootstrapToken(body.bootstrapToken ?? request.headers.get('x-bootstrap-setup-token') ?? '');
if (body.source !== 'configured-directory') {
return NextResponse.json({ message: 'Unsupported bootstrap commit source.' }, { status: 400 });
}
const source = await readConfiguredSetupImportSource({
requiredFiles: BOOTSTRAP_REQUIRED_TEMPLATE_FILES,
optionalFiles: BOOTSTRAP_OPTIONAL_TEMPLATE_FILES
});
if (source.report.errors.length > 0) {
return NextResponse.json(
{ message: 'Configured setup import folder is not ready.', importSource: source.report },
{ status: 400 }
);
}
await startSetupRun({ actorId: BOOTSTRAP_ACTOR_ID, mode: 'production', targetOrganizationId: null });
const report = await commitSetupCsvImport({
files: source.files,
previewHash: body.previewHash ?? '',
dryRun: parseBoolean(body.dryRun),
actorId: BOOTSTRAP_ACTOR_ID
});
if (!report.dryRun && report.status !== 'FAIL') {
await createCsvImportSeedManifest({ files: source.files, report, actorId: BOOTSTRAP_ACTOR_ID });
}
await saveConfiguredCommitSteps(report);
return NextResponse.json({ ...report, importSource: source.report }, { status: report.status === 'FAIL' ? 400 : 200 });
}
async function commitUploadedStep(request: NextRequest) {
const formData = await request.formData();
assertBootstrapToken(String(formData.get('bootstrapToken') ?? request.headers.get('x-bootstrap-setup-token') ?? ''));
const stepKey = String(formData.get('stepKey') ?? '');
const previewHash = String(formData.get('previewHash') ?? '');
const dryRun = parseBoolean(formData.get('dryRun'));
const files = await readMultipartFiles(formData);
if (files.length === 0) {
return NextResponse.json(
{ message: 'Upload at least one setup CSV file using multipart/form-data.' },
{ status: 400 }
);
}
const step = assertBootstrapStepFiles(stepKey, files);
await startSetupRun({ actorId: BOOTSTRAP_ACTOR_ID, mode: 'production', targetOrganizationId: null });
const report = await commitSetupCsvImport({
files,
previewHash,
dryRun,
actorId: BOOTSTRAP_ACTOR_ID
});
if (!dryRun && report.status !== 'FAIL') {
await createCsvImportSeedManifest({ files, report, actorId: BOOTSTRAP_ACTOR_ID });
}
await saveSetupStep({
stepKey: step.stepKey,
status: setupStepStatusFromImportReport(report),
inputHash: report.committedHash,
output: toSerializableRecord(report),
errors: report.errors,
warnings: report.warnings
});
return NextResponse.json(report, { status: report.status === 'FAIL' ? 400 : 200 });
}
export async function POST(request: NextRequest) {
try {
await assertBootstrapSetupAvailable();
const formData = await request.formData();
assertBootstrapToken(String(formData.get('bootstrapToken') ?? request.headers.get('x-bootstrap-setup-token') ?? ''));
const stepKey = String(formData.get('stepKey') ?? '');
const previewHash = String(formData.get('previewHash') ?? '');
const dryRun = parseBoolean(formData.get('dryRun'));
const uploadedFiles = Array.from(formData.values()).filter(isFile);
if (uploadedFiles.length === 0) {
return NextResponse.json({ message: 'Upload at least one setup CSV file using multipart/form-data.' }, { status: 400 });
if (request.headers.get('content-type')?.includes('application/json')) {
return await commitConfiguredDirectory(request);
}
const files = await Promise.all(
uploadedFiles.map(async (file) => ({
fileName: file.name,
bytes: new Uint8Array(await file.arrayBuffer())
}))
);
const step = assertBootstrapStepFiles(stepKey, files);
await startSetupRun({ actorId: BOOTSTRAP_ACTOR_ID, mode: 'production', targetOrganizationId: null });
const report = await commitSetupCsvImport({
files,
previewHash,
dryRun,
actorId: BOOTSTRAP_ACTOR_ID
});
if (!dryRun && report.status !== 'FAIL') {
await createCsvImportSeedManifest({ files, report, actorId: BOOTSTRAP_ACTOR_ID });
}
await saveSetupStep({
stepKey: step.stepKey,
status: setupStepStatusFromImportReport(report),
inputHash: report.committedHash,
output: toSerializableRecord(report),
errors: report.errors,
warnings: report.warnings
});
return NextResponse.json(report, { status: report.status === 'FAIL' ? 400 : 200 });
return await commitUploadedStep(request);
} catch (error) {
if (error instanceof BootstrapSetupError) {
return NextResponse.json({ message: error.message, details: error.details }, { status: error.status });

View File

@@ -1,15 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { previewSetupCsvImport } from '@/features/setup/server/csv-preview.service';
import {
assertBootstrapStepFiles,
assertBootstrapSetupAvailable,
assertBootstrapStepFiles,
assertBootstrapToken,
BOOTSTRAP_ACTOR_ID,
BOOTSTRAP_OPTIONAL_TEMPLATE_FILES,
BOOTSTRAP_REQUIRED_STEPS,
BOOTSTRAP_REQUIRED_TEMPLATE_FILES,
BootstrapSetupError,
sanitizeCsvPreviewFileForPersistence,
sanitizeCsvPreviewForResponse,
setupStepStatusFromPreview,
setupStepStatusFromReportStatus,
toSerializableRecord
} from '@/features/setup/server/setup-bootstrap.service';
import { readConfiguredSetupImportSource } from '@/features/setup/server/setup-import-source.service';
import { saveSetupStep, startSetupRun } from '@/features/setup/server/setup-state.service';
import type { CsvPreviewFileInput, CsvPreviewResult } from '@/features/setup/server/csv/types';
function isFile(value: FormDataEntryValue): value is File {
return (
@@ -21,42 +30,109 @@ function isFile(value: FormDataEntryValue): value is File {
);
}
async function saveConfiguredPreviewSteps(preview: CsvPreviewResult) {
await Promise.all(
BOOTSTRAP_REQUIRED_STEPS.map((step) => {
const file = preview.files.find(
(candidate) => candidate.fileName.toLowerCase() === step.fileName.toLowerCase()
);
if (!file) return Promise.resolve();
return saveSetupStep({
stepKey: step.stepKey,
status: setupStepStatusFromReportStatus(file.status),
inputHash: preview.previewHash,
output: toSerializableRecord(sanitizeCsvPreviewFileForPersistence(preview, step.fileName)),
errors: file.errors,
warnings: file.warnings,
skipStaleInvalidation: true
});
})
);
}
async function readMultipartFiles(formData: FormData): Promise<CsvPreviewFileInput[]> {
const uploadedFiles = Array.from(formData.values()).filter(isFile);
return Promise.all(
uploadedFiles.map(async (file) => ({
fileName: file.name,
bytes: new Uint8Array(await file.arrayBuffer())
}))
);
}
async function previewConfiguredDirectory(request: NextRequest) {
const body = (await request.json().catch(() => ({}))) as {
bootstrapToken?: string;
source?: string;
};
assertBootstrapToken(body.bootstrapToken ?? request.headers.get('x-bootstrap-setup-token') ?? '');
if (body.source !== 'configured-directory') {
return NextResponse.json({ message: 'Unsupported bootstrap preview source.' }, { status: 400 });
}
const source = await readConfiguredSetupImportSource({
requiredFiles: BOOTSTRAP_REQUIRED_TEMPLATE_FILES,
optionalFiles: BOOTSTRAP_OPTIONAL_TEMPLATE_FILES
});
if (source.report.errors.length > 0) {
return NextResponse.json(
{ message: 'Configured setup import folder is not ready.', importSource: source.report },
{ status: 400 }
);
}
await startSetupRun({ actorId: BOOTSTRAP_ACTOR_ID, mode: 'production', targetOrganizationId: null });
const preview = await previewSetupCsvImport(source.files);
await saveConfiguredPreviewSteps(preview);
return NextResponse.json({ ...sanitizeCsvPreviewForResponse(preview), importSource: source.report });
}
async function previewUploadedStep(request: NextRequest) {
const formData = await request.formData();
assertBootstrapToken(String(formData.get('bootstrapToken') ?? request.headers.get('x-bootstrap-setup-token') ?? ''));
const stepKey = String(formData.get('stepKey') ?? '');
const files = await readMultipartFiles(formData);
if (files.length === 0) {
return NextResponse.json(
{ message: 'Upload at least one setup CSV file using multipart/form-data.' },
{ status: 400 }
);
}
const step = assertBootstrapStepFiles(stepKey, files);
await startSetupRun({ actorId: BOOTSTRAP_ACTOR_ID, mode: 'production', targetOrganizationId: null });
const preview = await previewSetupCsvImport(files);
await saveSetupStep({
stepKey: step.stepKey,
status: setupStepStatusFromPreview(preview),
inputHash: preview.previewHash,
output: toSerializableRecord(sanitizeCsvPreviewFileForPersistence(preview, step.fileName)),
errors: preview.files.flatMap((file) => file.errors),
warnings: preview.files.flatMap((file) => file.warnings)
});
return NextResponse.json(sanitizeCsvPreviewForResponse(preview));
}
export async function POST(request: NextRequest) {
try {
await assertBootstrapSetupAvailable();
const formData = await request.formData();
assertBootstrapToken(String(formData.get('bootstrapToken') ?? request.headers.get('x-bootstrap-setup-token') ?? ''));
const stepKey = String(formData.get('stepKey') ?? '');
const uploadedFiles = Array.from(formData.values()).filter(isFile);
if (uploadedFiles.length === 0) {
return NextResponse.json({ message: 'Upload at least one setup CSV file using multipart/form-data.' }, { status: 400 });
if (request.headers.get('content-type')?.includes('application/json')) {
return await previewConfiguredDirectory(request);
}
const files = await Promise.all(
uploadedFiles.map(async (file) => ({
fileName: file.name,
bytes: new Uint8Array(await file.arrayBuffer())
}))
);
const step = assertBootstrapStepFiles(stepKey, files);
await startSetupRun({ actorId: BOOTSTRAP_ACTOR_ID, mode: 'production', targetOrganizationId: null });
const preview = await previewSetupCsvImport(files);
await saveSetupStep({
stepKey: step.stepKey,
status: setupStepStatusFromPreview(preview),
inputHash: preview.previewHash,
output: toSerializableRecord(preview),
errors: preview.files.flatMap((file) => file.errors),
warnings: preview.files.flatMap((file) => file.warnings)
});
return NextResponse.json(preview);
return await previewUploadedStep(request);
} catch (error) {
if (error instanceof BootstrapSetupError) {
return NextResponse.json({ message: error.message, details: error.details }, { status: error.status });
@@ -66,14 +142,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ message: 'Upload setup CSV files using multipart/form-data.' }, { status: 400 });
}
console.error('Bootstrap preview failed');
if (error instanceof Error) {
for (const line of (error.stack ?? error.message).split('\n').slice(0, 12)) {
console.error(line);
}
} else {
console.error(error);
}
return NextResponse.json({ message: 'Unable preview bootstrap setup CSV import' }, { status: 500 });
}
}

View File

@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import {
assertBootstrapSetupAvailable,
assertBootstrapToken,
BootstrapSetupError,
} from "@/features/setup/server/setup-bootstrap.service";
import { getCurrentSetupRun } from "@/features/setup/server/setup-state.service";
export async function GET(request: NextRequest) {
try {
await assertBootstrapSetupAvailable();
assertBootstrapToken(request.headers.get("x-bootstrap-setup-token") ?? "");
return NextResponse.json(await getCurrentSetupRun());
} catch (error) {
if (error instanceof BootstrapSetupError) {
return NextResponse.json(
{ message: error.message, details: error.details },
{ status: error.status },
);
}
return NextResponse.json(
{ message: "Unable load bootstrap setup run" },
{ status: 500 },
);
}
}

View File

@@ -1,55 +1,109 @@
import { NextRequest, NextResponse } from 'next/server';
import { commitSetupCsvImport } from '@/features/setup/server/csv-commit.service';
import { createCsvImportSeedManifest } from '@/features/setup/server/seed-manifest.service';
import {
BOOTSTRAP_OPTIONAL_TEMPLATE_FILES,
BOOTSTRAP_REQUIRED_TEMPLATE_FILES
} from '@/features/setup/server/setup-bootstrap.service';
import { readConfiguredSetupImportSource } from '@/features/setup/server/setup-import-source.service';
import { AuthError, requireSystemRole } from '@/lib/auth/session';
import type { CsvPreviewFileInput } from '@/features/setup/server/csv/types';
function isFile(value: FormDataEntryValue): value is File {
return typeof value === 'object' && 'arrayBuffer' in value && 'name' in value;
}
function parseBoolean(value: FormDataEntryValue | null): boolean {
function parseBoolean(value: FormDataEntryValue | string | boolean | undefined | null): boolean {
if (typeof value === 'boolean') return value;
if (typeof value !== 'string') return false;
return value.trim().toLowerCase() === 'true';
}
async function readMultipartFiles(formData: FormData): Promise<CsvPreviewFileInput[]> {
const uploadedFiles = Array.from(formData.values()).filter(isFile);
return Promise.all(
uploadedFiles.map(async (file) => ({
fileName: file.name,
bytes: new Uint8Array(await file.arrayBuffer())
}))
);
}
async function commitConfiguredDirectory(request: NextRequest, actorId: string) {
const body = (await request.json().catch(() => ({}))) as {
source?: string;
previewHash?: string;
dryRun?: boolean | string;
};
if (body.source !== 'configured-directory') {
return NextResponse.json({ message: 'Unsupported setup import commit source.' }, { status: 400 });
}
const source = await readConfiguredSetupImportSource({
requiredFiles: BOOTSTRAP_REQUIRED_TEMPLATE_FILES,
optionalFiles: BOOTSTRAP_OPTIONAL_TEMPLATE_FILES
});
if (source.report.errors.length > 0) {
return NextResponse.json(
{ message: 'Configured setup import folder is not ready.', importSource: source.report },
{ status: 400 }
);
}
const report = await commitSetupCsvImport({
files: source.files,
previewHash: body.previewHash ?? '',
dryRun: parseBoolean(body.dryRun),
actorId
});
if (!report.dryRun && report.status !== 'FAIL') {
await createCsvImportSeedManifest({ files: source.files, report, actorId });
}
return NextResponse.json({ ...report, importSource: source.report }, { status: report.status === 'FAIL' ? 400 : 200 });
}
async function commitUploadedFiles(request: NextRequest, actorId: string) {
const formData = await request.formData();
const previewHash = String(formData.get('previewHash') ?? '');
const dryRun = parseBoolean(formData.get('dryRun'));
const files = await readMultipartFiles(formData);
if (files.length === 0) {
return NextResponse.json(
{ message: 'Upload at least one setup CSV file using multipart/form-data.' },
{ status: 400 }
);
}
const report = await commitSetupCsvImport({
files,
previewHash,
dryRun,
actorId
});
if (!dryRun && report.status !== 'FAIL') {
await createCsvImportSeedManifest({ files, report, actorId });
}
return NextResponse.json(report, { status: report.status === 'FAIL' ? 400 : 200 });
}
export async function POST(request: NextRequest) {
try {
const session = await requireSystemRole('super_admin');
const formData = await request.formData();
const previewHash = String(formData.get('previewHash') ?? '');
const dryRun = parseBoolean(formData.get('dryRun'));
const uploadedFiles = Array.from(formData.values()).filter(isFile);
if (uploadedFiles.length === 0) {
return NextResponse.json(
{ message: 'Upload at least one setup CSV file using multipart/form-data.' },
{ status: 400 }
);
if (request.headers.get('content-type')?.includes('application/json')) {
return await commitConfiguredDirectory(request, session.user.id);
}
const files = await Promise.all(
uploadedFiles.map(async (file) => ({
fileName: file.name,
bytes: new Uint8Array(await file.arrayBuffer())
}))
);
const report = await commitSetupCsvImport({
files,
previewHash,
dryRun,
actorId: session.user.id
});
if (!dryRun && report.status !== 'FAIL') {
await createCsvImportSeedManifest({
files,
report,
actorId: session.user.id
});
}
return NextResponse.json(report, { status: report.status === 'FAIL' ? 400 : 200 });
return await commitUploadedFiles(request, session.user.id);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });

View File

@@ -1,34 +1,77 @@
import { NextRequest, NextResponse } from 'next/server';
import { previewSetupCsvImport } from '@/features/setup/server/csv-preview.service';
import {
BOOTSTRAP_OPTIONAL_TEMPLATE_FILES,
BOOTSTRAP_REQUIRED_TEMPLATE_FILES,
sanitizeCsvPreviewForResponse
} from '@/features/setup/server/setup-bootstrap.service';
import { readConfiguredSetupImportSource } from '@/features/setup/server/setup-import-source.service';
import { AuthError, requireSystemRole } from '@/lib/auth/session';
import type { CsvPreviewFileInput } from '@/features/setup/server/csv/types';
function isFile(value: FormDataEntryValue): value is File {
return typeof value === 'object' && 'arrayBuffer' in value && 'name' in value;
}
async function readMultipartFiles(formData: FormData): Promise<CsvPreviewFileInput[]> {
const uploadedFiles = Array.from(formData.values()).filter(isFile);
return Promise.all(
uploadedFiles.map(async (file) => ({
fileName: file.name,
bytes: new Uint8Array(await file.arrayBuffer())
}))
);
}
async function previewConfiguredDirectory(request: NextRequest) {
const body = (await request.json().catch(() => ({}))) as { source?: string };
if (body.source !== 'configured-directory') {
return NextResponse.json({ message: 'Unsupported setup import preview source.' }, { status: 400 });
}
const source = await readConfiguredSetupImportSource({
requiredFiles: BOOTSTRAP_REQUIRED_TEMPLATE_FILES,
optionalFiles: BOOTSTRAP_OPTIONAL_TEMPLATE_FILES
});
if (source.report.errors.length > 0) {
return NextResponse.json(
{ message: 'Configured setup import folder is not ready.', importSource: source.report },
{ status: 400 }
);
}
const preview = await previewSetupCsvImport(source.files);
return NextResponse.json({ ...sanitizeCsvPreviewForResponse(preview), importSource: source.report });
}
async function previewUploadedFiles(request: NextRequest) {
const formData = await request.formData();
const files = await readMultipartFiles(formData);
if (files.length === 0) {
return NextResponse.json(
{ message: 'Upload at least one setup CSV file using multipart/form-data.' },
{ status: 400 }
);
}
const preview = await previewSetupCsvImport(files);
return NextResponse.json(sanitizeCsvPreviewForResponse(preview));
}
export async function POST(request: NextRequest) {
try {
await requireSystemRole('super_admin');
const formData = await request.formData();
const uploadedFiles = Array.from(formData.values()).filter(isFile);
if (uploadedFiles.length === 0) {
return NextResponse.json(
{ message: 'Upload at least one setup CSV file using multipart/form-data.' },
{ status: 400 }
);
if (request.headers.get('content-type')?.includes('application/json')) {
return await previewConfiguredDirectory(request);
}
const files = await Promise.all(
uploadedFiles.map(async (file) => ({
fileName: file.name,
bytes: new Uint8Array(await file.arrayBuffer())
}))
);
const preview = await previewSetupCsvImport(files);
return NextResponse.json(preview);
return await previewUploadedFiles(request);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });

View File

@@ -13,7 +13,7 @@ export default async function AdministrationSetupPage() {
return (
<PageContainer
pageTitle='Administration Setup Wizard'
pageDescription='Run readiness checks, review setup templates, preview CSV imports, commit imports, and verify the configured system.'
pageDescription='Preview setup CSV files from SETUP_IMPORT_DIR, run the import, and verify the configured system.'
access={canAccessSetup}
accessFallback={
<div className='rounded-lg border border-dashed p-8 text-center text-muted-foreground'>

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) =>

View File

@@ -26,7 +26,7 @@ export function assertFileExists(relativePath: string) {
export function parseCsv(relativePath: string): CsvFile {
const content = readText(relativePath).trim();
const [headerLine, ...rowLines] = content.split(/\r?\n/);
const [headerLine = '', ...rowLines] = content.split(/\r?\n/);
return {
header: headerLine.split(','),
rows: rowLines.filter(Boolean).map((line) => line.split(','))

View File

@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { assertFileExists, readText } from './helpers.ts';
test('first-run bootstrap setup public surfaces exist', () => {
@@ -8,8 +9,9 @@ test('first-run bootstrap setup public surfaces exist', () => {
}
assertFileExists('src/app/administration/setup/page.tsx');
assertFileExists('src/features/setup/components/bootstrap-setup.tsx');
assertFileExists('src/features/setup/components/setup-wizard.tsx');
assertFileExists('src/features/setup/server/setup-bootstrap.service.ts');
assertFileExists('src/features/setup/server/setup-import-source.service.ts');
});
test('bootstrap setup guards public access after initialization', () => {
@@ -29,7 +31,7 @@ test('bootstrap setup guards public access after initialization', () => {
assert.match(proxy, /\/api\/setup\/templates/);
});
test('bootstrap setup enforces required CSV minimum and preview hash commit', () => {
test('bootstrap setup supports configured-directory preview and commit', () => {
const service = readText('src/features/setup/server/setup-bootstrap.service.ts');
const commitRoute = readText('src/app/api/setup/bootstrap/commit/route.ts');
const previewRoute = readText('src/app/api/setup/bootstrap/preview/route.ts');
@@ -38,6 +40,7 @@ test('bootstrap setup enforces required CSV minimum and preview hash commit', ()
'users.csv',
'organizations.csv',
'memberships.csv',
'crm-role-assignments.csv',
'master-options.csv',
'branches.csv',
'product-types.csv',
@@ -49,8 +52,16 @@ test('bootstrap setup enforces required CSV minimum and preview hash commit', ()
assert.match(service, new RegExp(fileName.replace('.', '\\.')));
}
assert.match(previewRoute, /assertBootstrapRequiredFiles/);
assert.match(previewRoute, /application\/json/);
assert.match(previewRoute, /configured-directory/);
assert.match(previewRoute, /readConfiguredSetupImportSource/);
assert.match(previewRoute, /saveConfiguredPreviewSteps/);
assert.match(commitRoute, /application\/json/);
assert.match(commitRoute, /configured-directory/);
assert.match(commitRoute, /previewHash/);
assert.match(commitRoute, /commitSetupCsvImport/);
assert.match(commitRoute, /createCsvImportSeedManifest/);
assert.match(commitRoute, /saveConfiguredCommitSteps/);
assert.match(commitRoute, /skipStaleInvalidation: true/);
assert.match(previewRoute, /skipStaleInvalidation: true/);
});

View File

@@ -1,8 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { assertFileExists, readText } from './helpers.ts';
test('CSV commit API and seed manifest integration exist', () => {
test('CSV commit API seed manifest integration exist', () => {
assertFileExists('src/app/api/setup/import/commit/route.ts');
assertFileExists('src/features/setup/server/seed-manifest.service.ts');
@@ -17,3 +18,12 @@ test('commit engine keeps preview hash validation contract', () => {
assert.match(service, /PREVIEW_HASH_REQUIRED/);
assert.match(service, /PREVIEW_HASH_MISMATCH/);
});
test('organization CSV import uses deterministic UUID primary keys', () => {
const executor = readText('src/features/setup/server/csv/upsert-executor.ts');
assert.match(executor, /function deterministicOrganizationId/);
assert.match(executor, /deterministicId\(`organization:\$\{normalized\}`\)/);
assert.match(executor, /const id = deterministicOrganizationId\(values\.organization_code\)/);
assert.doesNotMatch(executor, /const id = normalizeOrganizationCode\(values\.organization_code\)/);
});

View File

@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { assertFileExists, readJson, readText, repoRoot } from './helpers.ts';
interface TemplateMetadata {
templates: Array<{ file: string; supported: boolean }>;
}
const requiredFiles = [
'users.csv',
'organizations.csv',
'memberships.csv',
'crm-role-assignments.csv',
'master-options.csv',
'branches.csv',
'product-types.csv',
'document-sequences.csv',
'approval-workflows.csv',
'approval-steps.csv',
'approval-matrix.csv'
];
test('setup import source service resolves env-based configured folder', () => {
const service = readText('src/features/setup/server/setup-import-source.service.ts');
assert.match(service, /SETUP_IMPORT_DIR/);
assert.match(service, /\.\/setup\/ALLA-ONVALLA/);
assert.match(service, /process\.cwd\(\)/);
assert.match(service, /path\.resolve/);
assert.match(service, /missingRequiredFiles/);
assert.match(service, /ignoredFiles/);
assert.match(service, /checksum/);
assert.match(service, /getCsvTemplateRegistry/);
});
test('sample ALLA setup folder contains required bootstrap CSV files', () => {
const folder = path.join(repoRoot, 'setup', 'ALLA-ONVALLA');
assert.equal(existsSync(folder), true, 'setup/ALLA-ONVALLA should exist');
for (const fileName of requiredFiles) {
assertFileExists(`setup/ALLA-ONVALLA/${fileName}`);
}
});
test('configured setup import source only reads registered supported templates', () => {
const metadata = readJson<TemplateMetadata>('setup/templates/templates.json');
const supportedFiles = new Set(
metadata.templates.filter((template) => template.supported).map((template) => template.file)
);
for (const fileName of requiredFiles) {
assert.equal(supportedFiles.has(fileName), true, `${fileName} should be a supported setup template`);
}
const service = readText('src/features/setup/server/setup-import-source.service.ts');
assert.match(service, /template\.supported/);
assert.match(service, /File is not a registered supported setup CSV template/);
});
test('environment documents setup import directory', () => {
const env = readText('.env');
assert.match(env, /SETUP_IMPORT_DIR=\.\/setup\/ALLA-ONVALLA/);
});

View File

@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { assertFileExists, readText } from './helpers.ts';
test('setup plugin registry files exist', () => {
@@ -15,12 +16,13 @@ test('setup plugin registry files exist', () => {
}
});
test('installation profile API and wizard profile loading exist', () => {
test('installation profile API remains available while wizard uses configured import source', () => {
assertFileExists('src/app/api/setup/profiles/route.ts');
const wizard = readText('src/features/setup/components/setup-wizard.tsx');
assert.match(wizard, /setupProfilesQueryOptions/);
assert.match(wizard, /Installation profile/);
assert.match(wizard, /setupTemplatesQueryOptions/);
assert.match(wizard, /Configured Import Folder/);
assert.match(wizard, /source\.envKey/);
});
test('template API exposes plugin-aware metadata without removing legacy fields', () => {

View File

@@ -1,18 +1,23 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { assertFileExists, readText } from './helpers.ts';
test('setup resume APIs exist', () => {
for (const route of ['current', 'start', 'steps', 'complete', 'report']) {
assertFileExists(`src/app/api/setup/run/${route}/route.ts`);
}
assertFileExists('src/app/api/setup/bootstrap/run/current/route.ts');
assertFileExists('src/features/setup/server/setup-state.service.ts');
});
test('setup wizard persists core step outputs', () => {
test('setup wizard refreshes run state after configured-folder import', () => {
const wizard = readText('src/features/setup/components/setup-wizard.tsx');
for (const stepKey of ['readiness', 'csv_preview', 'csv_commit', 'verification']) {
assert.match(wizard, new RegExp(stepKey));
}
assert.match(wizard, /useConfiguredBootstrapCsvPreview/);
assert.match(wizard, /useConfiguredBootstrapCsvCommit/);
assert.match(wizard, /setupRunQuery\.refetch/);
assert.match(wizard, /bootstrapStatusQuery\.refetch/);
assert.match(wizard, /Mark setup completed/);
});