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

@@ -0,0 +1,80 @@
# Task D.7.9 Installation Profiles & Setup Plugin Registry
Date: 2026-07-03
Status: implemented
## Scope Delivered
Added setup plugin platform layer:
- `src/features/setup/plugins/plugin-types.ts`
- `src/features/setup/plugins/plugin-registry.ts`
- `src/features/setup/plugins/installation-profile.ts`
- `src/features/setup/plugins/foundation/foundation-plugin.ts`
- `src/features/setup/plugins/crm/crm-setup-plugin.ts`
- `src/features/setup/plugins/crm/crm-installation-profile.ts`
Added installation profile API:
- `GET /api/setup/profiles`
Updated existing setup APIs:
- `GET /api/setup/templates?profile=<profileId>` now returns plugin-aware metadata while preserving legacy template fields.
- `GET /api/setup/readiness?profile=<profileId>` includes plugin registry readiness checks.
- `POST /api/setup/verify` accepts optional `profileId` and includes plugin registry verification checks.
Updated Setup Wizard:
- Loads installation profiles from the setup plugin registry.
- Allows profile selection.
- Builds wizard stepper from profile-resolved wizard steps with fallback to existing steps.
- Displays installed setup modules/plugins.
Added plugin documentation:
- `docs/setup/installation-profiles.md`
- `docs/setup/setup-plugin-development.md`
Added setup tests:
- `src/tests/setup/setup-plugin-registry.test.ts`
## Profiles
Implemented profiles:
- `crm_starter`
- `crm_demo`
- `production`
- `restore_backup`
- `custom`
## Plugins
Implemented built-in plugins:
- `foundation`
- owns setup platform foundations, setup state, seed manifests, reset framework, CSV framework, and foundation golden dataset registration
- `crm`
- owns CRM templates, CRM verification registration, CRM reset scopes, CRM reseed target registration, and CRM golden dataset registration
## Compatibility Notes
- No breaking API field removals were introduced.
- Existing template metadata fields remain: `file`, `seedType`, `importOrder`, `supported`, `requiredColumns`, `description`.
- New template metadata fields are additive: `plugin`, `profiles`, `visible`.
- Existing CSV preview/commit behavior is unchanged.
- Existing reset/reseed behavior is unchanged.
- External plugin loading, marketplace behavior, and new business modules remain out of scope.
## Validation
- `npm run typecheck -- --pretty false` passed.
- `npm run test:setup` passed: 20 tests.
- `npx oxlint src/features/setup/plugins src/app/api/setup/profiles src/app/api/setup/templates src/app/api/setup/readiness src/app/api/setup/verify src/features/setup/components/setup-wizard.tsx src/features/setup/api src/tests/setup` passed.
## Residual Notes
- Readiness and verification services now aggregate plugin registry checks additively. Existing concrete CRM checks remain in place to avoid regression.
- Future module plugins should register through `plugin-registry.ts` rather than editing the setup wizard directly.

View File

@@ -0,0 +1,20 @@
# Installation Profiles
Installation profiles select setup plugins, wizard steps, required templates, verification checks, reset scopes, and reseed targets.
Current profiles:
- `crm_starter`: foundation plus CRM structure.
- `crm_demo`: foundation plus CRM golden/demo dataset.
- `production`: foundation plus CRM production setup, with destructive maintenance disabled by default.
- `restore_backup`: minimal readiness and verification for restored environments.
- `custom`: operator-selected setup modules and templates.
Profiles are resolved by `src/features/setup/plugins/plugin-registry.ts` and exposed by:
- `GET /api/setup/profiles`
- `GET /api/setup/templates?profile=<profileId>`
- `GET /api/setup/readiness?profile=<profileId>`
- `POST /api/setup/verify` with optional `profileId`
The setup wizard uses the selected profile to render profile-aware plugin metadata and wizard steps.

View File

@@ -0,0 +1,37 @@
# Setup Plugin Development
Setup plugins let modules extend setup without editing the setup wizard directly.
Plugin source lives under:
```text
src/features/setup/plugins/
```
Required plugin contract:
- plugin id and version
- installation profiles
- setup templates
- wizard steps
- readiness checks
- verification checks
- CSV preview validators
- import handlers
- reset scopes
- reseed targets
- golden dataset registration
Core plugins:
- `foundation`: setup platform foundations, state, seed manifests, reset framework, CSV framework
- `crm`: CRM templates, CRM verification, CRM reset scopes, and CRM golden dataset
Future modules should add a plugin class and register it in `plugin-registry.ts`. They should not add one-off setup logic directly to the wizard.
Safety rules:
- Plugins cannot bypass Auth.js setup route protection.
- Reset scopes must declare destructive behavior and affected tables.
- CSV templates must declare ownership to avoid duplicate file contracts.
- Seed tasks must be idempotent before execution is exposed.

749
plans/task-d.7.9.md Normal file
View File

@@ -0,0 +1,749 @@
# Task D.7.9 Installation Profiles & Setup Plugin Registry
## Objective
Transform the ALLA OS Setup Platform into a fully extensible platform by introducing Installation Profiles and a Setup Plugin Registry.
The current Setup Platform (D.7.1D.7.10) already supports:
- Readiness
- Verification
- CSV Templates
- CSV Preview
- CSV Commit
- Setup Wizard
- Setup State
- Seed Manifest
- Reset / Reseed
- Golden Dataset
D.7.9 must remove remaining CRM-specific assumptions so future modules (CRM, HR, Asset, Inventory, Purchase, Service, etc.) can integrate into the Setup Platform without modifying the core wizard.
No existing setup behavior may regress.
---
# Review Before Implementation
Review
- AGENTS.md
- plans/task-d.7.md
- plans/task-d.7.1.md
- plans/task-d.7.2.md
- plans/task-d.7.3.md
- plans/task-d.7.4.md
- plans/task-d.7.5.md
- plans/task-d.7.6.md
- plans/task-d.7.7.md
- plans/task-d.7.8.md
- plans/task-d.7.10.md
Review
docs/setup/*
Review
setup/templates/*
Review
setup/golden-dataset/*
Review
src/features/setup/**
---
# Objective
Replace hardcoded setup behavior with configuration-driven module registration.
The Setup Platform must become reusable by every ALLA OS module.
---
# Implementation Scope
Create
src/features/setup/plugins/
Recommended structure
src/features/setup/plugins/
plugin-registry.ts
plugin-types.ts
installation-profile.ts
crm/
crm-setup-plugin.ts
crm-installation-profile.ts
foundation/
foundation-plugin.ts
---
# Installation Profiles
Introduce Installation Profiles.
Supported profiles
minimal
crm_starter
crm_demo
production
restore_backup
custom
Each profile defines
- visible wizard steps
- required templates
- verification checks
- setup sequence
- supported reset scopes
- reseed targets
- default options
Profile interface
```ts
export interface InstallationProfile {
id:string;
name:string;
description:string;
wizardSteps:string[];
requiredTemplates:string[];
verificationChecks:string[];
resetScopes:string[];
reseedTargets:string[];
defaultOptions:Record<string,unknown>;
}
```
---
# Setup Plugin Interface
Create
plugin-types.ts
```ts
export interface SetupPlugin {
id:string;
version:string;
displayName:string;
description:string;
installationProfiles():InstallationProfile[];
templates():SetupTemplateDefinition[];
wizardSteps():SetupWizardStep[];
readinessChecks():SetupReadinessCheck[];
verificationChecks():SetupVerificationCheck[];
previewValidators():CsvPreviewValidator[];
importHandlers():CsvImporter[];
resetScopes():ResetScopeDefinition[];
reseedTargets():ReseedTargetDefinition[];
}
```
---
# Plugin Registry
Create
plugin-registry.ts
Responsibilities
Register plugins
Resolve plugins
Resolve installation profiles
Resolve templates
Resolve verification checks
Resolve reset scopes
Resolve reseed targets
Resolve wizard steps
Example
registerPlugin(new FoundationPlugin());
registerPlugin(new CrmSetupPlugin());
---
# Foundation Plugin
Move common setup behavior into Foundation Plugin.
Foundation Plugin owns
Readiness
Verification
Setup State
Seed Manifest
CSV Framework
Reset Framework
Golden Dataset framework
No CRM assumptions.
---
# CRM Setup Plugin
CRM Plugin owns
CRM Templates
CRM CSV Importers
CRM Verification
CRM Wizard Steps
CRM Reset Scope
CRM Golden Dataset registration
CRM Approval registration
CRM Document Sequence registration
CRM Reports registration
---
# Dynamic Wizard
Current wizard should stop hardcoding steps.
Instead
Plugin Registry
Installation Profile
Resolve Wizard Steps
Render Wizard
Adding a module must not require editing the wizard.
---
# Dynamic Verification
Current verification service should allow plugins to register checks.
Instead of
CRM Check
Approval Check
PDF Check
Hardcoded
Use
Plugin Registry
Collect Checks
Execute
Aggregate Report
---
# Dynamic Readiness
Allow plugins to register readiness checks.
Example
Storage Plugin
Storage Check
PDF Plugin
Template Check
CRM Plugin
CRM Readiness
---
# Dynamic Template Registry
Extend
setup/templates/templates.json
Support
plugin
profile
visibility
Example
{
"file":"customers.csv",
"plugin":"crm",
"profiles":[
"crm_demo",
"production"
]
}
Wizard automatically filters templates.
---
# Dynamic Navigation
Wizard should display
Installed Modules
Foundation
CRM
Approval
Storage
Future
HR
Asset
Inventory
Purchase
Modules not installed
Hidden
---
# Profile Resolution
Selecting
crm_demo
Automatically
Enable
CRM plugin
Foundation plugin
Demo dataset
CRM templates
CRM verification
Demo reset
Disable
Restore Backup steps
Selecting
minimal
Only
Foundation
Verification
No CRM
---
# API Updates
Extend
GET /api/setup/templates
Include
plugin
profile
visible
supported
Update
GET /api/setup/readiness
Collect plugin readiness.
Update
POST /api/setup/verify
Collect plugin verification.
No API breaking changes.
---
# Setup Wizard Updates
Wizard
Load Installation Profiles
User selects profile
Resolve Plugin Graph
Build Wizard
Render Steps
Run Setup
Wizard must no longer contain CRM-specific logic.
---
# Golden Dataset Integration
Allow every plugin to register
Golden Dataset
Example
CRM
setup/golden-dataset/crm/
Future
HR
setup/golden-dataset/hr/
Asset
setup/golden-dataset/asset/
---
# Future Module Example
Adding
Asset
Should require only
registerPlugin(
AssetSetupPlugin
);
No modification
Wizard
Verification
Reset
Template Registry
CSV Engine
---
# Out Of Scope
New business module
HR implementation
Asset implementation
Inventory implementation
Purchase implementation
Plugin marketplace
External plugin loading
Remote plugins
Installation package download
Backup restore implementation
CRM business behavior changes
---
# Deliverables
Required
src/features/setup/plugins/plugin-types.ts
src/features/setup/plugins/plugin-registry.ts
src/features/setup/plugins/installation-profile.ts
src/features/setup/plugins/foundation/foundation-plugin.ts
src/features/setup/plugins/crm/crm-setup-plugin.ts
src/features/setup/plugins/crm/crm-installation-profile.ts
Update
Setup Wizard
Readiness Service
Verification Service
Template Metadata Service
Template Registry
Optional
docs/setup/setup-plugin-development.md
docs/setup/installation-profiles.md
---
# Acceptance Criteria
✓ Installation Profiles exist.
✓ Plugin Registry exists.
✓ Foundation Plugin exists.
✓ CRM Plugin exists.
✓ Wizard builds dynamically from selected profile.
✓ Wizard no longer hardcodes CRM steps.
✓ Readiness collects plugin checks.
✓ Verification collects plugin checks.
✓ Template registry supports plugin ownership.
✓ Template registry supports profile visibility.
✓ Golden Dataset supports plugin registration.
✓ Future modules can integrate by registering one plugin.
✓ Existing Setup Wizard behavior remains unchanged.
✓ Existing APIs remain backward compatible.
✓ Existing CSV Preview/Commit logic is unaffected.
✓ Typecheck passes.
✓ Existing setup integration tests continue to pass.
---
# Suggested Verification
Run
npm run typecheck
npm run test:setup
Verify
Minimal Profile
Foundation only
CRM Starter
CRM steps only
CRM Demo
CRM + Demo dataset
Production
Production profile
Confirm
Readiness still PASS
Verification still PASS
CSV Preview unchanged
CSV Commit unchanged
Setup Resume unchanged
Golden Dataset unchanged
Register a temporary mock plugin
Confirm
Wizard automatically includes plugin steps
Verification automatically executes plugin checks
Template API exposes plugin templates
Without modifying Setup Wizard source code.
---
# Completion Criteria
After D.7.9
The ALLA OS Setup Platform is officially module-driven.
All future modules (CRM, HR, Asset, Purchase, Inventory, Service, Calendar, etc.) must integrate through the Setup Plugin Registry rather than extending the Setup Wizard directly.
D.7.9 marks the architectural freeze of Setup Platform v1.0.

View File

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
import { getInstallationProfileBundle } from '@/features/setup/plugins/plugin-registry';
import { AuthError, requireSystemRole } from '@/lib/auth/session';
export async function GET(request: NextRequest) {
try {
await requireSystemRole('super_admin');
const profileId = request.nextUrl.searchParams.get('profile') ?? 'crm_demo';
return NextResponse.json({
success: true,
time: new Date().toISOString(),
...getInstallationProfileBundle(profileId)
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load setup installation profiles' }, { status: 500 });
}
}

View File

@@ -1,14 +1,15 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { getSetupReadinessReport } from '@/features/setup/server/readiness.service';
import { AuthError, requireSystemRole } from '@/lib/auth/session';
export async function GET() {
export async function GET(request: NextRequest) {
try {
const session = await requireSystemRole('super_admin');
const profileId = request.nextUrl.searchParams.get('profile') ?? 'crm_demo';
const report = await getSetupReadinessReport({
organizationId: session.user.activeOrganizationId ?? null
organizationId: session.user.activeOrganizationId ?? null,
profileId
});
return NextResponse.json(report);
} catch (error) {
if (error instanceof AuthError) {

View File

@@ -1,9 +1,10 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { getInstallationProfileBundle } from '@/features/setup/plugins/plugin-registry';
import { AuthError, requireSystemRole } from '@/lib/auth/session';
type SetupTemplateMetadata = {
interface SetupTemplateMetadata {
version: string;
generatedFrom: string;
templates: Array<{
@@ -14,20 +15,38 @@ type SetupTemplateMetadata = {
requiredColumns: string[];
description: string;
}>;
};
}
export async function GET() {
export async function GET(request: NextRequest) {
try {
await requireSystemRole('super_admin');
const profileId = request.nextUrl.searchParams.get('profile') ?? 'crm_demo';
const metadataPath = path.join(process.cwd(), 'setup', 'templates', 'templates.json');
const metadata = JSON.parse(await readFile(metadataPath, 'utf8')) as SetupTemplateMetadata;
const profileBundle = getInstallationProfileBundle(profileId);
const pluginTemplateByFile = new Map(
profileBundle.templates.map((template) => [template.file, template])
);
const selectedTemplateFiles = new Set(profileBundle.selectedProfile?.requiredTemplates ?? []);
const templates = metadata.templates.map((template) => {
const pluginTemplate = pluginTemplateByFile.get(template.file);
return {
...template,
plugin: pluginTemplate?.plugin ?? (template.importOrder < 30 ? 'foundation' : 'crm'),
profiles: pluginTemplate?.profiles ?? ['crm_demo', 'production', 'custom'],
visible: selectedTemplateFiles.size === 0 || selectedTemplateFiles.has(template.file)
};
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
version: metadata.version,
generatedFrom: metadata.generatedFrom,
templates: metadata.templates
profile: profileBundle.selectedProfile,
plugins: profileBundle.plugins,
templates
});
} catch (error) {
if (error instanceof AuthError) {

View File

@@ -6,7 +6,8 @@ import { AuthError, requireSystemRole } from '@/lib/auth/session';
const verificationSchema = z
.object({
organizationId: z.string().min(1).optional(),
administratorEmail: z.string().email().optional()
administratorEmail: z.string().email().optional(),
profileId: z.string().min(1).optional()
})
.optional();
@@ -16,7 +17,8 @@ export async function POST(request: NextRequest) {
const body = verificationSchema.parse(await request.json().catch(() => undefined)) ?? {};
const report = await getSetupVerificationReport({
organizationId: body.organizationId ?? session.user.activeOrganizationId ?? null,
administratorEmail: body.administratorEmail
administratorEmail: body.administratorEmail,
profileId: body.profileId
});
return NextResponse.json(report);

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

View File

@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { assertFileExists, readText } from './helpers.ts';
test('setup plugin registry files exist', () => {
for (const file of [
'src/features/setup/plugins/plugin-types.ts',
'src/features/setup/plugins/plugin-registry.ts',
'src/features/setup/plugins/installation-profile.ts',
'src/features/setup/plugins/foundation/foundation-plugin.ts',
'src/features/setup/plugins/crm/crm-setup-plugin.ts',
'src/features/setup/plugins/crm/crm-installation-profile.ts'
]) {
assertFileExists(file);
}
});
test('installation profile API and wizard profile loading exist', () => {
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/);
});
test('template API exposes plugin-aware metadata without removing legacy fields', () => {
const route = readText('src/app/api/setup/templates/route.ts');
assert.match(route, /plugin/);
assert.match(route, /profiles/);
assert.match(route, /requiredColumns/);
assert.match(route, /importOrder/);
});