task-d.7.9

This commit is contained in:
phaichayon
2026-07-03 11:39:37 +07:00
parent 503f34e22f
commit 9c8d6a0d6d
21 changed files with 1809 additions and 42 deletions

View File

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

View File

@@ -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> {

View File

@@ -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'

View File

@@ -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>

View 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']
}
];

View 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 }];
}
}

View 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 }];
}
}

View 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'];

View 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)
};
}

View 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[];
}

View File

@@ -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.');
}

View File

@@ -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 };
}