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:
@@ -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));
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user