task-d.7.12
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import {
|
||||
getBootstrapSetupStatus,
|
||||
getCurrentSetupRun,
|
||||
getSetupProfiles,
|
||||
getSetupReadiness,
|
||||
@@ -14,6 +15,7 @@ export const setupKeys = {
|
||||
readiness: (profileId?: string) => [...setupKeys.all, 'readiness', profileId ?? 'crm_demo'] as const,
|
||||
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,
|
||||
run: () => [...setupKeys.all, 'run'] as const,
|
||||
report: () => [...setupKeys.all, 'run', 'report'] as const,
|
||||
verification: (payload: SetupVerificationPayload = {}) =>
|
||||
@@ -38,6 +40,12 @@ export const setupProfilesQueryOptions = (profileId?: string) =>
|
||||
queryFn: () => getSetupProfiles(profileId)
|
||||
});
|
||||
|
||||
export const bootstrapSetupStatusQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: setupKeys.bootstrapStatus(),
|
||||
queryFn: () => getBootstrapSetupStatus()
|
||||
});
|
||||
|
||||
export const setupRunQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: setupKeys.run(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiClientError, apiClient } from '@/lib/api-client';
|
||||
import type {
|
||||
CsvPreviewResult,
|
||||
BootstrapSetupStatusResponse,
|
||||
ImportReport,
|
||||
SaveSetupStepPayload,
|
||||
SetupProfilesResponse,
|
||||
@@ -34,6 +35,10 @@ export async function getSetupProfiles(profileId?: string): Promise<SetupProfile
|
||||
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');
|
||||
}
|
||||
@@ -102,6 +107,23 @@ export async function previewSetupCsvFiles(files: File[]): Promise<CsvPreviewRes
|
||||
);
|
||||
}
|
||||
|
||||
export async function previewBootstrapCsvStep(input: {
|
||||
stepKey: string;
|
||||
file: File;
|
||||
bootstrapToken?: string;
|
||||
}): Promise<CsvPreviewResult> {
|
||||
const formData = buildCsvFormData([input.file]);
|
||||
formData.set('stepKey', input.stepKey);
|
||||
if (input.bootstrapToken) formData.set('bootstrapToken', input.bootstrapToken);
|
||||
|
||||
return readUploadResponse<CsvPreviewResult>(
|
||||
await fetch('/api/setup/bootstrap/preview', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function commitSetupCsvFiles(input: {
|
||||
files: File[];
|
||||
previewHash: string;
|
||||
@@ -118,3 +140,31 @@ export async function commitSetupCsvFiles(input: {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function commitBootstrapCsvStep(input: {
|
||||
stepKey: string;
|
||||
file: File;
|
||||
previewHash: string;
|
||||
dryRun?: boolean;
|
||||
bootstrapToken?: string;
|
||||
}): Promise<ImportReport> {
|
||||
const formData = buildCsvFormData([input.file]);
|
||||
formData.set('stepKey', input.stepKey);
|
||||
formData.set('previewHash', input.previewHash);
|
||||
formData.set('dryRun', String(Boolean(input.dryRun)));
|
||||
if (input.bootstrapToken) formData.set('bootstrapToken', input.bootstrapToken);
|
||||
|
||||
return readUploadResponse<ImportReport>(
|
||||
await fetch('/api/setup/bootstrap/commit', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function completeBootstrapSetup(report: Record<string, unknown>): Promise<SetupRunResponse> {
|
||||
return apiClient<SetupRunResponse>('/setup/bootstrap/complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ report })
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,6 +99,28 @@ export interface SetupTemplatesResponse {
|
||||
templates: SetupTemplateMetadataItem[];
|
||||
}
|
||||
|
||||
export interface BootstrapRequiredStepDto {
|
||||
stepKey: string;
|
||||
fileName: string;
|
||||
title: string;
|
||||
description: string;
|
||||
dependsOn: string[];
|
||||
}
|
||||
|
||||
export interface BootstrapSetupStatusResponse {
|
||||
initialized: boolean;
|
||||
tokenRequired: boolean;
|
||||
checks: {
|
||||
completedSetupRun: boolean;
|
||||
superAdminUser: boolean;
|
||||
organization: boolean;
|
||||
adminMembership: boolean;
|
||||
};
|
||||
requiredFiles: string[];
|
||||
optionalFiles: string[];
|
||||
requiredSteps: BootstrapRequiredStepDto[];
|
||||
}
|
||||
|
||||
export interface SetupProfilesResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { commitSetupCsvFiles, previewSetupCsvFiles } from './service';
|
||||
import {
|
||||
commitBootstrapCsvStep,
|
||||
commitSetupCsvFiles,
|
||||
previewBootstrapCsvStep,
|
||||
previewSetupCsvFiles
|
||||
} from './service';
|
||||
|
||||
export function useCsvPreview() {
|
||||
return useMutation({
|
||||
@@ -15,3 +20,22 @@ export function useCsvCommit() {
|
||||
commitSetupCsvFiles(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function useBootstrapCsvPreview() {
|
||||
return useMutation({
|
||||
mutationFn: (input: { stepKey: string; file: File; bootstrapToken?: string }) =>
|
||||
previewBootstrapCsvStep(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function useBootstrapCsvCommit() {
|
||||
return useMutation({
|
||||
mutationFn: (input: {
|
||||
stepKey: string;
|
||||
file: File;
|
||||
previewHash: string;
|
||||
dryRun?: boolean;
|
||||
bootstrapToken?: string;
|
||||
}) => commitBootstrapCsvStep(input)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { completeSetupRun, saveSetupStep, startSetupRun } from './service';
|
||||
import { completeBootstrapSetup, completeSetupRun, saveSetupStep, startSetupRun } from './service';
|
||||
import { setupKeys } from './queries';
|
||||
import type { SaveSetupStepPayload } from './types';
|
||||
|
||||
@@ -40,3 +40,12 @@ export function useCompleteSetupRun() {
|
||||
onSuccess: invalidate
|
||||
});
|
||||
}
|
||||
|
||||
export function useCompleteBootstrapSetup() {
|
||||
const invalidate = useInvalidateSetupRun();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (report: Record<string, unknown>) => completeBootstrapSetup(report),
|
||||
onSuccess: invalidate
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -883,11 +883,11 @@ export function buildCsvPreviewBatchIndex(files: CsvParsedFile[]): CsvPreviewBat
|
||||
files.forEach((file) => {
|
||||
file.rows.forEach((row) => {
|
||||
const values = row.values;
|
||||
const organizationCode = normalizeOrganizationCode(values.organization_code);
|
||||
const organizationCode = values.organization_code ? normalizeOrganizationCode(values.organization_code) : '';
|
||||
|
||||
switch (file.template.file) {
|
||||
case 'organizations.csv':
|
||||
index.organizations.add(normalizeOrganizationCode(values.organization_code));
|
||||
if (values.organization_code) index.organizations.add(normalizeOrganizationCode(values.organization_code));
|
||||
break;
|
||||
case 'users.csv':
|
||||
index.users.add(normalizeEmail(values.email));
|
||||
|
||||
@@ -27,7 +27,7 @@ function issue(
|
||||
}
|
||||
|
||||
function organizationCode(values: Record<string, string>): string {
|
||||
return normalizeOrganizationCode(values.organization_code);
|
||||
return values.organization_code ? normalizeOrganizationCode(values.organization_code) : '';
|
||||
}
|
||||
|
||||
function businessKey(file: CsvParsedFile, values: Record<string, string>): string {
|
||||
|
||||
@@ -13,6 +13,7 @@ export const BOOTSTRAP_REQUIRED_TEMPLATE_FILES = [
|
||||
'users.csv',
|
||||
'organizations.csv',
|
||||
'memberships.csv',
|
||||
'crm-role-assignments.csv',
|
||||
'master-options.csv',
|
||||
'branches.csv',
|
||||
'product-types.csv',
|
||||
@@ -34,6 +35,96 @@ export const BOOTSTRAP_OPTIONAL_TEMPLATE_FILES = [
|
||||
'quotation-topic-items.csv'
|
||||
] as const;
|
||||
|
||||
export const BOOTSTRAP_REQUIRED_STEPS = [
|
||||
{
|
||||
stepKey: 'bootstrap_users',
|
||||
fileName: 'users.csv',
|
||||
title: 'Users',
|
||||
description: 'Create the setup administrator and required app users.',
|
||||
dependsOn: []
|
||||
},
|
||||
{
|
||||
stepKey: 'bootstrap_organizations',
|
||||
fileName: 'organizations.csv',
|
||||
title: 'Organizations',
|
||||
description: 'Create tenant organization records after users exist.',
|
||||
dependsOn: ['bootstrap_users']
|
||||
},
|
||||
{
|
||||
stepKey: 'bootstrap_memberships',
|
||||
fileName: 'memberships.csv',
|
||||
title: 'Memberships',
|
||||
description: 'Connect users to organizations with app-level roles.',
|
||||
dependsOn: ['bootstrap_users', 'bootstrap_organizations']
|
||||
},
|
||||
{
|
||||
stepKey: 'bootstrap_crm_role_assignments',
|
||||
fileName: 'crm-role-assignments.csv',
|
||||
title: 'CRM Roles',
|
||||
description: 'Assign CRM role profiles and branch/product scopes.',
|
||||
dependsOn: ['bootstrap_users', 'bootstrap_organizations', 'bootstrap_memberships']
|
||||
},
|
||||
{
|
||||
stepKey: 'bootstrap_master_options',
|
||||
fileName: 'master-options.csv',
|
||||
title: 'Master Options',
|
||||
description: 'Seed governed CRM option categories and labels.',
|
||||
dependsOn: ['bootstrap_users', 'bootstrap_organizations', 'bootstrap_memberships']
|
||||
},
|
||||
{
|
||||
stepKey: 'bootstrap_branches',
|
||||
fileName: 'branches.csv',
|
||||
title: 'Branches',
|
||||
description: 'Create branch options used for CRM scope and numbering.',
|
||||
dependsOn: [
|
||||
'bootstrap_users',
|
||||
'bootstrap_organizations',
|
||||
'bootstrap_memberships',
|
||||
'bootstrap_master_options'
|
||||
]
|
||||
},
|
||||
{
|
||||
stepKey: 'bootstrap_product_types',
|
||||
fileName: 'product-types.csv',
|
||||
title: 'Product Types',
|
||||
description: 'Create product type options used for CRM scope and numbering.',
|
||||
dependsOn: [
|
||||
'bootstrap_users',
|
||||
'bootstrap_organizations',
|
||||
'bootstrap_memberships',
|
||||
'bootstrap_master_options'
|
||||
]
|
||||
},
|
||||
{
|
||||
stepKey: 'bootstrap_document_sequences',
|
||||
fileName: 'document-sequences.csv',
|
||||
title: 'Document Sequences',
|
||||
description: 'Configure document numbering for CRM entities.',
|
||||
dependsOn: ['bootstrap_organizations', 'bootstrap_branches', 'bootstrap_product_types']
|
||||
},
|
||||
{
|
||||
stepKey: 'bootstrap_approval_workflows',
|
||||
fileName: 'approval-workflows.csv',
|
||||
title: 'Approval Workflows',
|
||||
description: 'Create approval workflow headers.',
|
||||
dependsOn: ['bootstrap_organizations']
|
||||
},
|
||||
{
|
||||
stepKey: 'bootstrap_approval_steps',
|
||||
fileName: 'approval-steps.csv',
|
||||
title: 'Approval Steps',
|
||||
description: 'Create ordered approval steps for each workflow.',
|
||||
dependsOn: ['bootstrap_approval_workflows']
|
||||
},
|
||||
{
|
||||
stepKey: 'bootstrap_approval_matrix',
|
||||
fileName: 'approval-matrix.csv',
|
||||
title: 'Approval Matrix',
|
||||
description: 'Create approval routing rules by scope and amount.',
|
||||
dependsOn: ['bootstrap_approval_workflows', 'bootstrap_branches', 'bootstrap_product_types']
|
||||
}
|
||||
] as const;
|
||||
|
||||
export class BootstrapSetupError extends Error {
|
||||
status: number;
|
||||
details?: Record<string, unknown>;
|
||||
@@ -57,11 +148,16 @@ export interface BootstrapSetupStatus {
|
||||
};
|
||||
requiredFiles: string[];
|
||||
optionalFiles: string[];
|
||||
requiredSteps: typeof BOOTSTRAP_REQUIRED_STEPS;
|
||||
}
|
||||
|
||||
async function hasRows<T>(query: Promise<Array<T & { value: number }>>) {
|
||||
const [row] = await query;
|
||||
return Number(row?.value ?? 0) > 0;
|
||||
try {
|
||||
const [row] = await query;
|
||||
return Number(row?.value ?? 0) > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBootstrapSetupStatus(): Promise<BootstrapSetupStatus> {
|
||||
@@ -99,7 +195,8 @@ export async function getBootstrapSetupStatus(): Promise<BootstrapSetupStatus> {
|
||||
tokenRequired: Boolean(process.env.BOOTSTRAP_SETUP_TOKEN?.trim()),
|
||||
checks,
|
||||
requiredFiles: [...BOOTSTRAP_REQUIRED_TEMPLATE_FILES],
|
||||
optionalFiles: [...BOOTSTRAP_OPTIONAL_TEMPLATE_FILES]
|
||||
optionalFiles: [...BOOTSTRAP_OPTIONAL_TEMPLATE_FILES],
|
||||
requiredSteps: [...BOOTSTRAP_REQUIRED_STEPS]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,7 +223,12 @@ export function assertBootstrapToken(token: string | null | undefined) {
|
||||
}
|
||||
|
||||
export function getMissingBootstrapRequiredFiles(files: CsvPreviewFileInput[]) {
|
||||
const uploaded = new Set(files.map((file) => file.fileName.trim().toLowerCase()));
|
||||
const uploaded = new Set(
|
||||
files
|
||||
.map((file) => file.fileName)
|
||||
.filter((fileName): fileName is string => typeof fileName === 'string')
|
||||
.map((fileName) => fileName.trim().toLowerCase())
|
||||
);
|
||||
return BOOTSTRAP_REQUIRED_TEMPLATE_FILES.filter((fileName) => !uploaded.has(fileName));
|
||||
}
|
||||
|
||||
@@ -140,6 +242,37 @@ export function assertBootstrapRequiredFiles(files: CsvPreviewFileInput[]) {
|
||||
}
|
||||
}
|
||||
|
||||
export function assertBootstrapStepFiles(stepKey: string, files: CsvPreviewFileInput[]) {
|
||||
const step = BOOTSTRAP_REQUIRED_STEPS.find((candidate) => candidate.stepKey === stepKey);
|
||||
|
||||
if (!step) {
|
||||
throw new BootstrapSetupError('Unknown bootstrap setup step.', 400, {
|
||||
stepKey,
|
||||
allowedStepKeys: BOOTSTRAP_REQUIRED_STEPS.map((candidate) => candidate.stepKey)
|
||||
});
|
||||
}
|
||||
|
||||
if (files.length !== 1) {
|
||||
throw new BootstrapSetupError('Upload exactly one CSV file for this setup step.', 400, {
|
||||
stepKey,
|
||||
expectedFileName: step.fileName,
|
||||
receivedFiles: files.map((file) => file.fileName)
|
||||
});
|
||||
}
|
||||
|
||||
const [file] = files;
|
||||
|
||||
if (file.fileName.toLowerCase() !== step.fileName.toLowerCase()) {
|
||||
throw new BootstrapSetupError('Uploaded CSV file does not match the current setup step.', 400, {
|
||||
stepKey,
|
||||
expectedFileName: step.fileName,
|
||||
receivedFileName: file.fileName
|
||||
});
|
||||
}
|
||||
|
||||
return step;
|
||||
}
|
||||
|
||||
export function setupStepStatusFromReportStatus(status: 'PASS' | 'WARNING' | 'FAIL'): SetupStepStatus {
|
||||
if (status === 'PASS') return 'passed';
|
||||
if (status === 'WARNING') return 'warning';
|
||||
|
||||
@@ -77,7 +77,46 @@ const STALE_DEPENDENCIES: Record<string, string[]> = {
|
||||
sequences: ['verification'],
|
||||
approval: ['csv_preview', 'csv_commit', 'verification'],
|
||||
csv_preview: ['csv_commit'],
|
||||
csv_commit: ['verification']
|
||||
csv_commit: ['verification'],
|
||||
bootstrap_users: [
|
||||
'bootstrap_organizations',
|
||||
'bootstrap_memberships',
|
||||
'bootstrap_crm_role_assignments',
|
||||
'bootstrap_master_options',
|
||||
'bootstrap_branches',
|
||||
'bootstrap_product_types',
|
||||
'bootstrap_document_sequences',
|
||||
'bootstrap_approval_workflows',
|
||||
'bootstrap_approval_steps',
|
||||
'bootstrap_approval_matrix'
|
||||
],
|
||||
bootstrap_organizations: [
|
||||
'bootstrap_memberships',
|
||||
'bootstrap_crm_role_assignments',
|
||||
'bootstrap_master_options',
|
||||
'bootstrap_branches',
|
||||
'bootstrap_product_types',
|
||||
'bootstrap_document_sequences',
|
||||
'bootstrap_approval_workflows',
|
||||
'bootstrap_approval_steps',
|
||||
'bootstrap_approval_matrix'
|
||||
],
|
||||
bootstrap_memberships: [
|
||||
'bootstrap_crm_role_assignments',
|
||||
'bootstrap_master_options',
|
||||
'bootstrap_branches',
|
||||
'bootstrap_product_types',
|
||||
'bootstrap_document_sequences'
|
||||
],
|
||||
bootstrap_master_options: [
|
||||
'bootstrap_branches',
|
||||
'bootstrap_product_types',
|
||||
'bootstrap_document_sequences',
|
||||
'bootstrap_approval_matrix'
|
||||
],
|
||||
bootstrap_branches: ['bootstrap_document_sequences', 'bootstrap_approval_matrix'],
|
||||
bootstrap_product_types: ['bootstrap_document_sequences', 'bootstrap_approval_matrix'],
|
||||
bootstrap_approval_workflows: ['bootstrap_approval_steps', 'bootstrap_approval_matrix']
|
||||
};
|
||||
|
||||
function now() {
|
||||
|
||||
Reference in New Issue
Block a user