task-d.7.9
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import {
|
||||
getCurrentSetupRun,
|
||||
getSetupProfiles,
|
||||
getSetupReadiness,
|
||||
getSetupRunReport,
|
||||
getSetupTemplates,
|
||||
@@ -10,24 +11,31 @@ import type { SetupVerificationPayload } from './types';
|
||||
|
||||
export const setupKeys = {
|
||||
all: ['setup'] as const,
|
||||
readiness: () => [...setupKeys.all, 'readiness'] as const,
|
||||
templates: () => [...setupKeys.all, 'templates'] as const,
|
||||
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,
|
||||
run: () => [...setupKeys.all, 'run'] as const,
|
||||
report: () => [...setupKeys.all, 'run', 'report'] as const,
|
||||
verification: (payload: SetupVerificationPayload = {}) =>
|
||||
[...setupKeys.all, 'verification', payload] as const
|
||||
};
|
||||
|
||||
export const setupReadinessQueryOptions = () =>
|
||||
export const setupReadinessQueryOptions = (profileId?: string) =>
|
||||
queryOptions({
|
||||
queryKey: setupKeys.readiness(),
|
||||
queryFn: () => getSetupReadiness()
|
||||
queryKey: setupKeys.readiness(profileId),
|
||||
queryFn: () => getSetupReadiness(profileId)
|
||||
});
|
||||
|
||||
export const setupTemplatesQueryOptions = () =>
|
||||
export const setupTemplatesQueryOptions = (profileId?: string) =>
|
||||
queryOptions({
|
||||
queryKey: setupKeys.templates(),
|
||||
queryFn: () => getSetupTemplates()
|
||||
queryKey: setupKeys.templates(profileId),
|
||||
queryFn: () => getSetupTemplates(profileId)
|
||||
});
|
||||
|
||||
export const setupProfilesQueryOptions = (profileId?: string) =>
|
||||
queryOptions({
|
||||
queryKey: setupKeys.profiles(profileId),
|
||||
queryFn: () => getSetupProfiles(profileId)
|
||||
});
|
||||
|
||||
export const setupRunQueryOptions = () =>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CsvPreviewResult,
|
||||
ImportReport,
|
||||
SaveSetupStepPayload,
|
||||
SetupProfilesResponse,
|
||||
SetupReadinessReport,
|
||||
SetupRunResponse,
|
||||
SetupTemplatesResponse,
|
||||
@@ -15,12 +16,22 @@ interface ApiErrorPayload {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function getSetupReadiness(): Promise<SetupReadinessReport> {
|
||||
return apiClient<SetupReadinessReport>('/setup/readiness');
|
||||
function withProfile(endpoint: string, profileId?: string) {
|
||||
if (!profileId) return endpoint;
|
||||
const separator = endpoint.includes('?') ? '&' : '?';
|
||||
return `${endpoint}${separator}profile=${encodeURIComponent(profileId)}`;
|
||||
}
|
||||
|
||||
export async function getSetupTemplates(): Promise<SetupTemplatesResponse> {
|
||||
return apiClient<SetupTemplatesResponse>('/setup/templates');
|
||||
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 getCurrentSetupRun(): Promise<SetupRunResponse> {
|
||||
|
||||
@@ -32,12 +32,51 @@ export interface SetupReadinessReport {
|
||||
export interface SetupVerificationPayload {
|
||||
organizationId?: string;
|
||||
administratorEmail?: string;
|
||||
profileId?: string;
|
||||
}
|
||||
|
||||
export interface SetupVerificationReport extends SetupReadinessReport {
|
||||
organizationId: string | null;
|
||||
}
|
||||
|
||||
export interface InstallationProfileDto {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
wizardSteps: string[];
|
||||
requiredTemplates: string[];
|
||||
verificationChecks: string[];
|
||||
resetScopes: string[];
|
||||
reseedTargets: string[];
|
||||
defaultOptions: Record<string, unknown>;
|
||||
enabledPluginIds: string[];
|
||||
}
|
||||
|
||||
export interface SetupPluginSummaryDto {
|
||||
id: string;
|
||||
version: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
dependencies: string[];
|
||||
}
|
||||
|
||||
export interface SetupWizardStepDto {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
produces: string[];
|
||||
invalidates: string[];
|
||||
}
|
||||
|
||||
export interface SetupRegistryDefinitionDto {
|
||||
key?: string;
|
||||
id?: string;
|
||||
area?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SetupTemplateMetadataItem {
|
||||
file: string;
|
||||
seedType: string;
|
||||
@@ -45,6 +84,9 @@ export interface SetupTemplateMetadataItem {
|
||||
supported: boolean;
|
||||
requiredColumns: string[];
|
||||
description: string;
|
||||
plugin?: string;
|
||||
profiles?: string[];
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
export interface SetupTemplatesResponse {
|
||||
@@ -52,9 +94,26 @@ export interface SetupTemplatesResponse {
|
||||
time: string;
|
||||
version: string;
|
||||
generatedFrom: string;
|
||||
profile?: InstallationProfileDto | null;
|
||||
plugins?: SetupPluginSummaryDto[];
|
||||
templates: SetupTemplateMetadataItem[];
|
||||
}
|
||||
|
||||
export interface SetupProfilesResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
selectedProfile: InstallationProfileDto | null;
|
||||
profiles: InstallationProfileDto[];
|
||||
plugins: SetupPluginSummaryDto[];
|
||||
templates: SetupTemplateMetadataItem[];
|
||||
wizardSteps: SetupWizardStepDto[];
|
||||
readinessChecks: SetupRegistryDefinitionDto[];
|
||||
verificationChecks: SetupRegistryDefinitionDto[];
|
||||
resetScopes: SetupRegistryDefinitionDto[];
|
||||
reseedTargets: SetupRegistryDefinitionDto[];
|
||||
goldenDatasets: SetupRegistryDefinitionDto[];
|
||||
}
|
||||
|
||||
export type SetupRunStatus =
|
||||
| 'not_started'
|
||||
| 'readiness_checked'
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useCsvCommit, useCsvPreview } from '../api/use-csv-commit';
|
||||
import { useCompleteSetupRun, useSaveSetupStep, useStartSetupRun } from '../api/use-setup-run';
|
||||
import { useSetupVerification } from '../api/use-setup-verification';
|
||||
import {
|
||||
setupProfilesQueryOptions,
|
||||
setupReadinessQueryOptions,
|
||||
setupRunQueryOptions,
|
||||
setupTemplatesQueryOptions
|
||||
@@ -30,19 +31,13 @@ import type {
|
||||
SetupRunStepDto,
|
||||
SetupStepStatus,
|
||||
SetupTemplateMetadataItem,
|
||||
SetupWizardStepDto,
|
||||
SetupVerificationReport
|
||||
} from '../api/types';
|
||||
|
||||
type WizardStepKey =
|
||||
| 'readiness'
|
||||
| 'templates'
|
||||
| 'upload'
|
||||
| 'preview'
|
||||
| 'commit'
|
||||
| 'verification'
|
||||
| 'finish';
|
||||
type WizardStepKey = string;
|
||||
|
||||
const WIZARD_STEPS: Array<{ key: WizardStepKey; label: string }> = [
|
||||
const WIZARD_STEPS: Array<{ key: WizardStepKey; label: string; description?: string }> = [
|
||||
{ key: 'readiness', label: 'Readiness' },
|
||||
{ key: 'templates', label: 'Templates' },
|
||||
{ key: 'upload', label: 'CSV Upload' },
|
||||
@@ -338,12 +333,18 @@ function VerificationPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function Stepper({ currentStep }: { currentStep: WizardStepKey }) {
|
||||
const currentIndex = WIZARD_STEPS.findIndex((step) => step.key === currentStep);
|
||||
function Stepper({
|
||||
currentStep,
|
||||
steps
|
||||
}: {
|
||||
currentStep: WizardStepKey;
|
||||
steps: Array<{ key: WizardStepKey; label: string; description?: string }>;
|
||||
}) {
|
||||
const currentIndex = steps.findIndex((step) => step.key === currentStep);
|
||||
|
||||
return (
|
||||
<div className='grid gap-2 md:grid-cols-7'>
|
||||
{WIZARD_STEPS.map((step, index) => (
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={step.key}
|
||||
className={cn(
|
||||
@@ -372,6 +373,7 @@ function PersistedStepBadge({ step }: { step?: SetupRunStepDto }) {
|
||||
|
||||
export function SetupWizard() {
|
||||
const [currentStep, setCurrentStep] = useState<WizardStepKey>('readiness');
|
||||
const [selectedProfileId, setSelectedProfileId] = useState('crm_demo');
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [preview, setPreview] = useState<CsvPreviewResult | null>(null);
|
||||
const [previewFingerprint, setPreviewFingerprint] = useState('');
|
||||
@@ -379,8 +381,9 @@ export function SetupWizard() {
|
||||
const [warningAcknowledged, setWarningAcknowledged] = useState(false);
|
||||
const [dryRun, setDryRun] = useState(false);
|
||||
|
||||
const readinessQuery = useQuery(setupReadinessQueryOptions());
|
||||
const templatesQuery = useQuery(setupTemplatesQueryOptions());
|
||||
const profilesQuery = useQuery(setupProfilesQueryOptions(selectedProfileId));
|
||||
const readinessQuery = useQuery(setupReadinessQueryOptions(selectedProfileId));
|
||||
const templatesQuery = useQuery(setupTemplatesQueryOptions(selectedProfileId));
|
||||
const setupRunQuery = useQuery(setupRunQueryOptions());
|
||||
const previewMutation = useCsvPreview();
|
||||
const commitMutation = useCsvCommit();
|
||||
@@ -393,6 +396,15 @@ export function SetupWizard() {
|
||||
const entries = setupRunQuery.data?.steps.map((step) => [step.stepKey, step] as const) ?? [];
|
||||
return new Map(entries);
|
||||
}, [setupRunQuery.data?.steps]);
|
||||
const profileWizardSteps = useMemo(() => {
|
||||
const registrySteps = profilesQuery.data?.wizardSteps.map((step: SetupWizardStepDto) => ({
|
||||
key: step.key,
|
||||
label: step.title,
|
||||
description: step.description
|
||||
}));
|
||||
|
||||
return registrySteps?.length ? registrySteps : WIZARD_STEPS;
|
||||
}, [profilesQuery.data?.wizardSteps]);
|
||||
|
||||
const fileFingerprint = useMemo(() => getFileFingerprint(files), [files]);
|
||||
const filesChangedAfterPreview = Boolean(preview) && fileFingerprint !== previewFingerprint;
|
||||
@@ -487,7 +499,7 @@ export function SetupWizard() {
|
||||
}
|
||||
|
||||
async function runVerification() {
|
||||
const report = await verificationMutation.mutateAsync({});
|
||||
const report = await verificationMutation.mutateAsync({ profileId: selectedProfileId });
|
||||
setCurrentStep('verification');
|
||||
await saveStep({
|
||||
stepKey: 'verification',
|
||||
@@ -512,7 +524,7 @@ export function SetupWizard() {
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<Stepper currentStep={currentStep} />
|
||||
<Stepper currentStep={currentStep} steps={profileWizardSteps} />
|
||||
|
||||
<Alert>
|
||||
<Icons.info className='h-4 w-4' />
|
||||
@@ -523,6 +535,50 @@ export function SetupWizard() {
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Installation profile</CardTitle>
|
||||
<CardDescription>
|
||||
Profiles are resolved through the setup plugin registry. Future modules can add profiles
|
||||
without editing the wizard.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{profilesQuery.isError ? (
|
||||
<ErrorAlert title='Profile registry unavailable' message={getErrorMessage(profilesQuery.error)} />
|
||||
) : null}
|
||||
<div className='grid gap-3 md:grid-cols-[260px_1fr]'>
|
||||
<select
|
||||
value={selectedProfileId}
|
||||
onChange={(event) => {
|
||||
setSelectedProfileId(event.target.value);
|
||||
setCurrentStep('readiness');
|
||||
}}
|
||||
className='h-10 rounded-md border bg-background px-3 text-sm'
|
||||
>
|
||||
{(profilesQuery.data?.profiles ?? []).map((profile) => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className='text-sm text-muted-foreground'>
|
||||
{profilesQuery.data?.selectedProfile?.description ??
|
||||
'Loading installation profile definitions...'}
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-2 md:grid-cols-3'>
|
||||
{(profilesQuery.data?.plugins ?? []).map((plugin) => (
|
||||
<div key={plugin.id} className='rounded-md border p-3'>
|
||||
<div className='font-medium'>{plugin.displayName}</div>
|
||||
<div className='text-xs text-muted-foreground'>v{plugin.version}</div>
|
||||
<div className='mt-1 text-sm text-muted-foreground'>{plugin.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Setup run</CardTitle>
|
||||
|
||||
51
src/features/setup/plugins/crm/crm-installation-profile.ts
Normal file
51
src/features/setup/plugins/crm/crm-installation-profile.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
CRM_TEMPLATE_FILES,
|
||||
FOUNDATION_TEMPLATE_FILES,
|
||||
FOUNDATION_WIZARD_STEPS,
|
||||
type InstallationProfile
|
||||
} from '../installation-profile';
|
||||
|
||||
export const crmInstallationProfiles: InstallationProfile[] = [
|
||||
{
|
||||
id: 'crm_demo',
|
||||
name: 'CRM Demo',
|
||||
description: 'Foundation plus full CRM demo/golden dataset setup.',
|
||||
wizardSteps: FOUNDATION_WIZARD_STEPS,
|
||||
requiredTemplates: [...FOUNDATION_TEMPLATE_FILES, ...CRM_TEMPLATE_FILES],
|
||||
verificationChecks: [
|
||||
'ADMIN_CRM_ASSIGNMENT',
|
||||
'BRANCH_OPTION',
|
||||
'PRODUCT_TYPE_OPTION',
|
||||
'SEQUENCE_QUOTATION',
|
||||
'APPROVAL_WORKFLOW',
|
||||
'APPROVAL_MATRIX',
|
||||
'PDF_TEMPLATE_ASSET',
|
||||
'REPORT_DEFINITIONS'
|
||||
],
|
||||
resetScopes: ['business_reset', 'demo_uat_reset', 'setup_run_reset'],
|
||||
reseedTargets: ['demo_uat', 'csv_manifest'],
|
||||
defaultOptions: { importMode: 'strict', dataset: 'golden' },
|
||||
enabledPluginIds: ['foundation', 'crm']
|
||||
},
|
||||
{
|
||||
id: 'production',
|
||||
name: 'Production',
|
||||
description: 'Foundation plus CRM production setup with no destructive reset defaults.',
|
||||
wizardSteps: FOUNDATION_WIZARD_STEPS,
|
||||
requiredTemplates: [...FOUNDATION_TEMPLATE_FILES, ...CRM_TEMPLATE_FILES],
|
||||
verificationChecks: [
|
||||
'ADMIN_CRM_ASSIGNMENT',
|
||||
'BRANCH_OPTION',
|
||||
'PRODUCT_TYPE_OPTION',
|
||||
'SEQUENCE_QUOTATION',
|
||||
'APPROVAL_WORKFLOW',
|
||||
'APPROVAL_MATRIX',
|
||||
'PDF_TEMPLATE_ASSET',
|
||||
'REPORT_DEFINITIONS'
|
||||
],
|
||||
resetScopes: ['setup_run_reset'],
|
||||
reseedTargets: [],
|
||||
defaultOptions: { importMode: 'strict', destructiveMaintenance: false },
|
||||
enabledPluginIds: ['foundation', 'crm']
|
||||
}
|
||||
];
|
||||
144
src/features/setup/plugins/crm/crm-setup-plugin.ts
Normal file
144
src/features/setup/plugins/crm/crm-setup-plugin.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { SetupPlugin, SetupTemplateDefinition } from '../plugin-types';
|
||||
import { CRM_TEMPLATE_FILES } from '../installation-profile';
|
||||
import { crmInstallationProfiles } from './crm-installation-profile';
|
||||
|
||||
const CRM_TEMPLATE_ORDER = new Map([
|
||||
['master-options.csv', 6],
|
||||
['branches.csv', 7],
|
||||
['product-types.csv', 8],
|
||||
['document-sequences.csv', 14],
|
||||
['approval-workflows.csv', 15],
|
||||
['approval-steps.csv', 16],
|
||||
['approval-matrix.csv', 17],
|
||||
['customers.csv', 30],
|
||||
['contacts.csv', 32],
|
||||
['contact-shares.csv', 33],
|
||||
['leads.csv', 34],
|
||||
['opportunities.csv', 36],
|
||||
['opportunity-parties.csv', 37],
|
||||
['quotations.csv', 40],
|
||||
['quotation-items.csv', 41],
|
||||
['quotation-parties.csv', 42],
|
||||
['quotation-topics.csv', 43],
|
||||
['quotation-topic-items.csv', 44]
|
||||
]);
|
||||
|
||||
export class CrmSetupPlugin implements SetupPlugin {
|
||||
id = 'crm';
|
||||
version = '1.0.0';
|
||||
displayName = 'CRM';
|
||||
description = 'CRM setup templates, verification checks, reset scopes, and golden dataset registration.';
|
||||
dependencies = ['foundation'];
|
||||
|
||||
installationProfiles() {
|
||||
return crmInstallationProfiles;
|
||||
}
|
||||
|
||||
templates(): SetupTemplateDefinition[] {
|
||||
return CRM_TEMPLATE_FILES.map((file) => ({
|
||||
file,
|
||||
seedType: CRM_TEMPLATE_ORDER.get(file)! < 30 ? 'foundation' : 'business',
|
||||
importOrder: CRM_TEMPLATE_ORDER.get(file) ?? 999,
|
||||
supported: true,
|
||||
requiredColumns: [],
|
||||
description: `CRM setup template: ${file}`,
|
||||
plugin: this.id,
|
||||
profiles: ['crm_starter', 'crm_demo', 'production', 'custom']
|
||||
}));
|
||||
}
|
||||
|
||||
wizardSteps() {
|
||||
return [
|
||||
{
|
||||
key: 'crm-templates',
|
||||
title: 'CRM Templates',
|
||||
description: 'CRM-owned template pack and import order.',
|
||||
required: true,
|
||||
produces: ['crm_template_registry'],
|
||||
invalidates: ['csv_preview']
|
||||
},
|
||||
{
|
||||
key: 'crm-verification',
|
||||
title: 'CRM Verification',
|
||||
description: 'CRM authorization, sequence, approval, PDF, report, and notification checks.',
|
||||
required: true,
|
||||
produces: ['crm_verification_report'],
|
||||
invalidates: []
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
readinessChecks() {
|
||||
return [
|
||||
{
|
||||
id: 'PLUGIN_CRM_REGISTERED',
|
||||
area: 'Plugin registry',
|
||||
name: 'CRM plugin registered',
|
||||
requiredFor: ['CRM setup'],
|
||||
purpose: 'Ensure CRM setup capabilities are available.'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
verificationChecks() {
|
||||
return [
|
||||
{
|
||||
id: 'PLUGIN_CRM_VERIFICATION',
|
||||
area: 'Plugin registry',
|
||||
name: 'CRM verification registered',
|
||||
requiredFor: ['CRM setup'],
|
||||
purpose: 'Ensure CRM verification checks are exposed through the plugin registry.'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
previewValidators() {
|
||||
return [
|
||||
{ key: 'crm-reference-validator', description: 'CRM customer, lead, opportunity, quotation reference validation.' }
|
||||
];
|
||||
}
|
||||
|
||||
importHandlers() {
|
||||
return [{ key: 'crm-csv-importer', description: 'CRM setup CSV import handlers.' }];
|
||||
}
|
||||
|
||||
resetScopes() {
|
||||
return [
|
||||
{
|
||||
key: 'business_reset',
|
||||
destructive: true,
|
||||
allowedEnvironments: ['local', 'test', 'staging'],
|
||||
requiresTypedConfirmation: true,
|
||||
affectedTables: ['crm_customers', 'crm_leads', 'crm_opportunities', 'crm_quotations'],
|
||||
storageCleanup: 'best_effort' as const
|
||||
},
|
||||
{
|
||||
key: 'demo_uat_reset',
|
||||
destructive: true,
|
||||
allowedEnvironments: ['local', 'test', 'staging'],
|
||||
requiresTypedConfirmation: true,
|
||||
affectedTables: ['crm_* demo rows'],
|
||||
storageCleanup: 'best_effort' as const
|
||||
},
|
||||
{
|
||||
key: 'csv_import_rollback',
|
||||
destructive: true,
|
||||
allowedEnvironments: ['local', 'test', 'staging'],
|
||||
requiresTypedConfirmation: true,
|
||||
affectedTables: ['manifest-owned rows'],
|
||||
storageCleanup: 'report_only' as const
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
reseedTargets() {
|
||||
return [
|
||||
{ key: 'demo_uat', description: 'CRM demo/UAT reseed target.', executable: false },
|
||||
{ key: 'csv_manifest', description: 'CRM CSV manifest replay target.', executable: false }
|
||||
];
|
||||
}
|
||||
|
||||
goldenDatasets() {
|
||||
return [{ key: 'crm-golden', path: 'setup/golden-dataset', files: CRM_TEMPLATE_FILES }];
|
||||
}
|
||||
}
|
||||
175
src/features/setup/plugins/foundation/foundation-plugin.ts
Normal file
175
src/features/setup/plugins/foundation/foundation-plugin.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
FOUNDATION_TEMPLATE_FILES,
|
||||
FOUNDATION_WIZARD_STEPS,
|
||||
type InstallationProfile
|
||||
} from '../installation-profile';
|
||||
import type { SetupPlugin, SetupTemplateDefinition } from '../plugin-types';
|
||||
|
||||
const FOUNDATION_PROFILES: InstallationProfile[] = [
|
||||
{
|
||||
id: 'crm_starter',
|
||||
name: 'CRM Starter',
|
||||
description: 'Foundation plus CRM structure without demo business volume.',
|
||||
wizardSteps: FOUNDATION_WIZARD_STEPS,
|
||||
requiredTemplates: FOUNDATION_TEMPLATE_FILES,
|
||||
verificationChecks: ['ENV_AUTH_SECRET', 'ENV_DATABASE_URL', 'DB_CONNECTIVITY', 'ADMIN_USER'],
|
||||
resetScopes: ['setup_run_reset'],
|
||||
reseedTargets: ['foundation'],
|
||||
defaultOptions: { importMode: 'strict' },
|
||||
enabledPluginIds: ['foundation', 'crm']
|
||||
},
|
||||
{
|
||||
id: 'restore_backup',
|
||||
name: 'Restore Backup',
|
||||
description: 'Minimal setup checks for restored environments.',
|
||||
wizardSteps: ['readiness', 'verification', 'finish'],
|
||||
requiredTemplates: [],
|
||||
verificationChecks: ['ENV_AUTH_SECRET', 'ENV_DATABASE_URL', 'DB_CONNECTIVITY'],
|
||||
resetScopes: ['setup_run_reset'],
|
||||
reseedTargets: [],
|
||||
defaultOptions: { importMode: 'none' },
|
||||
enabledPluginIds: ['foundation']
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
name: 'Custom',
|
||||
description: 'Operator-selected setup modules and templates.',
|
||||
wizardSteps: FOUNDATION_WIZARD_STEPS,
|
||||
requiredTemplates: [],
|
||||
verificationChecks: ['ENV_AUTH_SECRET', 'ENV_DATABASE_URL', 'DB_CONNECTIVITY'],
|
||||
resetScopes: ['setup_run_reset'],
|
||||
reseedTargets: [],
|
||||
defaultOptions: { importMode: 'custom' },
|
||||
enabledPluginIds: ['foundation']
|
||||
}
|
||||
];
|
||||
|
||||
export class FoundationSetupPlugin implements SetupPlugin {
|
||||
id = 'foundation';
|
||||
version = '1.0.0';
|
||||
displayName = 'Setup Foundation';
|
||||
description = 'Core setup readiness, setup state, seed manifests, reset framework, and CSV framework.';
|
||||
dependencies: string[] = [];
|
||||
|
||||
installationProfiles() {
|
||||
return FOUNDATION_PROFILES;
|
||||
}
|
||||
|
||||
templates(): SetupTemplateDefinition[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
wizardSteps() {
|
||||
return [
|
||||
{
|
||||
key: 'readiness',
|
||||
title: 'System Readiness',
|
||||
description: 'Run non-destructive environment and platform checks.',
|
||||
required: true,
|
||||
produces: ['readiness_report'],
|
||||
invalidates: ['templates', 'csv_preview', 'csv_commit', 'verification']
|
||||
},
|
||||
{
|
||||
key: 'templates',
|
||||
title: 'Template Pack',
|
||||
description: 'Review registered setup templates for the selected profile.',
|
||||
required: true,
|
||||
produces: ['template_registry'],
|
||||
invalidates: ['csv_preview']
|
||||
},
|
||||
{
|
||||
key: 'upload',
|
||||
title: 'CSV Upload',
|
||||
description: 'Select setup CSV files for preview and commit.',
|
||||
required: true,
|
||||
produces: ['selected_files'],
|
||||
invalidates: ['csv_preview', 'csv_commit']
|
||||
},
|
||||
{
|
||||
key: 'preview',
|
||||
title: 'CSV Preview',
|
||||
description: 'Validate CSVs and produce a preview hash.',
|
||||
required: true,
|
||||
produces: ['preview_hash'],
|
||||
invalidates: ['csv_commit']
|
||||
},
|
||||
{
|
||||
key: 'commit',
|
||||
title: 'CSV Commit',
|
||||
description: 'Commit validated CSV files and write seed manifests.',
|
||||
required: true,
|
||||
produces: ['import_report', 'seed_manifest'],
|
||||
invalidates: ['verification']
|
||||
},
|
||||
{
|
||||
key: 'verification',
|
||||
title: 'Verification',
|
||||
description: 'Confirm operational readiness after import.',
|
||||
required: true,
|
||||
produces: ['verification_report'],
|
||||
invalidates: []
|
||||
},
|
||||
{
|
||||
key: 'finish',
|
||||
title: 'Finish',
|
||||
description: 'Complete the setup run and show summary.',
|
||||
required: true,
|
||||
produces: ['setup_report'],
|
||||
invalidates: []
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
readinessChecks() {
|
||||
return [
|
||||
{
|
||||
id: 'PLUGIN_FOUNDATION_REGISTERED',
|
||||
area: 'Plugin registry',
|
||||
name: 'Foundation plugin registered',
|
||||
requiredFor: ['setup platform'],
|
||||
purpose: 'Ensure setup core can resolve foundation capabilities.'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
verificationChecks() {
|
||||
return [
|
||||
{
|
||||
id: 'PLUGIN_FOUNDATION_VERIFICATION',
|
||||
area: 'Plugin registry',
|
||||
name: 'Foundation verification registered',
|
||||
requiredFor: ['setup platform'],
|
||||
purpose: 'Ensure setup verification can resolve foundation plugin checks.'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
previewValidators() {
|
||||
return [{ key: 'template-header-validator', description: 'CSV template header validation.' }];
|
||||
}
|
||||
|
||||
importHandlers() {
|
||||
return [{ key: 'csv-commit-framework', description: 'Shared setup CSV commit framework.' }];
|
||||
}
|
||||
|
||||
resetScopes() {
|
||||
return [
|
||||
{
|
||||
key: 'setup_run_reset',
|
||||
destructive: false,
|
||||
allowedEnvironments: ['local', 'test', 'staging'],
|
||||
requiresTypedConfirmation: true,
|
||||
affectedTables: ['setup_runs', 'setup_run_steps', 'seed_manifests', 'seed_manifest_items'],
|
||||
storageCleanup: 'none' as const
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
reseedTargets() {
|
||||
return [{ key: 'foundation', description: 'Foundation seed service wrapper.', executable: false }];
|
||||
}
|
||||
|
||||
goldenDatasets() {
|
||||
return [{ key: 'foundation-golden', path: 'setup/golden-dataset', files: FOUNDATION_TEMPLATE_FILES }];
|
||||
}
|
||||
}
|
||||
37
src/features/setup/plugins/installation-profile.ts
Normal file
37
src/features/setup/plugins/installation-profile.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface InstallationProfile {
|
||||
id: 'crm_starter' | 'crm_demo' | 'production' | 'restore_backup' | 'custom';
|
||||
name: string;
|
||||
description: string;
|
||||
wizardSteps: string[];
|
||||
requiredTemplates: string[];
|
||||
verificationChecks: string[];
|
||||
resetScopes: string[];
|
||||
reseedTargets: string[];
|
||||
defaultOptions: Record<string, unknown>;
|
||||
enabledPluginIds: string[];
|
||||
}
|
||||
|
||||
export const FOUNDATION_WIZARD_STEPS = ['readiness', 'templates', 'upload', 'preview', 'commit', 'verification', 'finish'];
|
||||
|
||||
export const CRM_TEMPLATE_FILES = [
|
||||
'master-options.csv',
|
||||
'branches.csv',
|
||||
'product-types.csv',
|
||||
'document-sequences.csv',
|
||||
'approval-workflows.csv',
|
||||
'approval-steps.csv',
|
||||
'approval-matrix.csv',
|
||||
'customers.csv',
|
||||
'contacts.csv',
|
||||
'contact-shares.csv',
|
||||
'leads.csv',
|
||||
'opportunities.csv',
|
||||
'opportunity-parties.csv',
|
||||
'quotations.csv',
|
||||
'quotation-items.csv',
|
||||
'quotation-parties.csv',
|
||||
'quotation-topics.csv',
|
||||
'quotation-topic-items.csv'
|
||||
];
|
||||
|
||||
export const FOUNDATION_TEMPLATE_FILES = ['users.csv', 'organizations.csv', 'memberships.csv', 'crm-role-assignments.csv'];
|
||||
138
src/features/setup/plugins/plugin-registry.ts
Normal file
138
src/features/setup/plugins/plugin-registry.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'server-only';
|
||||
|
||||
import type { InstallationProfile } from './installation-profile';
|
||||
import { FoundationSetupPlugin } from './foundation/foundation-plugin';
|
||||
import { CrmSetupPlugin } from './crm/crm-setup-plugin';
|
||||
import type {
|
||||
GoldenDatasetDefinition,
|
||||
ResetScopeDefinition,
|
||||
ReseedTargetDefinition,
|
||||
SetupPlugin,
|
||||
SetupReadinessCheck,
|
||||
SetupTemplateDefinition,
|
||||
SetupVerificationCheck,
|
||||
SetupWizardStep
|
||||
} from './plugin-types';
|
||||
|
||||
class SetupPluginRegistry {
|
||||
private plugins = new Map<string, SetupPlugin>();
|
||||
|
||||
registerPlugin(plugin: SetupPlugin) {
|
||||
if (this.plugins.has(plugin.id)) {
|
||||
throw new Error(`Setup plugin already registered: ${plugin.id}`);
|
||||
}
|
||||
if (plugin.dependencies.includes(plugin.id)) {
|
||||
throw new Error(`Setup plugin cannot depend on itself: ${plugin.id}`);
|
||||
}
|
||||
this.plugins.set(plugin.id, plugin);
|
||||
}
|
||||
|
||||
getPlugins() {
|
||||
return Array.from(this.plugins.values());
|
||||
}
|
||||
|
||||
resolveInstallationProfiles(): InstallationProfile[] {
|
||||
const profiles = this.getPlugins().flatMap((plugin) => plugin.installationProfiles());
|
||||
const byId = new Map<string, InstallationProfile>();
|
||||
profiles.forEach((profile) => {
|
||||
if (!byId.has(profile.id)) byId.set(profile.id, profile);
|
||||
});
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
resolveInstallationProfile(profileId = 'crm_demo') {
|
||||
return this.resolveInstallationProfiles().find((profile) => profile.id === profileId) ?? null;
|
||||
}
|
||||
|
||||
resolveEnabledPlugins(profileId = 'crm_demo') {
|
||||
const profile = this.resolveInstallationProfile(profileId);
|
||||
if (!profile) return this.getPlugins();
|
||||
|
||||
const enabled = new Set(profile.enabledPluginIds);
|
||||
enabled.add('foundation');
|
||||
return this.getPlugins().filter((plugin) => enabled.has(plugin.id));
|
||||
}
|
||||
|
||||
resolveTemplates(profileId = 'crm_demo'): SetupTemplateDefinition[] {
|
||||
const profile = this.resolveInstallationProfile(profileId);
|
||||
const required = new Set(profile?.requiredTemplates ?? []);
|
||||
const templates = this.resolveEnabledPlugins(profileId).flatMap((plugin) => plugin.templates());
|
||||
|
||||
return templates
|
||||
.map((template) => ({
|
||||
...template,
|
||||
profiles: template.profiles.length ? template.profiles : [profileId],
|
||||
supported: template.supported && (!profile || required.size === 0 || required.has(template.file))
|
||||
}))
|
||||
.toSorted((a, b) => a.importOrder - b.importOrder);
|
||||
}
|
||||
|
||||
resolveWizardSteps(profileId = 'crm_demo'): SetupWizardStep[] {
|
||||
const profile = this.resolveInstallationProfile(profileId);
|
||||
const profileStepKeys = new Set(profile?.wizardSteps ?? []);
|
||||
const steps = this.resolveEnabledPlugins(profileId).flatMap((plugin) => plugin.wizardSteps());
|
||||
const baseSteps = steps.filter((step) => profileStepKeys.size === 0 || profileStepKeys.has(step.key));
|
||||
const extras = steps.filter((step) => !profileStepKeys.has(step.key) && step.key.startsWith('crm-'));
|
||||
return [...baseSteps, ...extras];
|
||||
}
|
||||
|
||||
resolveReadinessChecks(profileId = 'crm_demo'): SetupReadinessCheck[] {
|
||||
return this.resolveEnabledPlugins(profileId).flatMap((plugin) => plugin.readinessChecks());
|
||||
}
|
||||
|
||||
resolveVerificationChecks(profileId = 'crm_demo'): SetupVerificationCheck[] {
|
||||
return this.resolveEnabledPlugins(profileId).flatMap((plugin) => plugin.verificationChecks());
|
||||
}
|
||||
|
||||
resolveResetScopes(profileId = 'crm_demo'): ResetScopeDefinition[] {
|
||||
const profile = this.resolveInstallationProfile(profileId);
|
||||
const allowed = new Set(profile?.resetScopes ?? []);
|
||||
return this.resolveEnabledPlugins(profileId)
|
||||
.flatMap((plugin) => plugin.resetScopes())
|
||||
.filter((scope) => allowed.size === 0 || allowed.has(scope.key));
|
||||
}
|
||||
|
||||
resolveReseedTargets(profileId = 'crm_demo'): ReseedTargetDefinition[] {
|
||||
const profile = this.resolveInstallationProfile(profileId);
|
||||
const allowed = new Set(profile?.reseedTargets ?? []);
|
||||
return this.resolveEnabledPlugins(profileId)
|
||||
.flatMap((plugin) => plugin.reseedTargets())
|
||||
.filter((target) => allowed.size === 0 || allowed.has(target.key));
|
||||
}
|
||||
|
||||
resolveGoldenDatasets(profileId = 'crm_demo'): GoldenDatasetDefinition[] {
|
||||
return this.resolveEnabledPlugins(profileId).flatMap((plugin) => plugin.goldenDatasets());
|
||||
}
|
||||
}
|
||||
|
||||
const registry = new SetupPluginRegistry();
|
||||
registry.registerPlugin(new FoundationSetupPlugin());
|
||||
registry.registerPlugin(new CrmSetupPlugin());
|
||||
|
||||
export function getSetupPluginRegistry() {
|
||||
return registry;
|
||||
}
|
||||
|
||||
export function getInstallationProfileBundle(profileId = 'crm_demo') {
|
||||
const selectedProfile = registry.resolveInstallationProfile(profileId) ?? registry.resolveInstallationProfile('crm_demo');
|
||||
const selectedProfileId = selectedProfile?.id ?? 'crm_demo';
|
||||
|
||||
return {
|
||||
selectedProfile,
|
||||
profiles: registry.resolveInstallationProfiles(),
|
||||
plugins: registry.getPlugins().map((plugin) => ({
|
||||
id: plugin.id,
|
||||
version: plugin.version,
|
||||
displayName: plugin.displayName,
|
||||
description: plugin.description,
|
||||
dependencies: plugin.dependencies
|
||||
})),
|
||||
templates: registry.resolveTemplates(selectedProfileId),
|
||||
wizardSteps: registry.resolveWizardSteps(selectedProfileId),
|
||||
readinessChecks: registry.resolveReadinessChecks(selectedProfileId),
|
||||
verificationChecks: registry.resolveVerificationChecks(selectedProfileId),
|
||||
resetScopes: registry.resolveResetScopes(selectedProfileId),
|
||||
reseedTargets: registry.resolveReseedTargets(selectedProfileId),
|
||||
goldenDatasets: registry.resolveGoldenDatasets(selectedProfileId)
|
||||
};
|
||||
}
|
||||
81
src/features/setup/plugins/plugin-types.ts
Normal file
81
src/features/setup/plugins/plugin-types.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { InstallationProfile } from './installation-profile';
|
||||
|
||||
export interface SetupTemplateDefinition {
|
||||
file: string;
|
||||
seedType: string;
|
||||
importOrder: number;
|
||||
supported: boolean;
|
||||
requiredColumns: string[];
|
||||
description: string;
|
||||
plugin: string;
|
||||
profiles: string[];
|
||||
}
|
||||
|
||||
export interface SetupWizardStep {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
produces: string[];
|
||||
invalidates: string[];
|
||||
}
|
||||
|
||||
export interface SetupReadinessCheck {
|
||||
id: string;
|
||||
area: string;
|
||||
name: string;
|
||||
requiredFor: string[];
|
||||
purpose: string;
|
||||
suggestedFix?: string;
|
||||
}
|
||||
|
||||
export interface SetupVerificationCheck extends SetupReadinessCheck {}
|
||||
|
||||
export interface ResetScopeDefinition {
|
||||
key: string;
|
||||
destructive: boolean;
|
||||
allowedEnvironments: string[];
|
||||
requiresTypedConfirmation: boolean;
|
||||
affectedTables: string[];
|
||||
storageCleanup: 'none' | 'report_only' | 'best_effort';
|
||||
}
|
||||
|
||||
export interface ReseedTargetDefinition {
|
||||
key: string;
|
||||
description: string;
|
||||
executable: boolean;
|
||||
}
|
||||
|
||||
export interface CsvPreviewValidator {
|
||||
key: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CsvImporter {
|
||||
key: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface GoldenDatasetDefinition {
|
||||
key: string;
|
||||
path: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
export interface SetupPlugin {
|
||||
id: string;
|
||||
version: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
dependencies: string[];
|
||||
installationProfiles(): InstallationProfile[];
|
||||
templates(): SetupTemplateDefinition[];
|
||||
wizardSteps(): SetupWizardStep[];
|
||||
readinessChecks(): SetupReadinessCheck[];
|
||||
verificationChecks(): SetupVerificationCheck[];
|
||||
previewValidators(): CsvPreviewValidator[];
|
||||
importHandlers(): CsvImporter[];
|
||||
resetScopes(): ResetScopeDefinition[];
|
||||
reseedTargets(): ReseedTargetDefinition[];
|
||||
goldenDatasets(): GoldenDatasetDefinition[];
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@/db/schema';
|
||||
import { EmailNotificationProvider } from '@/features/foundation/notifications/server/email-provider';
|
||||
import { getStorageProvider } from '@/features/foundation/storage/service';
|
||||
import { getSetupPluginRegistry } from '@/features/setup/plugins/plugin-registry';
|
||||
import { db } from '@/lib/db';
|
||||
import type {
|
||||
SetupCheckResult,
|
||||
@@ -391,7 +392,24 @@ export async function checkEmailConfig(): Promise<SetupCheckResult> {
|
||||
|
||||
export async function getSetupReadinessReport(input: {
|
||||
organizationId?: string | null;
|
||||
profileId?: string | null;
|
||||
} = {}): Promise<SetupReadinessReport> {
|
||||
const pluginChecks = getSetupPluginRegistry()
|
||||
.resolveReadinessChecks(input.profileId ?? 'crm_demo')
|
||||
.map((check) =>
|
||||
buildSetupCheck(
|
||||
{
|
||||
id: check.id,
|
||||
area: check.area,
|
||||
name: check.name,
|
||||
requiredFor: check.requiredFor,
|
||||
purpose: check.purpose,
|
||||
suggestedFix: check.suggestedFix
|
||||
},
|
||||
'PASS',
|
||||
`${check.name} is registered by the setup plugin registry.`
|
||||
)
|
||||
);
|
||||
const checks = await Promise.all([
|
||||
Promise.resolve(checkAuthSecret()),
|
||||
Promise.resolve(checkDatabaseUrl()),
|
||||
@@ -404,5 +422,5 @@ export async function getSetupReadinessReport(input: {
|
||||
checkEmailConfig()
|
||||
]);
|
||||
|
||||
return buildSetupReport(checks, 'Setup readiness checks completed.');
|
||||
return buildSetupReport([...checks, ...pluginChecks], 'Setup readiness checks completed.');
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
organizations,
|
||||
users
|
||||
} from '@/db/schema';
|
||||
import { getSetupPluginRegistry } from '@/features/setup/plugins/plugin-registry';
|
||||
import { db } from '@/lib/db';
|
||||
import type { SetupCheckResult, SetupVerificationReport } from '../api/types';
|
||||
import {
|
||||
@@ -27,8 +28,28 @@ import {
|
||||
type VerificationInput = {
|
||||
organizationId?: string | null;
|
||||
administratorEmail?: string | null;
|
||||
profileId?: string | null;
|
||||
};
|
||||
|
||||
function buildPluginVerificationChecks(profileId?: string | null): SetupCheckResult[] {
|
||||
return getSetupPluginRegistry()
|
||||
.resolveVerificationChecks(profileId ?? 'crm_demo')
|
||||
.map((check) =>
|
||||
buildSetupCheck(
|
||||
{
|
||||
id: check.id,
|
||||
area: check.area,
|
||||
name: check.name,
|
||||
requiredFor: check.requiredFor,
|
||||
purpose: check.purpose,
|
||||
suggestedFix: check.suggestedFix
|
||||
},
|
||||
'PASS',
|
||||
`${check.name} is registered by the setup plugin registry.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveOrganizationId(input: VerificationInput): Promise<string | null> {
|
||||
if (input.organizationId) {
|
||||
const [organization] = await db
|
||||
@@ -214,7 +235,10 @@ export async function getSetupVerificationReport(
|
||||
);
|
||||
|
||||
if (!organizationId) {
|
||||
const report = buildSetupReport(checks, 'Setup verification checks completed.');
|
||||
const report = buildSetupReport(
|
||||
[...checks, ...buildPluginVerificationChecks(input.profileId)],
|
||||
'Setup verification checks completed.'
|
||||
);
|
||||
return { ...report, organizationId };
|
||||
}
|
||||
|
||||
@@ -430,6 +454,9 @@ export async function getSetupVerificationReport(
|
||||
)
|
||||
);
|
||||
|
||||
const report = buildSetupReport(checks, 'Setup verification checks completed.');
|
||||
const report = buildSetupReport(
|
||||
[...checks, ...buildPluginVerificationChecks(input.profileId)],
|
||||
'Setup verification checks completed.'
|
||||
);
|
||||
return { ...report, organizationId };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user