Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
585 lines
18 KiB
TypeScript
585 lines
18 KiB
TypeScript
/**
|
|
* 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<string> {
|
|
// 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<IFormDefinition, 'tenant' | 'created_at' | 'updated_at'> = {
|
|
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<IFormSchema, 'tenant' | 'schema_id' | 'created_at' | 'updated_at'> = {
|
|
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<FormWithSchema | null> {
|
|
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<boolean> {
|
|
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<Omit<IFormDefinition, 'form_id' | 'tenant' | 'version' | 'created_at' | 'updated_at'>> = {};
|
|
|
|
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<Omit<IFormSchema, 'schema_id' | 'tenant' | 'form_id' | 'created_at' | 'updated_at'>> = {};
|
|
|
|
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<string> {
|
|
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<IFormDefinition, 'tenant' | 'created_at' | 'updated_at'> = {
|
|
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<IFormSchema, 'tenant' | 'schema_id' | 'created_at' | 'updated_at'> = {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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<IFormDefinition[]> {
|
|
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<IFormDefinition[]> {
|
|
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<string[]> {
|
|
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<string, any>,
|
|
version?: string
|
|
): Promise<FormValidationResult> {
|
|
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<string, any>;
|
|
uiSchema?: Record<string, any>;
|
|
defaultValues?: Record<string, any>;
|
|
} = {}
|
|
): Promise<FormWithSchema> {
|
|
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;
|
|
}
|