/** * Form Registry * * This service provides centralized management for form definitions across the workflow system. */ import { Knex } from 'knex'; import { v4 as uuidv4 } from 'uuid'; // Use ESM-compliant .js paths for NodeNext import { getFormValidationService } from './formValidationService'; import FormDefinitionModel from '../workflow/persistence/formDefinitionModel'; import FormSchemaModel from '../workflow/persistence/formSchemaModel'; import type { IFormDefinition, IFormSchema } from '../workflow/persistence/formRegistryInterfaces'; import { IFormDefinition as IFormDefinitionType, IFormSchema as IFormSchemaType, FormStatus, FormRegistrationParams, FormUpdateParams, FormSearchParams, FormValidationResult, FormWithSchema } from '../workflow/persistence/formRegistryInterfaces'; export class FormRegistry { /** * Register a new form definition */ async register( knex: Knex, tenant: string, params: FormRegistrationParams, userId?: string ): Promise { // Start a transaction return knex.transaction(async (trx) => { try { // Check if form already exists with this ID and version const existingForm = await FormDefinitionModel.getByIdAndVersion( trx, params.formId, params.version, 'tenant', // Registering a tenant-specific form tenant ); if (existingForm) { throw new Error(`Form with ID ${params.formId} and version ${params.version} already exists`); } // Create form definition const formDefinition: Omit = { form_id: params.formId, name: params.name, description: params.description, version: params.version, status: params.status || FormStatus.DRAFT, category: params.category, created_by: userId }; await FormDefinitionModel.create(trx, tenant, formDefinition); // Create form schema const formSchema: Omit = { form_id: params.formId, json_schema: params.jsonSchema, ui_schema: params.uiSchema, default_values: params.defaultValues }; await FormSchemaModel.create(trx, tenant, formSchema); return params.formId; } catch (error) { console.error('Error registering form:', error); throw error; } }); } /** * Get a form definition by ID and version */ async getForm( knex: Knex, tenant: string, // The current tenant context. For system forms, this might be a specific tenant ID or a generic system indicator if the caller knows. formId: string, version?: string ): Promise { let formDefinition: IFormDefinition | null = null; let formSchema: IFormSchema | null = null; let determinedFormType: 'tenant' | 'system' | undefined; try { // Attempt to get as tenant-specific form first if (version) { formDefinition = await FormDefinitionModel.getByIdAndVersion(knex, formId, version, 'tenant', tenant); } else { formDefinition = await FormDefinitionModel.getLatestVersion(knex, formId, 'tenant', tenant); } if (formDefinition) { determinedFormType = 'tenant'; } else { // If not found as tenant-specific, try as system form if (version) { formDefinition = await FormDefinitionModel.getByIdAndVersion(knex, formId, version, 'system'); } else { formDefinition = await FormDefinitionModel.getLatestVersion(knex, formId, 'system'); } if (formDefinition) { determinedFormType = 'system'; } else { return null; // Not found as tenant or system } } // Now get the schema using the determined form type and the original tenant for tenant forms, // or no tenant for system forms (as per FormSchemaModel.getByFormId's updated signature). // The 'version' is passed to getByFormId because system schemas are tied to system definitions which are versioned. // For tenant schemas, 'version' is not used by getByFormId as they are linked by form_id. if (determinedFormType === 'system') { // System forms embed schema directly formSchema = { schema_id: `system-schema-${formDefinition.definition_id}`, // Using definition_id as a base for schema_id form_id: formDefinition.name, // System forms are identified by name tenant: undefined, // System forms are not tenant-specific json_schema: formDefinition.json_schema, ui_schema: formDefinition.ui_schema, default_values: formDefinition.default_values, created_at: formDefinition.created_at, updated_at: formDefinition.updated_at } as IFormSchema; } else { // Tenant forms have schemas stored separately, linked by form_id (UUID) formSchema = await FormSchemaModel.getByFormId( knex, formDefinition.form_id, // Use the UUID form_id from the definition determinedFormType, tenant, // Always pass tenant for tenant forms undefined // Version is not used for tenant schemas ); } if (!formSchema) { // This should ideally not happen if a definition was found, implies data inconsistency console.error(`[FormRegistry] Definition found for form ${formId} (type: ${determinedFormType}), but schema is missing.`); throw new Error(`Schema not found for form ${formId} (type: ${determinedFormType})`); } return { definition: formDefinition, schema: formSchema }; } catch (error) { console.error(`[FormRegistry] Error getting form ${formId} (Version: ${version || 'latest'}) for tenant ${tenant}. Determined type (if any): ${determinedFormType || 'N/A'}:`, error); throw error; } } /** * Update a form definition */ async updateForm( knex: Knex, tenant: string, formId: string, version: string, updates: FormUpdateParams ): Promise { return knex.transaction(async (trx) => { try { // Check if form exists const existingForm = await FormDefinitionModel.getByIdAndVersion(trx, formId, version, 'tenant', tenant); if (!existingForm) { throw new Error(`Form with ID ${formId} and version ${version} not found`); } // Update form definition const definitionUpdates: Partial> = {}; if (updates.name !== undefined) definitionUpdates.name = updates.name; if (updates.description !== undefined) definitionUpdates.description = updates.description; if (updates.category !== undefined) definitionUpdates.category = updates.category; if (updates.status !== undefined) definitionUpdates.status = updates.status; if (Object.keys(definitionUpdates).length > 0) { await FormDefinitionModel.update(trx, tenant, formId, version, definitionUpdates); } // Update form schema if needed const schemaUpdates: Partial> = {}; if (updates.jsonSchema !== undefined) schemaUpdates.json_schema = updates.jsonSchema; if (updates.uiSchema !== undefined) schemaUpdates.ui_schema = updates.uiSchema; if (updates.defaultValues !== undefined) schemaUpdates.default_values = updates.defaultValues; if (Object.keys(schemaUpdates).length > 0) { await FormSchemaModel.update(trx, tenant, formId, schemaUpdates); } return true; } catch (error) { console.error(`Error updating form ${formId}:`, error); throw error; } }); } /** * Create a new version of a form */ async createNewVersion( knex: Knex, tenant: string, formId: string, newVersion: string, updates: FormUpdateParams = {} ): Promise { return knex.transaction(async (trx) => { try { // Get the latest version of the form const latestForm = await this.getForm(trx, tenant, formId); if (!latestForm) { throw new Error(`Form with ID ${formId} not found`); } // Check if the new version already exists const existingVersion = await FormDefinitionModel.getByIdAndVersion( trx, formId, newVersion, 'tenant', // Creating new version for a tenant-specific form tenant ); if (existingVersion) { throw new Error(`Version ${newVersion} already exists for form ${formId}`); } // Create new form definition const formDefinition: Omit = { form_id: formId, name: updates.name || latestForm.definition.name, description: updates.description || latestForm.definition.description, version: newVersion, status: updates.status || FormStatus.DRAFT, // New versions start as draft category: updates.category || latestForm.definition.category, created_by: latestForm.definition.created_by }; await FormDefinitionModel.create(trx, tenant, formDefinition); // Create new form schema const formSchema: Omit = { form_id: formId, json_schema: updates.jsonSchema || latestForm.schema.json_schema, ui_schema: updates.uiSchema || latestForm.schema.ui_schema, default_values: updates.defaultValues || latestForm.schema.default_values }; await FormSchemaModel.create(trx, tenant, formSchema); return formId; } catch (error) { console.error(`Error creating new version for form ${formId}:`, error); throw error; } }); } /** * Update form status */ async updateStatus( knex: Knex, tenant: string, formId: string, version: string, status: FormStatus ): Promise { try { // Check if form exists const existingForm = await FormDefinitionModel.getByIdAndVersion(knex, formId, version, 'tenant', tenant); if (!existingForm) { throw new Error(`Form with ID ${formId} and version ${version} not found`); } // Update status return FormDefinitionModel.updateStatus(knex, tenant, formId, version, status); } catch (error) { console.error(`Error updating status for form ${formId}:`, error); throw error; } } /** * Delete a form definition and its schema */ async deleteForm( knex: Knex, tenant: string, formId: string, version: string ): Promise { return knex.transaction(async (trx) => { try { // Check if form exists const existingForm = await FormDefinitionModel.getByIdAndVersion(trx, formId, version, 'tenant', tenant); if (!existingForm) { throw new Error(`Form with ID ${formId} and version ${version} not found`); } // Delete form schema await FormSchemaModel.delete(trx, tenant, formId); // Delete form definition await FormDefinitionModel.delete(trx, tenant, formId, version); return true; } catch (error) { console.error(`Error deleting form ${formId}:`, error); throw error; } }); } /** * Search for forms */ async searchForms( knex: Knex, tenant: string, searchParams: FormSearchParams, pagination: { limit?: number; offset?: number; } = {} ): Promise<{ total: number; forms: IFormDefinition[] }> { try { return FormDefinitionModel.search( knex, tenant, { name: searchParams.name, category: searchParams.category, status: searchParams.status, formId: searchParams.formId }, pagination ); } catch (error) { console.error('Error searching forms:', error); throw error; } } /** * Get all versions of a form */ async getAllVersions( knex: Knex, tenant: string, formId: string ): Promise { try { return FormDefinitionModel.getAllVersions(knex, tenant, formId); } catch (error) { console.error(`Error getting versions for form ${formId}:`, error); throw error; } } /** * Get all forms by category */ async getFormsByCategory( knex: Knex, tenant: string, category: string ): Promise { try { return FormDefinitionModel.getByCategory(knex, tenant, category); } catch (error) { console.error(`Error getting forms for category ${category}:`, error); throw error; } } /** * Get all form categories */ async getAllCategories( knex: Knex, tenant: string ): Promise { try { return FormDefinitionModel.getAllCategories(knex, tenant); } catch (error) { console.error('Error getting form categories:', error); throw error; } } /** * Validate form data against a form schema */ async validateFormData( knex: Knex, tenant: string, formId: string, data: Record, version?: string ): Promise { try { // Get form with schema const form = await this.getForm(knex, tenant, formId, version); if (!form) { throw new Error(`Form with ID ${formId}${version ? ` and version ${version}` : ''} not found`); } // Validate data against schema const validationService = getFormValidationService(); return validationService.validate(form.schema.json_schema, data); } catch (error) { console.error(`Error validating form data for ${formId}:`, error); throw error; } } /** * Generate a unique form ID */ generateFormId(): string { return `form-${uuidv4()}`; } /** * Compose a form from multiple form definitions * This allows for form inheritance and composition */ async composeForm( knex: Knex, tenant: string, baseFormId: string, extensionFormIds: string[], overrides: { name?: string; description?: string; category?: string; jsonSchema?: Record; uiSchema?: Record; defaultValues?: Record; } = {} ): Promise { try { // Get base form const baseForm = await this.getForm(knex, tenant, baseFormId); if (!baseForm) { throw new Error(`Base form with ID ${baseFormId} not found`); } // Start with base form properties const composedDefinition: IFormDefinition = { ...baseForm.definition, form_id: this.generateFormId(), name: overrides.name || baseForm.definition.name, description: overrides.description || baseForm.definition.description, category: overrides.category || baseForm.definition.category, status: FormStatus.DRAFT, version: '1.0.0', created_at: new Date().toISOString(), updated_at: new Date().toISOString() }; // Start with base form schema let composedJsonSchema = { ...baseForm.schema.json_schema }; let composedUiSchema = baseForm.schema.ui_schema ? { ...baseForm.schema.ui_schema } : {}; let composedDefaultValues = baseForm.schema.default_values ? { ...baseForm.schema.default_values } : {}; // Apply extensions in order for (const extensionId of extensionFormIds) { const extensionForm = await this.getForm(knex, tenant, extensionId); if (!extensionForm) { throw new Error(`Extension form with ID ${extensionId} not found`); } // Merge JSON schema properties if (extensionForm.schema.json_schema.properties) { composedJsonSchema.properties = { ...composedJsonSchema.properties, ...extensionForm.schema.json_schema.properties }; } // Merge required fields if (extensionForm.schema.json_schema.required) { composedJsonSchema.required = [ ...(composedJsonSchema.required || []), ...extensionForm.schema.json_schema.required ]; } // Merge UI schema if (extensionForm.schema.ui_schema) { composedUiSchema = { ...composedUiSchema, ...extensionForm.schema.ui_schema }; } // Merge default values if (extensionForm.schema.default_values) { composedDefaultValues = { ...composedDefaultValues, ...extensionForm.schema.default_values }; } } // Apply overrides if (overrides.jsonSchema) { composedJsonSchema = { ...composedJsonSchema, ...overrides.jsonSchema }; } if (overrides.uiSchema) { composedUiSchema = { ...composedUiSchema, ...overrides.uiSchema }; } if (overrides.defaultValues) { composedDefaultValues = { ...composedDefaultValues, ...overrides.defaultValues }; } // Create composed schema const composedSchema: IFormSchema = { schema_id: `schema-${uuidv4()}`, form_id: composedDefinition.form_id, tenant, json_schema: composedJsonSchema, ui_schema: composedUiSchema, default_values: composedDefaultValues, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }; return { definition: composedDefinition, schema: composedSchema }; } catch (error) { console.error('Error composing form:', error); throw error; } } } // Singleton instance let registryInstance: FormRegistry | null = null; /** * Get the form registry instance */ export function getFormRegistry(): FormRegistry { if (!registryInstance) { registryInstance = new FormRegistry(); } return registryInstance; }