feat: Runtime configuration and Docker deployment improvements

Frontend:
- Add runtime configuration service for deployment-time API URL injection
- Create docker-entrypoint.sh to generate config.json from environment variables
- Update ApiService, ApprovalService, and DocumentViewer to use RuntimeConfigService
- Add APP_INITIALIZER to load runtime config before app starts

Backend:
- Fix init-blockchain.js to properly quote mnemonic phrases in .env file
- Improve docker-entrypoint.sh with health checks and better error handling

Docker:
- Add API_BASE_URL environment variable to frontend container
- Update docker-compose.yml with clear documentation for remote deployment
- Reorganize .env.example with clear categories (REQUIRED FOR REMOTE, PRODUCTION, AUTO-GENERATED)

Workflow fixes:
- Fix DepartmentApproval interface to match backend schema
- Fix stage transformation for 0-indexed stageOrder
- Fix workflow list to show correct stage count from definition.stages

Cleanup:
- Move development artifacts to .trash directory
- Remove root-level package.json (was only for utility scripts)
- Add .trash/ to .gitignore
This commit is contained in:
Mahi
2026-02-08 18:44:05 -04:00
parent 2c10cd5662
commit d9de183e51
171 changed files with 10236 additions and 8386 deletions

View File

@@ -54,8 +54,8 @@ function validateCreateWorkflowDto(dto: CreateWorkflowDto | null | undefined): C
throw new Error('Workflow name cannot exceed 200 characters');
}
if (!dto.departmentId || typeof dto.departmentId !== 'string' || dto.departmentId.trim().length === 0) {
throw new Error('Department ID is required');
if (!dto.workflowType || typeof dto.workflowType !== 'string') {
throw new Error('Workflow type is required');
}
if (!dto.stages || !Array.isArray(dto.stages) || dto.stages.length === 0) {
@@ -63,11 +63,11 @@ function validateCreateWorkflowDto(dto: CreateWorkflowDto | null | undefined): C
}
// Validate each stage
dto.stages.forEach((stage, index) => {
if (!stage.name || typeof stage.name !== 'string' || stage.name.trim().length === 0) {
dto.stages.forEach((stage: any, index: number) => {
if (!stage.stageName || typeof stage.stageName !== 'string' || stage.stageName.trim().length === 0) {
throw new Error(`Stage ${index + 1}: Name is required`);
}
if (!stage.order || typeof stage.order !== 'number' || stage.order < 1) {
if (typeof stage.stageOrder !== 'number' || stage.stageOrder < 1) {
throw new Error(`Stage ${index + 1}: Valid order is required`);
}
});
@@ -76,7 +76,6 @@ function validateCreateWorkflowDto(dto: CreateWorkflowDto | null | undefined): C
...dto,
name: dto.name.trim(),
description: dto.description?.trim() || undefined,
departmentId: dto.departmentId.trim(),
};
}
@@ -118,6 +117,13 @@ function validateUpdateWorkflowDto(dto: UpdateWorkflowDto | null | undefined): U
sanitized.stages = dto.stages;
}
if (dto.metadata !== undefined) {
if (typeof dto.metadata !== 'object' || dto.metadata === null) {
throw new Error('Metadata must be an object');
}
sanitized.metadata = dto.metadata;
}
return sanitized;
}
@@ -131,7 +137,7 @@ export class WorkflowService {
const validated = validatePagination(page, limit);
return this.api
.get<PaginatedWorkflowsResponse>('/workflows', {
.getRaw<PaginatedWorkflowsResponse>('/workflows', {
page: validated.page,
limit: validated.limit,
})
@@ -148,16 +154,13 @@ export class WorkflowService {
try {
const validId = validateId(id, 'Workflow ID');
return this.api.get<WorkflowResponseDto>(`/workflows/${validId}`).pipe(
return this.api.getRaw<WorkflowResponseDto>(`/workflows/${validId}`).pipe(
map((response) => {
if (!response) {
throw new Error('Workflow not found');
}
// Ensure nested arrays are valid
return {
...response,
stages: Array.isArray(response.stages) ? response.stages : [],
};
// Response structure has stages inside definition
return response;
}),
catchError((error: unknown) => {
const message = error instanceof Error ? error.message : `Failed to fetch workflow: ${id}`;
@@ -173,7 +176,7 @@ export class WorkflowService {
try {
const sanitizedDto = validateCreateWorkflowDto(dto);
return this.api.post<WorkflowResponseDto>('/workflows', sanitizedDto).pipe(
return this.api.postRaw<WorkflowResponseDto>('/workflows', sanitizedDto).pipe(
catchError((error: unknown) => {
const message = error instanceof Error ? error.message : 'Failed to create workflow';
return throwError(() => new Error(message));

View File

@@ -21,7 +21,7 @@ import { WorkflowService } from '../services/workflow.service';
import { DepartmentService } from '../../departments/services/department.service';
import { NotificationService } from '../../../core/services/notification.service';
import { AuthService } from '../../../core/services/auth.service';
import { WorkflowResponseDto, WorkflowStage, DepartmentResponseDto } from '../../../api/models';
import { WorkflowResponseDto, DepartmentResponseDto } from '../../../api/models';
// Node position interface for canvas positioning
interface NodePosition {
@@ -29,13 +29,20 @@ interface NodePosition {
y: number;
}
// Extended stage with visual properties
interface VisualStage extends WorkflowStage {
// Visual stage interface for canvas display (decoupled from backend model)
interface VisualStage {
id: string;
name: string;
description?: string;
departmentId: string;
order: number;
isRequired: boolean;
position: NodePosition;
isSelected: boolean;
isStartNode?: boolean;
isEndNode?: boolean;
connections: string[]; // IDs of connected stages (outgoing)
metadata?: Record<string, any>;
}
// Connection between stages
@@ -152,7 +159,7 @@ export class WorkflowBuilderComponent implements OnInit {
private loadDepartments(): void {
this.departmentService.getDepartments(1, 100).subscribe({
next: (response) => {
this.departments.set(response.data);
this.departments.set(response?.data ?? []);
},
});
}
@@ -168,15 +175,31 @@ export class WorkflowBuilderComponent implements OnInit {
isActive: workflow.isActive,
});
// Convert stages to visual stages with positions
const visualStages = workflow.stages.map((stage, index) => ({
...stage,
position: this.calculateStagePosition(index, workflow.stages.length),
isSelected: false,
isStartNode: index === 0,
isEndNode: index === workflow.stages.length - 1,
connections: index < workflow.stages.length - 1 ? [workflow.stages[index + 1].id] : [],
}));
// Get stages from definition (backend format)
const backendStages = workflow.definition?.stages || [];
// Convert backend stages to visual stages with positions
const visualStages: VisualStage[] = backendStages.map((stage, index) => {
// Find department by code to get departmentId
const dept = this.departments().find(d =>
d.code === stage.requiredApprovals?.[0]?.departmentCode
);
return {
id: stage.stageId,
name: stage.stageName,
description: stage.metadata?.['description'] || '',
departmentId: dept?.id || '',
order: stage.stageOrder,
isRequired: stage.metadata?.['isRequired'] ?? true,
position: stage.metadata?.['position'] || this.calculateStagePosition(index, backendStages.length),
isSelected: false,
isStartNode: index === 0,
isEndNode: index === backendStages.length - 1,
connections: index < backendStages.length - 1 ? [backendStages[index + 1].stageId] : [],
metadata: stage.metadata,
};
});
this.stages.set(visualStages);
this.rebuildConnections();
@@ -513,24 +536,34 @@ export class WorkflowBuilderComponent implements OnInit {
const departmentId = currentUser?.departmentId ||
this.stages().find(s => s.departmentId)?.departmentId || '';
const dto = {
// Transform visual stages to backend DTO format
const dto: any = {
name: workflowData.name,
description: workflowData.description || undefined,
workflowType: workflowData.workflowType,
departmentId: departmentId,
stages: this.stages().map((s, index) => ({
id: s.id,
name: s.name,
description: s.description,
departmentId: s.departmentId,
order: index + 1,
isRequired: s.isRequired,
metadata: {
...s.metadata,
position: s.position,
connections: s.connections,
},
})),
stages: this.stages().map((s, index) => {
const department = this.departments().find(d => d.id === s.departmentId);
return {
stageId: s.id,
stageName: s.name,
stageOrder: index, // Backend expects 0-indexed
executionType: s.metadata?.['executionType'] || 'SEQUENTIAL',
requiredApprovals: [{
departmentCode: department?.code || '',
departmentName: department?.name || 'Unknown',
canDelegate: false,
}],
completionCriteria: s.metadata?.['completionCriteria'] || 'ALL',
rejectionHandling: 'FAIL_REQUEST',
metadata: {
description: s.description,
position: s.position,
connections: s.connections,
timeoutHours: s.metadata?.['timeoutHours'],
isRequired: s.isRequired,
},
};
}),
metadata: {
visualLayout: {
stages: this.stages().map(s => ({

View File

@@ -304,7 +304,7 @@ export class WorkflowFormComponent implements OnInit {
private loadDepartments(): void {
this.departmentService.getDepartments(1, 100).subscribe({
next: (response) => {
this.departments.set(response.data);
this.departments.set(response?.data ?? []);
},
});
}
@@ -322,14 +322,19 @@ export class WorkflowFormComponent implements OnInit {
});
this.stagesArray.clear();
workflow.stages.forEach((stage) => {
const stages = workflow.definition?.stages || [];
stages.forEach((stage) => {
// Find department by code to get departmentId
const dept = this.departments().find(d =>
d.code === stage.requiredApprovals?.[0]?.departmentCode
);
this.stagesArray.push(
this.fb.group({
id: [stage.id],
name: [stage.name, Validators.required],
departmentId: [stage.departmentId, Validators.required],
order: [stage.order],
isRequired: [stage.isRequired],
id: [stage.stageId],
name: [stage.stageName, Validators.required],
departmentId: [dept?.id || '', Validators.required],
order: [stage.stageOrder],
isRequired: [stage.metadata?.['isRequired'] ?? true],
})
);
});
@@ -414,18 +419,30 @@ export class WorkflowFormComponent implements OnInit {
const departmentId = currentUser?.departmentId ||
(values.stages[0]?.departmentId) || '';
const dto = {
// Transform to backend DTO format
const dto: any = {
name: normalizeWhitespace(values.name),
description: normalizeWhitespace(values.description) || undefined,
workflowType: values.workflowType!,
departmentId: departmentId,
stages: values.stages.map((s, i) => ({
id: s.id || `stage-${i + 1}`,
name: normalizeWhitespace(s.name) || `Stage ${i + 1}`,
departmentId: s.departmentId || '',
isRequired: s.isRequired ?? true,
order: i + 1,
})),
stages: values.stages.map((s, i) => {
const department = this.departments().find(d => d.id === s.departmentId);
return {
stageId: s.id || `stage-${i + 1}`,
stageName: normalizeWhitespace(s.name) || `Stage ${i + 1}`,
stageOrder: i, // Backend expects 0-indexed
executionType: 'SEQUENTIAL' as const,
requiredApprovals: [{
departmentCode: department?.code || '',
departmentName: department?.name || 'Unknown',
canDelegate: false,
}],
completionCriteria: 'ALL' as const,
rejectionHandling: 'FAIL_REQUEST' as const,
metadata: {
isRequired: s.isRequired ?? true,
},
};
}),
};
const action$ = this.isEditMode()

View File

@@ -81,7 +81,7 @@ import { WorkflowResponseDto } from '../../../api/models';
<ng-container matColumnDef="stages">
<th mat-header-cell *matHeaderCellDef>Stages</th>
<td mat-cell *matCellDef="let row">
<mat-chip>{{ row.stages?.length || 0 }} stages</mat-chip>
<mat-chip>{{ row.definition?.stages?.length || 0 }} stages</mat-chip>
</td>
</ng-container>

View File

@@ -61,7 +61,7 @@ import { WorkflowResponseDto } from '../../../api/models';
</div>
<div class="info-item">
<span class="label">Total Stages</span>
<span class="value">{{ wf.stages.length || 0 }}</span>
<span class="value">{{ wf.definition?.stages?.length || 0 }}</span>
</div>
<div class="info-item">
<span class="label">Created</span>
@@ -75,14 +75,14 @@ import { WorkflowResponseDto } from '../../../api/models';
<div class="stages-section">
<h3>Approval Stages</h3>
<div class="stages-flow">
@for (stage of wf.stages; track stage.id; let i = $index; let last = $last) {
@for (stage of (wf.definition?.stages || []); track stage.stageId; let i = $index; let last = $last) {
<div class="stage-item">
<div class="stage-number">{{ i + 1 }}</div>
<mat-card class="stage-card">
<div class="stage-content">
<div class="stage-name">{{ stage.name }}</div>
<div class="stage-dept">{{ stage.departmentId }}</div>
@if (stage.isRequired) {
<div class="stage-name">{{ stage.stageName }}</div>
<div class="stage-dept">{{ stage.requiredApprovals?.[0]?.departmentCode || 'N/A' }}</div>
@if (stage.metadata?.['isRequired'] !== false) {
<mat-chip>Required</mat-chip>
}
</div>