diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b3bea0..12072f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,207 +2,12 @@ All notable changes to this service will be documented in this file. -## [2.0.0] -### Notes -- FlexForms (Forms Engine SaaS) - ---------------------------------------------- -## [1.0.0] -### Notes -- First formally versioned public beta release. - -## [1.0.1] -### Notes -- Added Client Side AppInsights SDK. - -## [1.1.0] -### Notes -- Added Support for Multi-Tenancy. This service is now a Tenant of EAT API. - -## [1.2.0] -### Notes -- As part of the Multi-Tenancy, we have now converted EAT Web to a single repository deployed to multiple services -- Each service will have it's own set of appsettings.json files, and it is decided at the time of the deployment which one is deployed to the container. - -## [1.2.1] -### Notes -- Updated LSRP Test env appsettings with an update DSI auth details and Front-Door URL. - -## [1.2.2] -### Notes -- Improved Event-Mapping to support multi handlers when an application is submitted. - -## [1.2.3] -### Notes -- Added postcode to the Academies Auto-Complete confirmation page. -- Fixed a bug in Auto-Complete where duplicate items couldn't be selected. - -## [1.2.4] -### Notes -- Enabled Test Auth in LSRP Test Environment. - -## [1.2.5] -### Notes -- Updated Collection Flows to save user changes on each click of Save and Continue - -## [1.2.6] -### Notes -- Fixed Collection Flow validation and conditional logic issues - -## [1.2.8] -### Notes -- Added a flag to the template's Task node to support moving of the Task Summary to the end of the Task journey. - -## [1.2.9] -### Notes -- Added support for checkboxes field - -## [1.2.10] -### Notes -- Added support custom application names per tenant. - -## [1.2.11] -### Notes -- Added max-words property to character count field. - -## [1.2.12] -### Notes -- Added root level template property "hideFieldLabelWhenOnlyOneField" to toggle labels hiding for single field pages. - -## [1.2.13] -### Notes -- Replaced hardcoded values with Layout:ServiceName - -## [1.3.0] -### Notes -- Upgraded to .NET10 - -## [1.3.1] -### Notes -- Fixed issue with backlink not always returning to task summary - -## [1.3.2] -### Notes -- Fixed wrong message showing when a new item has been added to a collection - -## [1.3.3] -### Notes -- Reduced complexity of collection item added and updated messages - -## [1.3.4] -### Notes -- Updated appsettings to include LSRP Prod environment details - -## [1.3.5] -### Notes -- Fixed issue with CSS on flow descriptions - -## [1.3.6] -### Notes -- Fixed issue with derived collection not being saved when edited -## [1.3.7] -### Notes -- Increased request size for Json template saving logic. - -## [1.3.8] -### Notes -- Added support for EntraSSO authentication scheme. - -## [1.3.9] -### Notes -- Fixed character count not counting characters properly on validation. - -## [1.3.10] -### Notes -- Set Entra SSO enabled to false - -## [1.3.11] -### Notes -- Added site-wide notification banner and feature flag - -## [1.3.12] -### Notes -- Make email address configurable on invite a contributor page - -## [1.3.13] -### Notes -- Fix Vision page executive summary word count bug - -## [1.3.14] -### Notes -- Remove the word "form" from the plan version label - -## [1.3.15] -### Notes -- Make the lead applicant label configurable in form header - -## [1.3.16] -### Notes -- Added SignedOutCallBackUri to the DSI config - -## [1.3.17] -### Notes -- Use a feature flag to disable submitting an application - -## [1.3.18] -### Notes -- Added pagination to the dashboard - -## [1.3.19] -### Notes -- Added Test env appsettings -- Updated pagination default page size to show 50 applications per page - -## [1.3.20] -### Notes -- Added Test env appsettings - -## [1.3.21] -### Notes -- Improved Logout functionality -- Improved caching and API error handling - -## [1.3.22] -### Notes -- Allow contributor pattern to be disabled from the Template - -## [1.3.23] -### Notes -- Added Prod env appsettings - -## [1.3.24] -### Notes -- Improved caching - -## [1.3.25] -### Notes -- Added LA and Diocese ComplexFields and details to the Auto-Complete search results and confirmation page. - -## [1.3.26] -### Notes -- Added Academy filtering feature flag on the endpoint - -## [1.3.27] -### Notes -- Added application search functionality - -## [1.3.28] -### Notes -- Updated RGVisits service name - -## [1.3.29] -### Notes -- Created read-only dashboard for listing all applications -## [1.3.30] +## [2.1.0] ### Notes -- UCD changes for disabling the submit buuton +- Refactored to follow Clean Architecture principles -## [1.4.0] -### Notes -- Added feature for overriding the application statuses - -## [1.4.1] +## [2.0.0] ### Notes -- Accessibility issue with override custom application status form fixed \ No newline at end of file +- FlexForms (Forms Engine SaaS) \ No newline at end of file diff --git a/GovUK.Dfe.FlexForms.Web.sln b/GovUK.Dfe.FlexForms.Web.sln index 086a5f3..89054c9 100644 --- a/GovUK.Dfe.FlexForms.Web.sln +++ b/GovUK.Dfe.FlexForms.Web.sln @@ -16,6 +16,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GovUK.Dfe.FlexForms.Web.Uni EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GovUK.Dfe.FlexForms.Infrastructure.UnitTests", "src\Tests\GovUK.Dfe.FlexForms.Infrastructure.UnitTests\GovUK.Dfe.FlexForms.Infrastructure.UnitTests.csproj", "{FB4D1E39-01AB-47D4-8394-270993A56B0D}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GovUK.Dfe.FlexForms.Application.Tests", "src\Tests\GovUK.Dfe.FlexForms.Application.Tests\GovUK.Dfe.FlexForms.Application.Tests.csproj", "{B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GovUK.Dfe.FlexForms.Domain.Tests", "src\Tests\GovUK.Dfe.FlexForms.Domain.Tests\GovUK.Dfe.FlexForms.Domain.Tests.csproj", "{E6C12A90-4B7D-4F18-9A33-2C8D1E5F0471}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -46,6 +50,14 @@ Global {FB4D1E39-01AB-47D4-8394-270993A56B0D}.Debug|Any CPU.Build.0 = Debug|Any CPU {FB4D1E39-01AB-47D4-8394-270993A56B0D}.Release|Any CPU.ActiveCfg = Release|Any CPU {FB4D1E39-01AB-47D4-8394-270993A56B0D}.Release|Any CPU.Build.0 = Release|Any CPU + {B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24}.Release|Any CPU.Build.0 = Release|Any CPU + {E6C12A90-4B7D-4F18-9A33-2C8D1E5F0471}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6C12A90-4B7D-4F18-9A33-2C8D1E5F0471}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6C12A90-4B7D-4F18-9A33-2C8D1E5F0471}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6C12A90-4B7D-4F18-9A33-2C8D1E5F0471}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -53,6 +65,8 @@ Global GlobalSection(NestedProjects) = preSolution {A5568E05-5568-49E3-BF8A-08EA7AB74960} = {F62DE500-90E9-431D-B84C-8DF4CB166F54} {FB4D1E39-01AB-47D4-8394-270993A56B0D} = {F62DE500-90E9-431D-B84C-8DF4CB166F54} + {B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24} = {F62DE500-90E9-431D-B84C-8DF4CB166F54} + {E6C12A90-4B7D-4F18-9A33-2C8D1E5F0471} = {F62DE500-90E9-431D-B84C-8DF4CB166F54} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {F20FD7F1-3208-45E6-B45D-AF2EBDF28903} diff --git a/README.md b/README.md index 370542c..90e35ff 100644 --- a/README.md +++ b/README.md @@ -24,12 +24,43 @@ Template authoring guide: [`docs/Form-Template-Designer-Manual.md`](docs/Form-Te ## Architecture overview -| Layer | Project | Purpose | -|-------|---------|---------| -| Web | `GovUK.Dfe.FlexForms.Web` | Razor Pages, middleware, auth overlays, admin | -| Application | `GovUK.Dfe.FlexForms.Application` | Interfaces; references `GovUK.Dfe.FlexForms.Api.Client` | -| Domain | `GovUK.Dfe.FlexForms.Domain` | `FormTemplate` models, conditional logic, complex fields | -| Infrastructure | `GovUK.Dfe.FlexForms.Infrastructure` | API template store, form services, MassTransit consumers | +This repository follows **Clean Architecture**. Dependencies point inward: Domain has no external dependencies, Application depends only on Domain, and Infrastructure implements Application ports. The Web layer is a thin composition root and UI host. + +| Layer | Project | Depends on | Purpose | +|-------|---------|------------|---------| +| **Domain** | `GovUK.Dfe.FlexForms.Domain` | — | `FormTemplate` models, `FormRouteParser`, `FormStepPolicy`, `CheckboxValueNormalizer` | +| **Application** | `GovUK.Dfe.FlexForms.Application` | Domain | Use-case services, port interfaces, work-state bags, outcome types, `AdminApiErrorMapper` | +| **Infrastructure** | `GovUK.Dfe.FlexForms.Infrastructure` | Application | Adapter implementations (API clients, session stores, Redis, MassTransit consumers) | +| **Web** | `GovUK.Dfe.FlexForms.Web` | Application, Infrastructure | Razor Pages (thin PageModels), middleware, auth, DI composition root | + +### Dependency rule + +```mermaid +flowchart LR + Domain["Domain"] + Application["Application"] + Infrastructure["Infrastructure"] + Web["Web
(composition root)"] + + Web --> Application + Web --> Infrastructure + Infrastructure --> Application + Application --> Domain + + style Domain fill:#e8f5e9,stroke:#2e7d32 + style Application fill:#e3f2fd,stroke:#1565c0 + style Infrastructure fill:#fff3e0,stroke:#e65100 + style Web fill:#fce4ec,stroke:#c62828 +``` + +These boundaries are enforced at build time by **NetArchTest guard tests** (`Architecture/CleanArchitectureGuardTests.cs`): + +- PageModels must not reference `GovUK.Dfe.FlexForms.Infrastructure` +- Application must not reference Infrastructure or Web +- Application must not take `ISession`, `ModelStateDictionary`, or `HttpContext` +- Domain must not reference any outer layer + +### System context ```mermaid flowchart TB @@ -219,6 +250,254 @@ See `Security/AdminAccessHelper.cs`. --- +## Clean Architecture pattern (use cases) + +Every page in the application follows the same pattern. Business logic lives in the **Application** layer as a use-case service. The **PageModel** is a thin dispatcher that binds HTTP, calls the use case, and maps the result to `Page()` / `Redirect()` / `File()`. + +### Pattern overview (how a request flows through the layers) + +```mermaid +sequenceDiagram + participant Browser + participant PM as PageModel
(Web) + participant UC as Use-Case Service
(Application) + participant API as API Client
(Infrastructure) + + Browser->>PM: HTTP GET / POST + PM->>PM: CaptureWorkState() + PM->>UC: service.ExecuteAsync(workState, ...) + UC->>API: API client call + API-->>UC: DTO response + UC->>UC: Validate, map, set workState fields + UC-->>PM: AdminPageOutcome / FormEngineOutcome + PM->>PM: ApplyWorkState(state) + PM->>PM: MapOutcome → Page() / Redirect() / File() + PM-->>Browser: HTML / redirect +``` + +### What lives where + +```mermaid +flowchart TB + subgraph Web ["Web Layer (Razor Pages)"] + PM["PageModel"] + TD["TempData / Session"] + Auth["Authorization attributes"] + Cache["Local cache invalidation"] + Bind["BindProperty / ModelState"] + end + + subgraph App ["Application Layer"] + IF["Interface
(e.g. ITenantSettingsAdmin)"] + SVC["Service
(e.g. TenantSettingsAdminService)"] + WS["WorkState bag
(e.g. TenantSettingsWorkState)"] + OC["Outcome
(AdminPageOutcome /
FormEngineOutcome)"] + MSG["Messages class
(user-facing copy)"] + ERR["AdminApiErrorMapper"] + end + + subgraph Infra ["Infrastructure Layer"] + IMPL["Adapter implementations
(API stores, Redis, session)"] + end + + subgraph Dom ["Domain Layer"] + MOD["FormTemplate, Task, Page, Field"] + POL["FormRouteParser, FormStepPolicy"] + NORM["CheckboxValueNormalizer"] + end + + PM --> IF + IF -.->|implemented by| SVC + SVC --> WS + SVC --> OC + SVC --> MSG + SVC --> ERR + SVC -.->|calls| IMPL + IMPL -.->|implements ports in| App + SVC --> MOD + SVC --> POL + + style Web fill:#fce4ec,stroke:#c62828 + style App fill:#e3f2fd,stroke:#1565c0 + style Infra fill:#fff3e0,stroke:#e65100 + style Dom fill:#e8f5e9,stroke:#2e7d32 +``` + +### The four artefacts per feature + +Every feature (Admin page, form engine handler, dashboard) produces up to four files in `Application/`: + +| Artefact | Example | Purpose | +|----------|---------|---------| +| **Interface** | `ITenantSettingsAdmin` | Port the PageModel depends on | +| **Service** | `TenantSettingsAdminService` | Implements the interface; calls API clients, applies business rules | +| **WorkState** | `TenantSettingsWorkState` | Mutable bag of view-state. PageModel populates it before the call (`CaptureWorkState`), the service mutates it, PageModel reads it back (`ApplyWorkState`) | +| **Messages** | `TenantSettingsMessages` | `const string` user-facing copy (error/success text). Keeps strings identical to the original PageModel for backward compatibility | + +Shared helpers: + +| Helper | Location | Purpose | +|--------|----------|---------| +| `AdminPageOutcome` | `Application/Admin/` | HTTP-agnostic result: `Stay`, `Redirect`, or `File` with optional success/error messages and cache-refresh flag | +| `FormEngineOutcome` | `Application/FormEngine/` | Same idea for form engine: redirect URL, validation errors, file downloads, notification context | +| `AdminApiErrorMapper` | `Application/Admin/` | Maps `ExternalApplicationsException` to user-friendly messages; optional WAF/gateway hint | + +### What stays on the PageModel + +The PageModel remains responsible for HTTP concerns that cannot cross into Application: + +- `[Authorize]` policies and `[BindProperty]` attributes +- `TempData` read/write (PRG pattern) +- Tenant resolution (`ITenantRequestContext`) +- Local cache invalidation (`ITenantConfigurationCache`, `ITenantIdResolver`) +- `ModelState` manipulation and `Page()` / `RedirectToPage()` / `File()` return +- Session reads for presentation (e.g. `FormSessionKeys`) +- `HttpContext.User` claims extraction (passed as values into the use case) + +### PageModel lifecycle (step by step) + +```csharp +// 1. Capture current state into a work-state bag +var state = CaptureWorkState(); + +// 2. Call the Application use case +var outcome = await tenantSettingsAdmin.UpdateAsync( + state, category, target, settingsJson, isSecret, cancellationToken); + +// 3. Copy mutated state back to PageModel properties +ApplyWorkState(state); + +// 4. Map the outcome to an HTTP result +return MapOutcome(outcome); +``` + +### Concrete example: Tenant Settings + +```mermaid +flowchart LR + subgraph Web + TSM["TenantSettingsModel
(PageModel, 250 lines)"] + end + + subgraph Application + ITSA["ITenantSettingsAdmin"] + TSA["TenantSettingsAdminService"] + TSWS["TenantSettingsWorkState"] + APO["AdminPageOutcome"] + TSMsg["TenantSettingsMessages"] + end + + subgraph Infrastructure + TAC["ITenantAdminClient
(API client)"] + end + + TSM -->|depends on| ITSA + ITSA -.->|implemented by| TSA + TSA -->|mutates| TSWS + TSA -->|returns| APO + TSA -->|uses copy from| TSMsg + TSA -->|calls| TAC + + style Web fill:#fce4ec,stroke:#c62828 + style Application fill:#e3f2fd,stroke:#1565c0 + style Infrastructure fill:#fff3e0,stroke:#e65100 +``` + +### Project folder structure + +``` +src/ +├── GovUK.Dfe.FlexForms.Domain/ +│ ├── Models/ # FormTemplate, Task, Page, Field, ... +│ └── FormEngine/ # FormRouteParser, FormStepPolicy, CheckboxValueNormalizer +│ +├── GovUK.Dfe.FlexForms.Application/ +│ ├── Interfaces/ # Ports: IFormSessionStore, IApplicationResponseService, ... +│ ├── Admin/ # Admin use cases (one interface + service + workstate + messages per page) +│ │ ├── ITenantSettingsAdmin + TenantSettingsAdminService +│ │ ├── IUserManagerAdmin + UserManagerAdminService +│ │ ├── IRoleManagerAdmin + RoleManagerAdminService +│ │ ├── IDuplicateTenantAdmin + DuplicateTenantAdminService +│ │ ├── IOrganisationSettingsAdmin + OrganisationSettingsAdminService +│ │ ├── IAdminHome + AdminHomeService +│ │ ├── ... (EventMappings, TemplateManager, CustomStatusLabels, ContributorManagement) +│ │ ├── AdminPageOutcome # shared outcome type +│ │ ├── AdminApiErrorMapper # shared error formatting +│ │ └── AdminSettingsEncoding # Base64 helper +│ ├── Dashboard/ # IDashboardApplications, DashboardColumnResolver, DashboardAnswerReader +│ ├── FormEngine/ # Form engine use cases +│ │ ├── IPrepareFormEngineGet + PrepareFormEngineGetService +│ │ ├── ISaveFormPage + SaveFormPageService +│ │ ├── ICompleteFormTask + CompleteFormTaskService +│ │ ├── ISubmitFormApplication + SubmitFormApplicationService +│ │ ├── IUploadFormFile / IDeleteFormFile / IDownloadFormFile +│ │ ├── IRemoveCollectionItem + RemoveCollectionItemService +│ │ ├── FormEngineOutcome / FormEngineWorkState +│ │ └── FormFileFieldService, InfectedUploadFilter, ... +│ └── Validation/ # FormValidationResult, FormValidationError +│ +├── GovUK.Dfe.FlexForms.Infrastructure/ +│ ├── DependencyInjection.cs # AddInfrastructureDependencyGroup() — all adapter registrations +│ ├── Services/ # ApplicationResponseService, FormStateManager, ConditionalLogicEngine, ... +│ ├── Stores/ # HttpFormSessionStore, RedisInfectedFileStore, ApiTemplateStore +│ ├── Parsers/ # JsonFormTemplateParser +│ ├── Providers/ # FormTemplateProvider, SchemaEventDefinitionProvider +│ ├── Consumers/ # ScanResultConsumer (MassTransit) +│ └── Messaging/ # MessagingEventBusConfigurator +│ +├── GovUK.Dfe.FlexForms.Web/ +│ ├── Pages/ +│ │ ├── FormEngine/ # RenderForm (partial class, ~340+300 lines), BaseFormEngineModel (~80 lines) +│ │ ├── Admin/ # Thin PageModels: TenantSettings (250), UserManager (83), RoleManager (124), ... +│ │ ├── Applications/ # Dashboard (280), Index, Contributors, ... +│ │ └── Shared/ # BaseFormPageModel +│ ├── Extensions/ +│ │ └── ServiceCollectionExtensions.cs # AddWebLayerServices() → calls AddInfrastructureDependencyGroup() +│ ├── Program.cs # Composition root (auth, middleware, MassTransit) +│ └── ... +│ +└── Tests/ + ├── GovUK.Dfe.FlexForms.Domain.Tests/ # 43 tests + ├── GovUK.Dfe.FlexForms.Application.Tests/ # 100 tests (Admin + FormEngine use cases) + ├── GovUK.Dfe.FlexForms.Infrastructure.UnitTests/ # 64 tests + └── GovUK.Dfe.FlexForms.Web.UnitTests/ # 228 tests (incl. architecture guard tests) +``` + +### DI wiring + +All Infrastructure adapters are registered in one place: + +``` +Infrastructure/DependencyInjection.cs → AddInfrastructureDependencyGroup() +``` + +The Web composition root calls it via: + +``` +Web/Extensions/ServiceCollectionExtensions.cs → AddWebLayerServices() + ↳ services.AddInfrastructureDependencyGroup() // Infrastructure adapters + ↳ services.AddScoped // Application use cases + ↳ services.AddScoped // Web-only services +``` + +`Program.cs` calls `AddWebLayerServices()` once. It no longer duplicates Infrastructure registrations. + +### Adding a new Admin page (recipe) + +1. Create in `Application/Admin/`: + - `IMyFeatureAdmin` (interface with XML docs) + - `MyFeatureAdminService` (sealed, primary constructor) + - `MyFeatureWorkState` (mutable bag) + - `MyFeatureMessages` (const strings) +2. Register in `ServiceCollectionExtensions.AddWebLayerServices()` +3. Thin the PageModel: + - Constructor takes `IMyFeatureAdmin` (not API clients) + - `CaptureWorkState()` → use case → `ApplyWorkState()` → `MapOutcome()` + - Keep authorization, TempData, cache invalidation on the PageModel +4. Add tests in `Application.Tests/Admin/` (validation failures + happy path) + +--- + ## Form engine ### Domain model @@ -263,17 +542,19 @@ Full authoring reference: [`docs/Form-Template-Designer-Manual.md`](docs/Form-Te ### Runtime flow -| Concern | Implementation | -|---------|----------------| -| Entry route | `/applications/{referenceNumber}/{taskId?}/{*pageId}` → `RenderForm` | -| Template load | `ITemplatesClient` → `ApiTemplateStore` → `JsonFormTemplateParser` | -| Navigation | `FormStateManager`, `FormNavigationService` | -| Save | Session accumulate → Base64 JSON → `AddApplicationResponseAsync` | -| Conditional logic | `ConditionalLogicEngine` / orchestrator | -| Collections | Multi + derived flow handlers on `RenderForm` | -| Complex fields | Tenant `FormEngine:ComplexFields` (Trust/Academy search, uploads) | -| File validation | Status column on upload fields; `GetFileValidationGateAsync` blocks preview submit when the API gate says so | -| Submit | `SubmitApplicationAsync` + `ApplicationSubmissionOrchestrator` (e.g. publish event) | +| Concern | Use case (Application) | Infrastructure adapter | +|---------|------------------------|----------------------| +| Entry / page load | `IPrepareFormEngineGet` | `IFormStateManager`, `IFormNavigationService`, `IFormTemplateProvider` | +| Save answers | `ISaveFormPage` | `IApplicationResponseService`, `IFormValidationOrchestrator` | +| Complete task | `ICompleteFormTask` | `IApplicationResponseService` | +| Submit application | `ISubmitFormApplication` | `IApplicationsClient` | +| Upload file | `IUploadFormFile` | `IFileUploadService` | +| Delete file | `IDeleteFormFile` | `IFileUploadService`, `IInfectedFileStore` | +| Download file | `IDownloadFormFile` | `IApplicationsClient` | +| Remove collection item | `IRemoveCollectionItem` | `IFormSessionStore` | +| Conditional logic | `FormEngineConditionalLogic` | `IConditionalLogicEngine` / `IConditionalLogicOrchestrator` | +| Complex fields | — | `IComplexFieldConfigurationService`, `IComplexFieldRendererFactory` | +| File validation gate | — | `GetFileValidationGateAsync` blocks preview submit when the API gate says so | ### Template selection @@ -287,18 +568,27 @@ Full authoring reference: [`docs/Form-Template-Designer-Manual.md`](docs/Form-Te ## Admin area -Hub: `/admin` (`CanAccessAdminArea`). - -| Tool | Route | Who | -|------|-------|-----| -| Template Manager | `/admin/template-manager` | Admin / SuperAdmin / Template Manage | -| Create Template | `/admin/create-template` | Same | -| Custom status labels | `/admin/custom-status-label-overrides` | Same | -| User Manager | `/admin/user-manager` | Admin / SuperAdmin / User Manage | -| Role Manager | `/admin/role-manager` | Admin / SuperAdmin | -| Tenant Settings | `/admin/tenant-settings` | **SuperAdmin only** | - -Tenant Settings uses `ITenantAdminClient` (Base64 settings payloads), then refreshes API tenant cache and local `ITenantConfigurationCache`. +Hub: `/admin` (`CanAccessAdminArea`). Each admin page follows the [Clean Architecture use-case pattern](#clean-architecture-pattern-use-cases) described above. + +| Tool | Route | Who | Application use case | +|------|-------|-----|---------------------| +| Admin Home | `/admin` | Admin / SuperAdmin | `IAdminHome` | +| Template Manager | `/admin/template-manager` | Admin / SuperAdmin / Template Manage | `ITemplateManagerAdmin` | +| Create Template | `/admin/create-template` | Same | `ITemplateManagerAdmin` | +| Custom status labels | `/admin/custom-status-label-overrides` | Same | `ICustomStatusLabelOverridesAdmin` | +| User Manager | `/admin/user-manager` | Admin / SuperAdmin / User Manage | `IUserManagerAdmin` | +| Add User | `/admin/user-manager-add` | Same | `IUserManagerAddAdmin` | +| Edit User | `/admin/user-manager-edit` | Same | `IUserManagerEditAdmin` | +| User Permissions | `/admin/user-manager-permissions` | Same | `IUserManagerPermissionsAdmin` | +| Role Manager | `/admin/role-manager` | Admin / SuperAdmin | `IRoleManagerAdmin` | +| Role Permissions | `/admin/role-manager-permissions` | Same | `IRoleManagerPermissionsAdmin` | +| Organisation Settings | `/admin/organisation-settings` | Admin / SuperAdmin | `IOrganisationSettingsAdmin` | +| Contributor Management | `/admin/contributor-management` | Admin / SuperAdmin | `IContributorManagementAdmin` | +| Duplicate Tenant | `/admin/duplicate-tenant` | **SuperAdmin only** | `IDuplicateTenantAdmin` | +| Tenant Settings | `/admin/tenant-settings` | **SuperAdmin only** | `ITenantSettingsAdmin` | +| Event Mappings | `/admin/event-mappings` | **SuperAdmin only** | `IEventMappingsAdmin` | + +All admin use cases return `AdminPageOutcome` and use `AdminApiErrorMapper` for consistent error presentation. --- diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/AdminApiErrorMapper.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminApiErrorMapper.cs new file mode 100644 index 0000000..3d26b81 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminApiErrorMapper.cs @@ -0,0 +1,43 @@ +using GovUK.Dfe.CoreLibs.Http.Models; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Maps API client exceptions to the Admin UI copy previously inlined on PageModels. +/// +public static class AdminApiErrorMapper +{ + public const string GatewayBlockedMessage = + "Save was blocked with HTTP 403 (HTML response). " + + "This usually means an Azure gateway/WAF rejected the request before the API. " + + "Check Front Door / App Gateway logs for /v1/admin/tenants/.../settings."; + + /// + /// Returns the API message when present, otherwise a status-aware fallback. + /// + /// + /// When true, HTML 403 responses use the gateway/WAF copy (Tenant Settings). + /// Event Mappings omits that hint. + /// + public static string Format(Exception ex, string fallback, bool includeGatewayHint = false) + { + if (ex is ExternalApplicationsException apiEx + && !string.IsNullOrWhiteSpace(apiEx.Result?.Message)) + { + return apiEx.Result.Message; + } + + if (ex is ExternalApplicationsException clientEx) + { + var body = clientEx.Response?.TrimStart() ?? string.Empty; + if (includeGatewayHint && clientEx.StatusCode == 403 && body.StartsWith('<')) + return GatewayBlockedMessage; + + if (clientEx.StatusCode > 0) + return $"{fallback} (HTTP {clientEx.StatusCode})"; + } + + return fallback; + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/AdminHomeMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminHomeMessages.cs new file mode 100644 index 0000000..29d02f6 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminHomeMessages.cs @@ -0,0 +1,19 @@ +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// User-facing copy for the Admin home page. Keep these strings identical to the previous PageModel. +/// +public static class AdminHomeMessages +{ + public const string TemplateLive = "Template is now live for end users."; + + public const string TemplateNotLive = "Template is no longer live for end users."; + + public const string SetLiveFailed = "Failed to update template live status. Please try again."; + + public const string TemplateNotInCatalogue = "Template was not found in the tenant catalogue."; + + public const string OpenFailed = "Failed to open template. Please try again."; + + public const string LoadTemplateFailed = "Failed to load template information. Please try again."; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/AdminHomeService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminHomeService.cs new file mode 100644 index 0000000..37da2a5 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminHomeService.cs @@ -0,0 +1,169 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using Microsoft.Extensions.Logging; +using Task = System.Threading.Tasks.Task; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Loads tenant templates, template details, and live-status updates for the Admin home page. +/// +public interface IAdminHome +{ + Task LoadAsync(AdminHomeWorkState state, CancellationToken cancellationToken = default); + + Task SetTemplateLiveAsync( + Guid templateId, + bool isLive, + CancellationToken cancellationToken = default); + + Task OpenTemplateAsync( + AdminHomeWorkState state, + Guid templateId, + CancellationToken cancellationToken = default); +} + +public sealed class AdminHomeService( + IFormTemplateProvider templateProvider, + ITemplatesClient templatesClient, + ITenantAdminClient tenantAdminClient, + ILogger logger) : IAdminHome +{ + public async Task LoadAsync(AdminHomeWorkState state, CancellationToken cancellationToken = default) + { + await LoadTenantTemplatesAsync(state, cancellationToken); + await LoadTemplateInformationAsync(state, cancellationToken); + await LoadTenantConfigurationSummaryAsync(state, cancellationToken); + } + + public async Task SetTemplateLiveAsync( + Guid templateId, + bool isLive, + CancellationToken cancellationToken = default) + { + try + { + logger.LogInformation( + "Setting template {TemplateId} live status to {IsLive}", + templateId, + isLive); + + await templatesClient.SetTemplateLiveAsync( + templateId, + new SetTemplateLiveRequest { IsLive = isLive }, + cancellationToken); + + return AdminPageOutcome.Redirect( + successMessage: isLive ? AdminHomeMessages.TemplateLive : AdminHomeMessages.TemplateNotLive); + } + catch (Exception ex) + { + logger.LogError( + ex, + "Failed to set live status to {IsLive} for template {TemplateId}", + isLive, + templateId); + return AdminPageOutcome.Redirect(errorMessage: AdminHomeMessages.SetLiveFailed); + } + } + + public async Task OpenTemplateAsync( + AdminHomeWorkState state, + Guid templateId, + CancellationToken cancellationToken = default) + { + try + { + await LoadTenantTemplatesAsync(state, cancellationToken); + if (state.TenantTemplates.All(t => t.TemplateId != templateId)) + { + state.HasError = true; + state.ErrorMessage = AdminHomeMessages.TemplateNotInCatalogue; + await LoadTemplateInformationAsync(state, cancellationToken); + return AdminPageOutcome.Stay(errorMessage: AdminHomeMessages.TemplateNotInCatalogue); + } + + state.TemplateToOpen = state.TenantTemplates.First(t => t.TemplateId == templateId); + return AdminPageOutcome.Redirect(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to open template {TemplateId}", templateId); + state.HasError = true; + state.ErrorMessage = AdminHomeMessages.OpenFailed; + await LoadTenantTemplatesAsync(state, cancellationToken); + await LoadTemplateInformationAsync(state, cancellationToken); + return AdminPageOutcome.Stay(errorMessage: AdminHomeMessages.OpenFailed); + } + } + + private async Task LoadTenantTemplatesAsync(AdminHomeWorkState state, CancellationToken cancellationToken) + { + try + { + var templates = await templatesClient.GetAccessibleTemplatesAsync(cancellationToken) ?? []; + state.TenantTemplates = templates + .OrderByDescending(t => t.IsLive) + .ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to load tenant templates for admin page"); + state.TenantTemplates = []; + } + } + + private async Task LoadTemplateInformationAsync(AdminHomeWorkState state, CancellationToken cancellationToken) + { + try + { + if (state.SkipTemplateDetails || string.IsNullOrEmpty(state.TemplateId)) + return; + + var template = await templateProvider.GetTemplateAsync(state.TemplateId, cancellationToken); + if (template != null) + { + state.TemplateName = template.TemplateName; + state.TemplateDescription = template.Description; + state.TaskGroupCount = template.TaskGroups?.Count ?? 0; + } + + var templateResponse = await templatesClient.GetLatestTemplateSchemaAsync( + new Guid(state.TemplateId), + cancellationToken); + state.CurrentTemplateVersion = templateResponse?.VersionNumber; + + logger.LogDebug("Loaded admin information for template {TemplateId}", state.TemplateId); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load template information for admin page"); + state.HasError = true; + state.ErrorMessage = AdminHomeMessages.LoadTemplateFailed; + } + } + + private async Task LoadTenantConfigurationSummaryAsync( + AdminHomeWorkState state, + CancellationToken cancellationToken) + { + if (!state.IncludeTenantConfigurationSummary) + return; + + if (state.TenantId is not { } tenantId || tenantId == Guid.Empty) + return; + + try + { + state.TenantConfigurationSummary = await tenantAdminClient.GetEffectiveConfigurationAsync( + tenantId, + cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to load tenant configuration summary for admin dashboard"); + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/AdminHomeWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminHomeWorkState.cs new file mode 100644 index 0000000..6db8ea8 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminHomeWorkState.cs @@ -0,0 +1,35 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Mutable view-state bag for the Admin home page. +/// +public sealed class AdminHomeWorkState +{ + public Guid? TenantId { get; set; } + + public bool IncludeTenantConfigurationSummary { get; set; } + + public bool SkipTemplateDetails { get; set; } + + public string? TemplateId { get; set; } + + public string? TemplateName { get; set; } + + public string? TemplateDescription { get; set; } + + public int TaskGroupCount { get; set; } + + public string? CurrentTemplateVersion { get; set; } + + public IReadOnlyList TenantTemplates { get; set; } = []; + + public TenantEffectiveConfigurationDto? TenantConfigurationSummary { get; set; } + + public TemplateDto? TemplateToOpen { get; set; } + + public bool HasError { get; set; } + + public string? ErrorMessage { get; set; } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/AdminPageOutcome.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminPageOutcome.cs new file mode 100644 index 0000000..e54568f --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminPageOutcome.cs @@ -0,0 +1,80 @@ +using GovUK.Dfe.FlexForms.Application.Validation; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +public enum AdminPageOutcomeKind +{ + StayOnPage, + RedirectToPage, + FileDownload +} + +/// +/// HTTP-agnostic result of an Admin use case. The PageModel maps this to Page() / RedirectToPage() / File(). +/// +public sealed class AdminPageOutcome +{ + public AdminPageOutcomeKind Kind { get; init; } + + public string? SuccessMessage { get; init; } + + public string? ErrorMessage { get; init; } + + public IReadOnlyList Errors { get; init; } = []; + + public bool RefreshLocalCaches { get; init; } + + public byte[]? FileBytes { get; init; } + + public string? FileContentType { get; init; } + + public string? FileDownloadName { get; init; } + + public IReadOnlyDictionary RouteValues { get; init; } = + new Dictionary(StringComparer.Ordinal); + + public IReadOnlyList ModelStateKeysToRemove { get; init; } = []; + + public bool ClearModelState { get; init; } + + public static AdminPageOutcome Stay( + string? errorMessage = null, + string? successMessage = null, + IReadOnlyList? errors = null, + IReadOnlyList? modelStateKeysToRemove = null, + bool clearModelState = false, + bool refreshLocalCaches = false) => + new() + { + Kind = AdminPageOutcomeKind.StayOnPage, + ErrorMessage = errorMessage, + SuccessMessage = successMessage, + Errors = errors ?? [], + ModelStateKeysToRemove = modelStateKeysToRemove ?? [], + ClearModelState = clearModelState, + RefreshLocalCaches = refreshLocalCaches + }; + + public static AdminPageOutcome Redirect( + string? successMessage = null, + string? errorMessage = null, + bool refreshLocalCaches = false, + IReadOnlyDictionary? routeValues = null) => + new() + { + Kind = AdminPageOutcomeKind.RedirectToPage, + SuccessMessage = successMessage, + ErrorMessage = errorMessage, + RefreshLocalCaches = refreshLocalCaches, + RouteValues = routeValues ?? new Dictionary(StringComparer.Ordinal) + }; + + public static AdminPageOutcome File(byte[] bytes, string contentType, string fileName) => + new() + { + Kind = AdminPageOutcomeKind.FileDownload, + FileBytes = bytes, + FileContentType = contentType, + FileDownloadName = fileName + }; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/AdminPermissionGrants.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminPermissionGrants.cs new file mode 100644 index 0000000..fcea416 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminPermissionGrants.cs @@ -0,0 +1,131 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Encoding, parsing, and validation for Admin permission grant keys +/// ({ResourceType}|{ResourceKey}|{AccessType}). +/// +public static class AdminPermissionGrants +{ + public const string AnyResourceKey = "Any"; + + public static string EncodeGrantKey(ResourceType resourceType, string resourceKey, AccessType accessType) => + $"{resourceType}|{resourceKey.Trim()}|{accessType}"; + + public static string FormatGrant(string key) + { + var parsed = ParseGrantKey(key); + return parsed is null + ? key + : $"{parsed.Value.ResourceType} / {parsed.Value.ResourceKey} / {parsed.Value.AccessType}"; + } + + public static List NormalizeGrants(IEnumerable? grants) => + (grants ?? []) + .Select(ParseGrantKey) + .Where(g => g is not null) + .Select(g => EncodeGrantKey(g!.Value.ResourceType, g.Value.ResourceKey, g.Value.AccessType)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(g => g, StringComparer.OrdinalIgnoreCase) + .ToList(); + + public static (ResourceType ResourceType, string ResourceKey, AccessType AccessType)? ParseGrantKey(string? key) + { + if (string.IsNullOrWhiteSpace(key)) + return null; + + var parts = key.Split('|', 3); + if (parts.Length != 3) + return null; + + if (!Enum.TryParse(parts[0], ignoreCase: true, out var resourceType)) + return null; + + if (string.IsNullOrWhiteSpace(parts[1])) + return null; + + if (!Enum.TryParse(parts[2], ignoreCase: true, out var accessType)) + return null; + + return (resourceType, parts[1].Trim(), accessType); + } + + /// + /// Mirrors API RolePermissionGrantRules. + /// + public static string? ValidateGrant(ResourceType resourceType, string resourceKey, AccessType accessType) + { + var key = resourceKey.Trim(); + if (accessType == AccessType.Manage) + { + if (resourceType != ResourceType.Template && resourceType != ResourceType.User) + { + return "Access type 'Manage' is only allowed for Template or User permissions."; + } + + if (string.Equals(key, AnyResourceKey, StringComparison.OrdinalIgnoreCase)) + return null; + } + else if (string.Equals(key, AnyResourceKey, StringComparison.OrdinalIgnoreCase)) + { + if ((resourceType == ResourceType.Template && accessType == AccessType.Write) + || (resourceType == ResourceType.Template && accessType == AccessType.Manage) + || (resourceType == ResourceType.User && accessType == AccessType.Manage) + || (resourceType == ResourceType.Application && accessType == AccessType.Read) + || (resourceType == ResourceType.ApplicationFiles && accessType == AccessType.Read) + || (resourceType == ResourceType.FileValidation && accessType == AccessType.Write)) + { + return null; + } + + return $"Resource key '{AnyResourceKey}' is only allowed for Template — Write, " + + "Template — Manage, User — Manage, Application — Read, " + + "ApplicationFiles — Read, or FileValidation — Write. " + + "For other combinations, use a specific resource id or email."; + } + + return resourceType switch + { + ResourceType.Application or ResourceType.ApplicationFiles or ResourceType.Template + or ResourceType.File or ResourceType.FileValidation or ResourceType.Task or ResourceType.TaskGroup + or ResourceType.Page or ResourceType.Field + when !Guid.TryParse(key, out var id) || id == Guid.Empty + => $"{resourceType} resource key must be a valid non-empty GUID (the resource id) or 'Any' (where allowed).", + + ResourceType.User or ResourceType.Notifications + when !key.Contains('@', StringComparison.Ordinal) && !Guid.TryParse(key, out _) + => $"{resourceType} resource key must be a user email (or a service client id).", + + _ => null + }; + } + + /// + /// Same shape rules as role grants, but Manage is never allowed on an individual user. + /// + public static string? ValidateUserGrant(ResourceType resourceType, string resourceKey, AccessType accessType) + { + if (accessType == AccessType.Manage) + { + return "Access type 'Manage' cannot be granted to an individual user. " + + "Assign Manage via a tenant role instead."; + } + + return ValidateGrant(resourceType, resourceKey, accessType); + } + + public static List ToGrantDtos(IEnumerable grants) => + grants + .Select(ParseGrantKey) + .Where(g => g is not null) + .Select(g => g.GetValueOrDefault()) + .Select(g => new RolePermissionGrantDto + { + ResourceType = g.ResourceType, + ResourceKey = g.ResourceKey, + AccessType = g.AccessType + }) + .ToList(); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/AdminSelectOption.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminSelectOption.cs new file mode 100644 index 0000000..2cef1c7 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminSelectOption.cs @@ -0,0 +1,6 @@ +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// HTTP-agnostic dropdown option. The PageModel maps this to SelectListItem. +/// +public sealed record AdminSelectOption(string Text, string Value, bool Selected); diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/AdminSettingsEncoding.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminSettingsEncoding.cs new file mode 100644 index 0000000..8566d65 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminSettingsEncoding.cs @@ -0,0 +1,12 @@ +using System.Text; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Encodes TenantConfig JSON as Base64 for the API (WAF-safe; mirrors template schema transport). +/// +public static class AdminSettingsEncoding +{ + public static string ToBase64(string settingsJson) => + Convert.ToBase64String(Encoding.UTF8.GetBytes(settingsJson)); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/ContributorManagementAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/ContributorManagementAdminService.cs new file mode 100644 index 0000000..b0fcbf1 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/ContributorManagementAdminService.cs @@ -0,0 +1,58 @@ +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Looks up application contributors by reference number. +/// +public interface IContributorManagementAdmin +{ + Task LookupAsync(ContributorManagementWorkState state, CancellationToken cancellationToken = default); +} + +public sealed class ContributorManagementAdminService( + IApplicationsClient applicationsClient, + ILogger logger) : IContributorManagementAdmin +{ + public async Task LookupAsync( + ContributorManagementWorkState state, + CancellationToken cancellationToken = default) + { + state.LookupPerformed = true; + state.ApplicationReference = state.ReferenceNumber; + + try + { + var application = await applicationsClient.GetApplicationByReferenceAsync( + state.ReferenceNumber, + cancellationToken); + + state.ApplicationId = application.ApplicationId; + state.ApplicationReference = string.IsNullOrWhiteSpace(application.ApplicationReference) + ? state.ReferenceNumber + : application.ApplicationReference; + state.TemplateName = application.TemplateName; + + var contributors = await applicationsClient.GetContributorsAsync( + application.ApplicationId, + includePermissionDetails: false, + cancellationToken); + + state.Contributors = contributors? + .OrderBy(c => c.Name) + .ThenBy(c => c.Email) + .ToList() ?? []; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to look up contributors for {ReferenceNumber}", state.ReferenceNumber); + state.HasError = true; + state.ErrorMessage = AdminApiErrorMapper.Format( + ex, + ContributorManagementMessages.LookupFailed, + includeGatewayHint: false); + state.Contributors = []; + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/ContributorManagementMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/ContributorManagementMessages.cs new file mode 100644 index 0000000..7f45088 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/ContributorManagementMessages.cs @@ -0,0 +1,9 @@ +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// User-facing copy for Contributor Management. Keep these strings identical to the previous PageModel. +/// +public static class ContributorManagementMessages +{ + public const string LookupFailed = "Could not find that application or load its contributors."; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/ContributorManagementWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/ContributorManagementWorkState.cs new file mode 100644 index 0000000..5684b18 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/ContributorManagementWorkState.cs @@ -0,0 +1,25 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Mutable view-state bag for Contributor Management. +/// +public sealed class ContributorManagementWorkState +{ + public string ReferenceNumber { get; set; } = string.Empty; + + public bool LookupPerformed { get; set; } + + public string? ApplicationReference { get; set; } + + public Guid? ApplicationId { get; set; } + + public string? TemplateName { get; set; } + + public IReadOnlyList Contributors { get; set; } = []; + + public bool HasError { get; set; } + + public string? ErrorMessage { get; set; } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/CustomStatusLabelOverridesAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/CustomStatusLabelOverridesAdminService.cs new file mode 100644 index 0000000..c889a8d --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/CustomStatusLabelOverridesAdminService.cs @@ -0,0 +1,139 @@ +using System.ComponentModel; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using Microsoft.Extensions.Logging; +using Task = System.Threading.Tasks.Task; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Loads templates and overrides application status labels. +/// +public interface ICustomStatusLabelOverridesAdmin +{ + Task LoadAvailableTemplatesAsync( + CustomStatusLabelOverridesWorkState state, + CancellationToken cancellationToken = default); + + Task LoadTemplateDataAsync( + CustomStatusLabelOverridesWorkState state, + Guid templateId, + CancellationToken cancellationToken = default); + + Task LoadStatusOverrideAsync( + CustomStatusLabelOverridesWorkState state, + Guid templateId, + ApplicationStatus status, + CancellationToken cancellationToken = default); + + Task OverrideAsync( + Guid templateId, + ApplicationStatus status, + string label, + CancellationToken cancellationToken = default); + + void PopulateBaseStatuses(CustomStatusLabelOverridesWorkState state); +} + +public sealed class CustomStatusLabelOverridesAdminService( + IFormTemplateProvider formTemplateProvider, + ITemplatesClient templatesClient, + ILogger logger) : ICustomStatusLabelOverridesAdmin +{ + public async Task LoadAvailableTemplatesAsync( + CustomStatusLabelOverridesWorkState state, + CancellationToken cancellationToken = default) + { + try + { + var templates = await templatesClient.GetAccessibleTemplatesAsync(cancellationToken) ?? []; + state.AvailableTemplates = templates + .OrderByDescending(t => t.IsLive) + .ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to load available templates for custom status page"); + state.AvailableTemplates = []; + } + } + + public async Task LoadTemplateDataAsync( + CustomStatusLabelOverridesWorkState state, + Guid templateId, + CancellationToken cancellationToken = default) + { + var apiResponse = await templatesClient.GetLatestTemplateSchemaAsync(templateId, cancellationToken); + state.CurrentVersionNumber = apiResponse.VersionNumber; + state.CurrentTemplate = await formTemplateProvider.GetTemplateAsync(templateId.ToString(), cancellationToken); + } + + public async Task LoadStatusOverrideAsync( + CustomStatusLabelOverridesWorkState state, + Guid templateId, + ApplicationStatus status, + CancellationToken cancellationToken = default) + { + state.BaseStatuses = GetBaseApplicationStatuses().OrderBy(x => x.Key).ToList(); + var statuses = await templatesClient.GetCustomApplicationStatusesAsync(templateId, cancellationToken); + state.BaseStatusOverrideValue = GetStatusLabel(status, statuses); + } + + public async Task OverrideAsync( + Guid templateId, + ApplicationStatus status, + string label, + CancellationToken cancellationToken = default) + { + await templatesClient.CreateCustomApplicationStatusAsync( + templateId, + new CustomApplicationStatusRequest + { + Label = label, + ApplicationStatus = status + }, + cancellationToken); + + logger.LogInformation("Successfully overridden application status for {TemplateId}", templateId); + } + + public void PopulateBaseStatuses(CustomStatusLabelOverridesWorkState state) + { + state.BaseStatuses = GetBaseApplicationStatuses().OrderBy(x => x.Key).ToList(); + } + + internal static List> GetBaseApplicationStatuses() + { + var baseStatuses = new List>(); + foreach (var status in Enum.GetValues()) + { + baseStatuses.Add(new KeyValuePair(status, GetBaseStatusLabel(status))); + } + + return baseStatuses; + } + + internal static string GetStatusLabel( + ApplicationStatus status, + IEnumerable? customStatuses) + { + if (customStatuses != null) + { + var customStatus = customStatuses.FirstOrDefault(x => x.ApplicationStatus == status); + if (customStatus?.Label != null) + return customStatus.Label; + } + + return GetBaseStatusLabel(status); + } + + private static string GetBaseStatusLabel(ApplicationStatus status) + { + var appStatusInfo = status.GetType().GetField(status.ToString()); + var descriptionAttributes = (DescriptionAttribute[])appStatusInfo!.GetCustomAttributes(typeof(DescriptionAttribute), false); + return descriptionAttributes.Length > 0 ? descriptionAttributes[0].Description : status.ToString(); + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/CustomStatusLabelOverridesMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/CustomStatusLabelOverridesMessages.cs new file mode 100644 index 0000000..b4c54d7 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/CustomStatusLabelOverridesMessages.cs @@ -0,0 +1,11 @@ +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// User-facing copy for Custom Status Label Overrides. Keep these strings identical to the previous PageModel. +/// +public static class CustomStatusLabelOverridesMessages +{ + public const string SelectTemplate = "Please select a template."; + + public const string OverrideRequired = "An override value is required and cannot be empty"; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/CustomStatusLabelOverridesWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/CustomStatusLabelOverridesWorkState.cs new file mode 100644 index 0000000..62217f9 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/CustomStatusLabelOverridesWorkState.cs @@ -0,0 +1,25 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Domain.Models; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Mutable view-state bag for Custom Status Label Overrides. +/// +public sealed class CustomStatusLabelOverridesWorkState +{ + public Guid? SelectedTemplateId { get; set; } + + public ApplicationStatus SelectedBaseStatus { get; set; } + + public string BaseStatusOverrideValue { get; set; } = string.Empty; + + public FormTemplate? CurrentTemplate { get; set; } + + public string? CurrentVersionNumber { get; set; } + + public IReadOnlyList AvailableTemplates { get; set; } = []; + + public IReadOnlyList> BaseStatuses { get; set; } = []; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/DuplicateTenantAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/DuplicateTenantAdminService.cs new file mode 100644 index 0000000..a25fae1 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/DuplicateTenantAdminService.cs @@ -0,0 +1,210 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Validation; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Clones the current tenant's TenantConfig into a new tenant. +/// +public interface IDuplicateTenantAdmin +{ + Task LoadInternalServiceAuthServicesAsync( + DuplicateTenantWorkState state, + CancellationToken cancellationToken = default); + + IReadOnlyList ValidateInput(DuplicateTenantWorkState state); + + Task CloneAsync( + DuplicateTenantWorkState state, + CancellationToken cancellationToken = default); +} + +public sealed class DuplicateTenantAdminService( + ITenantAdminClient tenantAdminClient, + ILogger logger) : IDuplicateTenantAdmin +{ + private static readonly JsonSerializerOptions PayloadSerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public static string GenerateSecretKey(int byteLength = 48) => + Convert.ToBase64String(RandomNumberGenerator.GetBytes(byteLength)); + + public static string ToBase64Utf8(string value) => + Convert.ToBase64String(Encoding.UTF8.GetBytes(value ?? string.Empty)); + + public async Task LoadInternalServiceAuthServicesAsync( + DuplicateTenantWorkState state, + CancellationToken cancellationToken = default) + { + try + { + var response = await tenantAdminClient.GetTenantSettingsAsync(state.SourceTenantId, cancellationToken); + var template = response.Settings + .Where(s => string.Equals(s.Category, "InternalServiceAuth", StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(s => string.Equals(s.Target, "Api", StringComparison.OrdinalIgnoreCase)) + .ThenBy(s => s.Target, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + + if (template is null || string.IsNullOrWhiteSpace(template.SettingsJson)) + { + state.InternalServiceAuthServiceApiKeys = []; + return; + } + + state.InternalServiceAuthServiceApiKeys = ParseServiceEmails(template.SettingsJson) + .Select(email => new DuplicateTenantServiceSecret + { + Email = email, + ApiKey = GenerateSecretKey() + }) + .ToList(); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Could not load InternalServiceAuth services for tenant {TenantId}. Service ApiKey fields will be empty.", + state.SourceTenantId); + state.InternalServiceAuthServiceApiKeys = []; + } + } + + public IReadOnlyList ValidateInput(DuplicateTenantWorkState state) + { + var errors = new List(); + for (var i = 0; i < state.InternalServiceAuthServiceApiKeys.Count; i++) + { + var service = state.InternalServiceAuthServiceApiKeys[i]; + if (string.IsNullOrWhiteSpace(service.Email)) + { + errors.Add(new FormValidationError( + $"InternalServiceAuthServiceApiKeys[{i}].Email", + DuplicateTenantMessages.ServiceEmailRequired)); + } + + if (string.IsNullOrWhiteSpace(service.ApiKey) || service.ApiKey.Length < 32) + { + errors.Add(new FormValidationError( + $"InternalServiceAuthServiceApiKeys[{i}].ApiKey", + DuplicateTenantMessages.ServiceApiKeyRequired)); + } + } + + if (state.NewTenantId == Guid.Empty) + { + errors.Add(new FormValidationError("NewTenantId", DuplicateTenantMessages.TenantIdRequired)); + } + else if (state.NewTenantId == state.SourceTenantId) + { + errors.Add(new FormValidationError("NewTenantId", DuplicateTenantMessages.TenantIdMustDiffer)); + } + + return errors; + } + + public async Task CloneAsync( + DuplicateTenantWorkState state, + CancellationToken cancellationToken = default) + { + var errors = ValidateInput(state); + if (errors.Count > 0) + return AdminPageOutcome.Stay(errors: errors); + + try + { + // WAF-safe: hostname, frontendOrigin, serviceName, and secrets live only inside Base64 payloadJson + // so Application Gateway does not see cleartext https:// ARGS (rule 931130 RFI). + var secretsPayload = new CloneTenantSecretsPayload + { + Hostname = state.Hostname, + FrontendOrigin = state.FrontendOrigin, + AuthorizationApiSecretKey = state.AuthorizationApiSecretKey, + InternalServiceAuthSecretKey = state.InternalServiceAuthSecretKey, + InternalServiceAuthServiceApiKeys = state.InternalServiceAuthServiceApiKeys + .Select(s => new CloneTenantServiceApiKeyPayload + { + Email = s.Email, + ApiKey = s.ApiKey + }) + .ToList() + }; + + var payloadNode = JsonSerializer.SerializeToNode(secretsPayload, PayloadSerializerOptions)!.AsObject(); + payloadNode["serviceName"] = state.ServiceName; + + var body = new CloneTenantRequest( + state.NewTenantId, + state.NewTenantName, + ToBase64Utf8(payloadNode.ToJsonString(PayloadSerializerOptions))); + + var response = await tenantAdminClient.CloneTenantAsync(state.SourceTenantId, body, cancellationToken); + + return AdminPageOutcome.Redirect( + successMessage: DuplicateTenantMessages.Created( + response.NewTenantName, + response.NewTenantId, + response.SettingsCopied, + response.Hostname)); + } + catch (Exception ex) + { + logger.LogError( + ex, + "Failed to duplicate tenant {SourceTenantId} to {NewTenantId}", + state.SourceTenantId, + state.NewTenantId); + var message = FormatCloneError(ex); + state.HasError = true; + state.ErrorMessage = message; + return AdminPageOutcome.Stay(errorMessage: message); + } + } + + internal static IReadOnlyList ParseServiceEmails(string settingsJson) + { + try + { + if (JsonNode.Parse(settingsJson) is not JsonObject root || + root["Services"] is not JsonArray services) + { + return []; + } + + return services + .OfType() + .Select(s => s["Email"]?.GetValue()?.Trim() ?? string.Empty) + .Where(email => !string.IsNullOrWhiteSpace(email)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch (JsonException) + { + return []; + } + } + + private static string FormatCloneError(Exception ex) + { + if (ex is ExternalApplicationsException clientEx) + { + var body = clientEx.Response?.TrimStart() ?? string.Empty; + if (clientEx.StatusCode == 403 && body.StartsWith('<')) + return DuplicateTenantMessages.CloneBlocked; + + if (clientEx.StatusCode > 0) + return DuplicateTenantMessages.CloneFailedHttp(clientEx.StatusCode); + } + + return AdminApiErrorMapper.Format(ex, DuplicateTenantMessages.CloneFailed, includeGatewayHint: true); + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/DuplicateTenantMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/DuplicateTenantMessages.cs new file mode 100644 index 0000000..ff88f90 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/DuplicateTenantMessages.cs @@ -0,0 +1,37 @@ +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// User-facing copy for Duplicate Tenant. Keep these strings identical to the previous PageModel. +/// +public static class DuplicateTenantMessages +{ + public const string TenantContextMissing = "Tenant context is not available for this request."; + + public const string ServiceEmailRequired = "Service email is required."; + + public const string ServiceApiKeyRequired = "Enter an ApiKey of at least 32 characters."; + + public const string TenantIdRequired = "Enter a valid tenant id."; + + public const string TenantIdMustDiffer = "New tenant id must be different from the current tenant."; + + public const string CloneFailed = "Could not duplicate tenant."; + + public const string CloneBlocked = + "Clone was blocked with HTTP 403 (HTML response). " + + "This usually means Front Door / WAF rejected the request before the API. " + + "Check WAF logs for POST /v1/admin/tenants/.../clone."; + + public static string CloneFailedHttp(int statusCode) => + $"{CloneFailed} (HTTP {statusCode})"; + + public static string Created( + string newTenantName, + Guid newTenantId, + int settingsCopied, + string hostname) => + $"Created tenant '{newTenantName}' ({newTenantId}). " + + $"Copied {settingsCopied} setting(s). Hostname: {hostname}. " + + "Authorization and InternalServiceAuth secrets (SecretKey + service ApiKeys) were applied. " + + "Create a template for this tenant before users can access the dashboard."; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/DuplicateTenantWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/DuplicateTenantWorkState.cs new file mode 100644 index 0000000..a3c2c0d --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/DuplicateTenantWorkState.cs @@ -0,0 +1,41 @@ +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Mutable view-state bag for the Duplicate Tenant admin page. +/// +public sealed class DuplicateTenantWorkState +{ + public Guid SourceTenantId { get; set; } + + public string SourceTenantName { get; set; } = string.Empty; + + public Guid NewTenantId { get; set; } + + public string NewTenantName { get; set; } = string.Empty; + + public string ServiceName { get; set; } = string.Empty; + + public string Hostname { get; set; } = string.Empty; + + public string FrontendOrigin { get; set; } = string.Empty; + + public string AuthorizationApiSecretKey { get; set; } = string.Empty; + + public string InternalServiceAuthSecretKey { get; set; } = string.Empty; + + public List InternalServiceAuthServiceApiKeys { get; set; } = []; + + public bool HasError { get; set; } + + public string? ErrorMessage { get; set; } +} + +/// +/// One InternalServiceAuth Services[] ApiKey row on the Duplicate Tenant form. +/// +public sealed class DuplicateTenantServiceSecret +{ + public string Email { get; set; } = string.Empty; + + public string ApiKey { get; set; } = string.Empty; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsAdminService.cs new file mode 100644 index 0000000..4197490 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsAdminService.cs @@ -0,0 +1,919 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Application.Options; +using GovUK.Dfe.FlexForms.Application.Validation; +using GovUK.Dfe.FlexForms.Domain.Models.EventMapping; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Loads and persists EventMappings, SchemaEvents, and EventTriggers for the current tenant. +/// +public interface IEventMappingsAdmin +{ + Task LoadAsync(EventMappingsWorkState state, CancellationToken cancellationToken = default); + + Task SaveTriggerAsync(EventMappingsWorkState state, CancellationToken cancellationToken = default); + + Task DeleteTriggerAsync(EventMappingsWorkState state, CancellationToken cancellationToken = default); + + Task SaveMappingAsync(EventMappingsWorkState state, CancellationToken cancellationToken = default); + + Task SaveSchemaAsync(EventMappingsWorkState state, CancellationToken cancellationToken = default); +} + +public sealed class EventMappingsAdminService( + ITenantAdminClient tenantAdminClient, + ITemplatesClient templatesClient, + IEventTypeRegistry eventTypeRegistry, + ISchemaEventDefinitionProvider schemaEventDefinitionProvider, + ILogger logger) : IEventMappingsAdmin +{ + public const string TargetShared = "Shared"; + public const string TargetWeb = "Web"; + public const string CategoryEventMappings = "EventMappings"; + public const string CategorySchemaEvents = "SchemaEvents"; + public const string CategoryEventTriggers = "EventTriggers"; + public const string SystemOnlyEventType = "ScanRequestedEvent"; + + public static readonly string[] TriggerNames = ["ApplicationSubmitted", "FileUploaded"]; + + private static readonly JsonSerializerOptions JsonWriteOptions = new() + { + PropertyNameCaseInsensitive = true, + WriteIndented = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + private static readonly JsonSerializerOptions JsonReadOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private static readonly JsonSerializerOptions JsonPersistOptions = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + public async Task LoadAsync(EventMappingsWorkState state, CancellationToken cancellationToken = default) + { + await LoadPageDataAsync(state, cancellationToken); + await LoadSavedTypedMappingsAsync(state, cancellationToken); + await LoadSavedTriggersAsync(state, cancellationToken); + await LoadSelectedMappingAsync(state, cancellationToken); + await LoadSelectedSchemaDefinitionAsync(state, cancellationToken); + } + + public async Task SaveTriggerAsync( + EventMappingsWorkState state, + CancellationToken cancellationToken = default) + { + await LoadAsync(state, cancellationToken); + + var trigger = state.TriggerName?.Trim(); + var eventType = state.TriggerEventType?.Trim(); + var mappingId = state.TriggerMappingId?.Trim(); + var eventKind = string.IsNullOrWhiteSpace(state.TriggerEventKind) + ? EventPublishKind.Typed + : state.TriggerEventKind.Trim(); + + var errors = new List(); + if (string.IsNullOrWhiteSpace(trigger) + || !TriggerNames.Contains(trigger, StringComparer.OrdinalIgnoreCase)) + { + errors.Add(new FormValidationError(nameof(EventMappingsWorkState.TriggerName), EventMappingsMessages.SelectTrigger)); + } + + if (string.IsNullOrWhiteSpace(eventType)) + { + errors.Add(new FormValidationError(nameof(EventMappingsWorkState.TriggerEventType), EventMappingsMessages.SelectEventType)); + } + else if (string.Equals(eventType, SystemOnlyEventType, StringComparison.OrdinalIgnoreCase)) + { + errors.Add(new FormValidationError( + nameof(EventMappingsWorkState.TriggerEventType), + EventMappingsMessages.SystemOnlyEventType(SystemOnlyEventType))); + } + + if (string.IsNullOrWhiteSpace(mappingId)) + errors.Add(new FormValidationError(nameof(EventMappingsWorkState.TriggerMappingId), EventMappingsMessages.EnterMappingId)); + + if (!string.Equals(eventKind, EventPublishKind.Typed, StringComparison.OrdinalIgnoreCase) + && !string.Equals(eventKind, EventPublishKind.Schema, StringComparison.OrdinalIgnoreCase)) + { + errors.Add(new FormValidationError( + nameof(EventMappingsWorkState.TriggerEventKind), + EventMappingsMessages.EventKindMustBeTypedOrSchema)); + } + + if (errors.Count > 0) + return Stay(state, errors); + + try + { + var root = await LoadCategoryRootAsync(state, CategoryEventTriggers, cancellationToken); + var bindings = root[trigger!] as JsonArray ?? new JsonArray(); + + var replaced = false; + for (var i = 0; i < bindings.Count; i++) + { + if (bindings[i] is not JsonObject existing) + continue; + + if (!string.Equals(ReadBindingValue(existing, "eventType"), eventType, StringComparison.OrdinalIgnoreCase)) + continue; + + bindings[i] = BuildBindingNode(eventKind, eventType!, mappingId!); + replaced = true; + break; + } + + if (!replaced) + bindings.Add(BuildBindingNode(eventKind, eventType!, mappingId!)); + + root[trigger!] = bindings; + + await UpsertCategoryAsync(state, CategoryEventTriggers, root, cancellationToken); + await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken); + + return Redirect( + state, + EventMappingsMessages.SavedTrigger(eventType!, trigger!), + refreshLocalCaches: true); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to save EventTriggers for {TenantId}", state.TenantId); + return StayWithError(state, AdminApiErrorMapper.Format(ex, EventMappingsMessages.SaveTriggerFailed)); + } + } + + public async Task DeleteTriggerAsync( + EventMappingsWorkState state, + CancellationToken cancellationToken = default) + { + var trigger = state.TriggerName?.Trim(); + var eventType = state.TriggerEventType?.Trim(); + + if (string.IsNullOrWhiteSpace(trigger) || string.IsNullOrWhiteSpace(eventType)) + return Redirect(state, errorMessage: EventMappingsMessages.DeleteTriggerUnidentified); + + try + { + var root = await LoadCategoryRootAsync(state, CategoryEventTriggers, cancellationToken); + if (root[trigger] is JsonArray bindings) + { + var remaining = new JsonArray(); + foreach (var binding in bindings) + { + if (binding is JsonObject obj + && string.Equals(ReadBindingValue(obj, "eventType"), eventType, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + remaining.Add(binding?.DeepClone()); + } + + if (remaining.Count == 0) + root.Remove(trigger); + else + root[trigger] = remaining; + + await UpsertCategoryAsync(state, CategoryEventTriggers, root, cancellationToken); + await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken); + } + + return Redirect( + state, + EventMappingsMessages.RemovedTrigger(eventType, trigger), + refreshLocalCaches: true); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete EventTriggers binding for {TenantId}", state.TenantId); + return Redirect( + state, + errorMessage: AdminApiErrorMapper.Format(ex, EventMappingsMessages.DeleteTriggerFailed)); + } + } + + public async Task SaveMappingAsync( + EventMappingsWorkState state, + CancellationToken cancellationToken = default) + { + await LoadPageDataAsync(state, cancellationToken); + await LoadSavedTypedMappingsAsync(state, cancellationToken); + await LoadSavedTriggersAsync(state, cancellationToken); + await LoadSelectedSchemaDefinitionAsync(state, cancellationToken); + + state.SelectedTemplateId = state.SelectedTemplateId?.Trim(); + state.SelectedEventType = state.SelectedEventType?.Trim(); + + var errors = new List(); + if (string.IsNullOrWhiteSpace(state.SelectedTemplateId)) + errors.Add(new FormValidationError(nameof(EventMappingsWorkState.SelectedTemplateId), EventMappingsMessages.SelectTemplate)); + else if (!await IsTemplateAllowedForCurrentTenantAsync(state, state.SelectedTemplateId, cancellationToken)) + { + errors.Add(new FormValidationError( + nameof(EventMappingsWorkState.SelectedTemplateId), + EventMappingsMessages.SelectTenantTemplate)); + } + + if (string.IsNullOrWhiteSpace(state.SelectedEventType)) + errors.Add(new FormValidationError(nameof(EventMappingsWorkState.SelectedEventType), EventMappingsMessages.SelectEventType)); + + if (string.IsNullOrWhiteSpace(state.MappingJson)) + errors.Add(new FormValidationError(nameof(EventMappingsWorkState.MappingJson), EventMappingsMessages.EnterMappingJson)); + + if (errors.Count > 0) + return Stay(state, errors); + + EventFieldMapping? mapping; + try + { + mapping = JsonSerializer.Deserialize(state.MappingJson!, JsonReadOptions); + } + catch (JsonException ex) + { + return Stay(state, [new FormValidationError( + nameof(EventMappingsWorkState.MappingJson), + EventMappingsMessages.InvalidJson(ex.Message))]); + } + + if (mapping is null) + { + return Stay(state, [new FormValidationError( + nameof(EventMappingsWorkState.MappingJson), + EventMappingsMessages.MappingParseFailed)]); + } + + if (string.IsNullOrWhiteSpace(mapping.MappingId)) + errors.Add(new FormValidationError(nameof(EventMappingsWorkState.MappingJson), EventMappingsMessages.MappingIdRequired)); + + if (string.IsNullOrWhiteSpace(mapping.EventType)) + mapping.EventType = state.SelectedEventType!; + else if (!string.Equals(mapping.EventType, state.SelectedEventType, StringComparison.OrdinalIgnoreCase)) + errors.Add(new FormValidationError(nameof(EventMappingsWorkState.MappingJson), EventMappingsMessages.EventTypeMustMatch)); + + if (mapping.FieldMappings is null || mapping.FieldMappings.Count == 0) + errors.Add(new FormValidationError(nameof(EventMappingsWorkState.MappingJson), EventMappingsMessages.FieldMappingsRequired)); + + var catalogueItem = state.Catalogue.FirstOrDefault(c => + string.Equals(c.EventTypeName, state.SelectedEventType, StringComparison.OrdinalIgnoreCase)); + if (catalogueItem is { Kind: EventPublishKind.Typed, Properties.Count: > 0 } + && mapping.FieldMappings is { Count: > 0 }) + { + var known = catalogueItem.Properties.Select(p => p).ToHashSet(StringComparer.OrdinalIgnoreCase); + var unknown = mapping.FieldMappings.Keys + .Where(k => !known.Contains(k)) + .OrderBy(k => k, StringComparer.OrdinalIgnoreCase) + .ToList(); + if (unknown.Count > 0) + { + state.ValidationWarnings = unknown + .Select(k => EventMappingsMessages.UnknownProperty(k, state.SelectedEventType!)) + .ToList(); + } + } + + if (errors.Count > 0) + return Stay(state, errors); + + try + { + var root = await LoadCategoryRootAsync(state, CategoryEventMappings, cancellationToken); + var mappingJson = JsonSerializer.Serialize(mapping, JsonPersistOptions); + var templateKeys = await ResolveTemplateMappingKeysAsync(state.SelectedTemplateId!, cancellationToken); + + foreach (var templateKey in templateKeys) + { + var templateNode = root[templateKey] as JsonObject ?? new JsonObject(); + root[templateKey] = templateNode; + + var mappingNode = JsonNode.Parse(mappingJson) + ?? throw new InvalidOperationException("Failed to serialise mapping."); + templateNode[state.SelectedEventType!] = mappingNode; + } + + await UpsertCategoryAsync(state, CategoryEventMappings, root, cancellationToken); + await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken); + + return Redirect( + state, + EventMappingsMessages.SavedMapping(string.Join(", ", templateKeys), state.SelectedEventType!), + refreshLocalCaches: true); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to save EventMappings for {TenantId}", state.TenantId); + return StayWithError(state, AdminApiErrorMapper.Format(ex, EventMappingsMessages.SaveMappingFailed)); + } + } + + public async Task SaveSchemaAsync( + EventMappingsWorkState state, + CancellationToken cancellationToken = default) + { + await LoadPageDataAsync(state, cancellationToken); + await LoadSavedTypedMappingsAsync(state, cancellationToken); + await LoadSavedTriggersAsync(state, cancellationToken); + await LoadSelectedMappingAsync(state, cancellationToken); + + var schemaKey = (state.NewSchemaEventType ?? state.SelectedSchemaEventType)?.Trim(); + if (string.IsNullOrWhiteSpace(schemaKey)) + { + return Stay(state, [new FormValidationError( + nameof(EventMappingsWorkState.NewSchemaEventType), + EventMappingsMessages.EnterSchemaEventType)]); + } + + if (state.Catalogue.Any(c => + string.Equals(c.Kind, EventPublishKind.Typed, StringComparison.OrdinalIgnoreCase) + && string.Equals(c.EventTypeName, schemaKey, StringComparison.OrdinalIgnoreCase))) + { + return Stay(state, [new FormValidationError( + nameof(EventMappingsWorkState.NewSchemaEventType), + EventMappingsMessages.TypedEventNameClash(schemaKey))]); + } + + if (string.IsNullOrWhiteSpace(state.SchemaDefinitionJson)) + { + return Stay(state, [new FormValidationError( + nameof(EventMappingsWorkState.SchemaDefinitionJson), + EventMappingsMessages.EnterSchemaDefinitionJson)]); + } + + JsonNode? definitionNode; + try + { + definitionNode = JsonNode.Parse(state.SchemaDefinitionJson); + } + catch (JsonException ex) + { + return Stay(state, [new FormValidationError( + nameof(EventMappingsWorkState.SchemaDefinitionJson), + EventMappingsMessages.InvalidJson(ex.Message))]); + } + + if (definitionNode is not JsonObject defObj) + { + return Stay(state, [new FormValidationError( + nameof(EventMappingsWorkState.SchemaDefinitionJson), + EventMappingsMessages.SchemaMustBeObject)]); + } + + var errors = new List(); + if (defObj["topicName"] is null && defObj["TopicName"] is null) + errors.Add(new FormValidationError(nameof(EventMappingsWorkState.SchemaDefinitionJson), EventMappingsMessages.TopicNameRequired)); + + if (defObj["jsonSchema"] is null && defObj["JsonSchema"] is null) + errors.Add(new FormValidationError(nameof(EventMappingsWorkState.SchemaDefinitionJson), EventMappingsMessages.JsonSchemaRequired)); + + if (errors.Count > 0) + return Stay(state, errors); + + try + { + var root = await LoadCategoryRootAsync(state, CategorySchemaEvents, cancellationToken); + root[schemaKey] = definitionNode; + await UpsertCategoryAsync(state, CategorySchemaEvents, root, cancellationToken); + await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken); + + state.SelectedEventType = schemaKey; + state.SelectedSchemaEventType = schemaKey; + return Redirect( + state, + EventMappingsMessages.SavedSchema(schemaKey), + refreshLocalCaches: true); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to save SchemaEvents for {TenantId}", state.TenantId); + return StayWithError(state, AdminApiErrorMapper.Format(ex, EventMappingsMessages.SaveSchemaFailed)); + } + } + + private async Task LoadPageDataAsync(EventMappingsWorkState state, CancellationToken cancellationToken) + { + await LoadCatalogueAsync(state, cancellationToken); + await LoadTemplateOptionsAsync(state, cancellationToken); + + state.SchemaEvents = schemaEventDefinitionProvider.GetAll() + .OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase) + .Select(kv => new SchemaEventRow( + kv.Key, + kv.Value.TopicName, + kv.Value.Version, + kv.Value.Description)) + .ToList(); + + var eventOptions = state.Catalogue + .Select(e => new AdminSelectOption( + $"{e.EventTypeName} ({e.Kind})", + e.EventTypeName, + string.Equals(e.EventTypeName, state.SelectedEventType, StringComparison.OrdinalIgnoreCase))) + .ToList(); + + foreach (var schema in state.SchemaEvents) + { + if (eventOptions.Any(o => string.Equals(o.Value, schema.MessageType, StringComparison.OrdinalIgnoreCase))) + continue; + eventOptions.Add(new AdminSelectOption( + $"{schema.MessageType} (Schema)", + schema.MessageType, + string.Equals(schema.MessageType, state.SelectedEventType, StringComparison.OrdinalIgnoreCase))); + } + + state.EventTypeOptions = eventOptions + .OrderBy(o => o.Text, StringComparer.OrdinalIgnoreCase) + .ToList(); + + state.TriggerOptions = TriggerNames + .Select(t => new AdminSelectOption(t, t, string.Equals(t, state.TriggerName, StringComparison.OrdinalIgnoreCase))) + .ToList(); + + state.TriggerEventTypeOptions = state.EventTypeOptions + .Where(o => !string.Equals(o.Value, SystemOnlyEventType, StringComparison.OrdinalIgnoreCase)) + .Select(o => new AdminSelectOption( + o.Text, + o.Value, + string.Equals(o.Value, state.TriggerEventType, StringComparison.OrdinalIgnoreCase))) + .ToList(); + + if (!string.IsNullOrWhiteSpace(state.SelectedEventType)) + { + var item = state.Catalogue.FirstOrDefault(c => + string.Equals(c.EventTypeName, state.SelectedEventType, StringComparison.OrdinalIgnoreCase)); + state.ClrPropertyHints = item?.Properties ?? []; + } + } + + private async Task LoadCatalogueAsync(EventMappingsWorkState state, CancellationToken cancellationToken) + { + try + { + var response = await tenantAdminClient.GetEventCatalogueAsync(cancellationToken); + state.Catalogue = (response.Events ?? []) + .Select(e => new EventCatalogueRow( + e.EventTypeName, + e.TopicName ?? "(no topic resolved)", + e.ClrTypeName, + e.Description, + e.Version, + string.IsNullOrWhiteSpace(e.Kind) ? EventPublishKind.Typed : e.Kind, + (e.Properties ?? []).Select(p => p.Name).ToList())) + .ToList(); + state.CatalogueSource = "API"; + return; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Event catalogue API unavailable; falling back to local registry."); + } + + state.Catalogue = eventTypeRegistry.GetCatalogue() + .Select(e => new EventCatalogueRow( + e.EventTypeName, + e.TopicName ?? "(no topic resolved)", + e.ClrType.FullName ?? e.ClrType.Name, + Description: null, + Version: "local", + Kind: EventPublishKind.Typed, + Properties: e.ClrType.GetProperties().Select(p => p.Name).ToList())) + .ToList(); + state.CatalogueSource = "local registry (API unavailable)"; + } + + private async Task LoadTemplateOptionsAsync(EventMappingsWorkState state, CancellationToken cancellationToken) + { + try + { + var templates = await templatesClient.GetAccessibleTemplatesAsync(cancellationToken) ?? []; + var allowed = new HashSet(StringComparer.OrdinalIgnoreCase); + + state.TemplateOptions = templates + .Where(t => t.TemplateId != Guid.Empty) + .OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase) + .Select(t => + { + var id = t.TemplateId.ToString(); + allowed.Add(id); + var label = string.IsNullOrWhiteSpace(t.Name) ? id : $"{t.Name} ({id})"; + return new AdminSelectOption( + label, + id, + string.Equals(id, state.SelectedTemplateId, StringComparison.OrdinalIgnoreCase)); + }) + .ToList(); + + foreach (var template in templates.Where(t => t.TemplateId != Guid.Empty)) + { + foreach (var key in await ResolveTemplateMappingKeysAsync( + template.TemplateId.ToString(), + cancellationToken)) + { + allowed.Add(key); + } + } + + state.AllowedTemplateKeys = allowed; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not load templates for EventMappings editor"); + state.TemplateOptions = []; + state.AllowedTemplateKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + } + } + + private async Task IsTemplateAllowedForCurrentTenantAsync( + EventMappingsWorkState state, + string templateId, + CancellationToken cancellationToken) + { + if (state.AllowedTemplateKeys.Count == 0) + await LoadTemplateOptionsAsync(state, cancellationToken); + + return state.AllowedTemplateKeys.Contains(templateId); + } + + private async Task> ResolveTemplateMappingKeysAsync( + string selectedTemplateId, + CancellationToken cancellationToken) + { + var keys = new List { selectedTemplateId }; + + if (!Guid.TryParse(selectedTemplateId, out var templateGuid)) + return keys; + + try + { + var schema = await templatesClient.GetLatestTemplateSchemaAsync(templateGuid, cancellationToken); + if (string.IsNullOrWhiteSpace(schema?.JsonSchema)) + return keys; + + var schemaText = schema.JsonSchema.Trim(); + if (!schemaText.StartsWith('{') && !schemaText.StartsWith('[')) + { + try + { + schemaText = Encoding.UTF8.GetString(Convert.FromBase64String(schemaText)); + } + catch (FormatException) + { + return keys; + } + } + + using var doc = JsonDocument.Parse(schemaText); + if (doc.RootElement.TryGetProperty("templateId", out var embeddedId) + && embeddedId.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(embeddedId.GetString())) + { + var schemaTemplateId = embeddedId.GetString()!.Trim(); + if (!keys.Contains(schemaTemplateId, StringComparer.OrdinalIgnoreCase)) + keys.Add(schemaTemplateId); + } + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Could not resolve schema templateId alias for EventMappings key {TemplateId}", + selectedTemplateId); + } + + return keys; + } + + private async Task LoadSavedTypedMappingsAsync(EventMappingsWorkState state, CancellationToken cancellationToken) + { + try + { + var root = await LoadCategoryRootAsync(state, CategoryEventMappings, cancellationToken); + var schemaNames = state.SchemaEvents + .Select(s => s.MessageType) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var rows = new List(); + foreach (var templateProperty in root) + { + if (string.Equals(templateProperty.Key, "BasePath", StringComparison.OrdinalIgnoreCase)) + continue; + + if (templateProperty.Value is not JsonObject templateNode) + continue; + + foreach (var eventProperty in templateNode) + { + if (eventProperty.Value is not JsonObject mappingNode) + continue; + + var eventType = eventProperty.Key; + if (schemaNames.Contains(eventType)) + continue; + + var mappingId = mappingNode["mappingId"]?.GetValue() + ?? mappingNode["MappingId"]?.GetValue() + ?? "—"; + var description = mappingNode["description"]?.GetValue() + ?? mappingNode["Description"]?.GetValue(); + + rows.Add(new SavedMappingRow( + templateProperty.Key, + eventType, + mappingId, + description)); + } + } + + state.SavedTypedMappings = rows + .Where(r => state.AllowedTemplateKeys.Count == 0 || state.AllowedTemplateKeys.Contains(r.TemplateId)) + .GroupBy( + r => $"{r.TemplateId}|{r.EventType}", + StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .OrderBy(r => r.EventType, StringComparer.OrdinalIgnoreCase) + .ThenBy(r => r.TemplateId, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not load saved typed EventMappings"); + state.SavedTypedMappings = []; + } + } + + private static JsonObject BuildBindingNode(string eventKind, string eventType, string mappingId) => + new() + { + ["eventKind"] = eventKind, + ["eventType"] = eventType, + ["mappingId"] = mappingId + }; + + private static string? ReadBindingValue(JsonObject binding, string camelCaseName) + { + var pascalCaseName = char.ToUpperInvariant(camelCaseName[0]) + camelCaseName[1..]; + var node = binding[camelCaseName] ?? binding[pascalCaseName]; + return node?.GetValue(); + } + + private async Task LoadSavedTriggersAsync(EventMappingsWorkState state, CancellationToken cancellationToken) + { + try + { + var root = await LoadCategoryRootAsync(state, CategoryEventTriggers, cancellationToken); + var rows = new List(); + + foreach (var triggerProperty in root) + { + if (triggerProperty.Value is not JsonArray bindings) + continue; + + foreach (var binding in bindings) + { + if (binding is not JsonObject obj) + continue; + + var eventType = ReadBindingValue(obj, "eventType"); + if (string.IsNullOrWhiteSpace(eventType)) + continue; + + rows.Add(new TriggerBindingRow( + triggerProperty.Key, + ReadBindingValue(obj, "eventKind") ?? EventPublishKind.Typed, + eventType, + ReadBindingValue(obj, "mappingId") ?? "—")); + } + } + + state.SavedTriggers = rows + .OrderBy(r => r.Trigger, StringComparer.OrdinalIgnoreCase) + .ThenBy(r => r.EventType, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not load saved EventTriggers"); + state.SavedTriggers = []; + } + } + + private async Task LoadSelectedSchemaDefinitionAsync(EventMappingsWorkState state, CancellationToken cancellationToken) + { + var key = state.SelectedSchemaEventType?.Trim(); + if (string.IsNullOrWhiteSpace(key)) + { + if (string.IsNullOrWhiteSpace(state.SchemaDefinitionJson)) + state.SchemaDefinitionJson = GetEmptySchemaTemplate(); + return; + } + + state.NewSchemaEventType = key; + state.SelectedSchemaEventType = key; + + try + { + var root = await LoadCategoryRootAsync(state, CategorySchemaEvents, cancellationToken); + JsonNode? definitionNode = null; + foreach (var property in root) + { + if (string.Equals(property.Key, key, StringComparison.OrdinalIgnoreCase)) + { + definitionNode = property.Value; + state.NewSchemaEventType = property.Key; + state.SelectedSchemaEventType = property.Key; + break; + } + } + + if (definitionNode is not null) + { + state.SchemaDefinitionJson = definitionNode.ToJsonString(JsonWriteOptions); + return; + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not load SchemaEvents definition for {SchemaKey}", key); + } + + var def = schemaEventDefinitionProvider.GetDefinition(key); + if (def is not null) + { + state.SchemaDefinitionJson = JsonSerializer.Serialize(new + { + topicName = def.TopicName, + version = def.Version, + description = def.Description, + jsonSchema = def.JsonSchema + ?? new Dictionary { ["type"] = "object", ["properties"] = new { } } + }, JsonWriteOptions); + return; + } + + state.SchemaDefinitionJson = GetEmptySchemaTemplate(); + } + + private async Task LoadSelectedMappingAsync(EventMappingsWorkState state, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(state.SelectedTemplateId) || string.IsNullOrWhiteSpace(state.SelectedEventType)) + { + state.MappingJson = GetEmptyMappingTemplate(state.SelectedEventType); + return; + } + + try + { + var root = await LoadCategoryRootAsync(state, CategoryEventMappings, cancellationToken); + var lookupKeys = await ResolveTemplateMappingKeysAsync(state.SelectedTemplateId, cancellationToken); + foreach (var key in lookupKeys) + { + if (root[key] is JsonObject template + && template[state.SelectedEventType] is JsonNode mappingNode) + { + state.MappingJson = mappingNode.ToJsonString(JsonWriteOptions); + return; + } + } + + foreach (var property in root) + { + if (property.Value is JsonObject template + && template[state.SelectedEventType] is JsonNode mappingNode) + { + state.MappingJson = mappingNode.ToJsonString(JsonWriteOptions); + return; + } + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not load existing EventMappings for editor"); + } + + state.MappingJson = GetEmptyMappingTemplate(state.SelectedEventType); + } + + private async Task LoadCategoryRootAsync( + EventMappingsWorkState state, + string category, + CancellationToken cancellationToken) + { + var response = await tenantAdminClient.GetSafeTenantSettingsAsync(state.TenantId, cancellationToken); + state.TenantName = response.TenantName ?? state.TenantName; + + var candidates = (response.Settings ?? []) + .Where(s => string.Equals(s.Category, category, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var setting = candidates.FirstOrDefault(s => string.Equals(s.Target, TargetShared, StringComparison.OrdinalIgnoreCase)) + ?? candidates.FirstOrDefault(s => string.Equals(s.Target, TargetWeb, StringComparison.OrdinalIgnoreCase)) + ?? candidates.FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(setting?.SettingsJson)) + return new JsonObject(); + + try + { + return JsonNode.Parse(setting.SettingsJson) as JsonObject ?? new JsonObject(); + } + catch (JsonException) + { + return new JsonObject(); + } + } + + private async Task UpsertCategoryAsync( + EventMappingsWorkState state, + string category, + JsonObject root, + CancellationToken cancellationToken) + { + var payloadJson = root.ToJsonString(new JsonSerializerOptions { WriteIndented = false }); + await tenantAdminClient.UpsertSafeTenantSettingAsync( + state.TenantId, + new UpsertTenantSettingRequest( + category, + TargetShared, + AdminSettingsEncoding.ToBase64(payloadJson), + IsSecret: false), + cancellationToken); + } + + private static string GetEmptyMappingTemplate(string? eventType) + { + var mapping = new EventFieldMapping + { + MappingId = string.IsNullOrWhiteSpace(eventType) + ? "mapping-v1" + : $"{ToKebab(eventType)}-v1", + EventType = eventType ?? string.Empty, + Description = null, + FieldMappings = new Dictionary() + }; + + return JsonSerializer.Serialize(mapping, JsonWriteOptions); + } + + private static string GetEmptySchemaTemplate() => + JsonSerializer.Serialize(new + { + topicName = "my-custom-topic", + version = "1.0", + description = "Tenant-defined schema event", + jsonSchema = new + { + type = "object", + properties = new { } + } + }, JsonWriteOptions); + + private static string ToKebab(string value) + { + var sb = new StringBuilder(); + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (char.IsUpper(c) && i > 0) + sb.Append('-'); + sb.Append(char.ToLowerInvariant(c)); + } + + return sb.ToString(); + } + + private static AdminPageOutcome Stay(EventMappingsWorkState state, IReadOnlyList errors) => + AdminPageOutcome.Stay(errors: errors); + + private static AdminPageOutcome StayWithError(EventMappingsWorkState state, string errorMessage) + { + state.HasError = true; + state.ErrorMessage = errorMessage; + return AdminPageOutcome.Stay(errorMessage: errorMessage); + } + + private static AdminPageOutcome Redirect( + EventMappingsWorkState state, + string? successMessage = null, + string? errorMessage = null, + bool refreshLocalCaches = false) => + AdminPageOutcome.Redirect( + successMessage: successMessage, + errorMessage: errorMessage, + refreshLocalCaches: refreshLocalCaches, + routeValues: new Dictionary + { + ["SelectedTemplateId"] = state.SelectedTemplateId, + ["SelectedEventType"] = state.SelectedEventType, + ["SelectedSchemaEventType"] = state.SelectedSchemaEventType + }); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsMessages.cs new file mode 100644 index 0000000..8e85f17 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsMessages.cs @@ -0,0 +1,74 @@ +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// User-facing copy for Event Mappings. Keep these strings identical to the previous PageModel. +/// +public static class EventMappingsMessages +{ + public const string TenantContextMissing = "Tenant context is not available for this request."; + + public const string SelectTrigger = "Select a trigger."; + + public const string SelectEventType = "Select an event type."; + + public const string EnterMappingId = "Enter the mapping ID to use."; + + public const string EventKindMustBeTypedOrSchema = "Event kind must be Typed or Schema."; + + public const string SaveTriggerFailed = "Could not save event trigger."; + + public const string DeleteTriggerUnidentified = "Could not identify the trigger binding to remove."; + + public const string DeleteTriggerFailed = "Could not remove event trigger."; + + public const string SelectTemplate = "Select a template."; + + public const string SelectTenantTemplate = "Select a template that belongs to this tenant."; + + public const string EnterMappingJson = "Enter mapping JSON."; + + public const string MappingParseFailed = "Mapping JSON could not be parsed."; + + public const string MappingIdRequired = "mappingId is required."; + + public const string EventTypeMustMatch = "eventType in JSON must match the selected event type."; + + public const string FieldMappingsRequired = "fieldMappings must contain at least one property."; + + public const string SaveMappingFailed = "Could not save event mapping."; + + public const string EnterSchemaEventType = "Enter a schema event type name."; + + public const string EnterSchemaDefinitionJson = "Enter schema definition JSON."; + + public const string SchemaMustBeObject = "Schema definition must be a JSON object."; + + public const string TopicNameRequired = "topicName is required."; + + public const string JsonSchemaRequired = "jsonSchema is required."; + + public const string SaveSchemaFailed = "Could not save schema event."; + + public static string SystemOnlyEventType(string eventType) => + $"{eventType} is published by the platform for every upload and cannot be configured here."; + + public static string InvalidJson(string message) => $"Invalid JSON: {message}"; + + public static string SavedTrigger(string eventType, string trigger) => + $"Saved {eventType} on the {trigger} trigger."; + + public static string RemovedTrigger(string eventType, string trigger) => + $"Removed {eventType} from the {trigger} trigger."; + + public static string SavedMapping(string keysLabel, string eventType) => + $"Saved mapping for template key(s) [{keysLabel}] / {eventType}."; + + public static string SavedSchema(string schemaKey) => + $"Saved schema event '{schemaKey}'."; + + public static string TypedEventNameClash(string schemaKey) => + $"'{schemaKey}' is a platform typed event. Choose a different name for schema events."; + + public static string UnknownProperty(string property, string eventType) => + $"Property '{property}' is not on {eventType}."; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsModels.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsModels.cs new file mode 100644 index 0000000..8370ce0 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsModels.cs @@ -0,0 +1,30 @@ +namespace GovUK.Dfe.FlexForms.Application.Admin; + +public sealed record EventCatalogueRow( + string EventTypeName, + string TopicName, + string ClrTypeName, + string? Description, + string Version, + string Kind, + IReadOnlyList Properties); + +public sealed record SchemaEventRow( + string MessageType, + string TopicName, + string Version, + string? Description); + +public sealed record SavedMappingRow( + string TemplateId, + string EventType, + string MappingId, + string? Description); + +public sealed record TriggerBindingRow( + string Trigger, + string EventKind, + string EventType, + string MappingId); + +public sealed record MetadataKeyHint(string Key, string Description); diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsWorkState.cs new file mode 100644 index 0000000..fab91a0 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsWorkState.cs @@ -0,0 +1,60 @@ +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Mutable view-state bag for the Event Mappings admin page. +/// +public sealed class EventMappingsWorkState +{ + public Guid TenantId { get; set; } + + public string TenantName { get; set; } = string.Empty; + + public bool HasError { get; set; } + + public string? ErrorMessage { get; set; } + + public IReadOnlyList TemplateOptions { get; set; } = []; + + public IReadOnlyList EventTypeOptions { get; set; } = []; + + public IReadOnlyList Catalogue { get; set; } = []; + + public IReadOnlyList SchemaEvents { get; set; } = []; + + public IReadOnlyList SavedTypedMappings { get; set; } = []; + + public IReadOnlyList TriggerOptions { get; set; } = []; + + public IReadOnlyList TriggerEventTypeOptions { get; set; } = []; + + public IReadOnlyList SavedTriggers { get; set; } = []; + + public IReadOnlySet AllowedTemplateKeys { get; set; } = + new HashSet(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList ClrPropertyHints { get; set; } = []; + + public IReadOnlyList ValidationWarnings { get; set; } = []; + + public string? CatalogueSource { get; set; } + + public string? SelectedTemplateId { get; set; } + + public string? SelectedEventType { get; set; } + + public string? SelectedSchemaEventType { get; set; } + + public string? MappingJson { get; set; } + + public string? SchemaDefinitionJson { get; set; } + + public string? NewSchemaEventType { get; set; } + + public string? TriggerName { get; set; } + + public string? TriggerEventKind { get; set; } + + public string? TriggerEventType { get; set; } + + public string? TriggerMappingId { get; set; } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsAdminService.cs new file mode 100644 index 0000000..15078d6 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsAdminService.cs @@ -0,0 +1,201 @@ +using System.Text.Json; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Loads and saves non-secret organisation settings (terminology, banner, dashboard). +/// +public interface IOrganisationSettingsAdmin +{ + Task LoadAsync(OrganisationSettingsWorkState state, CancellationToken cancellationToken = default); + + Task SaveAsync(OrganisationSettingsWorkState state, CancellationToken cancellationToken = default); +} + +public sealed class OrganisationSettingsAdminService( + ITenantAdminClient tenantAdminClient, + ILogger logger) : IOrganisationSettingsAdmin +{ + private const string TargetWeb = "Web"; + private const string CategoryTerminology = "ApplicationTerminology"; + private const string CategoryBanner = "NotificationBanner"; + private const string CategoryDashboard = "Dashboard"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + WriteIndented = false + }; + + public async Task LoadAsync(OrganisationSettingsWorkState state, CancellationToken cancellationToken = default) + { + try + { + var response = await tenantAdminClient.GetSafeTenantSettingsAsync(state.TenantId, cancellationToken); + state.TenantName = response.TenantName; + + foreach (var setting in response.Settings ?? []) + { + ApplySettingJson(state, setting.Category, setting.SettingsJson); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load organisation settings for {TenantId}", state.TenantId); + state.HasError = true; + state.ErrorMessage = AdminApiErrorMapper.Format( + ex, + OrganisationSettingsMessages.LoadFailed, + includeGatewayHint: false); + } + } + + public async Task SaveAsync( + OrganisationSettingsWorkState state, + CancellationToken cancellationToken = default) + { + try + { + await UpsertCategoryAsync( + state.TenantId, + CategoryTerminology, + new { Singular = state.TerminologySingular, Plural = state.TerminologyPlural }, + cancellationToken); + + await UpsertCategoryAsync( + state.TenantId, + CategoryBanner, + new { Enabled = state.BannerEnabled, Heading = state.BannerHeading, Message = state.BannerMessage }, + cancellationToken); + + await UpsertCategoryAsync( + state.TenantId, + CategoryDashboard, + new { PageSize = state.DashboardPageSize, EnableApplicationFilters = state.DashboardEnableFilters }, + cancellationToken); + + await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken); + + return AdminPageOutcome.Redirect( + successMessage: OrganisationSettingsMessages.Saved, + refreshLocalCaches: true); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to save organisation settings for {TenantId}", state.TenantId); + var message = AdminApiErrorMapper.Format( + ex, + OrganisationSettingsMessages.SaveFailed, + includeGatewayHint: false); + state.HasError = true; + state.ErrorMessage = message; + return AdminPageOutcome.Stay(errorMessage: message); + } + } + + private async Task UpsertCategoryAsync( + Guid tenantId, + string category, + object payload, + CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(payload, JsonOptions); + await tenantAdminClient.UpsertSafeTenantSettingAsync( + tenantId, + new UpsertTenantSettingRequest(category, TargetWeb, AdminSettingsEncoding.ToBase64(json), IsSecret: false), + cancellationToken); + } + + private void ApplySettingJson(OrganisationSettingsWorkState state, string category, string? settingsJson) + { + if (string.IsNullOrWhiteSpace(settingsJson)) + return; + + try + { + using var doc = JsonDocument.Parse(settingsJson); + var root = doc.RootElement; + + if (string.Equals(category, CategoryTerminology, StringComparison.OrdinalIgnoreCase)) + { + if (TryGetString(root, "Singular", out var singular)) + state.TerminologySingular = singular; + if (TryGetString(root, "Plural", out var plural)) + state.TerminologyPlural = plural; + } + else if (string.Equals(category, CategoryBanner, StringComparison.OrdinalIgnoreCase)) + { + if (TryGetBool(root, "Enabled", out var enabled)) + state.BannerEnabled = enabled; + if (TryGetString(root, "Heading", out var heading)) + state.BannerHeading = heading; + if (TryGetString(root, "Message", out var message)) + state.BannerMessage = message; + } + else if (string.Equals(category, CategoryDashboard, StringComparison.OrdinalIgnoreCase)) + { + if (TryGetInt(root, "PageSize", out var pageSize)) + state.DashboardPageSize = pageSize; + if (TryGetBool(root, "EnableApplicationFilters", out var filters)) + state.DashboardEnableFilters = filters; + } + } + catch (JsonException ex) + { + logger.LogWarning(ex, "Could not parse settings JSON for category {Category}", category); + } + } + + private static bool TryGetString(JsonElement root, string name, out string value) + { + value = string.Empty; + if (!TryGetProperty(root, name, out var prop) || prop.ValueKind != JsonValueKind.String) + return false; + value = prop.GetString() ?? string.Empty; + return true; + } + + private static bool TryGetBool(JsonElement root, string name, out bool value) + { + value = false; + if (!TryGetProperty(root, name, out var prop)) + return false; + + if (prop.ValueKind == JsonValueKind.True || prop.ValueKind == JsonValueKind.False) + { + value = prop.GetBoolean(); + return true; + } + + return false; + } + + private static bool TryGetInt(JsonElement root, string name, out int value) + { + value = 0; + if (!TryGetProperty(root, name, out var prop) || prop.ValueKind != JsonValueKind.Number) + return false; + return prop.TryGetInt32(out value); + } + + private static bool TryGetProperty(JsonElement root, string name, out JsonElement property) + { + if (root.TryGetProperty(name, out property)) + return true; + + foreach (var p in root.EnumerateObject()) + { + if (string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) + { + property = p.Value; + return true; + } + } + + property = default; + return false; + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsMessages.cs new file mode 100644 index 0000000..8e355a9 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsMessages.cs @@ -0,0 +1,15 @@ +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// User-facing copy for Organisation Settings. Keep these strings identical to the previous PageModel. +/// +public static class OrganisationSettingsMessages +{ + public const string TenantContextMissing = "Tenant context is not available for this request."; + + public const string Saved = "Organisation settings saved."; + + public const string SaveFailed = "Could not save organisation settings."; + + public const string LoadFailed = "Could not load organisation settings."; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsWorkState.cs new file mode 100644 index 0000000..020a386 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsWorkState.cs @@ -0,0 +1,29 @@ +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Mutable view-state bag for Organisation Settings. +/// +public sealed class OrganisationSettingsWorkState +{ + public Guid TenantId { get; set; } + + public string TenantName { get; set; } = string.Empty; + + public string TerminologySingular { get; set; } = "application"; + + public string TerminologyPlural { get; set; } = "applications"; + + public bool BannerEnabled { get; set; } + + public string? BannerHeading { get; set; } = "Important"; + + public string? BannerMessage { get; set; } = string.Empty; + + public int DashboardPageSize { get; set; } = 50; + + public bool DashboardEnableFilters { get; set; } + + public bool HasError { get; set; } + + public string? ErrorMessage { get; set; } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerAdminService.cs new file mode 100644 index 0000000..bfd8fd3 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerAdminService.cs @@ -0,0 +1,148 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Validation; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Lists and creates tenant roles. +/// +public interface IRoleManagerAdmin +{ + Task LoadAsync(RoleManagerWorkState state, CancellationToken cancellationToken = default); + + Task CreateAsync(RoleManagerWorkState state, CancellationToken cancellationToken = default); + + Task CreateFromTemplateAsync( + RoleManagerWorkState state, + string? templateKey, + CancellationToken cancellationToken = default); + + Task DeleteAsync( + RoleManagerWorkState state, + Guid roleId, + CancellationToken cancellationToken = default); + + Task RenameAsync( + RoleManagerWorkState state, + Guid roleId, + string? name, + CancellationToken cancellationToken = default); +} + +public sealed class RoleManagerAdminService( + IRolesClient rolesClient, + ILogger logger) : IRoleManagerAdmin +{ + public async Task LoadAsync(RoleManagerWorkState state, CancellationToken cancellationToken = default) + { + try + { + var roles = await rolesClient.ListAsync(cancellationToken); + state.Roles = roles? + .OrderBy(r => r.IsSystem ? 0 : 1) + .ThenBy(r => r.Name) + .ToList() ?? []; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load tenant roles"); + state.HasError = true; + state.ErrorMessage = AdminApiErrorMapper.Format(ex, RoleManagerMessages.LoadFailed); + state.Roles = []; + } + } + + public async Task CreateAsync( + RoleManagerWorkState state, + CancellationToken cancellationToken = default) + { + try + { + var created = await rolesClient.CreateAsync( + new CreateTenantRoleRequest { Name = state.NewRoleName.Trim() }, + cancellationToken); + + return AdminPageOutcome.Redirect(successMessage: RoleManagerMessages.Created(created.Name)); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create role {Name}", state.NewRoleName); + return AdminPageOutcome.Stay(errors: + [ + new FormValidationError( + string.Empty, + AdminApiErrorMapper.Format(ex, RoleManagerMessages.CreateFailed)) + ]); + } + } + + public async Task CreateFromTemplateAsync( + RoleManagerWorkState state, + string? templateKey, + CancellationToken cancellationToken = default) + { + templateKey = templateKey?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(templateKey)) + return AdminPageOutcome.Redirect(errorMessage: RoleManagerMessages.TemplateRequired); + + try + { + var created = await rolesClient.CreateFromTemplateAsync( + new CreateTenantRoleFromTemplateRequest(templateKey), + cancellationToken); + return AdminPageOutcome.Redirect( + successMessage: RoleManagerMessages.CreatedFromTemplate(created.Name, templateKey)); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create role from template {TemplateKey}", templateKey); + return AdminPageOutcome.Redirect( + errorMessage: AdminApiErrorMapper.Format(ex, RoleManagerMessages.CreateFromTemplateFailed)); + } + } + + public async Task DeleteAsync( + RoleManagerWorkState state, + Guid roleId, + CancellationToken cancellationToken = default) + { + try + { + await rolesClient.DeleteAsync(roleId, cancellationToken); + return AdminPageOutcome.Redirect(successMessage: RoleManagerMessages.Deleted); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete role {RoleId}", roleId); + return AdminPageOutcome.Redirect( + errorMessage: AdminApiErrorMapper.Format(ex, RoleManagerMessages.DeleteFailed)); + } + } + + public async Task RenameAsync( + RoleManagerWorkState state, + Guid roleId, + string? name, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(name)) + return AdminPageOutcome.Redirect(errorMessage: RoleManagerMessages.NameRequired); + + try + { + await rolesClient.RenameAsync( + roleId, + new RenameTenantRoleRequest { Name = name.Trim() }, + cancellationToken); + return AdminPageOutcome.Redirect(successMessage: RoleManagerMessages.Renamed); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to rename role {RoleId}", roleId); + return AdminPageOutcome.Redirect( + errorMessage: AdminApiErrorMapper.Format(ex, RoleManagerMessages.RenameFailed)); + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerMessages.cs new file mode 100644 index 0000000..0799d40 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerMessages.cs @@ -0,0 +1,31 @@ +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// User-facing copy for Role Manager. Keep these strings identical to the previous PageModel. +/// +public static class RoleManagerMessages +{ + public const string TemplateRequired = "Choose a role template."; + + public const string NameRequired = "Enter a role name."; + + public const string Deleted = "Role deleted."; + + public const string Renamed = "Role renamed."; + + public const string CreateFailed = "Could not create the role."; + + public const string CreateFromTemplateFailed = "Could not create the role from template."; + + public const string DeleteFailed = "Could not delete the role."; + + public const string RenameFailed = "Could not rename the role."; + + public const string LoadFailed = "Could not load roles for this tenant."; + + public static string Created(string name) => + $"Role '{name}' has been created."; + + public static string CreatedFromTemplate(string name, string templateKey) => + $"Role '{name}' has been created from the {templateKey} template."; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerPermissionsAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerPermissionsAdminService.cs new file mode 100644 index 0000000..e471b71 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerPermissionsAdminService.cs @@ -0,0 +1,204 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Validation; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Sets RolePermissions for a custom tenant role (ResourceType + ResourceKey + AccessType). +/// +public interface IRoleManagerPermissionsAdmin +{ + Task LoadAsync( + RoleManagerPermissionsWorkState state, + CancellationToken cancellationToken = default); + + Task AddGrantAsync( + RoleManagerPermissionsWorkState state, + CancellationToken cancellationToken = default); + + Task RemoveGrantAsync( + RoleManagerPermissionsWorkState state, + string grantKey, + CancellationToken cancellationToken = default); +} + +public sealed class RoleManagerPermissionsAdminService( + IRolesClient rolesClient, + ILogger logger) : IRoleManagerPermissionsAdmin +{ + public async Task LoadAsync( + RoleManagerPermissionsWorkState state, + CancellationToken cancellationToken = default) + { + if (await LoadRoleMetaAsync(state, cancellationToken) is { } failure) + return failure; + + if (state.IsSystemRole) + { + return AdminPageOutcome.Redirect( + errorMessage: RoleManagerPermissionsMessages.SystemRoleCannotChangeCreateCustom); + } + + try + { + await LoadPermissionsAsync(state, cancellationToken); + return AdminPageOutcome.Stay(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load permissions for role {RoleId}", state.RoleId); + return AdminPageOutcome.Redirect( + errorMessage: AdminApiErrorMapper.Format(ex, RoleManagerPermissionsMessages.LoadPermissionsFailed)); + } + } + + public async Task AddGrantAsync( + RoleManagerPermissionsWorkState state, + CancellationToken cancellationToken = default) + { + if (await LoadRoleMetaAsync(state, cancellationToken) is { } failure) + return failure; + + if (state.IsSystemRole) + return AdminPageOutcome.Redirect(errorMessage: RoleManagerPermissionsMessages.SystemRoleCannotChange); + + state.SelectedGrants = AdminPermissionGrants.NormalizeGrants(state.SelectedGrants); + + var resourceKey = state.NewResourceKey?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(resourceKey)) + { + return AdminPageOutcome.Stay(errors: + [ + new FormValidationError( + nameof(RoleManagerPermissionsWorkState.NewResourceKey), + RoleManagerPermissionsMessages.ResourceKeyRequired) + ]); + } + + var validationError = AdminPermissionGrants.ValidateGrant( + state.NewResourceType, + resourceKey, + state.NewAccessType); + if (validationError is not null) + { + return AdminPageOutcome.Stay(errors: + [ + new FormValidationError(nameof(RoleManagerPermissionsWorkState.NewResourceKey), validationError) + ]); + } + + var key = AdminPermissionGrants.EncodeGrantKey(state.NewResourceType, resourceKey, state.NewAccessType); + if (state.SelectedGrants.Contains(key, StringComparer.OrdinalIgnoreCase)) + { + return AdminPageOutcome.Stay(errors: + [ + new FormValidationError( + string.Empty, + RoleManagerPermissionsMessages.DuplicateGrant( + state.NewResourceType.ToString(), + resourceKey, + state.NewAccessType.ToString())) + ]); + } + + state.SelectedGrants.Add(key); + state.SelectedGrants = AdminPermissionGrants.NormalizeGrants(state.SelectedGrants); + + return await SaveAndReloadAsync(state, cancellationToken); + } + + public async Task RemoveGrantAsync( + RoleManagerPermissionsWorkState state, + string grantKey, + CancellationToken cancellationToken = default) + { + if (await LoadRoleMetaAsync(state, cancellationToken) is { } failure) + return failure; + + if (state.IsSystemRole) + return AdminPageOutcome.Redirect(errorMessage: RoleManagerPermissionsMessages.SystemRoleCannotChange); + + state.SelectedGrants = AdminPermissionGrants.NormalizeGrants(state.SelectedGrants); + state.SelectedGrants.RemoveAll(g => string.Equals(g, grantKey, StringComparison.OrdinalIgnoreCase)); + + return await SaveAndReloadAsync(state, cancellationToken); + } + + private async Task SaveAndReloadAsync( + RoleManagerPermissionsWorkState state, + CancellationToken cancellationToken) + { + foreach (var grant in state.SelectedGrants.Select(AdminPermissionGrants.ParseGrantKey).Where(g => g is not null)) + { + var error = AdminPermissionGrants.ValidateGrant( + grant!.Value.ResourceType, + grant.Value.ResourceKey, + grant.Value.AccessType); + if (error is not null) + return AdminPageOutcome.Stay(errors: [new FormValidationError(string.Empty, error)]); + } + + try + { + var grants = AdminPermissionGrants.ToGrantDtos(state.SelectedGrants); + + await rolesClient.SetPermissionsAsync( + state.RoleId, + new SetRolePermissionsRequest { Permissions = grants }, + cancellationToken); + + state.NewResourceKey = string.Empty; + await LoadPermissionsAsync(state, cancellationToken); + return AdminPageOutcome.Stay(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to set permissions for role {RoleId}", state.RoleId); + await LoadPermissionsAsync(state, cancellationToken); + + return AdminPageOutcome.Stay(errors: + [ + new FormValidationError( + string.Empty, + AdminApiErrorMapper.Format(ex, RoleManagerPermissionsMessages.SaveFailed)) + ]); + } + } + + private async Task LoadPermissionsAsync( + RoleManagerPermissionsWorkState state, + CancellationToken cancellationToken) + { + var existing = await rolesClient.GetPermissionsAsync(state.RoleId, cancellationToken); + state.SelectedGrants = AdminPermissionGrants.NormalizeGrants( + existing? + .Select(p => AdminPermissionGrants.EncodeGrantKey(p.ResourceType, p.ResourceKey, p.AccessType)) + .ToList() ?? []); + } + + /// A redirect outcome when the role cannot be loaded; otherwise null. + private async Task LoadRoleMetaAsync( + RoleManagerPermissionsWorkState state, + CancellationToken cancellationToken) + { + try + { + var roles = await rolesClient.ListAsync(cancellationToken); + var role = roles?.FirstOrDefault(r => r.RoleId == state.RoleId); + if (role is null) + return AdminPageOutcome.Redirect(errorMessage: RoleManagerPermissionsMessages.RoleNotFound); + + state.RoleName = role.Name; + state.IsSystemRole = role.IsSystem; + return null; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load role {RoleId}", state.RoleId); + return AdminPageOutcome.Redirect( + errorMessage: AdminApiErrorMapper.Format(ex, RoleManagerPermissionsMessages.LoadRoleFailed)); + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerPermissionsMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerPermissionsMessages.cs new file mode 100644 index 0000000..5340829 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerPermissionsMessages.cs @@ -0,0 +1,25 @@ +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// User-facing copy for Role Manager Permissions. Keep these strings identical to the previous PageModel. +/// +public static class RoleManagerPermissionsMessages +{ + public const string SystemRoleCannotChange = "System role permissions cannot be changed."; + + public const string SystemRoleCannotChangeCreateCustom = + "System role permissions cannot be changed. Create a custom role instead."; + + public const string RoleNotFound = "Role not found."; + + public const string ResourceKeyRequired = "Enter a resource key."; + + public const string SaveFailed = "Could not save permissions."; + + public const string LoadPermissionsFailed = "Could not load role permissions."; + + public const string LoadRoleFailed = "Could not load role."; + + public static string DuplicateGrant(string resourceType, string resourceKey, string accessType) => + $"{resourceType} / {resourceKey} / {accessType} is already in the list."; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerPermissionsWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerPermissionsWorkState.cs new file mode 100644 index 0000000..8a2ca12 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerPermissionsWorkState.cs @@ -0,0 +1,23 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Mutable view-state bag for the Role Manager Permissions admin page. +/// +public sealed class RoleManagerPermissionsWorkState +{ + public Guid RoleId { get; set; } + + public string RoleName { get; set; } = string.Empty; + + public bool IsSystemRole { get; set; } + + public List SelectedGrants { get; set; } = []; + + public ResourceType NewResourceType { get; set; } = ResourceType.Application; + + public string? NewResourceKey { get; set; } + + public AccessType NewAccessType { get; set; } = AccessType.Read; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerWorkState.cs new file mode 100644 index 0000000..2d3f908 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerWorkState.cs @@ -0,0 +1,17 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Mutable view-state bag for the Role Manager admin page. +/// +public sealed class RoleManagerWorkState +{ + public IReadOnlyList Roles { get; set; } = []; + + public bool HasError { get; set; } + + public string? ErrorMessage { get; set; } + + public string NewRoleName { get; set; } = string.Empty; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/TemplateManagerAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/TemplateManagerAdminService.cs new file mode 100644 index 0000000..dbc63b3 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/TemplateManagerAdminService.cs @@ -0,0 +1,226 @@ +using System.Text; +using System.Text.Json; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Application.Validation; +using GovUK.Dfe.FlexForms.Domain.Models; +using GovUK.Dfe.FlexForms.Domain.Templates; +using Microsoft.Extensions.Logging; +using Task = System.Threading.Tasks.Task; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Loads template versions, validates schema, creates versions, and grants tenant-wide access. +/// +public interface ITemplateManagerAdmin +{ + Task LoadTemplateDataAsync(TemplateManagerWorkState state, Guid templateId, CancellationToken cancellationToken = default); + + void PrefillNewSchemaIfEmpty(TemplateManagerWorkState state, Guid templateId); + + AdminPageOutcome ValidateNewVersion(TemplateManagerWorkState state); + + Task CreateVersionAsync(TemplateManagerWorkState state, Guid templateId, CancellationToken cancellationToken = default); + + string SuggestNextVersion(string? latestVersion, string? currentVersion); + + Task GrantToAllUsersAsync(TemplateManagerWorkState state, Guid templateId, CancellationToken cancellationToken = default); +} + +public sealed class TemplateManagerAdminService( + ITemplatesClient templatesClient, + ITemplateValidationService templateValidationService, + ILogger logger) : ITemplateManagerAdmin +{ + public async Task LoadTemplateDataAsync( + TemplateManagerWorkState state, + Guid templateId, + CancellationToken cancellationToken = default) + { + try + { + logger.LogDebug("Loading template data for {TemplateId}", templateId); + + state.SelectedTemplate = state.TenantTemplates.First(template => template.TemplateId == templateId); + state.SelectedTemplateId = templateId; + state.LatestVersionNumber = state.SelectedTemplate.LatestVersionNumber; + + var versions = await templatesClient.GetTemplateVersionsAsync(templateId); + state.AvailableVersions = versions.ToList(); + + if (state.AvailableVersions.Count == 0) + { + state.CurrentVersionNumber = null; + state.SelectedVersionNumber = null; + state.CurrentTemplate = null; + state.CurrentTemplateJson = null; + return; + } + + var requestedVersion = state.SelectedVersionNumber ?? state.SessionVersionNumber; + var selectedVersion = state.AvailableVersions.FirstOrDefault(v => + !string.IsNullOrWhiteSpace(requestedVersion) && + string.Equals(v.VersionNumber, requestedVersion, StringComparison.OrdinalIgnoreCase)) + ?? state.AvailableVersions[0]; + + state.SelectedVersionNumber = selectedVersion.VersionNumber; + state.CurrentVersionNumber = selectedVersion.VersionNumber; + state.LatestVersionNumber = state.AvailableVersions[0].VersionNumber; + state.SessionVersionNumber = selectedVersion.VersionNumber; + + var apiResponse = await templatesClient.GetTemplateSchemaByVersionAsync( + templateId, + selectedVersion.VersionNumber); + + var schemaJson = apiResponse.JsonSchema; + if (string.IsNullOrWhiteSpace(schemaJson)) + { + state.CurrentTemplate = null; + state.CurrentTemplateJson = null; + return; + } + + var options = new JsonSerializerOptions { WriteIndented = true, PropertyNameCaseInsensitive = true }; + state.CurrentTemplate = JsonSerializer.Deserialize(schemaJson, options); + state.CurrentTemplateJson = state.CurrentTemplate != null + ? JsonSerializer.Serialize(state.CurrentTemplate, options) + : PrettyPrintJson(schemaJson); + + logger.LogDebug( + "Loaded template {TemplateId} version {VersionNumber} with {TaskGroupCount} task groups", + templateId, + state.CurrentVersionNumber, + state.CurrentTemplate?.TaskGroups?.Count ?? 0); + } + catch (Exception ex) + { + logger.LogError(ex, "Error loading template data for {TemplateId}", templateId); + state.HasError = true; + state.ErrorMessage = TemplateManagerMessages.LoadFailed; + } + } + + public void PrefillNewSchemaIfEmpty(TemplateManagerWorkState state, Guid templateId) + { + if (!state.ShowAddVersionForm || !string.IsNullOrWhiteSpace(state.NewSchema)) + return; + + if (!string.IsNullOrWhiteSpace(state.CurrentTemplateJson)) + { + state.NewSchema = state.CurrentTemplateJson; + } + else + { + state.NewSchema = StarterFormTemplateSchema.CreateJson( + templateId.ToString(), + state.SelectedTemplate?.Name ?? "New template"); + state.NewVersion ??= StarterFormTemplateSchema.DefaultVersionNumber; + } + } + + public AdminPageOutcome ValidateNewVersion(TemplateManagerWorkState state) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(state.NewVersion)) + errors.Add(new FormValidationError(nameof(TemplateManagerWorkState.NewVersion), TemplateManagerMessages.VersionRequired)); + + if (string.IsNullOrWhiteSpace(state.NewSchema)) + { + errors.Add(new FormValidationError(nameof(TemplateManagerWorkState.NewSchema), TemplateManagerMessages.SchemaRequired)); + } + else + { + var (templateIsValid, validationErrors) = templateValidationService.ValidateTemplateJson(state.NewSchema); + if (!templateIsValid) + { + logger.LogWarning("Template validation failed with {ErrorCount} errors", validationErrors.Count); + errors.AddRange(validationErrors.Select(error => + new FormValidationError(nameof(TemplateManagerWorkState.NewSchema), error))); + } + else + { + logger.LogInformation("Template validation passed successfully"); + } + } + + if (!state.AcknowledgeReportingImpact) + { + errors.Add(new FormValidationError( + nameof(TemplateManagerWorkState.AcknowledgeReportingImpact), + TemplateManagerMessages.AcknowledgeReportingImpact)); + } + + return errors.Count == 0 + ? AdminPageOutcome.Stay() + : AdminPageOutcome.Stay(errors: errors); + } + + public async Task CreateVersionAsync( + TemplateManagerWorkState state, + Guid templateId, + CancellationToken cancellationToken = default) + { + var base64Schema = Convert.ToBase64String(Encoding.UTF8.GetBytes(state.NewSchema!)); + await templatesClient.CreateTemplateVersionAsync( + templateId, + new CreateTemplateVersionRequest(VersionNumber: state.NewVersion!, JsonSchema: base64Schema)); + + logger.LogInformation("Successfully created template version {NewVersion} for {TemplateId}", + state.NewVersion, templateId); + + return AdminPageOutcome.Redirect( + routeValues: new Dictionary { ["success"] = "true" }); + } + + public string SuggestNextVersion(string? latestVersion, string? currentVersion) => + TemplateVersionPolicy.IncrementPatch(latestVersion ?? currentVersion); + + public async Task GrantToAllUsersAsync( + TemplateManagerWorkState state, + Guid templateId, + CancellationToken cancellationToken = default) + { + try + { + var result = await templatesClient.GrantTemplateAccessToAllUsersAsync(templateId, cancellationToken); + var summary = TemplateManagerMessages.GrantedSummary( + result.UsersGranted, + result.UsersAlreadyHadAccess, + result.TotalUsers); + + logger.LogInformation( + "Granted template {TemplateId} to all tenant users. Granted={Granted}, AlreadyHad={AlreadyHad}, Total={Total}", + templateId, + result.UsersGranted, + result.UsersAlreadyHadAccess, + result.TotalUsers); + + state.GrantToAllUsersSummary = summary; + return AdminPageOutcome.Redirect(successMessage: summary); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to grant template {TemplateId} to all tenant users", templateId); + state.HasError = true; + state.ErrorMessage = TemplateManagerMessages.GrantFailed; + await LoadTemplateDataAsync(state, templateId, cancellationToken); + return AdminPageOutcome.Stay(errorMessage: TemplateManagerMessages.GrantFailed); + } + } + + private static string PrettyPrintJson(string json) + { + try + { + using var document = JsonDocument.Parse(json); + return JsonSerializer.Serialize(document.RootElement, new JsonSerializerOptions { WriteIndented = true }); + } + catch (JsonException) + { + return json; + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/TemplateManagerMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/TemplateManagerMessages.cs new file mode 100644 index 0000000..9bb44a7 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/TemplateManagerMessages.cs @@ -0,0 +1,31 @@ +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// User-facing copy for Template Manager. Keep these strings identical to the previous PageModel. +/// +public static class TemplateManagerMessages +{ + public const string SelectTemplate = "Select a template."; + + public const string SelectTenantTemplate = "Select a template for this tenant."; + + public const string SelectVersion = "Select a template version."; + + public const string VersionRequired = "Version number is required"; + + public const string SchemaRequired = "JSON schema is required"; + + public const string AcknowledgeReportingImpact = + "You must confirm that you understand the reporting impact before saving."; + + public const string GrantRequiresTemplate = "Select a template before granting access to all users."; + + public const string GrantFailed = "Failed to grant this template to all users in the tenant."; + + public const string LoadFailed = "There was an error loading the template data."; + + public const string ClearFailed = "Failed to clear sessions and caches."; + + public static string GrantedSummary(int granted, int alreadyHad, int total) => + $"Granted to {granted} user(s). {alreadyHad} already had access. Total tenant users checked: {total}."; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/TemplateManagerWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/TemplateManagerWorkState.cs new file mode 100644 index 0000000..65d2aaa --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/TemplateManagerWorkState.cs @@ -0,0 +1,44 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Domain.Models; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Mutable view-state bag for Template Manager. +/// +public sealed class TemplateManagerWorkState +{ + public Guid? SelectedTemplateId { get; set; } + + public string? SelectedVersionNumber { get; set; } + + public string? NewVersion { get; set; } + + public string? NewSchema { get; set; } + + public bool AcknowledgeReportingImpact { get; set; } + + public bool ShowAddVersionForm { get; set; } + + public FormTemplate? CurrentTemplate { get; set; } + + public string? CurrentVersionNumber { get; set; } + + public string? LatestVersionNumber { get; set; } + + public string? CurrentTemplateJson { get; set; } + + public bool HasError { get; set; } + + public string ErrorMessage { get; set; } = string.Empty; + + public IReadOnlyList TenantTemplates { get; set; } = []; + + public IReadOnlyList AvailableVersions { get; set; } = []; + + public TemplateDto? SelectedTemplate { get; set; } + + public string? SessionVersionNumber { get; set; } + + public string? GrantToAllUsersSummary { get; set; } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/TenantSettingsAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/TenantSettingsAdminService.cs new file mode 100644 index 0000000..1f50397 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/TenantSettingsAdminService.cs @@ -0,0 +1,369 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using Microsoft.Extensions.Logging; +using System.Text; +using System.Text.Json; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Loads and mutates TenantConfig app settings for the current tenant. +/// +public interface ITenantSettingsAdmin +{ + Task LoadAsync(TenantSettingsWorkState state, CancellationToken cancellationToken = default); + + Task ValidateAsync( + TenantSettingsWorkState state, + string? category, + string? target, + string? settingsJson, + bool isSecret, + CancellationToken cancellationToken = default); + + Task DeleteAsync( + TenantSettingsWorkState state, + string? category, + string? target, + CancellationToken cancellationToken = default); + + Task UpdateAsync( + TenantSettingsWorkState state, + string? category, + string? target, + string? settingsJson, + bool isSecret, + CancellationToken cancellationToken = default); + + Task AddAsync( + TenantSettingsWorkState state, + string? category, + string? target, + string? settingsJson, + bool isSecret, + CancellationToken cancellationToken = default); + + Task ExportAsync( + TenantSettingsWorkState state, + CancellationToken cancellationToken = default); + + Task ImportAsync( + TenantSettingsWorkState state, + string json, + CancellationToken cancellationToken = default); + + Task RefreshAsync( + TenantSettingsWorkState state, + CancellationToken cancellationToken = default); +} + +public sealed class TenantSettingsAdminService( + ITenantAdminClient tenantAdminClient, + ILogger logger) : ITenantSettingsAdmin +{ + public static readonly string[] ValidTargets = ["Shared", "Api", "Web"]; + + public async Task LoadAsync(TenantSettingsWorkState state, CancellationToken cancellationToken = default) + { + await LoadSettingsAsync(state, cancellationToken); + await LoadHealthAsync(state, cancellationToken); + await LoadCookbookAsync(state, cancellationToken); + await LoadAuditLogAsync(state, cancellationToken); + } + + public async Task ValidateAsync( + TenantSettingsWorkState state, + string? category, + string? target, + string? settingsJson, + bool isSecret, + CancellationToken cancellationToken = default) + { + category = category?.Trim() ?? string.Empty; + target = target?.Trim() ?? string.Empty; + settingsJson = settingsJson?.Trim() ?? string.Empty; + state.ValidationCategory = category; + state.ValidationTarget = target; + state.ValidationIsSecret = isSecret; + + await LoadAsync(state, cancellationToken); + + if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(settingsJson)) + { + state.HasError = true; + state.ErrorMessage = TenantSettingsMessages.ValidateRequired; + return AdminPageOutcome.Stay(errorMessage: TenantSettingsMessages.ValidateRequired); + } + + try + { + state.ValidationPreview = await tenantAdminClient.ValidateTenantSettingAsync( + state.TenantId, + new ValidateTenantSettingRequest( + category, + target, + AdminSettingsEncoding.ToBase64(settingsJson), + isSecret), + cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to validate tenant setting {Category}/{Target}", category, target); + var message = AdminApiErrorMapper.Format(ex, TenantSettingsMessages.ValidateFailed, includeGatewayHint: true); + state.HasError = true; + state.ErrorMessage = message; + return AdminPageOutcome.Stay(errorMessage: message); + } + + return AdminPageOutcome.Stay(); + } + + public async Task DeleteAsync( + TenantSettingsWorkState state, + string? category, + string? target, + CancellationToken cancellationToken = default) + { + category = category?.Trim() ?? string.Empty; + target = target?.Trim() ?? string.Empty; + + try + { + await tenantAdminClient.DeleteTenantSettingAsync(state.TenantId, category, target, cancellationToken); + await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken); + return AdminPageOutcome.Redirect( + successMessage: TenantSettingsMessages.Deleted(category, target), + refreshLocalCaches: true); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete tenant setting {Category}/{Target}", category, target); + return AdminPageOutcome.Redirect( + errorMessage: AdminApiErrorMapper.Format(ex, TenantSettingsMessages.DeleteFailed, includeGatewayHint: true)); + } + } + + public async Task UpdateAsync( + TenantSettingsWorkState state, + string? category, + string? target, + string? settingsJson, + bool isSecret, + CancellationToken cancellationToken = default) + { + category = category?.Trim() ?? string.Empty; + target = target?.Trim() ?? string.Empty; + settingsJson = settingsJson?.Trim() ?? string.Empty; + + if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(settingsJson)) + return AdminPageOutcome.Redirect(errorMessage: TenantSettingsMessages.CategoryAndJsonRequired); + + if (!ValidTargets.Contains(target, StringComparer.OrdinalIgnoreCase)) + return AdminPageOutcome.Redirect(errorMessage: TenantSettingsMessages.InvalidTarget); + + try + { + await tenantAdminClient.UpsertTenantSettingAsync( + state.TenantId, + new UpsertTenantSettingRequest(category, target, AdminSettingsEncoding.ToBase64(settingsJson), isSecret), + cancellationToken); + await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken); + return AdminPageOutcome.Redirect( + successMessage: TenantSettingsMessages.Updated(category, target), + refreshLocalCaches: true); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to update tenant setting {Category}/{Target}", category, target); + return AdminPageOutcome.Redirect( + errorMessage: AdminApiErrorMapper.Format(ex, TenantSettingsMessages.UpdateFailed, includeGatewayHint: true)); + } + } + + public async Task AddAsync( + TenantSettingsWorkState state, + string? category, + string? target, + string? settingsJson, + bool isSecret, + CancellationToken cancellationToken = default) + { + category = category?.Trim() ?? string.Empty; + target = string.IsNullOrWhiteSpace(target) ? "Shared" : target.Trim(); + settingsJson = settingsJson?.Trim() ?? string.Empty; + + if (string.IsNullOrWhiteSpace(category)) + return AdminPageOutcome.Redirect(errorMessage: TenantSettingsMessages.CategoryRequired); + + if (category.Length > 50) + return AdminPageOutcome.Redirect(errorMessage: TenantSettingsMessages.CategoryTooLong); + + if (!ValidTargets.Contains(target, StringComparer.OrdinalIgnoreCase)) + return AdminPageOutcome.Redirect(errorMessage: TenantSettingsMessages.InvalidTarget); + + if (string.IsNullOrWhiteSpace(settingsJson)) + return AdminPageOutcome.Redirect(errorMessage: TenantSettingsMessages.SettingsJsonRequired); + + try + { + await tenantAdminClient.UpsertTenantSettingAsync( + state.TenantId, + new UpsertTenantSettingRequest(category, target, AdminSettingsEncoding.ToBase64(settingsJson), isSecret), + cancellationToken); + await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken); + return AdminPageOutcome.Redirect( + successMessage: TenantSettingsMessages.Added(category, target), + refreshLocalCaches: true); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to add tenant setting {Category}/{Target}", category, target); + return AdminPageOutcome.Redirect( + errorMessage: AdminApiErrorMapper.Format(ex, TenantSettingsMessages.AddFailed, includeGatewayHint: true)); + } + } + + public async Task ExportAsync( + TenantSettingsWorkState state, + CancellationToken cancellationToken = default) + { + try + { + var export = await tenantAdminClient.ExportConfigurationAsync(state.TenantId, cancellationToken); + var json = JsonSerializer.Serialize(export, new JsonSerializerOptions { WriteIndented = true }); + var bytes = Encoding.UTF8.GetBytes(json); + return AdminPageOutcome.File( + bytes, + "application/json", + $"tenant-config-{state.TenantId:N}-{DateTime.UtcNow:yyyyMMddHHmmss}.json"); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to export tenant configuration for {TenantId}", state.TenantId); + return AdminPageOutcome.Redirect( + errorMessage: AdminApiErrorMapper.Format(ex, TenantSettingsMessages.ExportFailed, includeGatewayHint: true)); + } + } + + public async Task ImportAsync( + TenantSettingsWorkState state, + string json, + CancellationToken cancellationToken = default) + { + try + { + var exportBundle = JsonSerializer.Deserialize( + json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (exportBundle?.Settings is null || exportBundle.Settings.Count == 0) + return AdminPageOutcome.Redirect(errorMessage: TenantSettingsMessages.ImportEmpty); + + var importItems = exportBundle.Settings + .Select(s => new TenantSettingImportItemDto(s.Category, s.Target, s.SettingsJson, s.IsSecret)) + .ToList(); + + var bundle = new ImportTenantConfigurationDto(importItems, SkipSecretPlaceholders: true); + var result = await tenantAdminClient.ImportConfigurationAsync(state.TenantId, bundle, cancellationToken); + await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken); + + return AdminPageOutcome.Redirect( + successMessage: TenantSettingsMessages.Imported(result.AppliedCount, result.SkippedCount), + refreshLocalCaches: true); + } + catch (JsonException) + { + return AdminPageOutcome.Redirect(errorMessage: TenantSettingsMessages.ImportInvalidJson); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to import tenant configuration for {TenantId}", state.TenantId); + return AdminPageOutcome.Redirect( + errorMessage: AdminApiErrorMapper.Format(ex, TenantSettingsMessages.ImportFailed, includeGatewayHint: true)); + } + } + + public async Task RefreshAsync( + TenantSettingsWorkState state, + CancellationToken cancellationToken = default) + { + try + { + await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken); + return AdminPageOutcome.Redirect( + successMessage: TenantSettingsMessages.RefreshSuccess, + refreshLocalCaches: true); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to refresh tenant configuration for {TenantId}", state.TenantId); + return AdminPageOutcome.Redirect( + errorMessage: AdminApiErrorMapper.Format(ex, TenantSettingsMessages.RefreshFailed, includeGatewayHint: true)); + } + } + + private async Task LoadSettingsAsync(TenantSettingsWorkState state, CancellationToken cancellationToken) + { + try + { + var response = await tenantAdminClient.GetTenantSettingsAsync(state.TenantId, cancellationToken); + state.TenantName = response.TenantName; + state.Settings = response.Settings?.ToList() ?? []; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load tenant settings for {TenantId}", state.TenantId); + state.HasError = true; + state.ErrorMessage = AdminApiErrorMapper.Format(ex, TenantSettingsMessages.LoadFailed, includeGatewayHint: true); + state.Settings = []; + } + } + + private async Task LoadHealthAsync(TenantSettingsWorkState state, CancellationToken cancellationToken) + { + try + { + state.TenantHealth = await tenantAdminClient.GetTenantHealthAsync(state.TenantId, cancellationToken); + state.EffectiveConfig = state.TenantHealth.EffectiveConfiguration; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to load tenant health for {TenantId}", state.TenantId); + try + { + state.EffectiveConfig = await tenantAdminClient.GetEffectiveConfigurationAsync(state.TenantId, cancellationToken); + } + catch (Exception inner) + { + logger.LogWarning(inner, "Failed to load effective configuration for {TenantId}", state.TenantId); + } + } + } + + private async Task LoadCookbookAsync(TenantSettingsWorkState state, CancellationToken cancellationToken) + { + try + { + var response = await tenantAdminClient.GetCategoryCookbookAsync(cancellationToken); + state.Cookbook = response.Categories?.ToList() ?? []; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to load category cookbook"); + } + } + + private async Task LoadAuditLogAsync(TenantSettingsWorkState state, CancellationToken cancellationToken) + { + try + { + var log = await tenantAdminClient.GetSettingAuditLogAsync(state.TenantId, 20, cancellationToken); + state.AuditEntries = log?.Entries?.ToList() ?? []; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to load audit log for {TenantId}", state.TenantId); + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/TenantSettingsMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/TenantSettingsMessages.cs new file mode 100644 index 0000000..0480e9a --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/TenantSettingsMessages.cs @@ -0,0 +1,57 @@ +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// User-facing copy for Tenant Settings. Keep these strings identical to the previous PageModel. +/// +public static class TenantSettingsMessages +{ + public const string TenantContextMissing = "Tenant context is not available for this request."; + + public const string ValidateRequired = "Category and settings JSON are required to validate."; + + public const string ValidateFailed = "Could not validate setting."; + + public const string CategoryAndJsonRequired = "Category and settings JSON are required."; + + public const string InvalidTarget = "Target must be Shared, Api, or Web."; + + public const string CategoryRequired = "Enter a category name."; + + public const string CategoryTooLong = "Category must not exceed 50 characters."; + + public const string SettingsJsonRequired = "Enter settings JSON."; + + public const string DeleteFailed = "Could not delete setting."; + + public const string UpdateFailed = "Could not update setting."; + + public const string AddFailed = "Could not add setting."; + + public const string ExportFailed = "Could not export configuration."; + + public const string ImportFileRequired = "Select a JSON file to import."; + + public const string ImportEmpty = "The import file contains no settings."; + + public const string ImportInvalidJson = "The file is not valid JSON."; + + public const string ImportFailed = "Could not import configuration."; + + public const string RefreshFailed = "Could not refresh settings."; + + public const string LoadFailed = "Could not load tenant settings."; + + public const string RefreshSuccess = "Tenant configuration cache refreshed."; + + public static string Deleted(string category, string target) => + $"Deleted '{category}' ({target})."; + + public static string Updated(string category, string target) => + $"Updated '{category}' ({target})."; + + public static string Added(string category, string target) => + $"Added '{category}' ({target})."; + + public static string Imported(int appliedCount, int skippedCount) => + $"Imported {appliedCount} settings ({skippedCount} secret placeholders skipped)."; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/TenantSettingsWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/TenantSettingsWorkState.cs new file mode 100644 index 0000000..1b7e1f3 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/TenantSettingsWorkState.cs @@ -0,0 +1,35 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Mutable view-state bag for the Tenant Settings admin page. +/// +public sealed class TenantSettingsWorkState +{ + public Guid TenantId { get; set; } + + public string TenantName { get; set; } = string.Empty; + + public IReadOnlyList Settings { get; set; } = []; + + public TenantEffectiveConfigurationDto? EffectiveConfig { get; set; } + + public TenantHealthDto? TenantHealth { get; set; } + + public IReadOnlyList Cookbook { get; set; } = []; + + public IReadOnlyList AuditEntries { get; set; } = []; + + public ValidateTenantSettingResponse? ValidationPreview { get; set; } + + public string? ValidationCategory { get; set; } + + public string? ValidationTarget { get; set; } + + public bool ValidationIsSecret { get; set; } + + public bool HasError { get; set; } + + public string? ErrorMessage { get; set; } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAddAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAddAdminService.cs new file mode 100644 index 0000000..1bf625b --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAddAdminService.cs @@ -0,0 +1,146 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Validation; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Registers a user into the tenant with a role and optional form access. +/// +public interface IUserManagerAddAdmin +{ + Task LoadAsync(UserManagerAddWorkState state, CancellationToken cancellationToken = default); + + Task AddAsync(UserManagerAddWorkState state, CancellationToken cancellationToken = default); +} + +public sealed class UserManagerAddAdminService( + IUsersClient usersClient, + ITemplatesClient templatesClient, + IRolesClient rolesClient, + ILogger logger) : IUserManagerAddAdmin +{ + public async Task LoadAsync(UserManagerAddWorkState state, CancellationToken cancellationToken = default) + { + var errors = new List(); + await LoadTemplatesAsync(state, errors, cancellationToken); + await LoadRolesAsync(state, errors, cancellationToken); + state.Errors = errors; + } + + public async Task AddAsync( + UserManagerAddWorkState state, + CancellationToken cancellationToken = default) + { + if (!state.AssignableRoles.Contains(state.Role, StringComparer.OrdinalIgnoreCase)) + { + return Stay(state, new FormValidationError( + nameof(UserManagerAddWorkState.Role), + UserManagerAddMessages.InvalidRole)); + } + + var isSystemUserRole = string.Equals(state.Role, "User", StringComparison.OrdinalIgnoreCase); + if (isSystemUserRole && (state.SelectedTemplateIds is null || state.SelectedTemplateIds.Count == 0)) + { + return Stay(state, new FormValidationError( + nameof(UserManagerAddWorkState.SelectedTemplateIds), + UserManagerAddMessages.UserRoleRequiresTemplate)); + } + + try + { + var existingUsers = await usersClient.GetTenantUsersAsync(cancellationToken); + if (existingUsers?.Any(u => + string.Equals(u.Email, state.Email.Trim(), StringComparison.OrdinalIgnoreCase)) == true) + { + return Stay(state, new FormValidationError( + nameof(UserManagerAddWorkState.Email), + UserManagerAddMessages.DuplicateEmail)); + } + + var created = await usersClient.AssignUserRoleAsync( + new AssignUserRoleRequest + { + Name = state.Name.Trim(), + Email = state.Email.Trim(), + Role = state.Role, + TemplateIds = state.SelectedTemplateIds + }, + createOnly: true, + cancellationToken); + + if (created?.UserId is Guid userId && state.SelectedTemplateIds is { Count: > 0 }) + { + await usersClient.UpdateUserTemplateAccessAsync( + userId, + new UpdateUserTemplateAccessRequest { TemplateIds = state.SelectedTemplateIds }, + cancellationToken); + } + + return AdminPageOutcome.Redirect( + successMessage: UserManagerAddMessages.Added(state.Email.Trim(), state.Role)); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to add user {Email}", state.Email); + return Stay(state, new FormValidationError( + string.Empty, + AdminApiErrorMapper.Format(ex, UserManagerAddMessages.AddFailed))); + } + } + + private async Task LoadTemplatesAsync( + UserManagerAddWorkState state, + List errors, + CancellationToken cancellationToken) + { + try + { + var templates = await templatesClient.GetAccessibleTemplatesAsync(cancellationToken); + state.AvailableTemplates = templates?.OrderBy(t => t.Name).ToList() ?? []; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load templates for add user"); + errors.Add(new FormValidationError( + string.Empty, + AdminApiErrorMapper.Format(ex, UserManagerAddMessages.LoadTemplatesFailed))); + state.AvailableTemplates = []; + } + } + + private async Task LoadRolesAsync( + UserManagerAddWorkState state, + List errors, + CancellationToken cancellationToken) + { + try + { + var roles = await rolesClient.ListAsync(cancellationToken); + state.AssignableRoles = UserManagerAssignableRoles.Resolve( + roles?.Select(r => (r.Name, r.IsSystem)), + state.IncludeTenantAdmin); + + if (string.IsNullOrWhiteSpace(state.Role) + || !state.AssignableRoles.Contains(state.Role, StringComparer.OrdinalIgnoreCase)) + { + state.Role = state.AssignableRoles.FirstOrDefault() ?? "User"; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load roles for add user"); + errors.Add(new FormValidationError( + string.Empty, + AdminApiErrorMapper.Format(ex, UserManagerAddMessages.LoadRolesFailed))); + state.AssignableRoles = UserManagerAssignableRoles.Resolve(null, state.IncludeTenantAdmin); + } + } + + private static AdminPageOutcome Stay(UserManagerAddWorkState state, FormValidationError error) + { + state.Errors = [error]; + return AdminPageOutcome.Stay(errors: [error]); + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAddMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAddMessages.cs new file mode 100644 index 0000000..383a5f2 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAddMessages.cs @@ -0,0 +1,22 @@ +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// User-facing copy for User Manager Add. Keep these strings identical to the previous PageModel. +/// +public static class UserManagerAddMessages +{ + public const string InvalidRole = "Select a valid role for this tenant."; + + public const string UserRoleRequiresTemplate = "Select at least one form for the User role."; + + public const string DuplicateEmail = "A user with this email address already exists in this tenant."; + + public const string AddFailed = "Could not add the user."; + + public const string LoadTemplatesFailed = "Could not load available forms."; + + public const string LoadRolesFailed = "Could not load available roles."; + + public static string Added(string email, string role) => + $"User {email} has been added with role {role}."; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAddWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAddWorkState.cs new file mode 100644 index 0000000..389c9eb --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAddWorkState.cs @@ -0,0 +1,26 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Application.Validation; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Mutable view-state bag for the User Manager Add admin page. +/// +public sealed class UserManagerAddWorkState +{ + public string Name { get; set; } = string.Empty; + + public string Email { get; set; } = string.Empty; + + public string Role { get; set; } = "User"; + + public List SelectedTemplateIds { get; set; } = []; + + public IReadOnlyList AvailableTemplates { get; set; } = []; + + public IReadOnlyList AssignableRoles { get; set; } = []; + + public bool IncludeTenantAdmin { get; set; } + + public IReadOnlyList Errors { get; set; } = []; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAdminService.cs new file mode 100644 index 0000000..9a883f1 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAdminService.cs @@ -0,0 +1,82 @@ +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Lists tenant users and removes membership for the current tenant. +/// +public interface IUserManagerAdmin +{ + Task LoadAsync(UserManagerWorkState state, CancellationToken cancellationToken = default); + + Task RemoveAsync( + UserManagerWorkState state, + Guid userId, + CancellationToken cancellationToken = default); +} + +public sealed class UserManagerAdminService( + IUsersClient usersClient, + ILogger logger) : IUserManagerAdmin +{ + public async Task LoadAsync(UserManagerWorkState state, CancellationToken cancellationToken = default) + { + await LoadUsersAsync(state, cancellationToken); + await LoadAccessAuditLogAsync(state, cancellationToken); + } + + public async Task RemoveAsync( + UserManagerWorkState state, + Guid userId, + CancellationToken cancellationToken = default) + { + try + { + await usersClient.RemoveUserFromTenantAsync(userId, cancellationToken); + return AdminPageOutcome.Redirect(successMessage: UserManagerMessages.Removed); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to remove user {UserId} from tenant", userId); + return AdminPageOutcome.Redirect( + errorMessage: AdminApiErrorMapper.Format(ex, UserManagerMessages.RemoveFailed)); + } + } + + private async Task LoadUsersAsync(UserManagerWorkState state, CancellationToken cancellationToken) + { + try + { + var users = await usersClient.GetTenantUsersAsync(cancellationToken); + state.Users = users?.OrderBy(u => u.Name).ToList() ?? []; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load tenant users"); + state.HasError = true; + state.ErrorMessage = AdminApiErrorMapper.Format(ex, UserManagerMessages.LoadFailed); + state.Users = []; + } + } + + private async Task LoadAccessAuditLogAsync(UserManagerWorkState state, CancellationToken cancellationToken) + { + try + { + var log = await usersClient.GetAccessAuditLogAsync(take: 50, cancellationToken); + state.AccessAuditEntries = log?.Entries? + .OrderByDescending(e => e.OccurredAtUtc) + .ToList() ?? []; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to load tenant access audit log"); + state.AccessAuditEntries = []; + state.AuditLogLoadFailed = true; + state.AuditLogLoadErrorMessage = AdminApiErrorMapper.Format( + ex, + UserManagerMessages.AuditLogLoadFailed); + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAssignableRoles.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAssignableRoles.cs new file mode 100644 index 0000000..b551b6b --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAssignableRoles.cs @@ -0,0 +1,36 @@ +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Roles shown in User Manager add/edit. Always includes User and non-system roles. +/// Tenant Admin is included only when is true. +/// +public static class UserManagerAssignableRoles +{ + public static IReadOnlyList Resolve( + IEnumerable<(string Name, bool IsSystem)>? roles, + bool includeTenantAdmin) + { + var names = (roles ?? []) + .Where(r => + string.Equals(r.Name, "User", StringComparison.OrdinalIgnoreCase) + || (includeTenantAdmin + && string.Equals(r.Name, "Admin", StringComparison.OrdinalIgnoreCase)) + || !r.IsSystem) + .Select(r => r.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (!names.Any(n => string.Equals(n, "User", StringComparison.OrdinalIgnoreCase))) + names.Add("User"); + + if (includeTenantAdmin + && !names.Any(n => string.Equals(n, "Admin", StringComparison.OrdinalIgnoreCase))) + { + names.Add("Admin"); + } + + return names + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) + .ToList(); + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerEditAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerEditAdminService.cs new file mode 100644 index 0000000..b5c89be --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerEditAdminService.cs @@ -0,0 +1,168 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Validation; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Edits a tenant user's role and form access. +/// +public interface IUserManagerEditAdmin +{ + Task LoadAsync(UserManagerEditWorkState state, CancellationToken cancellationToken = default); + + Task LoadForUpdateAsync( + UserManagerEditWorkState state, + CancellationToken cancellationToken = default); + + Task UpdateAsync(UserManagerEditWorkState state, CancellationToken cancellationToken = default); +} + +public sealed class UserManagerEditAdminService( + IUsersClient usersClient, + ITemplatesClient templatesClient, + IRolesClient rolesClient, + ILogger logger) : IUserManagerEditAdmin +{ + public async Task LoadAsync( + UserManagerEditWorkState state, + CancellationToken cancellationToken = default) + { + try + { + var errors = await LoadLookupsAsync(state, cancellationToken); + + var users = await usersClient.GetTenantUsersAsync(cancellationToken); + var user = users?.FirstOrDefault(u => u.UserId == state.UserId); + if (user is null) + return AdminPageOutcome.Redirect(errorMessage: UserManagerEditMessages.UserNotFound); + + state.UserName = user.Name; + state.UserEmail = user.Email; + state.Role = user.Role; + + if (!state.AssignableRoles.Contains(state.Role, StringComparer.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(state.Role)) + { + state.AssignableRoles = state.AssignableRoles + .Append(state.Role) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(r => r) + .ToList(); + } + + if (state.SelectedTemplateIds.Count == 0) + state.SelectedTemplateIds = user.Templates.Select(t => t.TemplateId).ToList(); + + state.Errors = errors; + return AdminPageOutcome.Stay(errors: errors); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load user {UserId} for edit", state.UserId); + return AdminPageOutcome.Redirect( + errorMessage: AdminApiErrorMapper.Format(ex, UserManagerEditMessages.LoadFailed)); + } + } + + public async Task LoadForUpdateAsync( + UserManagerEditWorkState state, + CancellationToken cancellationToken = default) + { + var errors = await LoadLookupsAsync(state, cancellationToken); + + var users = await usersClient.GetTenantUsersAsync(cancellationToken); + var user = users?.FirstOrDefault(u => u.UserId == state.UserId); + if (user is null) + return AdminPageOutcome.Redirect(errorMessage: UserManagerEditMessages.UserNotFound); + + state.UserName = user.Name; + state.UserEmail = user.Email; + state.Errors = errors; + return AdminPageOutcome.Stay(errors: errors); + } + + public async Task UpdateAsync( + UserManagerEditWorkState state, + CancellationToken cancellationToken = default) + { + if (!state.AssignableRoles.Contains(state.Role, StringComparer.OrdinalIgnoreCase)) + { + var error = new FormValidationError( + nameof(UserManagerEditWorkState.Role), + UserManagerEditMessages.InvalidRole); + state.Errors = [error]; + return AdminPageOutcome.Stay(errors: [error]); + } + + try + { + await usersClient.AssignUserRoleAsync( + new AssignUserRoleRequest + { + Name = state.UserName, + Email = state.UserEmail, + Role = state.Role, + TemplateIds = state.SelectedTemplateIds + }, + createOnly: false, + cancellationToken); + + await usersClient.UpdateUserTemplateAccessAsync( + state.UserId, + new UpdateUserTemplateAccessRequest { TemplateIds = state.SelectedTemplateIds ?? [] }, + cancellationToken); + + return AdminPageOutcome.Redirect(successMessage: UserManagerEditMessages.Updated); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to update user {UserId}", state.UserId); + var error = new FormValidationError( + string.Empty, + AdminApiErrorMapper.Format(ex, UserManagerEditMessages.UpdateFailed)); + state.Errors = [error]; + return AdminPageOutcome.Stay(errors: [error]); + } + } + + private async Task> LoadLookupsAsync( + UserManagerEditWorkState state, + CancellationToken cancellationToken) + { + var errors = new List(); + + try + { + var templates = await templatesClient.GetAccessibleTemplatesAsync(cancellationToken); + state.AvailableTemplates = templates?.OrderBy(t => t.Name).ToList() ?? []; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load templates for edit user"); + errors.Add(new FormValidationError( + string.Empty, + AdminApiErrorMapper.Format(ex, UserManagerEditMessages.LoadTemplatesFailed))); + state.AvailableTemplates = []; + } + + try + { + var roles = await rolesClient.ListAsync(cancellationToken); + state.AssignableRoles = UserManagerAssignableRoles.Resolve( + roles?.Select(r => (r.Name, r.IsSystem)), + state.IncludeTenantAdmin); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load roles for edit user"); + errors.Add(new FormValidationError( + string.Empty, + AdminApiErrorMapper.Format(ex, UserManagerEditMessages.LoadRolesFailed))); + state.AssignableRoles = UserManagerAssignableRoles.Resolve(null, state.IncludeTenantAdmin); + } + + return errors; + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerEditMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerEditMessages.cs new file mode 100644 index 0000000..47eba25 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerEditMessages.cs @@ -0,0 +1,21 @@ +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// User-facing copy for User Manager Edit. Keep these strings identical to the previous PageModel. +/// +public static class UserManagerEditMessages +{ + public const string UserNotFound = "User not found in this tenant."; + + public const string InvalidRole = "Select a valid role for this tenant."; + + public const string Updated = "User role and form access updated."; + + public const string UpdateFailed = "Could not update the user."; + + public const string LoadFailed = "Could not load user details."; + + public const string LoadTemplatesFailed = "Could not load available forms."; + + public const string LoadRolesFailed = "Could not load available roles."; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerEditWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerEditWorkState.cs new file mode 100644 index 0000000..6e69e31 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerEditWorkState.cs @@ -0,0 +1,28 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Application.Validation; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Mutable view-state bag for the User Manager Edit admin page. +/// +public sealed class UserManagerEditWorkState +{ + public Guid UserId { get; set; } + + public string UserName { get; set; } = string.Empty; + + public string UserEmail { get; set; } = string.Empty; + + public string Role { get; set; } = string.Empty; + + public List SelectedTemplateIds { get; set; } = []; + + public IReadOnlyList AvailableTemplates { get; set; } = []; + + public IReadOnlyList AssignableRoles { get; set; } = []; + + public bool IncludeTenantAdmin { get; set; } + + public IReadOnlyList Errors { get; set; } = []; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerMessages.cs new file mode 100644 index 0000000..12f18bf --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerMessages.cs @@ -0,0 +1,16 @@ +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// User-facing copy for User Manager. Keep these strings identical to the previous PageModel. +/// +public static class UserManagerMessages +{ + public const string Removed = "User removed from this tenant."; + + public const string RemoveFailed = "Could not remove the user from this tenant."; + + public const string LoadFailed = "Could not load users for this tenant."; + + public const string AuditLogLoadFailed = + "Could not load the access audit trail. Ensure the API is up to date and database migrations have been applied."; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerPermissionsAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerPermissionsAdminService.cs new file mode 100644 index 0000000..78f4390 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerPermissionsAdminService.cs @@ -0,0 +1,193 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Validation; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Sets user-level Permissions for a tenant member (ResourceType + ResourceKey + AccessType). +/// Does not affect permissions inherited from the user's role. +/// +public interface IUserManagerPermissionsAdmin +{ + Task LoadAsync( + UserManagerPermissionsWorkState state, + CancellationToken cancellationToken = default); + + Task AddGrantAsync( + UserManagerPermissionsWorkState state, + CancellationToken cancellationToken = default); + + Task RemoveGrantAsync( + UserManagerPermissionsWorkState state, + string grantKey, + CancellationToken cancellationToken = default); +} + +public sealed class UserManagerPermissionsAdminService( + IUsersClient usersClient, + ILogger logger) : IUserManagerPermissionsAdmin +{ + public async Task LoadAsync( + UserManagerPermissionsWorkState state, + CancellationToken cancellationToken = default) + { + if (await LoadUserMetaAsync(state, cancellationToken) is { } failure) + return failure; + + try + { + await LoadPermissionsAsync(state, cancellationToken); + return AdminPageOutcome.Stay(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load permissions for user {UserId}", state.UserId); + return AdminPageOutcome.Redirect( + errorMessage: AdminApiErrorMapper.Format(ex, UserManagerPermissionsMessages.LoadPermissionsFailed)); + } + } + + public async Task AddGrantAsync( + UserManagerPermissionsWorkState state, + CancellationToken cancellationToken = default) + { + if (await LoadUserMetaAsync(state, cancellationToken) is { } failure) + return failure; + + state.SelectedGrants = AdminPermissionGrants.NormalizeGrants(state.SelectedGrants); + + var resourceKey = state.NewResourceKey?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(resourceKey)) + { + return AdminPageOutcome.Stay(errors: + [ + new FormValidationError( + nameof(UserManagerPermissionsWorkState.NewResourceKey), + UserManagerPermissionsMessages.ResourceKeyRequired) + ]); + } + + var validationError = AdminPermissionGrants.ValidateUserGrant( + state.NewResourceType, + resourceKey, + state.NewAccessType); + if (validationError is not null) + { + return AdminPageOutcome.Stay(errors: + [ + new FormValidationError(nameof(UserManagerPermissionsWorkState.NewResourceKey), validationError) + ]); + } + + var key = AdminPermissionGrants.EncodeGrantKey(state.NewResourceType, resourceKey, state.NewAccessType); + if (state.SelectedGrants.Contains(key, StringComparer.OrdinalIgnoreCase)) + { + return AdminPageOutcome.Stay(errors: + [ + new FormValidationError( + string.Empty, + UserManagerPermissionsMessages.DuplicateGrant( + state.NewResourceType.ToString(), + resourceKey, + state.NewAccessType.ToString())) + ]); + } + + state.SelectedGrants.Add(key); + state.SelectedGrants = AdminPermissionGrants.NormalizeGrants(state.SelectedGrants); + + return await SaveAndReloadAsync(state, cancellationToken); + } + + public async Task RemoveGrantAsync( + UserManagerPermissionsWorkState state, + string grantKey, + CancellationToken cancellationToken = default) + { + if (await LoadUserMetaAsync(state, cancellationToken) is { } failure) + return failure; + + state.SelectedGrants = AdminPermissionGrants.NormalizeGrants(state.SelectedGrants); + state.SelectedGrants.RemoveAll(g => string.Equals(g, grantKey, StringComparison.OrdinalIgnoreCase)); + + return await SaveAndReloadAsync(state, cancellationToken); + } + + private async Task SaveAndReloadAsync( + UserManagerPermissionsWorkState state, + CancellationToken cancellationToken) + { + foreach (var grant in state.SelectedGrants.Select(AdminPermissionGrants.ParseGrantKey).Where(g => g is not null)) + { + var error = AdminPermissionGrants.ValidateUserGrant( + grant!.Value.ResourceType, + grant.Value.ResourceKey, + grant.Value.AccessType); + if (error is not null) + return AdminPageOutcome.Stay(errors: [new FormValidationError(string.Empty, error)]); + } + + try + { + var grants = AdminPermissionGrants.ToGrantDtos(state.SelectedGrants); + + await usersClient.SetUserPermissionsAsync( + state.UserId, + new SetUserPermissionsRequest { Permissions = grants }, + cancellationToken); + + state.NewResourceKey = string.Empty; + await LoadPermissionsAsync(state, cancellationToken); + return AdminPageOutcome.Stay(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to save permissions for user {UserId}", state.UserId); + await LoadPermissionsAsync(state, cancellationToken); + + return AdminPageOutcome.Stay(errors: + [ + new FormValidationError( + string.Empty, + AdminApiErrorMapper.Format(ex, UserManagerPermissionsMessages.SaveFailed)) + ]); + } + } + + private async Task LoadPermissionsAsync( + UserManagerPermissionsWorkState state, + CancellationToken cancellationToken) + { + var existing = await usersClient.GetUserPermissionsAsync(state.UserId, cancellationToken); + state.SelectedGrants = AdminPermissionGrants.NormalizeGrants( + existing? + .Select(p => AdminPermissionGrants.EncodeGrantKey(p.ResourceType, p.ResourceKey, p.AccessType)) + .ToList() ?? []); + } + + /// A redirect outcome when the user cannot be loaded; otherwise null. + private async Task LoadUserMetaAsync( + UserManagerPermissionsWorkState state, + CancellationToken cancellationToken) + { + try + { + var users = await usersClient.GetTenantUsersAsync(cancellationToken); + var user = users?.FirstOrDefault(u => u.UserId == state.UserId); + if (user is null) + return AdminPageOutcome.Redirect(errorMessage: UserManagerPermissionsMessages.UserNotFound); + + state.UserName = user.Name; + state.UserEmail = user.Email; + return null; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load user {UserId}", state.UserId); + return AdminPageOutcome.Redirect( + errorMessage: AdminApiErrorMapper.Format(ex, UserManagerPermissionsMessages.LoadUserFailed)); + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerPermissionsMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerPermissionsMessages.cs new file mode 100644 index 0000000..a8ebce2 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerPermissionsMessages.cs @@ -0,0 +1,20 @@ +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// User-facing copy for User Manager Permissions. Keep these strings identical to the previous PageModel. +/// +public static class UserManagerPermissionsMessages +{ + public const string UserNotFound = "User not found."; + + public const string ResourceKeyRequired = "Enter a resource key."; + + public const string SaveFailed = "Could not save permissions."; + + public const string LoadPermissionsFailed = "Could not load user permissions."; + + public const string LoadUserFailed = "Could not load user."; + + public static string DuplicateGrant(string resourceType, string resourceKey, string accessType) => + $"{resourceType} / {resourceKey} / {accessType} is already in the list."; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerPermissionsWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerPermissionsWorkState.cs new file mode 100644 index 0000000..006ee9e --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerPermissionsWorkState.cs @@ -0,0 +1,23 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Mutable view-state bag for the User Manager Permissions admin page. +/// +public sealed class UserManagerPermissionsWorkState +{ + public Guid UserId { get; set; } + + public string UserName { get; set; } = string.Empty; + + public string UserEmail { get; set; } = string.Empty; + + public List SelectedGrants { get; set; } = []; + + public ResourceType NewResourceType { get; set; } = ResourceType.Application; + + public string? NewResourceKey { get; set; } + + public AccessType NewAccessType { get; set; } = AccessType.Read; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerWorkState.cs new file mode 100644 index 0000000..9669b98 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerWorkState.cs @@ -0,0 +1,21 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; + +namespace GovUK.Dfe.FlexForms.Application.Admin; + +/// +/// Mutable view-state bag for the User Manager admin page. +/// +public sealed class UserManagerWorkState +{ + public IReadOnlyList Users { get; set; } = []; + + public IReadOnlyList AccessAuditEntries { get; set; } = []; + + public bool HasError { get; set; } + + public string? ErrorMessage { get; set; } + + public bool AuditLogLoadFailed { get; set; } + + public string? AuditLogLoadErrorMessage { get; set; } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Dashboard/ApplicationWithCalculatedStatus.cs b/src/GovUK.Dfe.FlexForms.Application/Dashboard/ApplicationWithCalculatedStatus.cs new file mode 100644 index 0000000..26ac1d4 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Dashboard/ApplicationWithCalculatedStatus.cs @@ -0,0 +1,28 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; + +namespace GovUK.Dfe.FlexForms.Application.Dashboard; + +/// +/// Dashboard row with a display status and optional custom column values. +/// Shared by the user dashboard and the admin applications index. +/// +public sealed class ApplicationWithCalculatedStatus +{ + public ApplicationDto Application { get; set; } = null!; + + public KeyValuePair CalculatedStatus { get; set; } + + public IReadOnlyDictionary CustomColumnValues { get; set; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public Guid ApplicationId => Application.ApplicationId; + + public string ApplicationReference => Application.ApplicationReference; + + public string TemplateName => Application.TemplateName; + + public DateTime DateCreated => Application.DateCreated; + + public DateTime? DateSubmitted => Application.DateSubmitted; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardApplicationListQuery.cs b/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardApplicationListQuery.cs new file mode 100644 index 0000000..6239455 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardApplicationListQuery.cs @@ -0,0 +1,64 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; + +namespace GovUK.Dfe.FlexForms.Application.Dashboard; + +public enum DashboardApplicationListScope +{ + Mine, + AllForTemplate +} + +/// +/// Query for listing applications on the dashboard or admin index. +/// +public sealed class DashboardApplicationListQuery +{ + public Guid TemplateId { get; init; } + + public int CurrentPage { get; init; } = 1; + + public int PageSize { get; init; } = 50; + + public DashboardApplicationListScope Scope { get; init; } = DashboardApplicationListScope.Mine; + + public bool IncludeCustomColumns { get; init; } + + public IReadOnlyList Columns { get; init; } = DashboardColumnResolver.DefaultColumns; + + public IReadOnlyList CustomStatuses { get; init; } = []; + + public string? SearchReference { get; init; } + + public DateTime? DateStartedFrom { get; init; } + + public DateTime? DateStartedTo { get; init; } + + public DateTime? DateSubmittedFrom { get; init; } + + public DateTime? DateSubmittedTo { get; init; } + + public ApplicationStatus? Status { get; init; } +} + +/// +/// Result of a dashboard application list query. +/// +public sealed class DashboardApplicationListResult +{ + public IReadOnlyList Applications { get; init; } = []; + + public int TotalPages { get; init; } + + public int CurrentPage { get; init; } = 1; +} + +/// +/// Result of creating an application from the dashboard. +/// +public sealed class DashboardCreateApplicationResult +{ + public ApplicationDto Application { get; init; } = null!; + + public bool ContributorsEnabled { get; init; } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardApplicationStatusCalculator.cs b/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardApplicationStatusCalculator.cs new file mode 100644 index 0000000..2f26f26 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardApplicationStatusCalculator.cs @@ -0,0 +1,99 @@ +using System.ComponentModel; +using System.Text.Json; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.Dashboard; + +/// +/// Calculates dashboard display status from an application and custom labels. +/// Behaviour matches the previous Web ApplicationStatusService helpers. +/// +public static class DashboardApplicationStatusCalculator +{ + public static KeyValuePair GetCalculatedStatus( + ApplicationDto application, + IReadOnlyList customStatuses, + ILogger logger) + { + try + { + if (application.Status == ApplicationStatus.Submitted) + { + return new KeyValuePair( + ApplicationStatus.Submitted, + GetStatusLabel(ApplicationStatus.Submitted, customStatuses)); + } + + if (application.LatestResponse?.ResponseBody != null) + { + try + { + string responseJson; + try + { + var decodedBytes = Convert.FromBase64String(application.LatestResponse.ResponseBody); + responseJson = System.Text.Encoding.UTF8.GetString(decodedBytes); + } + catch + { + responseJson = application.LatestResponse.ResponseBody; + } + + var responseData = JsonSerializer.Deserialize>(responseJson); + if (responseData != null && responseData.Any()) + { + var hasFieldData = responseData.Any(kvp => + !kvp.Key.StartsWith("TaskStatus_") && + kvp.Value.ValueKind != JsonValueKind.Null && + !string.IsNullOrWhiteSpace(kvp.Value.ToString())); + + if (hasFieldData) + { + return new KeyValuePair( + ApplicationStatus.InProgress, + GetStatusLabel(ApplicationStatus.InProgress, customStatuses)); + } + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to parse response data for application {ApplicationId}", application.ApplicationId); + } + } + + var currentStatus = application.Status.HasValue ? application.Status.Value : ApplicationStatus.Created; + return new KeyValuePair(currentStatus, GetStatusLabel(currentStatus, customStatuses)); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to calculate application status for {ApplicationId}, defaulting to InProgress", + application.ApplicationId); + return new KeyValuePair( + ApplicationStatus.InProgress, + GetStatusLabel(ApplicationStatus.InProgress, customStatuses)); + } + } + + public static string GetStatusLabel( + ApplicationStatus status, + IReadOnlyList? customStatuses) + { + if (customStatuses != null) + { + var customStatus = customStatuses.FirstOrDefault(x => x.ApplicationStatus == status); + if (customStatus?.Label != null) + return customStatus.Label; + } + + return GetBaseStatusLabel(status); + } + + public static string GetBaseStatusLabel(ApplicationStatus status) + { + var appStatusInfo = status.GetType().GetField(status.ToString()); + var descriptionAttributes = (DescriptionAttribute[])appStatusInfo!.GetCustomAttributes(typeof(DescriptionAttribute), false); + return descriptionAttributes.Length > 0 ? descriptionAttributes[0].Description : status.ToString(); + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardApplicationsService.cs b/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardApplicationsService.cs new file mode 100644 index 0000000..2c49323 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardApplicationsService.cs @@ -0,0 +1,147 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using Microsoft.Extensions.Logging; +using Task = System.Threading.Tasks.Task; + +namespace GovUK.Dfe.FlexForms.Application.Dashboard; + +/// +/// Loads dashboard columns and lists or creates applications for the current template. +/// +public interface IDashboardApplications +{ + Task> ResolveColumnsAsync( + Guid? templateId, + CancellationToken cancellationToken = default); + + Task ListAsync( + DashboardApplicationListQuery query, + CancellationToken cancellationToken = default); + + Task CreateAsync( + Guid templateId, + CancellationToken cancellationToken = default); +} + +public sealed class DashboardApplicationsService( + IApplicationsClient applicationsClient, + IFormTemplateProvider formTemplateProvider, + IContributorPatternService contributorPatternService, + ILogger logger) : IDashboardApplications +{ + public async Task> ResolveColumnsAsync( + Guid? templateId, + CancellationToken cancellationToken = default) + { + if (!templateId.HasValue) + return DashboardColumnResolver.DefaultColumns; + + try + { + var template = await formTemplateProvider.GetTemplateAsync(templateId.Value.ToString(), cancellationToken); + return DashboardColumnResolver.Resolve(template); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Failed to load dashboard columns from latest template {TemplateId}; using defaults", + templateId); + return DashboardColumnResolver.DefaultColumns; + } + } + + public async Task ListAsync( + DashboardApplicationListQuery query, + CancellationToken cancellationToken = default) + { + var result = query.Scope == DashboardApplicationListScope.AllForTemplate + ? await applicationsClient.GetApplicationsByTemplateAsync( + templateId: query.TemplateId, + pageNumber: query.CurrentPage, + pageSize: query.PageSize, + applicationReference: string.IsNullOrWhiteSpace(query.SearchReference) ? null : query.SearchReference, + dateStartedFrom: query.DateStartedFrom, + dateStartedTo: query.DateStartedTo, + dateSubmittedFrom: query.DateSubmittedFrom, + dateSubmittedTo: query.DateSubmittedTo, + status: query.Status, + cancellationToken: cancellationToken) + : await applicationsClient.GetMyApplicationsAsync( + templateId: query.TemplateId, + pageNumber: query.CurrentPage, + pageSize: query.PageSize, + applicationReference: string.IsNullOrWhiteSpace(query.SearchReference) ? null : query.SearchReference, + dateStartedFrom: query.DateStartedFrom, + dateStartedTo: query.DateStartedTo, + dateSubmittedFrom: query.DateSubmittedFrom, + dateSubmittedTo: query.DateSubmittedTo, + status: query.Status, + cancellationToken: cancellationToken); + + var totalPages = result.TotalPages; + var currentPage = Math.Clamp(query.CurrentPage, 1, Math.Max(1, totalPages)); + var fieldColumns = query.IncludeCustomColumns + ? query.Columns.Where(c => c.Kind == DashboardColumnKind.Field).ToList() + : []; + + var applications = result.Items + .Select(app => + { + IReadOnlyDictionary customValues = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (fieldColumns.Count > 0) + { + var formData = DashboardAnswerReader.ParseFormData(app.LatestResponse?.ResponseBody); + customValues = fieldColumns.ToDictionary( + c => c.Key, + c => DashboardAnswerReader.GetDisplayValue(c.FieldId!, formData), + StringComparer.OrdinalIgnoreCase); + } + + return new ApplicationWithCalculatedStatus + { + Application = app, + CalculatedStatus = DashboardApplicationStatusCalculator.GetCalculatedStatus( + app, + query.CustomStatuses, + logger), + CustomColumnValues = customValues + }; + }) + .OrderByDescending(a => a.DateCreated) + .ToList(); + + return new DashboardApplicationListResult + { + Applications = applications, + TotalPages = totalPages, + CurrentPage = currentPage + }; + } + + public async Task CreateAsync( + Guid templateId, + CancellationToken cancellationToken = default) + { + var response = await applicationsClient.CreateApplicationAsync( + new CreateApplicationRequest + { + InitialResponseBody = "{}", + TemplateId = templateId + }, + cancellationToken); + + var contributorsEnabled = await contributorPatternService.IsEnabledAsync( + templateId.ToString(), + cancellationToken: cancellationToken); + + return new DashboardCreateApplicationResult + { + Application = response, + ContributorsEnabled = contributorsEnabled + }; + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardMessages.cs new file mode 100644 index 0000000..4c3815e --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardMessages.cs @@ -0,0 +1,9 @@ +namespace GovUK.Dfe.FlexForms.Application.Dashboard; + +/// +/// User-facing copy for the applications dashboard. Keep these strings identical to the previous PageModel. +/// +public static class DashboardMessages +{ + public const string TemplateNotConfigured = "Template is not configured. Please refresh the page."; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/CollectionFlowProgressStore.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/CollectionFlowProgressStore.cs new file mode 100644 index 0000000..cd8dfa5 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/CollectionFlowProgressStore.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Caching; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +public sealed class CollectionFlowProgressStore(IFormSessionStore sessionStore) : ICollectionFlowProgressStore +{ + public Dictionary Load(string flowId, string instanceId) + { + if (string.IsNullOrEmpty(flowId) || string.IsNullOrEmpty(instanceId)) + return new Dictionary(); + + var json = sessionStore.GetString(FormSessionKeys.FlowProgress(flowId, instanceId)); + if (string.IsNullOrWhiteSpace(json)) + return new Dictionary(); + + try + { + return JsonSerializer.Deserialize>(json) + ?? new Dictionary(); + } + catch (JsonException) + { + return new Dictionary(); + } + } + + public void Save(string flowId, string instanceId, Dictionary latest) + { + if (string.IsNullOrEmpty(flowId) || string.IsNullOrEmpty(instanceId)) + return; + + var existing = Load(flowId, instanceId); + foreach (var kv in latest) + existing[kv.Key] = kv.Value; + + sessionStore.SetString(FormSessionKeys.FlowProgress(flowId, instanceId), JsonSerializer.Serialize(existing)); + } + + public void SetField(string flowId, string instanceId, string fieldId, object value) + { + if (string.IsNullOrEmpty(flowId) || string.IsNullOrEmpty(instanceId) || string.IsNullOrEmpty(fieldId)) + return; + + var existing = Load(flowId, instanceId); + existing[fieldId] = value; + sessionStore.SetString(FormSessionKeys.FlowProgress(flowId, instanceId), JsonSerializer.Serialize(existing)); + } + + public void Clear(string flowId, string instanceId) + { + if (string.IsNullOrEmpty(flowId) || string.IsNullOrEmpty(instanceId)) + return; + + sessionStore.Remove(FormSessionKeys.FlowProgress(flowId, instanceId)); + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/CompleteFormTaskService.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/CompleteFormTaskService.cs new file mode 100644 index 0000000..8d5be69 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/CompleteFormTaskService.cs @@ -0,0 +1,204 @@ +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Application.Validation; +using GovUK.Dfe.FlexForms.Domain.FormEngine; +using GovUK.Dfe.FlexForms.Domain.Models; +using Microsoft.Extensions.Logging; +using TaskModel = GovUK.Dfe.FlexForms.Domain.Models.Task; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +/// +/// Marks a task complete (or reverts it) from the task-summary POST. +/// +public interface ICompleteFormTask +{ + Task ExecuteAsync(FormEngineWorkState state, CancellationToken cancellationToken = default); +} + +public sealed class CompleteFormTaskService( + IApplicationStateService applicationStateService, + IFieldRequirementService fieldRequirementService, + IConditionalLogicOrchestrator conditionalLogicOrchestrator, + ILogger logger) : ICompleteFormTask +{ + public async Task ExecuteAsync( + FormEngineWorkState state, + CancellationToken cancellationToken = default) + { + var conditionalState = await ApplyTaskSummaryConditionalLogicAsync(state); + var visibility = new FormEngineVisibilityEvaluator( + state.Template, + conditionalState, + conditionalLogicOrchestrator, + state.CurrentPageId, + state.TaskId, + logger); + + if (state.CurrentTask != null && state.ApplicationId.HasValue) + { + if (state.IsTaskCompleted) + { + var errorLines = CollectCompletionErrors(state, visibility); + if (errorLines.Count > 0) + { + var errorMessage = + "You cannot mark this section as complete because some required questions have not been answered:\n" + + string.Join("\n", errorLines); + return FormEngineOutcome.Stay( + formState: FormState.TaskSummary, + errors: [new FormValidationError(string.Empty, errorMessage)], + clearModelState: true, + isTaskCompleted: false, + conditionalState: conditionalState); + } + + await applicationStateService.SaveTaskStatusAsync( + state.ApplicationId.Value, + state.CurrentTask.TaskId, + Domain.Models.TaskStatus.Completed); + } + else + { + var currentStatus = applicationStateService.CalculateTaskStatus( + state.CurrentTask.TaskId, + state.Template!, + state.FormData, + state.ApplicationId, + state.ApplicationStatus); + if (currentStatus == Domain.Models.TaskStatus.Completed) + { + var calculatedStatus = HasAnyTaskData(state.CurrentTask, state.FormData) + ? Domain.Models.TaskStatus.InProgress + : Domain.Models.TaskStatus.NotStarted; + await applicationStateService.SaveTaskStatusAsync( + state.ApplicationId.Value, + state.CurrentTask.TaskId, + calculatedStatus); + } + } + } + + return FormEngineOutcome.Redirect($"/applications/{state.ReferenceNumber}"); + } + + private async Task ApplyTaskSummaryConditionalLogicAsync(FormEngineWorkState state) + { + try + { + if (state.Template?.ConditionalLogic != null && state.Template.ConditionalLogic.Any()) + { + var context = new ConditionalLogicContext + { + CurrentPageId = state.CurrentPageId, + CurrentTaskId = state.TaskId, + IsClientSide = false, + Trigger = "task_summary_validation" + }; + return await conditionalLogicOrchestrator.ApplyConditionalLogicAsync( + state.Template, + state.FormData, + context); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error applying conditional logic in task summary validation"); + } + + return new FormConditionalState(); + } + + private List CollectCompletionErrors(FormEngineWorkState state, FormEngineVisibilityEvaluator visibility) + { + var errorLines = new List(); + var missingFieldsWithMessages = fieldRequirementService.GetMissingRequiredFieldsWithMessages( + state.CurrentTask!, + state.Template!, + state.FormData, + visibility.IsFieldHidden); + + errorLines.AddRange(missingFieldsWithMessages.Values); + + if (state.CurrentTask!.Summary?.Mode?.Equals("multiCollectionFlow", StringComparison.OrdinalIgnoreCase) != true + || state.CurrentTask.Summary.Flows == null + || !state.CurrentTask.Summary.Flows.Any()) + { + return errorLines; + } + + foreach (var flow in state.CurrentTask.Summary.Flows) + { + var items = FormEngineCollectionItems.Read(state.FormData, flow.FieldId); + var requiredMin = flow.MinItems ?? 1; + if (items.Count < requiredMin) + { + var flowTitle = string.IsNullOrWhiteSpace(flow.Title) + ? (string.IsNullOrWhiteSpace(state.CurrentTask.TaskName) ? "this section" : state.CurrentTask.TaskName) + : flow.Title; + errorLines.Add($"• Add at least {requiredMin} item(s) to {flowTitle}"); + logger.LogInformation( + "Collection flow '{FlowId}' requires at least {MinItems} items but has {Count}", + flow.FlowId, + requiredMin, + items.Count); + } + + if (flow.Pages == null || items.Count == 0) + continue; + + foreach (var item in items) + { + var requiredFieldIds = flow.Pages + .Where(p => p?.Fields != null) + .SelectMany(p => p.Fields) + .Where(f => fieldRequirementService.IsFieldRequired(f, state.Template!)) + .Select(f => f.FieldId) + .ToList(); + visibility.EnsureItemFieldVisibility(item, requiredFieldIds); + + var itemHasMissingFields = flow.Pages + .Where(page => page?.Fields != null) + .SelectMany(page => page.Fields) + .Where(field => fieldRequirementService.IsFieldRequired(field, state.Template!)) + .Where(field => !visibility.IsFieldHiddenForItem(field.FieldId, item)) + .Any(field => + { + var hasValue = item.TryGetValue(field.FieldId, out var val) + && val != null + && !string.IsNullOrWhiteSpace(val.ToString()); + return !hasValue; + }); + + if (!itemHasMissingFields) + continue; + + var flowTitle = string.IsNullOrWhiteSpace(flow.Title) + ? (string.IsNullOrWhiteSpace(state.CurrentTask.TaskName) ? "this section" : state.CurrentTask.TaskName) + : flow.Title; + errorLines.Add($"Complete all required questions for each item in {flowTitle}"); + logger.LogInformation("Collection flow '{FlowId}' has an item with incomplete required fields", flow.FlowId); + break; + } + } + + return errorLines; + } + + private static bool HasAnyTaskData(TaskModel task, Dictionary formData) + { + var taskFieldIds = new List(); + if (task.Pages != null) + { + taskFieldIds.AddRange(task.Pages.SelectMany(p => p.Fields).Select(f => f.FieldId)); + } + + if (task.Summary?.Mode?.Equals(FormStepPolicy.MultiCollectionFlowMode, StringComparison.OrdinalIgnoreCase) == true + && task.Summary.Flows != null) + { + taskFieldIds.AddRange(task.Summary.Flows.Select(f => f.FieldId)); + } + + return taskFieldIds.Any(fieldId => + formData.ContainsKey(fieldId) && !string.IsNullOrWhiteSpace(formData[fieldId]?.ToString())); + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/DeleteFormFileService.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/DeleteFormFileService.cs new file mode 100644 index 0000000..d15a671 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/DeleteFormFileService.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +public sealed record DeleteFormFileRequest( + Guid ApplicationId, + Guid FileId, + string FieldId, + string? ReturnUrl, + bool Confirmed); + +/// +/// Deletes an uploaded file and persists the remaining field list. +/// +public interface IDeleteFormFile +{ + Task ExecuteAsync( + FormEngineWorkState state, + DeleteFormFileRequest request, + CancellationToken cancellationToken = default); +} + +public sealed class DeleteFormFileService( + IFormFileFieldService formFileFieldService, + IFileUploadService fileUploadService, + IApplicationResponseService applicationResponseService, + ILogger logger) : IDeleteFormFile +{ + public async Task ExecuteAsync( + FormEngineWorkState state, + DeleteFormFileRequest request, + CancellationToken cancellationToken = default) + { + if (state.Template == null) + state.Template = FormEngineConstants.CreateDummyTemplate(); + + if (!request.Confirmed) + { + logger.LogInformation("DeleteFile handler executing for validation - file will not be deleted yet"); + return FormEngineOutcome.Redirect(request.ReturnUrl ?? $"/applications/{state.ReferenceNumber}"); + } + + try + { + await fileUploadService.DeleteFileAsync(request.FileId, request.ApplicationId, cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to delete file {FileId} for application {ApplicationId}", request.FileId, request.ApplicationId); + throw; + } + + var context = new FormFileFieldContext(request.ApplicationId, state.FlowId, state.InstanceId); + var currentFieldFiles = formFileFieldService.GetFiles(context, request.FieldId).ToList(); + currentFieldFiles.RemoveAll(f => f.Id == request.FileId); + formFileFieldService.SaveFiles(context, request.FieldId, currentFieldFiles); + await SaveUploadedFilesToResponseAsync(request.ApplicationId, request.FieldId, currentFieldFiles, cancellationToken); + + if (!string.IsNullOrEmpty(request.ReturnUrl)) + { + return FormEngineOutcome.Redirect( + request.ReturnUrl, + successMessage: FormEngineMessages.FileDeleted, + notificationContext: $"file-delete|{request.FileId}"); + } + + return FormEngineOutcome.Stay( + successMessage: FormEngineMessages.FileDeleted, + files: currentFieldFiles, + notificationContext: $"file-delete|{request.FileId}"); + } + + private async Task SaveUploadedFilesToResponseAsync( + Guid appId, + string fieldId, + IReadOnlyList files, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(fieldId)) + return; + + var json = JsonSerializer.Serialize(files); + await applicationResponseService.SaveApplicationResponseAsync( + appId, + new Dictionary { { fieldId, json } }, + cancellationToken); + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/DownloadFormFileService.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/DownloadFormFileService.cs new file mode 100644 index 0000000..514d87f --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/DownloadFormFileService.cs @@ -0,0 +1,63 @@ +using System.Net; +using System.Text.RegularExpressions; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +public sealed record DownloadFormFileRequest(Guid ApplicationId, Guid FileId); + +/// +/// Downloads an uploaded file as a stream with content-type and file name. +/// +public interface IDownloadFormFile +{ + Task ExecuteAsync( + FormEngineWorkState state, + DownloadFormFileRequest request, + CancellationToken cancellationToken = default); +} + +public sealed class DownloadFormFileService( + IFileUploadService fileUploadService, + ILogger logger) : IDownloadFormFile +{ + public async Task ExecuteAsync( + FormEngineWorkState state, + DownloadFormFileRequest request, + CancellationToken cancellationToken = default) + { + if (state.Template == null) + state.Template = FormEngineConstants.CreateDummyTemplate(); + + var fileResponse = await fileUploadService.DownloadFileAsync( + request.FileId, + request.ApplicationId, + cancellationToken); + + var contentType = fileResponse.Headers.TryGetValue("Content-Type", out var ct) + ? ct.FirstOrDefault() + : "application/octet-stream"; + + var fileName = "downloadedfile"; + if (fileResponse.Headers.TryGetValue("Content-Disposition", out var cd)) + { + var disposition = cd.FirstOrDefault(); + if (!string.IsNullOrEmpty(disposition)) + { + var fileNameMatch = Regex.Match( + disposition, + @"filename\*=UTF-8''(?.+)|filename=""?(?[^\"";]+)""?"); + if (fileNameMatch.Success) + fileName = WebUtility.UrlDecode(fileNameMatch.Groups["fileName"].Value); + } + } + + logger.LogInformation( + "Downloading file {FileId} for application {ApplicationId}", + request.FileId, + request.ApplicationId); + + return FormEngineOutcome.File(fileResponse.Stream, contentType ?? "application/octet-stream", fileName); + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineCollectionItems.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineCollectionItems.cs new file mode 100644 index 0000000..397f327 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineCollectionItems.cs @@ -0,0 +1,28 @@ +using System.Text.Json; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +/// +/// Reads collection-flow item lists stored as JSON in accumulated form data. +/// +public static class FormEngineCollectionItems +{ + public static List> Read(Dictionary formData, string fieldId) + { + if (!formData.TryGetValue(fieldId, out var value) || value == null) + return []; + + var json = value.ToString(); + if (string.IsNullOrWhiteSpace(json) || !json.TrimStart().StartsWith('[')) + return []; + + try + { + return JsonSerializer.Deserialize>>(json) ?? []; + } + catch (JsonException) + { + return []; + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineConditionalLogic.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineConditionalLogic.cs new file mode 100644 index 0000000..12cfdd9 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineConditionalLogic.cs @@ -0,0 +1,65 @@ +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +internal static class FormEngineConditionalLogic +{ + public static async Task ApplyAsync( + FormTemplate? template, + Dictionary data, + Dictionary formData, + IConditionalLogicOrchestrator orchestrator, + string pageId, + string taskId, + string trigger, + ILogger logger, + Dictionary? accumulatedForChange = null) + { + try + { + if (template?.ConditionalLogic == null || !template.ConditionalLogic.Any()) + return new FormConditionalState(); + + var dataForConditionalLogic = data.Count > 0 + ? new Dictionary(data) + : new Dictionary(formData); + + if (trigger == "change" && accumulatedForChange != null) + { + foreach (var kvp in accumulatedForChange) + { + if (!dataForConditionalLogic.ContainsKey(kvp.Key)) + dataForConditionalLogic[kvp.Key] = kvp.Value; + } + } + + var context = new ConditionalLogicContext + { + CurrentPageId = pageId, + CurrentTaskId = taskId, + IsClientSide = false, + Trigger = trigger + }; + + var conditionalState = await orchestrator.ApplyConditionalLogicAsync( + template, + dataForConditionalLogic, + context); + + if (conditionalState.FieldValues.Any()) + { + foreach (var kvp in conditionalState.FieldValues) + data[kvp.Key] = kvp.Value; + } + + return conditionalState; + } + catch (Exception ex) + { + logger.LogError(ex, "CONDITIONAL LOGIC ERROR: {Message}", ex.Message); + return new FormConditionalState(); + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineConstants.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineConstants.cs new file mode 100644 index 0000000..8de44c0 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineConstants.cs @@ -0,0 +1,19 @@ +using GovUK.Dfe.FlexForms.Domain.Models; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +public static class FormEngineConstants +{ + public const string UploadFieldSessionPlaceholder = "UPLOAD_FIELD_SESSION_DATA"; + + public const string CurrentAccumulatedApplicationIdWriteKey = "CurrentAccumulatedApplicationId"; + + public static FormTemplate CreateDummyTemplate() => + new() + { + TemplateId = "dummy", + TemplateName = "dummy", + Description = "dummy", + TaskGroups = [] + }; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineDerivedItems.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineDerivedItems.cs new file mode 100644 index 0000000..ec4143c --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineDerivedItems.cs @@ -0,0 +1,106 @@ +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Models; +using Microsoft.Extensions.Logging; +using PageModel = GovUK.Dfe.FlexForms.Domain.Models.Page; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +internal static class FormEngineDerivedItems +{ + public static void LoadItemData( + DerivedCollectionFlowConfiguration config, + string itemId, + FormEngineWorkState state, + IDerivedCollectionFlowService derivedCollectionFlowService, + ILogger logger) + { + try + { + var existingData = derivedCollectionFlowService.GetItemDeclarationData(config.FieldId, itemId, state.FormData); + foreach (var kvp in existingData) + state.Data[kvp.Key] = kvp.Value; + + var derivedItems = derivedCollectionFlowService.GenerateItemsFromSourceField( + config.SourceFieldId, + state.FormData, + config); + var currentItem = derivedItems.FirstOrDefault(item => item.Id == itemId); + + if (currentItem != null) + { + foreach (var kvp in currentItem.PrefilledData) + { + if (!state.Data.ContainsKey(kvp.Key)) + state.Data[kvp.Key] = kvp.Value; + } + + logger.LogInformation( + "Loaded derived item data for item {ItemId} in flow {FlowId} with {Count} fields", + itemId, + config.FlowId, + currentItem.PrefilledData.Count); + } + + if (state.CurrentPage != null) + { + foreach (var field in state.CurrentPage.Fields) + { + if (field.Label != null) + field.Label.IsVisible = true; + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load derived item data for item {ItemId} in flow {FlowId}", itemId, config.FlowId); + } + } + + public static void ApplyDisplayNamePlaceholders(PageModel page, string displayName) + { + if (!string.IsNullOrEmpty(page.Title)) + { + page.Title = page.Title + .Replace("{displayName}", displayName) + .Replace("{name}", displayName); + } + + if (!string.IsNullOrEmpty(page.Description)) + { + page.Description = page.Description + .Replace("{displayName}", displayName) + .Replace("{name}", displayName); + } + } + + public static string GetDisplayName( + DerivedCollectionFlowConfiguration config, + string itemId, + Dictionary formData, + IDerivedCollectionFlowService derivedCollectionFlowService) + { + try + { + var items = derivedCollectionFlowService.GenerateItemsFromSourceField(config.SourceFieldId, formData, config); + var match = items.FirstOrDefault(i => string.Equals(i.Id, itemId, StringComparison.OrdinalIgnoreCase)); + if (match != null) + { + if (!string.IsNullOrWhiteSpace(match.DisplayName)) + return match.DisplayName; + + if (match.PrefilledData != null + && match.PrefilledData.TryGetValue(config.ItemTitleBinding, out var value) + && !string.IsNullOrWhiteSpace(value?.ToString())) + { + return value.ToString()!; + } + } + } + catch (Exception) + { + // Fall back to the raw item id when source data cannot be read. + } + + return itemId; + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineMessages.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineMessages.cs new file mode 100644 index 0000000..f428985 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineMessages.cs @@ -0,0 +1,27 @@ +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +/// +/// User-facing copy for form-engine use cases. Keep these strings identical to the previous PageModel. +/// +public static class FormEngineMessages +{ + public const string NoWritePermission = + "You do not have permission to make changes to this application."; + + public const string AllSectionsMustBeCompleted = + "All sections must be completed before you can submit your application."; + + public const string ApplicationNotFound = + "Application not found. Please try again."; + + public const string SelectAFile = "Select a file to upload"; + + public const string DuplicateFileName = + "The selected file has already been uploaded. Upload a file with a different name.\n "; + + public const string InvalidFileId = "Invalid file ID."; + + public const string FileDeleted = "File deleted."; + + public const string FieldIdAndItemIdRequired = "Field ID and Item ID are required"; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineOutcome.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineOutcome.cs new file mode 100644 index 0000000..b9d2472 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineOutcome.cs @@ -0,0 +1,159 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Application.Validation; +using GovUK.Dfe.FlexForms.Domain.Models; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +public enum FormEngineOutcomeKind +{ + StayOnPage, + Redirect, + RedirectToPage, + NotFound, + BadRequest, + FileDownload +} + +/// +/// HTTP-agnostic result of a form-engine use case. The PageModel maps this to Page() / Redirect(). +/// +public sealed class FormEngineOutcome +{ + public FormEngineOutcomeKind Kind { get; init; } + + public string? RedirectUrl { get; init; } + + public string? PageName { get; init; } + + public object? RouteValues { get; init; } + + public IReadOnlyList Errors { get; init; } = []; + + public bool ClearModelState { get; init; } + + public FormState? FormState { get; init; } + + public bool? IsTaskCompleted { get; init; } + + public string? SuccessMessage { get; init; } + + public string? ErrorMessage { get; init; } + + public IReadOnlyList? Files { get; init; } + + public bool FileValidationBlocksSubmit { get; init; } + + public IReadOnlyList BlockingFiles { get; init; } = []; + + public bool PersistErrors { get; init; } + + public string? ErrorContextKey { get; init; } + + public IReadOnlyList ErrorStoreKeysToClear { get; init; } = []; + + public IReadOnlyList ModelStateKeysToRemove { get; init; } = []; + + public bool ReloadFormData { get; init; } + + public FormConditionalState? ConditionalState { get; init; } + + public Stream? FileStream { get; init; } + + public string? FileContentType { get; init; } + + public string? FileDownloadName { get; init; } + + public string? NotificationContext { get; init; } + + public static FormEngineOutcome Stay( + FormState? formState = null, + IReadOnlyList? errors = null, + bool clearModelState = false, + bool? isTaskCompleted = null, + bool reloadFormData = false, + FormConditionalState? conditionalState = null, + bool persistErrors = false, + string? errorContextKey = null, + string? errorMessage = null, + string? successMessage = null, + IReadOnlyList? files = null, + bool fileValidationBlocksSubmit = false, + IReadOnlyList? blockingFiles = null, + IReadOnlyList? errorStoreKeysToClear = null, + IReadOnlyList? modelStateKeysToRemove = null, + string? notificationContext = null) => + new() + { + Kind = FormEngineOutcomeKind.StayOnPage, + FormState = formState, + Errors = errors ?? [], + ClearModelState = clearModelState, + IsTaskCompleted = isTaskCompleted, + ReloadFormData = reloadFormData, + ConditionalState = conditionalState, + PersistErrors = persistErrors, + ErrorContextKey = errorContextKey, + ErrorMessage = errorMessage, + SuccessMessage = successMessage, + Files = files, + FileValidationBlocksSubmit = fileValidationBlocksSubmit, + BlockingFiles = blockingFiles ?? [], + ErrorStoreKeysToClear = errorStoreKeysToClear ?? [], + ModelStateKeysToRemove = modelStateKeysToRemove ?? [], + NotificationContext = notificationContext + }; + + public static FormEngineOutcome Redirect( + string url, + string? successMessage = null, + IReadOnlyList? errors = null, + bool persistErrors = false, + string? errorContextKey = null, + IReadOnlyList? files = null, + string? errorMessage = null, + IReadOnlyList? errorStoreKeysToClear = null, + IReadOnlyList? modelStateKeysToRemove = null, + string? notificationContext = null) => + new() + { + Kind = FormEngineOutcomeKind.Redirect, + RedirectUrl = url, + SuccessMessage = successMessage, + Errors = errors ?? [], + PersistErrors = persistErrors, + ErrorContextKey = errorContextKey, + Files = files, + ErrorMessage = errorMessage, + ErrorStoreKeysToClear = errorStoreKeysToClear ?? [], + ModelStateKeysToRemove = modelStateKeysToRemove ?? [], + NotificationContext = notificationContext + }; + + public static FormEngineOutcome RedirectToPage(string pageName, object? routeValues = null) => + new() + { + Kind = FormEngineOutcomeKind.RedirectToPage, + PageName = pageName, + RouteValues = routeValues + }; + + public static FormEngineOutcome NotFound() => + new() { Kind = FormEngineOutcomeKind.NotFound }; + + public static FormEngineOutcome BadRequest(string message) => + new() + { + Kind = FormEngineOutcomeKind.BadRequest, + ErrorMessage = message + }; + + public static FormEngineOutcome File(Stream stream, string contentType, string fileName) => + new() + { + Kind = FormEngineOutcomeKind.FileDownload, + FileStream = stream, + FileContentType = contentType, + FileDownloadName = fileName + }; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineSuccessMessages.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineSuccessMessages.cs new file mode 100644 index 0000000..9b149c0 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineSuccessMessages.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +/// +/// Success-message templates previously built in the PageModel/DisplayHelpers. +/// Keep the strings identical. +/// +internal static class FormEngineSuccessMessages +{ + public static Dictionary? ExpandEncodedJson(Dictionary? itemData) + { + if (itemData == null) + return null; + + var expanded = new Dictionary(); + foreach (var kvp in itemData) + expanded[kvp.Key] = TransformEncodedJsonString(kvp.Value); + return expanded; + } + + public static string Generate(string? customMessage, string operation, Dictionary? itemData, string? flowTitle) + { + if (!string.IsNullOrEmpty(customMessage)) + { + customMessage = customMessage.Replace("{flowTitle}", flowTitle ?? "collection"); + return Interpolate(customMessage, itemData); + } + + var displayName = GetDisplayNameFromItemData(itemData); + var lowerFlowTitle = flowTitle?.ToLowerInvariant() ?? "collection"; + + return operation switch + { + "add" => $"{displayName} has been added to {lowerFlowTitle}", + "update" => $"{displayName} has been updated", + "delete" => $"{displayName} has been removed from {lowerFlowTitle}", + _ => $"{displayName} has been processed" + }; + } + + private static object TransformEncodedJsonString(object value) + { + if (value is JsonElement { ValueKind: JsonValueKind.String } jsonString) + { + try + { + return JsonSerializer.Deserialize(jsonString.GetString() ?? "") ; + } + catch (JsonException) + { + return value; + } + } + + return value; + } + + private static string Interpolate(string message, Dictionary? itemData) + { + if (itemData == null || itemData.Count == 0) + return message; + + return PlaceholderRegex().Replace(message, match => + { + var key = match.Groups[1].Value; + if (itemData.TryGetValue(key, out var value) && value != null) + return value.ToString() ?? match.Value; + return match.Value; + }); + } + + private static string GetDisplayNameFromItemData(Dictionary? itemData) + { + var displayName = "Item"; + if (itemData == null || itemData.Count == 0) + return displayName; + + var nameFields = new[] { "firstName", "name", "title", "label" }; + var nameField = nameFields.FirstOrDefault(field => + itemData.ContainsKey(field) && !string.IsNullOrEmpty(itemData[field]?.ToString())); + + if (nameField != null) + return itemData[nameField]?.ToString() ?? "Item"; + + var firstValue = itemData.Values.FirstOrDefault(v => !string.IsNullOrEmpty(v?.ToString())); + return firstValue?.ToString() ?? "Item"; + } + + private static Regex PlaceholderRegex() => new(@"\{([^{}]+)\}", RegexOptions.CultureInvariant); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineVisibilityEvaluator.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineVisibilityEvaluator.cs new file mode 100644 index 0000000..5f1e869 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineVisibilityEvaluator.cs @@ -0,0 +1,229 @@ +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +/// +/// Field/page visibility against a template and the current . +/// Single place for the rules previously embedded in the PageModel. +/// +public sealed class FormEngineVisibilityEvaluator( + FormTemplate? template, + FormConditionalState? conditionalState, + IConditionalLogicOrchestrator conditionalLogicOrchestrator, + string pageId, + string taskId, + ILogger logger) +{ + private HashSet? _fieldsWithConditionalVisibility; + private readonly Dictionary _itemConditionalStateCache = + new(ReferenceEqualityComparer.Instance); + + public bool IsFieldHidden(string fieldId) + { + if (conditionalState == null) + { + if (template?.ConditionalLogic != null && HasFieldConditionalLogic(fieldId)) + return true; + return false; + } + + if (conditionalState.FieldVisibility.TryGetValue(fieldId, out var isVisible)) + return !isVisible; + + if (template?.ConditionalLogic != null && HasFieldConditionalLogic(fieldId)) + return true; + + return false; + } + + public bool IsPageHidden(string pageIdToCheck) + { + if (conditionalState == null) + { + if (template?.ConditionalLogic != null && HasPageConditionalLogic(pageIdToCheck)) + return true; + return false; + } + + if (conditionalState.SkippedPages.Contains(pageIdToCheck)) + return true; + + if (conditionalState.PageVisibility.TryGetValue(pageIdToCheck, out var isVisible)) + return !isVisible; + + if (template?.ConditionalLogic != null && HasPageConditionalLogic(pageIdToCheck)) + return true; + + return false; + } + + public bool HasConditionalLogicShowingPages(Dictionary data) + { + if (template?.ConditionalLogic == null) + return false; + + foreach (var rule in template.ConditionalLogic.Where(r => r.Enabled)) + { + var hasShowPageAction = rule.AffectedElements.Any(element => + element.ElementType == "page" && element.Action == "show"); + if (!hasShowPageAction) + continue; + + if (EvaluateRuleConditions(rule, data)) + return true; + } + + return false; + } + + public void EnsureItemFieldVisibility(Dictionary itemData, IEnumerable fieldIds) + { + if (template?.ConditionalLogic == null || !template.ConditionalLogic.Any()) + return; + + var needed = fieldIds + .Where(HasFieldConditionalLogic) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Where(id => + !_itemConditionalStateCache.TryGetValue(itemData, out var existing) + || !existing.FieldVisibility.ContainsKey(id)) + .ToList(); + + if (needed.Count == 0) + return; + + try + { + var context = new ConditionalLogicContext + { + CurrentPageId = pageId, + CurrentTaskId = taskId, + IsClientSide = false, + Trigger = "load" + }; + + var partial = conditionalLogicOrchestrator + .ApplyFieldVisibilityAsync(template, itemData, needed, context) + .GetAwaiter() + .GetResult(); + + if (!_itemConditionalStateCache.TryGetValue(itemData, out var state)) + { + _itemConditionalStateCache[itemData] = partial; + return; + } + + foreach (var kvp in partial.FieldVisibility) + state.FieldVisibility[kvp.Key] = kvp.Value; + } + catch (Exception ex) + { + logger.LogError(ex, "Error ensuring field visibility for collection item"); + } + } + + public bool IsFieldHiddenForItem(string fieldId, Dictionary itemData) + { + try + { + if (template?.ConditionalLogic == null || !template.ConditionalLogic.Any()) + return false; + + if (!HasFieldConditionalLogic(fieldId)) + return false; + + EnsureItemFieldVisibility(itemData, [fieldId]); + + if (_itemConditionalStateCache.TryGetValue(itemData, out var itemConditionalState) + && itemConditionalState.FieldVisibility.TryGetValue(fieldId, out var isVisible)) + { + return !isVisible; + } + + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking field visibility for collection item, field {FieldId}", fieldId); + return false; + } + } + + public bool HasFieldConditionalLogic(string fieldId) + { + if (template?.ConditionalLogic == null) + return false; + + _fieldsWithConditionalVisibility ??= BuildFieldsWithConditionalVisibility(); + return _fieldsWithConditionalVisibility.Contains(fieldId); + } + + private HashSet BuildFieldsWithConditionalVisibility() + { + var fields = new HashSet(StringComparer.OrdinalIgnoreCase); + if (template?.ConditionalLogic == null) + return fields; + + foreach (var rule in template.ConditionalLogic) + { + if (!rule.Enabled || rule.AffectedElements == null) + continue; + + foreach (var element in rule.AffectedElements) + { + if (element.ElementType == "field" + && (element.Action == "hide" || element.Action == "show") + && !string.IsNullOrEmpty(element.ElementId)) + { + fields.Add(element.ElementId); + } + } + } + + return fields; + } + + private bool HasPageConditionalLogic(string pageIdToCheck) + { + if (template?.ConditionalLogic == null) + return false; + + return template.ConditionalLogic.Any(rule => + rule.Enabled + && rule.AffectedElements.Any(e => + e.ElementType == "page" + && (e.Action == "hide" || e.Action == "show") + && e.ElementId == pageIdToCheck)); + } + + private static bool EvaluateRuleConditions(ConditionalLogic rule, Dictionary data) + { + if (rule.ConditionGroup?.Conditions == null || !rule.ConditionGroup.Conditions.Any()) + return false; + + var results = new List(); + foreach (var condition in rule.ConditionGroup.Conditions) + { + var fieldValue = data.TryGetValue(condition.TriggerField, out var value) ? value?.ToString() : ""; + var conditionValue = condition.Value?.ToString() ?? ""; + var conditionMet = condition.Operator.ToLowerInvariant() switch + { + "equals" => string.Equals(fieldValue, conditionValue, StringComparison.OrdinalIgnoreCase), + "not_equals" => !string.Equals(fieldValue, conditionValue, StringComparison.OrdinalIgnoreCase), + "contains" => fieldValue?.Contains(conditionValue, StringComparison.OrdinalIgnoreCase) == true, + "not_contains" => fieldValue?.Contains(conditionValue, StringComparison.OrdinalIgnoreCase) != true, + _ => false + }; + results.Add(conditionMet); + } + + return rule.ConditionGroup.LogicalOperator?.ToUpperInvariant() switch + { + "AND" => results.All(r => r), + "OR" => results.Any(r => r), + _ => results.All(r => r) + }; + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineWorkState.cs new file mode 100644 index 0000000..30f1e43 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineWorkState.cs @@ -0,0 +1,57 @@ +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Models; +using TaskModel = GovUK.Dfe.FlexForms.Domain.Models.Task; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +/// +/// Mutable view-state bag shared between the PageModel and form-engine use cases. +/// +public sealed class FormEngineWorkState +{ + public string ReferenceNumber { get; set; } = string.Empty; + + public string TaskId { get; set; } = string.Empty; + + public string CurrentPageId { get; set; } = string.Empty; + + public Guid? ApplicationId { get; set; } + + public string ApplicationStatus { get; set; } = "InProgress"; + + public FormTemplate? Template { get; set; } + + public Dictionary FormData { get; set; } = new(); + + public Dictionary Data { get; set; } = new(); + + public FormState CurrentFormState { get; set; } + + public TaskGroup? CurrentGroup { get; set; } + + public TaskModel? CurrentTask { get; set; } + + public Page? CurrentPage { get; set; } + + public string? FlowId { get; set; } + + public string? InstanceId { get; set; } + + public string? FlowPageId { get; set; } + + public string? DerivedFlowId { get; set; } + + public string? DerivedItemId { get; set; } + + public string? DerivedPageId { get; set; } + + public FormConditionalState? ConditionalState { get; set; } + + public bool IsEditable { get; set; } + + public bool IsTaskCompleted { get; set; } + + public string ErrorContextKey => $"{ReferenceNumber}_{TaskId}_{CurrentPageId}"; + + public FormFileFieldContext FileFieldContext => new(ApplicationId, FlowId, InstanceId); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormFileFieldContext.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormFileFieldContext.cs new file mode 100644 index 0000000..551dc46 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormFileFieldContext.cs @@ -0,0 +1,10 @@ +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +public sealed record FormFileFieldContext( + Guid? ApplicationId, + string? FlowId, + string? InstanceId) +{ + public bool IsCollectionFlow => + !string.IsNullOrEmpty(FlowId) && !string.IsNullOrEmpty(InstanceId); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormFileFieldService.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormFileFieldService.cs new file mode 100644 index 0000000..5e0cdd5 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormFileFieldService.cs @@ -0,0 +1,280 @@ +using System.Text.Json; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Caching; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +public sealed class FormFileFieldService( + IFormSessionStore sessionStore, + ICollectionFlowProgressStore progressStore, + IInfectedUploadFilter infectedUploadFilter, + IInfectedFileStore infectedFileStore, + IApplicationResponseService applicationResponseService, + ILogger logger) : IFormFileFieldService +{ + public IReadOnlyList GetFiles(FormFileFieldContext context, string fieldId) + { + if (string.IsNullOrEmpty(fieldId)) + return Array.Empty(); + + var applicationId = context.ApplicationId?.ToString(); + + if (context.IsCollectionFlow) + { + var progressData = progressStore.Load(context.FlowId!, context.InstanceId!); + if (progressData.TryGetValue(fieldId, out var progressValue) + && TryParseFiles(progressValue, out var sessionFiles)) + { + return infectedUploadFilter.FilterList(sessionFiles, applicationId); + } + + return GetFilesFromAccumulatedCollection(context, fieldId, applicationId); + } + + if (context.ApplicationId is { } appId) + { + var sessionFilesJson = sessionStore.GetString(FormSessionKeys.UploadedFiles(appId, fieldId)); + if (TryParseFiles(sessionFilesJson, out var sessionFiles)) + return infectedUploadFilter.FilterList(sessionFiles, applicationId); + } + + var accumulatedData = applicationResponseService.GetAccumulatedFormData(); + if (accumulatedData.TryGetValue(fieldId, out var fieldValue) + && TryParseFiles(fieldValue, out var accumulatedFiles)) + { + return infectedUploadFilter.FilterList(accumulatedFiles, applicationId); + } + + return Array.Empty(); + } + + public void SaveFiles(FormFileFieldContext context, string fieldId, IReadOnlyList files) + { + if (string.IsNullOrEmpty(fieldId)) + return; + + var serialized = JsonSerializer.Serialize(files); + + if (context.IsCollectionFlow) + { + progressStore.SetField(context.FlowId!, context.InstanceId!, fieldId, serialized); + return; + } + + if (context.ApplicationId is not { } appId) + return; + + sessionStore.SetString(FormSessionKeys.UploadedFiles(appId, fieldId), serialized); + } + + public void ReplaceUploadPlaceholders(Dictionary data, FormFileFieldContext context) + { + var applicationId = context.ApplicationId?.ToString(); + + if (context.IsCollectionFlow) + { + var flowProgress = progressStore.Load(context.FlowId!, context.InstanceId!); + var accumulatedData = applicationResponseService.GetAccumulatedFormData(); + + foreach (var key in data.Keys.ToList()) + { + if (data[key]?.ToString() != FormEngineConstants.UploadFieldSessionPlaceholder) + continue; + + if (flowProgress.TryGetValue(key, out var sessionValue)) + { + data[key] = infectedUploadFilter.FilterUploadDataJson(sessionValue?.ToString(), applicationId); + logger.LogInformation( + "Collection flow: Replaced upload placeholder for field {FieldId} with filtered session data", + key); + continue; + } + + logger.LogWarning("Collection flow: Session empty for field {FieldId}, falling back to database", key); + + try + { + foreach (var kvp in accumulatedData) + { + var collectionJson = kvp.Value?.ToString(); + if (string.IsNullOrWhiteSpace(collectionJson)) + continue; + + var items = JsonSerializer.Deserialize>>(collectionJson); + if (items == null) + continue; + + var existingItem = items.FirstOrDefault(item => + item.TryGetValue("id", out var idVal) && idVal?.ToString() == context.InstanceId); + if (existingItem == null || !existingItem.TryGetValue(key, out var fieldValue)) + continue; + + var fieldValueStr = fieldValue?.ToString(); + if (string.IsNullOrWhiteSpace(fieldValueStr)) + continue; + + data[key] = infectedUploadFilter.FilterUploadDataJson(fieldValueStr, applicationId); + logger.LogInformation( + "Collection flow: Replaced upload placeholder for field {FieldId} with filtered database data", + key); + break; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Collection flow: Error getting database data for field {FieldId}", key); + } + } + + return; + } + + foreach (var key in data.Keys.ToList()) + { + if (data[key]?.ToString() != FormEngineConstants.UploadFieldSessionPlaceholder) + continue; + + if (context.ApplicationId is not { } appId) + continue; + + var sessionFilesJson = sessionStore.GetString(FormSessionKeys.UploadedFiles(appId, key)); + if (!string.IsNullOrWhiteSpace(sessionFilesJson)) + { + data[key] = infectedUploadFilter.FilterUploadDataJson(sessionFilesJson, applicationId); + logger.LogInformation( + "Replaced upload placeholder for field {FieldId} with filtered session data from upload key", + key); + } + else + { + logger.LogInformation( + "No session data found for upload field {FieldId} - validation will detect empty field", + key); + } + } + } + + public bool ContainsFileName(FormFileFieldContext context, string fieldId, string fileName) + { + if (context.ApplicationId is { } appId) + { + try + { + if (infectedFileStore.IsFileNameInfected(appId.ToString(), fileName)) + return false; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error checking infected blacklist for file '{FileName}'", fileName); + } + } + + var files = GetFiles(context, fieldId); + if (files.Count > 0) + return files.Any(f => string.Equals(f.OriginalFileName, fileName, StringComparison.OrdinalIgnoreCase)); + + string? rawJson = null; + if (context.IsCollectionFlow) + { + var progress = progressStore.Load(context.FlowId!, context.InstanceId!); + if (progress.TryGetValue(fieldId, out var filesJson)) + rawJson = filesJson?.ToString(); + } + else if (context.ApplicationId is { } regularAppId) + { + rawJson = sessionStore.GetString(FormSessionKeys.UploadedFiles(regularAppId, fieldId)); + } + + return !string.IsNullOrEmpty(rawJson) + && rawJson.IndexOf(fileName, StringComparison.InvariantCultureIgnoreCase) >= 0; + } + + private IReadOnlyList GetFilesFromAccumulatedCollection( + FormFileFieldContext context, + string fieldId, + string? applicationId) + { + try + { + var accumulatedData = applicationResponseService.GetAccumulatedFormData(); + foreach (var kvp in accumulatedData) + { + var collectionJson = kvp.Value?.ToString(); + if (string.IsNullOrWhiteSpace(collectionJson)) + continue; + + try + { + var items = JsonSerializer.Deserialize>>(collectionJson) ?? []; + var existingItem = items.FirstOrDefault(item => + item.TryGetValue("id", out var idVal) && idVal?.ToString() == context.InstanceId); + if (existingItem == null + || !existingItem.TryGetValue(fieldId, out var innerValue) + || innerValue == null) + continue; + + if (TryParseFiles(innerValue, out var files)) + return infectedUploadFilter.FilterList(files, applicationId); + } + catch (Exception) + { + // Ignore parse errors for non-collection fields + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing accumulated data for collection flow"); + } + + return Array.Empty(); + } + + private static bool TryParseFiles(object? value, out List files) + { + files = []; + if (value == null) + return false; + + if (value is List list) + { + files = list; + return true; + } + + if (value is JsonElement innerElem) + { + if (innerElem.ValueKind == JsonValueKind.Array) + { + try + { + files = JsonSerializer.Deserialize>(innerElem.GetRawText()) ?? []; + return true; + } + catch (JsonException) + { + return false; + } + } + + if (innerElem.ValueKind == JsonValueKind.String) + return TryParseFiles(innerElem.GetString(), out files); + } + + var json = value.ToString(); + if (string.IsNullOrWhiteSpace(json)) + return false; + + try + { + files = JsonSerializer.Deserialize>(json) ?? []; + return true; + } + catch (JsonException) + { + return false; + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/HtmlInputSanitiser.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/HtmlInputSanitiser.cs new file mode 100644 index 0000000..d92034c --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/HtmlInputSanitiser.cs @@ -0,0 +1,15 @@ +using System.Text.Encodings.Web; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +/// +/// Encodes posted text to prevent XSS and normalises newlines to <br>. +/// +public static class HtmlInputSanitiser +{ + public static string Sanitise(string input) + { + var lines = input.Split("\r\n").SelectMany(s => s.Split('\r')).SelectMany(s => s.Split('\n')); + return string.Join("
", lines.Select(HtmlEncoder.Default.Encode)); + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/ICollectionFlowProgressStore.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/ICollectionFlowProgressStore.cs new file mode 100644 index 0000000..a875703 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/ICollectionFlowProgressStore.cs @@ -0,0 +1,15 @@ +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +/// +/// Session-backed in-progress data for a multi-collection flow instance. +/// +public interface ICollectionFlowProgressStore +{ + Dictionary Load(string flowId, string instanceId); + + void Save(string flowId, string instanceId, Dictionary latest); + + void SetField(string flowId, string instanceId, string fieldId, object value); + + void Clear(string flowId, string instanceId); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/IFormFileFieldService.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/IFormFileFieldService.cs new file mode 100644 index 0000000..07cef2a --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/IFormFileFieldService.cs @@ -0,0 +1,17 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +/// +/// Resolves and persists per-field upload lists (session, collection progress, accumulated data). +/// +public interface IFormFileFieldService +{ + IReadOnlyList GetFiles(FormFileFieldContext context, string fieldId); + + void SaveFiles(FormFileFieldContext context, string fieldId, IReadOnlyList files); + + void ReplaceUploadPlaceholders(Dictionary data, FormFileFieldContext context); + + bool ContainsFileName(FormFileFieldContext context, string fieldId, string fileName); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/IInfectedUploadFilter.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/IInfectedUploadFilter.cs new file mode 100644 index 0000000..8cdc173 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/IInfectedUploadFilter.cs @@ -0,0 +1,13 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +/// +/// Removes malware-blacklisted uploads from file lists and JSON payloads. +/// +public interface IInfectedUploadFilter +{ + List FilterList(IReadOnlyList? files, string? applicationId); + + string FilterUploadDataJson(string? uploadDataJson, string? applicationId); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/IPostedFormDataBinder.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/IPostedFormDataBinder.cs new file mode 100644 index 0000000..e1f9167 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/IPostedFormDataBinder.cs @@ -0,0 +1,16 @@ +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +/// +/// Maps posted Data[field] keys (including GOV.UK date parts) into the form data dictionary. +/// Date composition is a separate step so conditional logic can run first, matching the PageModel order. +/// +public interface IPostedFormDataBinder +{ + Dictionary Bind( + IReadOnlyDictionary> formFields, + Dictionary? existing = null); + + void ApplyDateParts( + IReadOnlyDictionary> formFields, + Dictionary data); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/InfectedUploadFilter.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/InfectedUploadFilter.cs new file mode 100644 index 0000000..fb0bb05 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/InfectedUploadFilter.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +public sealed class InfectedUploadFilter( + IInfectedFileStore infectedFileStore, + ILogger logger) : IInfectedUploadFilter +{ + public List FilterList(IReadOnlyList? files, string? applicationId) + { + if (files == null || files.Count == 0) + return files?.ToList() ?? []; + + try + { + var infectedFileIds = new HashSet(); + foreach (var file in files) + { + var fileIdExists = infectedFileStore.IsFileInfected(file.Id); + var filenameExists = !string.IsNullOrEmpty(applicationId) + && !string.IsNullOrEmpty(file.OriginalFileName) + && infectedFileStore.IsFileNameInfected(applicationId, file.OriginalFileName); + + if (fileIdExists || filenameExists) + infectedFileIds.Add(file.Id); + } + + if (infectedFileIds.Count == 0) + return files.ToList(); + + logger.LogWarning( + "Filtered out {RemovedCount} infected file(s) from a list of {FileCount}", + infectedFileIds.Count, + files.Count); + + return files.Where(f => !infectedFileIds.Contains(f.Id)).ToList(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to filter infected files; returning original list of {FileCount}", files.Count); + return files.ToList(); + } + } + + public string FilterUploadDataJson(string? uploadDataJson, string? applicationId) + { + if (string.IsNullOrWhiteSpace(uploadDataJson)) + return uploadDataJson ?? string.Empty; + + try + { + var files = JsonSerializer.Deserialize>(uploadDataJson); + if (files == null) + return uploadDataJson; + + return JsonSerializer.Serialize(FilterList(files, applicationId)); + } + catch (JsonException ex) + { + logger.LogDebug(ex, "Failed to parse upload data as file list, returning original value"); + return uploadDataJson; + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/PostedFormDataBinder.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/PostedFormDataBinder.cs new file mode 100644 index 0000000..b2f8b00 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/PostedFormDataBinder.cs @@ -0,0 +1,120 @@ +using System.Text.RegularExpressions; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +public sealed class PostedFormDataBinder : IPostedFormDataBinder +{ + private static readonly Regex DataFieldRegex = new( + @"^Data\[(.+?)\]$", + RegexOptions.None, + TimeSpan.FromMilliseconds(200)); + + private static readonly Regex DatePartRegex = new( + @"^Data\[(.+?)\](?:[.\-](day|month|year))$", + RegexOptions.IgnoreCase, + TimeSpan.FromMilliseconds(200)); + + public Dictionary Bind( + IReadOnlyDictionary> formFields, + Dictionary? existing = null) + { + var data = existing ?? new Dictionary(); + + foreach (var (key, values) in formFields) + { + var match = DataFieldRegex.Match(key); + if (!match.Success) + continue; + + var fieldId = match.Groups[1].Value; + var normalisedFieldId = fieldId.StartsWith("Data_", StringComparison.Ordinal) + ? fieldId[5..] + : fieldId; + + object bound = values.Count switch + { + 1 => HtmlInputSanitiser.Sanitise(values[0] ?? string.Empty), + > 1 => values.Select(v => HtmlInputSanitiser.Sanitise(v ?? string.Empty)).ToArray(), + _ => string.Empty + }; + + data[fieldId] = bound; + if (!string.Equals(fieldId, normalisedFieldId, StringComparison.Ordinal)) + data[normalisedFieldId] = bound; + } + + return data; + } + + public void ApplyDateParts( + IReadOnlyDictionary> formFields, + Dictionary data) + { + var dateParts = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var (key, values) in formFields) + { + var dateMatch = DatePartRegex.Match(key); + if (!dateMatch.Success) + continue; + + var dateFieldId = dateMatch.Groups[1].Value; + var part = dateMatch.Groups[2].Value.ToLowerInvariant(); + var formValue = values.Count > 0 ? values[0] : string.Empty; + + if (!dateParts.TryGetValue(dateFieldId, out var parts)) + parts = (null, null, null); + + parts = part switch + { + "day" => (formValue, parts.Month, parts.Year), + "month" => (parts.Day, formValue, parts.Year), + "year" => (parts.Day, parts.Month, formValue), + _ => parts + }; + + dateParts[dateFieldId] = parts; + } + + foreach (var (fieldId, parts) in dateParts) + { + var anyEntered = !string.IsNullOrWhiteSpace(parts.Day) + || !string.IsNullOrWhiteSpace(parts.Month) + || !string.IsNullOrWhiteSpace(parts.Year); + if (!anyEntered) + continue; + + var normalisedFieldId = fieldId.StartsWith("Data_", StringComparison.Ordinal) ? fieldId[5..] : fieldId; + string composed; + if (int.TryParse(parts.Year, out var y) + && int.TryParse(parts.Month, out var m) + && int.TryParse(parts.Day, out var d)) + { + var yearText = parts.Year?.Trim() ?? string.Empty; + if (yearText.Length != 4) + { + composed = $"{parts.Year}-{parts.Month}-{parts.Day}"; + } + else + { + try + { + composed = new DateTime(y, m, d).ToString("yyyy-MM-dd"); + } + catch (ArgumentOutOfRangeException) + { + composed = $"{parts.Year}-{parts.Month}-{parts.Day}"; + } + } + } + else + { + composed = $"{parts.Year}-{parts.Month}-{parts.Day}"; + } + + data[fieldId] = composed; + if (!string.Equals(fieldId, normalisedFieldId, StringComparison.Ordinal)) + data[normalisedFieldId] = composed; + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/PrepareFormEngineGetService.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/PrepareFormEngineGetService.cs new file mode 100644 index 0000000..f54bd49 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/PrepareFormEngineGetService.cs @@ -0,0 +1,427 @@ +using System.Text.Json; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Caching; +using GovUK.Dfe.FlexForms.Domain.FormEngine; +using GovUK.Dfe.FlexForms.Domain.Models; +using Microsoft.Extensions.Logging; +using Task = System.Threading.Tasks.Task; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +/// +/// Prepares form-engine GET state after common HTTP initialization. +/// +public interface IPrepareFormEngineGet +{ + Task ExecuteAsync( + FormEngineWorkState state, + bool isPreview, + bool isBackNav, + bool isEditable, + CancellationToken cancellationToken = default); +} + +public sealed class PrepareFormEngineGetService( + ITemplateManagementService templateManagementService, + IApplicationResponseService applicationResponseService, + ICollectionFlowProgressStore collectionFlowProgressStore, + IFormSessionStore sessionStore, + IConditionalLogicOrchestrator conditionalLogicOrchestrator, + IFormStateManager formStateManager, + IFormFileFieldService formFileFieldService, + IComplexFieldConfigurationService complexFieldConfigurationService, + IDerivedCollectionFlowService derivedCollectionFlowService, + IApplicationsClient applicationsClient, + INavigationHistoryService navigationHistoryService, + IApplicationStateService applicationStateService, + ILogger logger) : IPrepareFormEngineGet +{ + public async Task ExecuteAsync( + FormEngineWorkState state, + bool isPreview, + bool isBackNav, + bool isEditable, + CancellationToken cancellationToken = default) + { + if (state.Template == null) + { + logger.LogError( + "Template is null after CommonFormEngineInitializationAsync for ReferenceNumber: {ReferenceNumber}", + state.ReferenceNumber); + state.Template = FormEngineConstants.CreateDummyTemplate(); + } + + if (isPreview) + { + state.CurrentFormState = FormState.ApplicationPreview; + state.CurrentGroup = null; + state.CurrentTask = null; + state.CurrentPage = null; + await RefreshFileValidationGateAsync(state, cancellationToken); + } + else + { + if (!isEditable && !string.IsNullOrEmpty(state.CurrentPageId)) + return FormEngineOutcome.Redirect($"~/applications/{state.ReferenceNumber}"); + + ResolveRoute(state); + } + + CheckAndClearSessionForNewApplication(state); + await LoadAccumulatedDataFromSessionAsync(state); + MergeFlowProgressIntoFormDataForSummary(state); + + if (!string.IsNullOrEmpty(state.DerivedFlowId) && !string.IsNullOrEmpty(state.DerivedItemId) && state.CurrentTask != null) + { + var derivedConfig = FormStepPolicy.GetDerivedFlow(state.CurrentTask, state.DerivedFlowId); + if (derivedConfig != null) + FormEngineDerivedItems.LoadItemData(derivedConfig, state.DerivedItemId, state, derivedCollectionFlowService, logger); + } + + PopulateUploadFieldsFromSession(state); + + state.ConditionalState = await FormEngineConditionalLogic.ApplyAsync( + state.Template, + state.Data, + state.FormData, + conditionalLogicOrchestrator, + state.CurrentPageId, + state.TaskId, + "load", + logger); + + if (state.CurrentTask != null) + { + var isSummary = state.CurrentFormState == FormState.TaskSummary + || formStateManager.ShouldShowDerivedCollectionFlowSummary(state.CurrentTask); + if (isSummary) + { + var taskStatus = applicationStateService.CalculateTaskStatus( + state.CurrentTask.TaskId, + state.Template, + state.FormData, + state.ApplicationId, + state.ApplicationStatus); + state.IsTaskCompleted = taskStatus == Domain.Models.TaskStatus.Completed; + } + } + + if (isBackNav) + { + try + { + var scope = FormRouteParser.HistoryScope(state.ReferenceNumber, state.TaskId, state.CurrentPageId); + navigationHistoryService.Pop(scope); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to pop navigation history for back navigation"); + } + } + + return FormEngineOutcome.Stay( + formState: state.CurrentFormState, + clearModelState: true, + isTaskCompleted: state.IsTaskCompleted, + conditionalState: state.ConditionalState, + fileValidationBlocksSubmit: _fileValidationBlocksSubmit, + blockingFiles: _blockingFiles); + } + + private bool _fileValidationBlocksSubmit; + private IReadOnlyList _blockingFiles = []; + + private void ResolveRoute(FormEngineWorkState state) + { + if (!string.IsNullOrEmpty(state.CurrentPageId) && state.Template != null) + { + if (FormRouteParser.TryParseCollectionFlow(state.CurrentPageId, out var flowRoute)) + { + state.FlowId = flowRoute.FlowId; + state.InstanceId = flowRoute.InstanceId; + state.FlowPageId = flowRoute.PageId; + + var (group, task) = templateManagementService.FindTask(state.Template, state.TaskId); + state.CurrentGroup = group; + state.CurrentTask = task; + + var flowPages = FormStepPolicy.GetCollectionFlowPages(task, flowRoute.FlowId); + var flowFieldId = FormStepPolicy.GetCollectionFlowFieldId(task, flowRoute.FlowId); + + if (!string.IsNullOrEmpty(flowFieldId)) + { + var existenceKey = FormSessionKeys.FlowItemExisted(flowRoute.FlowId, flowRoute.InstanceId); + if (sessionStore.GetString(existenceKey) == null) + { + var existed = IsExistingCollectionItem(flowFieldId, flowRoute.InstanceId); + sessionStore.SetString(existenceKey, existed ? "true" : "false"); + } + } + + if (flowPages != null) + { + var page = FormStepPolicy.ResolvePage(flowPages, flowRoute.PageId); + if (page != null) + { + state.CurrentPage = page; + state.CurrentFormState = FormState.FormPage; + LoadExistingFlowItemData(state, flowRoute.FlowId, flowRoute.InstanceId); + + var progressData = collectionFlowProgressStore.Load(flowRoute.FlowId, flowRoute.InstanceId); + foreach (var kvp in progressData) + state.Data[kvp.Key] = kvp.Value; + } + } + + return; + } + + if (FormRouteParser.TryParseDerivedFlow(state.CurrentPageId, out var derivedRoute)) + { + var (group, task) = templateManagementService.FindTask(state.Template, state.TaskId); + state.CurrentGroup = group; + state.CurrentTask = task; + state.DerivedFlowId = derivedRoute.FlowId; + state.DerivedItemId = derivedRoute.ItemId; + state.DerivedPageId = derivedRoute.PageId; + + var derivedConfig = FormStepPolicy.GetDerivedFlow(task, derivedRoute.FlowId); + if (derivedConfig != null) + { + var page = FormStepPolicy.ResolvePage(derivedConfig.Pages, derivedRoute.PageId); + if (page != null) + { + state.CurrentPage = page; + state.CurrentFormState = FormState.FormPage; + FormEngineDerivedItems.LoadItemData( + derivedConfig, + derivedRoute.ItemId, + state, + derivedCollectionFlowService, + logger); + var displayName = FormEngineDerivedItems.GetDisplayName( + derivedConfig, + derivedRoute.ItemId, + state.FormData, + derivedCollectionFlowService); + FormEngineDerivedItems.ApplyDisplayNamePlaceholders(state.CurrentPage, displayName); + } + } + + return; + } + + var found = templateManagementService.FindPage(state.Template, state.CurrentPageId); + state.CurrentGroup = found.Group; + state.CurrentTask = found.Task; + state.CurrentPage = found.Page; + return; + } + + if (!string.IsNullOrEmpty(state.TaskId) && state.Template != null) + { + var (group, task) = templateManagementService.FindTask(state.Template, state.TaskId); + state.CurrentGroup = group; + state.CurrentTask = task; + state.CurrentPage = null; + + if (formStateManager.ShouldShowCollectionFlowSummary(state.CurrentTask)) + state.CurrentFormState = FormState.TaskSummary; + else if (formStateManager.ShouldShowDerivedCollectionFlowSummary(state.CurrentTask)) + state.CurrentFormState = FormState.DerivedCollectionFlowSummary; + } + } + + private void CheckAndClearSessionForNewApplication(FormEngineWorkState state) + { + var sessionApplicationId = sessionStore.GetString(FormSessionKeys.CurrentAccumulatedApplicationId); + var currentApplicationId = state.ApplicationId?.ToString(); + + if (!string.IsNullOrEmpty(sessionApplicationId) && sessionApplicationId != currentApplicationId) + { + applicationResponseService.ClearAccumulatedFormData(); + logger.LogInformation( + "Cleared accumulated form data for previous application {PreviousApplicationId}, now working with {CurrentApplicationId}", + sessionApplicationId, + currentApplicationId); + } + + if (state.ApplicationId.HasValue) + { + sessionStore.SetString( + FormEngineConstants.CurrentAccumulatedApplicationIdWriteKey, + state.ApplicationId.Value.ToString()); + } + } + + private async Task LoadAccumulatedDataFromSessionAsync(FormEngineWorkState state) + { + var accumulatedData = applicationResponseService.GetAccumulatedFormData(); + if (accumulatedData.Count > 0) + { + foreach (var kvp in accumulatedData) + state.Data[kvp.Key] = kvp.Value; + + logger.LogInformation("Loaded {Count} accumulated form data entries from session", accumulatedData.Count); + } + + state.ConditionalState = await FormEngineConditionalLogic.ApplyAsync( + state.Template, + state.Data, + state.FormData, + conditionalLogicOrchestrator, + state.CurrentPageId, + state.TaskId, + "load", + logger); + } + + private void MergeFlowProgressIntoFormDataForSummary(FormEngineWorkState state) + { + if (state.CurrentTask?.Summary?.Mode?.Equals("multiCollectionFlow", StringComparison.OrdinalIgnoreCase) != true + || state.CurrentTask.Summary?.Flows == null) + { + return; + } + + foreach (var flow in state.CurrentTask.Summary.Flows) + { + if (!state.FormData.TryGetValue(flow.FieldId, out var val) || string.IsNullOrWhiteSpace(val?.ToString())) + continue; + + var items = JsonSerializer.Deserialize>>(val.ToString()!) ?? []; + var changed = false; + + foreach (var item in items) + { + if (!item.TryGetValue("id", out var idObj)) + continue; + var instanceId = idObj?.ToString(); + if (string.IsNullOrWhiteSpace(instanceId)) + continue; + + var progress = collectionFlowProgressStore.Load(flow.FlowId, instanceId); + if (progress.Count == 0) + continue; + + foreach (var kv in progress) + item[kv.Key] = kv.Value; + changed = true; + } + + if (!changed) + continue; + + var updatedJson = JsonSerializer.Serialize(items); + state.FormData[flow.FieldId] = updatedJson; + state.Data[flow.FieldId] = updatedJson; + } + } + + private void PopulateUploadFieldsFromSession(FormEngineWorkState state) + { + if (state.CurrentPage == null || !state.ApplicationId.HasValue) + return; + + var uploadFields = state.CurrentPage.Fields + .Where(f => f.Type == "complexField" + && f.ComplexField != null + && complexFieldConfigurationService.GetConfiguration(f.ComplexField.Id).FieldType + .Equals("upload", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + foreach (var field in uploadFields) + { + var fieldId = field.FieldId; + var files = formFileFieldService.GetFiles(state.FileFieldContext, fieldId); + formFileFieldService.SaveFiles(state.FileFieldContext, fieldId, files.ToList()); + if (files.Count == 0) + continue; + + state.Data[fieldId] = JsonSerializer.Serialize(files); + } + } + + private void LoadExistingFlowItemData(FormEngineWorkState state, string flowId, string instanceId) + { + var fieldId = FormStepPolicy.GetCollectionFlowFieldId(state.CurrentTask, flowId); + if (string.IsNullOrEmpty(fieldId)) + return; + + var accumulated = applicationResponseService.GetAccumulatedFormData(); + if (accumulated.TryGetValue(fieldId, out var collectionValue)) + { + var json = collectionValue?.ToString() ?? "[]"; + try + { + var items = JsonSerializer.Deserialize>>(json) ?? []; + var existingItem = items.FirstOrDefault(item => item.TryGetValue("id", out var id) && id?.ToString() == instanceId); + + if (existingItem != null) + { + foreach (var kvp in existingItem) + { + if (kvp.Key == "id") + continue; + state.Data[kvp.Key] = kvp.Value; + } + } + else + { + LoadOrClearProgress(state, flowId, instanceId); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load existing flow item data for instance {InstanceId}", instanceId); + } + } + else + { + LoadOrClearProgress(state, flowId, instanceId); + } + } + + private void LoadOrClearProgress(FormEngineWorkState state, string flowId, string instanceId) + { + var existingProgress = collectionFlowProgressStore.Load(flowId, instanceId); + if (existingProgress.Count > 0) + { + foreach (var kvp in existingProgress) + state.Data[kvp.Key] = kvp.Value; + return; + } + + collectionFlowProgressStore.Clear(flowId, instanceId); + state.Data.Clear(); + } + + private bool IsExistingCollectionItem(string fieldId, string instanceId) + { + var accumulated = applicationResponseService.GetAccumulatedFormData(); + return FormEngineCollectionItems.Read(accumulated, fieldId) + .Any(item => item.TryGetValue("id", out var id) && id?.ToString() == instanceId); + } + + private async Task RefreshFileValidationGateAsync(FormEngineWorkState state, CancellationToken cancellationToken) + { + _fileValidationBlocksSubmit = false; + _blockingFiles = []; + + if (!state.ApplicationId.HasValue) + return; + + try + { + var gate = await applicationsClient.GetFileValidationGateAsync(state.ApplicationId.Value); + _fileValidationBlocksSubmit = !gate.CanSubmit; + _blockingFiles = gate.BlockingFiles ?? []; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not evaluate file validation gate for application {ApplicationId}", state.ApplicationId); + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/RemoveCollectionItemService.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/RemoveCollectionItemService.cs new file mode 100644 index 0000000..2545794 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/RemoveCollectionItemService.cs @@ -0,0 +1,198 @@ +using System.Text.Json; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Application.Validation; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +/// +/// Removes an item from a collection-flow field and deletes associated files. +/// +public interface IRemoveCollectionItem +{ + Task ExecuteAsync( + FormEngineWorkState state, + string fieldId, + string itemId, + string? flowId, + bool confirmed, + CancellationToken cancellationToken = default); +} + +public sealed class RemoveCollectionItemService( + ITemplateManagementService templateManagementService, + IApplicationResponseService applicationResponseService, + IFileUploadService fileUploadService, + IFormNavigationService formNavigationService, + ILogger logger) : IRemoveCollectionItem +{ + public async Task ExecuteAsync( + FormEngineWorkState state, + string fieldId, + string itemId, + string? flowId, + bool confirmed, + CancellationToken cancellationToken = default) + { + if (!string.IsNullOrEmpty(state.TaskId) && state.Template != null) + { + var (group, task) = templateManagementService.FindTask(state.Template, state.TaskId); + state.CurrentGroup = group; + state.CurrentTask = task; + } + + if (string.IsNullOrEmpty(fieldId) || string.IsNullOrEmpty(itemId)) + return FormEngineOutcome.BadRequest(FormEngineMessages.FieldIdAndItemIdRequired); + + if (!state.IsEditable) + { + return FormEngineOutcome.Stay( + clearModelState: true, + errors: [new FormValidationError(string.Empty, FormEngineMessages.NoWritePermission)]); + } + + var summaryUrl = formNavigationService.GetCollectionFlowSummaryUrl(state.TaskId, state.ReferenceNumber); + if (!confirmed) + { + logger.LogInformation("RemoveCollectionItem handler executing for validation - item will not be removed yet"); + return FormEngineOutcome.Redirect(summaryUrl); + } + + logger.LogInformation( + "RemoveCollectionItem handler executing confirmed removal for item {ItemId} from field {FieldId}", + itemId, + fieldId); + + var accumulatedData = applicationResponseService.GetAccumulatedFormData(); + string? successMessage = null; + + if (!string.IsNullOrEmpty(flowId) && state.CurrentTask != null) + { + var flow = state.CurrentTask.Summary?.Flows?.FirstOrDefault(f => f.FlowId == flowId); + if (flow != null) + { + Dictionary? itemData = null; + if (accumulatedData.TryGetValue(fieldId, out var collectionValue)) + { + var json = collectionValue?.ToString() ?? "[]"; + try + { + var items = JsonSerializer.Deserialize>>(json) ?? []; + itemData = items.FirstOrDefault(i => i.TryGetValue("id", out var id) && id?.ToString() == itemId); + } + catch (JsonException) + { + itemData = null; + } + } + + itemData = FormEngineSuccessMessages.ExpandEncodedJson(itemData); + successMessage = FormEngineSuccessMessages.Generate(flow.DeleteItemMessage, "delete", itemData, flow.Title); + } + } + + if (accumulatedData.TryGetValue(fieldId, out var collectionData)) + { + var json = collectionData?.ToString() ?? "[]"; + try + { + var items = JsonSerializer.Deserialize>>(json) ?? []; + var itemToRemove = items.FirstOrDefault(item => + item.TryGetValue("id", out var id) && id?.ToString() == itemId); + + if (itemToRemove != null && state.ApplicationId.HasValue) + { + var expandedItem = FormEngineSuccessMessages.ExpandEncodedJson(itemToRemove); + await DeleteFilesFromCollectionItemAsync(state.ApplicationId.Value, expandedItem, cancellationToken); + } + + items.RemoveAll(item => item.TryGetValue("id", out var id) && id?.ToString() == itemId); + var updatedJson = JsonSerializer.Serialize(items); + applicationResponseService.AccumulateFormData(new Dictionary { [fieldId] = updatedJson }); + + if (state.ApplicationId.HasValue) + { + await applicationResponseService.SaveApplicationResponseAsync( + state.ApplicationId.Value, + new Dictionary { [fieldId] = updatedJson }, + cancellationToken); + } + } + catch (ExternalApplicationsException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to remove collection item {ItemId} from field {FieldId}", itemId, fieldId); + } + } + + return FormEngineOutcome.Redirect(summaryUrl, successMessage); + } + + private async Task DeleteFilesFromCollectionItemAsync( + Guid applicationId, + Dictionary? itemData, + CancellationToken cancellationToken) + { + if (itemData == null) + return; + + var deletedCount = 0; + foreach (var kvp in itemData) + { + if (kvp.Key == "id" || kvp.Value == null) + continue; + + try + { + var valueStr = kvp.Value.ToString(); + if (string.IsNullOrEmpty(valueStr) || !valueStr.TrimStart().StartsWith('[')) + continue; + + var files = JsonSerializer.Deserialize>(valueStr); + if (files == null || files.Count == 0) + continue; + + foreach (var file in files) + { + try + { + await fileUploadService.DeleteFileAsync(file.Id, applicationId, cancellationToken); + deletedCount++; + logger.LogInformation( + "Deleted file {FileId} ({FileName}) from removed collection item in application {ApplicationId}", + file.Id, + file.OriginalFileName, + applicationId); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Failed to delete file {FileId} from collection item - file may already be deleted", + file.Id); + } + } + } + catch (JsonException) + { + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error processing field {FieldKey} for file cleanup", kvp.Key); + } + } + + if (deletedCount > 0) + { + logger.LogInformation( + "Successfully deleted {DeletedCount} file(s) from removed collection item in application {ApplicationId}", + deletedCount, + applicationId); + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/SaveFormPageService.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/SaveFormPageService.cs new file mode 100644 index 0000000..1ba3b53 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/SaveFormPageService.cs @@ -0,0 +1,862 @@ +using System.Text.Json; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Application.Validation; +using GovUK.Dfe.FlexForms.Domain.Caching; +using GovUK.Dfe.FlexForms.Domain.FormEngine; +using GovUK.Dfe.FlexForms.Domain.Models; +using Microsoft.Extensions.Logging; +using PageModel = GovUK.Dfe.FlexForms.Domain.Models.Page; +using Task = System.Threading.Tasks.Task; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +/// +/// Saves a posted form page and decides the next navigation target. +/// +public interface ISaveFormPage +{ + Task ExecuteAsync( + FormEngineWorkState state, + IReadOnlyDictionary> postedFields, + string? isTaskCompletedValue, + CancellationToken cancellationToken = default); +} + +public sealed class SaveFormPageService( + ITemplateManagementService templateManagementService, + IPostedFormDataBinder postedFormDataBinder, + IFormFileFieldService formFileFieldService, + IFormValidationOrchestrator formValidationOrchestrator, + IApplicationResponseService applicationResponseService, + ICollectionFlowProgressStore collectionFlowProgressStore, + IFormSessionStore sessionStore, + INavigationHistoryService navigationHistoryService, + IFormNavigationService formNavigationService, + IFormStateManager formStateManager, + IConditionalLogicOrchestrator conditionalLogicOrchestrator, + IComplexFieldConfigurationService complexFieldConfigurationService, + IDerivedCollectionFlowService derivedCollectionFlowService, + IApplicationStateService applicationStateService, + ILogger logger) : ISaveFormPage +{ + public async Task ExecuteAsync( + FormEngineWorkState state, + IReadOnlyDictionary> postedFields, + string? isTaskCompletedValue, + CancellationToken cancellationToken = default) + { + ResolveRoute(state); + + if (!state.IsEditable) + { + return FormEngineOutcome.Stay( + errors: [new FormValidationError(string.Empty, FormEngineMessages.NoWritePermission)]); + } + + state.Data = postedFormDataBinder.Bind(postedFields, state.Data); + formFileFieldService.ReplaceUploadPlaceholders(state.Data, state.FileFieldContext); + + state.ConditionalState = await FormEngineConditionalLogic.ApplyAsync( + state.Template, + state.Data, + state.FormData, + conditionalLogicOrchestrator, + state.CurrentPageId, + state.TaskId, + "change", + logger, + applicationResponseService.GetAccumulatedFormData()); + + postedFormDataBinder.ApplyDateParts(postedFields, state.Data); + + var isDerivedFlowRoute = FormRouteParser.TryParseDerivedFlow(state.CurrentPageId, out _); + if (!isDerivedFlowRoute && state.CurrentPage != null) + { + var validation = formValidationOrchestrator.ValidatePage(state.CurrentPage, state.Data, state.Template); + if (!validation.IsValid) + return await InvalidPageOutcomeAsync(state, validation); + } + + MergeAutocompleteMultiSelect(state); + + var isSubFlow = FormRouteParser.TryParseCollectionFlow(state.CurrentPageId, out _); + var isDerivedFlowSave = FormRouteParser.TryParseDerivedFlow(state.CurrentPageId, out _); + if (state.ApplicationId.HasValue && state.Data.Count > 0 && !isSubFlow && !isDerivedFlowSave) + { + await applicationResponseService.SaveApplicationResponseAsync(state.ApplicationId.Value, state.Data, cancellationToken); + logger.LogInformation( + "Successfully saved response for Application {ApplicationId}, Page {PageId}", + state.ApplicationId.Value, + state.CurrentPageId); + } + + PushNavigationHistory(state); + + if (state.CurrentTask != null && state.CurrentPage != null) + { + var collectionOutcome = await TryNavigateCollectionFlowAsync(state, cancellationToken); + if (collectionOutcome != null) + return collectionOutcome; + + logger.LogInformation("POST: Checking if CurrentPageId '{CurrentPageId}' is a derived flow route", state.CurrentPageId); + + if (FormRouteParser.TryParseDerivedFlow(state.CurrentPageId, out var derivedRoute)) + { + logger.LogInformation( + "POST: Detected derived flow route - flowId='{FlowId}', itemId='{ItemId}', pageId='{PageId}'", + derivedRoute.FlowId, + derivedRoute.ItemId, + derivedRoute.PageId); + } + else + { + logger.LogInformation("POST: CurrentPageId '{CurrentPageId}' is NOT a derived flow route", state.CurrentPageId); + } + + if (FormRouteParser.TryParseDerivedFlow(state.CurrentPageId, out derivedRoute)) + { + var derivedOutcome = await SaveDerivedFlowAsync(state, derivedRoute, cancellationToken); + if (derivedOutcome != null) + return derivedOutcome; + } + else if (formStateManager.ShouldShowDerivedCollectionFlowSummary(state.CurrentTask)) + { + return await CompleteDerivedSummaryFromPageAsync(state, isTaskCompletedValue, cancellationToken); + } + else + { + return await NavigateStandardPageAsync(state); + } + } + else if (state.CurrentTask != null) + { + if (formStateManager.ShouldShowCollectionFlowSummary(state.CurrentTask)) + { + return FormEngineOutcome.Redirect( + formNavigationService.GetCollectionFlowSummaryUrl(state.CurrentTask.TaskId, state.ReferenceNumber)); + } + + if (formStateManager.ShouldShowDerivedCollectionFlowSummary(state.CurrentTask)) + return await CompleteDerivedSummaryFallbackAsync(state, isTaskCompletedValue, cancellationToken); + + return FormEngineOutcome.Redirect($"/applications/{state.ReferenceNumber}/{state.CurrentTask.TaskId}"); + } + + return FormEngineOutcome.Redirect($"/applications/{state.ReferenceNumber}"); + } + + private void ResolveRoute(FormEngineWorkState state) + { + if (string.IsNullOrEmpty(state.CurrentPageId)) + { + if (string.IsNullOrEmpty(state.TaskId) || state.Template == null) + return; + + var (group, task) = templateManagementService.FindTask(state.Template, state.TaskId); + state.CurrentGroup = group; + state.CurrentTask = task; + state.CurrentPage = null; + logger.LogInformation("POST: Initialized CurrentTask '{TaskId}' for summary POST (no pageId)", state.CurrentTask?.TaskId); + return; + } + + if (state.Template == null) + return; + + if (FormRouteParser.TryParseCollectionFlow(state.CurrentPageId, out var flowRoute)) + { + var (group, task) = templateManagementService.FindTask(state.Template, state.TaskId); + state.CurrentGroup = group; + state.CurrentTask = task; + var flowPages = FormStepPolicy.GetCollectionFlowPages(task, flowRoute.FlowId); + var page = FormStepPolicy.ResolvePage(flowPages, flowRoute.PageId); + if (page != null) + state.CurrentPage = page; + return; + } + + if (FormRouteParser.TryParseDerivedFlow(state.CurrentPageId, out var derivedRoute)) + { + var (group, task) = templateManagementService.FindTask(state.Template, state.TaskId); + state.CurrentGroup = group; + state.CurrentTask = task; + var derivedConfig = FormStepPolicy.GetDerivedFlow(task, derivedRoute.FlowId); + var page = FormStepPolicy.ResolvePage(derivedConfig?.Pages, derivedRoute.PageId); + if (page != null) + state.CurrentPage = page; + return; + } + + var found = templateManagementService.FindPage(state.Template, state.CurrentPageId); + state.CurrentGroup = found.Group; + state.CurrentTask = found.Task; + state.CurrentPage = found.Page; + } + + private async Task InvalidPageOutcomeAsync(FormEngineWorkState state, FormValidationResult validation) + { + logger.LogWarning("ModelState invalid on POST Page"); + + if (FormRouteParser.TryParseCollectionFlow(state.CurrentPageId, out var flowRoute)) + { + try + { + collectionFlowProgressStore.Save(flowRoute.FlowId, flowRoute.InstanceId, state.Data); + logger.LogInformation( + "Saved in-progress flow data for flow {FlowId}, instance {InstanceId} with {Count} fields due to validation errors.", + flowRoute.FlowId, + flowRoute.InstanceId, + state.Data.Count); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to save flow progress on validation failure."); + } + } + + var selfUrl = $"/applications/{state.ReferenceNumber}/{state.TaskId}/{state.CurrentPageId}"; + if (FormRouteParser.IsDerivedFlow(state.CurrentPageId) || FormRouteParser.IsCollectionFlow(state.CurrentPageId)) + { + return FormEngineOutcome.Redirect( + selfUrl, + errors: validation.Errors, + persistErrors: true, + errorContextKey: state.ErrorContextKey); + } + + return FormEngineOutcome.Stay( + errors: validation.Errors, + persistErrors: true, + errorContextKey: state.ErrorContextKey); + } + + private void MergeAutocompleteMultiSelect(FormEngineWorkState state) + { + if (state.CurrentPage == null) + return; + + try + { + foreach (var field in state.CurrentPage.Fields.Where(f => f.Type == "complexField" && f.ComplexField != null)) + { + var cfg = complexFieldConfigurationService.GetConfiguration(field.ComplexField.Id); + if (!string.Equals(cfg.FieldType, "autocomplete", StringComparison.OrdinalIgnoreCase) || !cfg.AllowMultiple) + continue; + + var key = field.FieldId; + if (!state.Data.TryGetValue(key, out var newValObj)) + continue; + + var newVal = newValObj?.ToString(); + if (string.IsNullOrWhiteSpace(newVal)) + continue; + + var acc = applicationResponseService.GetAccumulatedFormData(); + var list = new List(); + if (acc.TryGetValue(key, out var existing) && !string.IsNullOrWhiteSpace(existing?.ToString())) + { + var existingText = existing!.ToString()!; + var addedExisting = TryParseExistingAutocomplete(existingText, list); + if (!addedExisting && !string.IsNullOrWhiteSpace(existingText)) + list.Add(existingText); + } + + var exists = list.Any(x => (x?.ToString() ?? "") == newVal); + if (!exists) + list.Add(ParseAutocompleteValue(newVal)); + + var updatedJson = JsonSerializer.Serialize(list); + state.Data[key] = updatedJson; + state.Data[$"Data_{key}"] = updatedJson; + applicationResponseService.AccumulateFormData(new Dictionary { [key] = updatedJson }); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to merge multi-select autocomplete values"); + } + } + + private static bool TryParseExistingAutocomplete(string existingText, List list) + { + try + { + var parsedArray = JsonSerializer.Deserialize>(existingText); + if (parsedArray != null) + { + list.AddRange(parsedArray); + return true; + } + } + catch (JsonException) + { + } + + try + { + using var doc = JsonDocument.Parse(existingText); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + { + list.Add(doc.RootElement.Clone()); + return true; + } + } + catch (JsonException) + { + } + + return false; + } + + private static object ParseAutocompleteValue(string newVal) + { + try + { + using var newDoc = JsonDocument.Parse(newVal); + if (newDoc.RootElement.ValueKind is JsonValueKind.Object or JsonValueKind.Array) + return newDoc.RootElement.Clone(); + if (newDoc.RootElement.ValueKind == JsonValueKind.String) + return newDoc.RootElement.GetString() ?? string.Empty; + return newDoc.RootElement.ToString(); + } + catch (JsonException) + { + return newVal; + } + } + + private void PushNavigationHistory(FormEngineWorkState state) + { + try + { + if (!string.IsNullOrEmpty(state.CurrentPageId)) + { + var scope = FormRouteParser.HistoryScope(state.ReferenceNumber, state.TaskId, state.CurrentPageId); + var currentUrl = $"/applications/{state.ReferenceNumber}/{state.TaskId}/{state.CurrentPageId}"; + navigationHistoryService.Push(scope, currentUrl); + } + else if (!string.IsNullOrEmpty(state.TaskId)) + { + var scope = FormRouteParser.HistoryScope(state.ReferenceNumber, state.TaskId, state.CurrentPageId); + var currentUrl = $"/applications/{state.ReferenceNumber}/{state.TaskId}"; + navigationHistoryService.Push(scope, currentUrl); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to push navigation history"); + } + } + + private async Task TryNavigateCollectionFlowAsync( + FormEngineWorkState state, + CancellationToken cancellationToken) + { + if (!FormRouteParser.TryParseCollectionFlow(state.CurrentPageId, out var flowRoute)) + return null; + + var flowPages = FormStepPolicy.GetCollectionFlowPages(state.CurrentTask, flowRoute.FlowId)?.ToList(); + var flowFieldId = FormStepPolicy.GetCollectionFlowFieldId(state.CurrentTask, flowRoute.FlowId); + if (flowPages == null || string.IsNullOrEmpty(flowFieldId)) + return null; + + var existenceKey = FormSessionKeys.FlowItemExisted(flowRoute.FlowId, flowRoute.InstanceId); + var existedValue = sessionStore.GetString(existenceKey); + var itemExistedBeforeSave = existedValue is not null && bool.TryParse(existedValue, out var parsed) + ? parsed + : IsExistingCollectionItem(flowFieldId, flowRoute.InstanceId); + _ = itemExistedBeforeSave; + + collectionFlowProgressStore.Save(flowRoute.FlowId, flowRoute.InstanceId, state.Data); + + if (state.ApplicationId.HasValue) + { + var accumulatedProgress = collectionFlowProgressStore.Load(flowRoute.FlowId, flowRoute.InstanceId); + AppendCollectionItemToSession(flowPages, flowFieldId, flowRoute.InstanceId, accumulatedProgress); + + var accData = applicationResponseService.GetAccumulatedFormData(); + if (accData.TryGetValue(flowFieldId, out var collectionValue)) + { + await applicationResponseService.SaveApplicationResponseAsync( + state.ApplicationId.Value, + new Dictionary { [flowFieldId] = collectionValue }, + cancellationToken); + logger.LogInformation( + "Saved partial collection item to database for flow {FlowId}, instance {InstanceId}, page {PageId}", + flowRoute.FlowId, + flowRoute.InstanceId, + state.CurrentPageId); + } + } + + var index = FormStepPolicy.IndexOfPage(flowPages, state.CurrentPage!.PageId); + var isLast = FormStepPolicy.IsLastPage(flowPages, state.CurrentPage.PageId); + if (!isLast) + { + string? nextPageId = null; + if (state.ConditionalState != null) + { + logger.LogDebug( + "Sub-flow navigation: checking conditional logic for pages. Current page: {CurrentPageId}, Flow: {FlowId}", + state.CurrentPage.PageId, + flowRoute.FlowId); + + var mergedData = collectionFlowProgressStore.Load(state.FlowId, state.InstanceId); + foreach (var kvp in state.Data) + mergedData[kvp.Key] = kvp.Value; + + var navContext = new ConditionalLogicContext + { + CurrentPageId = state.CurrentPageId, + CurrentTaskId = state.TaskId, + IsClientSide = false, + Trigger = "change" + }; + + var updatedConditionalState = await conditionalLogicOrchestrator.ApplyConditionalLogicAsync( + state.Template, + mergedData, + navContext); + + for (var i = index + 1; i < flowPages.Count; i++) + { + var candidatePage = flowPages[i]; + var isHidden = updatedConditionalState.PageVisibility.TryGetValue(candidatePage.PageId, out var isVisible) && !isVisible; + var isSkipped = updatedConditionalState.SkippedPages.Contains(candidatePage.PageId); + if (!isHidden && !isSkipped) + { + nextPageId = candidatePage.PageId; + break; + } + } + } + else + { + nextPageId = flowPages[index + 1].PageId; + } + + if (!string.IsNullOrEmpty(nextPageId)) + { + var nextUrl = formNavigationService.GetSubFlowPageUrl( + state.CurrentTask!.TaskId, + state.ReferenceNumber, + flowRoute.FlowId, + flowRoute.InstanceId, + nextPageId); + return FormEngineOutcome.Redirect(nextUrl); + } + } + + var accumulated = collectionFlowProgressStore.Load(flowRoute.FlowId, flowRoute.InstanceId); + foreach (var kv in state.Data) + { + if (kv.Value?.ToString() == FormEngineConstants.UploadFieldSessionPlaceholder && accumulated.ContainsKey(kv.Key)) + continue; + accumulated[kv.Key] = kv.Value; + } + + AppendCollectionItemToSession(flowPages, flowFieldId, flowRoute.InstanceId, accumulated); + + var flow = state.CurrentTask!.Summary?.Flows?.FirstOrDefault(f => f.FlowId == flowRoute.FlowId); + var taskTitle = state.CurrentTask?.TaskName ?? flow?.Title ?? "Item"; + var successMessage = $"{taskTitle} updated"; + + if (state.ApplicationId.HasValue) + { + var acc = applicationResponseService.GetAccumulatedFormData(); + if (acc.TryGetValue(flowFieldId, out var collectionValue)) + { + await applicationResponseService.SaveApplicationResponseAsync( + state.ApplicationId.Value, + new Dictionary { [flowFieldId] = collectionValue }, + cancellationToken); + } + } + + collectionFlowProgressStore.Clear(flowRoute.FlowId, flowRoute.InstanceId); + var scope = FormRouteParser.HistoryScope(state.ReferenceNumber, state.TaskId, state.CurrentPageId); + navigationHistoryService.Clear(scope); + + var backToSummary = formNavigationService.GetCollectionFlowSummaryUrl(state.CurrentTask!.TaskId, state.ReferenceNumber); + return FormEngineOutcome.Redirect(backToSummary, successMessage); + } + + private async Task SaveDerivedFlowAsync( + FormEngineWorkState state, + DerivedFlowRoute derivedRoute, + CancellationToken cancellationToken) + { + var correctTask = state.Template?.TaskGroups?.SelectMany(g => g.Tasks)?.FirstOrDefault(t => t.TaskId == state.TaskId); + var derivedConfig = FormStepPolicy.GetDerivedFlow(correctTask, derivedRoute.FlowId); + if (derivedConfig == null) + { + logger.LogError("DerivedFlow POST: Could not find derived config for flowId='{FlowId}'", derivedRoute.FlowId); + return null; + } + + var currentDerivedPage = FormStepPolicy.ResolvePage(derivedConfig.Pages, derivedRoute.PageId); + if (currentDerivedPage != null) + { + var validation = formValidationOrchestrator.ValidatePage(currentDerivedPage, state.Data, state.Template); + if (!validation.IsValid) + { + var selfUrl = $"/applications/{state.ReferenceNumber}/{state.TaskId}/{state.CurrentPageId}"; + return FormEngineOutcome.Redirect( + selfUrl, + errors: validation.Errors, + persistErrors: true, + errorContextKey: state.ErrorContextKey); + } + } + + derivedCollectionFlowService.SaveItemDeclaration( + derivedConfig.FieldId, + derivedRoute.ItemId, + state.Data, + "Signed", + state.FormData); + + if (state.ApplicationId.HasValue) + { + var statusKey = $"{derivedConfig.FieldId}_status_{derivedRoute.ItemId}"; + var dataKey = $"{derivedConfig.FieldId}_data_{derivedRoute.ItemId}"; + var derivedUpdates = new Dictionary + { + [statusKey] = state.FormData[statusKey], + [dataKey] = state.FormData[dataKey] + }; + await applicationResponseService.SaveApplicationResponseAsync( + state.ApplicationId.Value, + derivedUpdates, + cancellationToken); + } + else + { + logger.LogWarning("DerivedFlow POST: No ApplicationId found, skipping API save"); + } + + var displayName = FormEngineDerivedItems.GetDisplayName( + derivedConfig, + derivedRoute.ItemId, + state.FormData, + derivedCollectionFlowService); + var templateMessage = derivedConfig.SignedMessage ?? "Declaration for {displayName} has been signed"; + var successMessage = templateMessage + .Replace("{displayName}", displayName) + .Replace("{name}", displayName); + + return FormEngineOutcome.Redirect( + $"/applications/{state.ReferenceNumber}/{state.TaskId}", + successMessage); + } + + private async Task CompleteDerivedSummaryFromPageAsync( + FormEngineWorkState state, + string? isTaskCompletedValue, + CancellationToken cancellationToken) + { + var isCompleted = ParseCompleted(isTaskCompletedValue); + if (isCompleted) + { + await applicationResponseService.SaveApplicationResponseAsync( + state.ApplicationId!.Value, + new Dictionary { [$"{state.TaskId}_completed"] = true }, + cancellationToken); + + if (state.CurrentTask != null) + { + await applicationStateService.SaveTaskStatusAsync( + state.ApplicationId.Value, + state.CurrentTask.TaskId, + Domain.Models.TaskStatus.Completed); + } + + logger.LogInformation( + "POST: About to redirect to task list using RedirectToPage with ReferenceNumber: {ReferenceNumber}", + state.ReferenceNumber); + return FormEngineOutcome.RedirectToPage("/FormEngine/RenderForm", new { referenceNumber = state.ReferenceNumber }); + } + + if (state.CurrentTask != null && state.ApplicationId.HasValue) + { + var hasAnyData = applicationStateService.CalculateTaskStatus( + state.CurrentTask.TaskId, + state.Template, + state.FormData, + state.ApplicationId, + state.ApplicationStatus) + != Domain.Models.TaskStatus.NotStarted; + var newStatus = hasAnyData ? Domain.Models.TaskStatus.InProgress : Domain.Models.TaskStatus.NotStarted; + await applicationStateService.SaveTaskStatusAsync(state.ApplicationId.Value, state.CurrentTask.TaskId, newStatus); + } + + return FormEngineOutcome.RedirectToPage("/FormEngine/RenderForm", new { referenceNumber = state.ReferenceNumber }); + } + + private async Task NavigateStandardPageAsync(FormEngineWorkState state) + { + if (state.CurrentPage!.ReturnToSummaryPage) + { + string? conditionalNextPageId = null; + var hasConditionalTrigger = false; + + if (state.ConditionalState != null && state.Template != null) + { + var visibility = new FormEngineVisibilityEvaluator( + state.Template, + state.ConditionalState, + conditionalLogicOrchestrator, + state.CurrentPageId, + state.TaskId, + logger); + hasConditionalTrigger = visibility.HasConditionalLogicShowingPages(state.Data); + + logger.LogInformation( + "[FLOW DEBUG] ReturnToSummaryPage=true path - hasConditionalTrigger: {HasTrigger}, currentPageId: {PageId}", + hasConditionalTrigger, + state.CurrentPage.PageId); + + if (hasConditionalTrigger) + { + LogDataPreview(state.Data); + var context = new ConditionalLogicContext + { + CurrentPageId = state.CurrentPageId, + CurrentTaskId = state.TaskId, + IsClientSide = false, + Trigger = "change" + }; + conditionalNextPageId = await conditionalLogicOrchestrator.GetNextPageAsync( + state.Template, + state.Data, + state.CurrentPage.PageId, + context); + logger.LogInformation("[FLOW DEBUG] GetNextPageAsync returned: {NextPageId}", conditionalNextPageId ?? "null"); + } + } + + if (hasConditionalTrigger && !string.IsNullOrEmpty(conditionalNextPageId)) + { + var nextUrl = $"/applications/{state.ReferenceNumber}/{state.CurrentTask!.TaskId}/{conditionalNextPageId}"; + return FormEngineOutcome.Redirect(nextUrl); + } + + var summaryScope = FormRouteParser.HistoryScope(state.ReferenceNumber, state.TaskId, state.CurrentPageId); + navigationHistoryService.Clear(summaryScope); + var summaryUrl = formNavigationService.GetTaskSummaryUrl(state.CurrentTask!.TaskId, state.ReferenceNumber); + return FormEngineOutcome.Redirect(summaryUrl); + } + + string? nextPageId = null; + if (state.ConditionalState != null && state.Template != null) + { + logger.LogInformation("[FLOW DEBUG] ReturnToSummaryPage=false path - currentPageId: {PageId}", state.CurrentPage.PageId); + LogDataPreview(state.Data); + var context = new ConditionalLogicContext + { + CurrentPageId = state.CurrentPageId, + CurrentTaskId = state.TaskId, + IsClientSide = false, + Trigger = "change" + }; + nextPageId = await conditionalLogicOrchestrator.GetNextPageAsync( + state.Template, + state.Data, + state.CurrentPage.PageId, + context); + logger.LogInformation("[FLOW DEBUG] GetNextPageAsync returned: {NextPageId}", nextPageId ?? "null"); + } + + if (!string.IsNullOrEmpty(nextPageId)) + { + var nextUrl = $"/applications/{state.ReferenceNumber}/{state.CurrentTask!.TaskId}/{nextPageId}"; + return FormEngineOutcome.Redirect(nextUrl); + } + + var sequentialNextPage = FormStepPolicy.GetNextPage(state.CurrentTask!.Pages, state.CurrentPage.PageId); + if (sequentialNextPage != null) + { + var nextUrl = $"/applications/{state.ReferenceNumber}/{state.CurrentTask.TaskId}/{sequentialNextPage.PageId}"; + return FormEngineOutcome.Redirect(nextUrl); + } + + var summaryFallbackScope = FormRouteParser.HistoryScope(state.ReferenceNumber, state.TaskId, state.CurrentPageId); + navigationHistoryService.Clear(summaryFallbackScope); + var fallbackUrl = formNavigationService.GetTaskSummaryUrl(state.CurrentTask.TaskId, state.ReferenceNumber); + return FormEngineOutcome.Redirect(fallbackUrl); + } + + private async Task CompleteDerivedSummaryFallbackAsync( + FormEngineWorkState state, + string? isTaskCompletedValue, + CancellationToken cancellationToken) + { + var isCompleted = ParseCompleted(isTaskCompletedValue); + if (isCompleted) + { + var derivedFlows = state.CurrentTask?.Summary?.DerivedFlows; + var errorLines = new List(); + + if (derivedFlows != null && derivedFlows.Count > 0) + { + foreach (var derivedFlow in derivedFlows) + { + var derivedItems = derivedCollectionFlowService.GenerateItemsFromSourceField( + derivedFlow.SourceFieldId, + state.FormData, + derivedFlow); + + if (derivedItems.Count == 0) + { + var errorMessage = !string.IsNullOrEmpty(derivedFlow.NoItemsErrorMessage) + ? derivedFlow.NoItemsErrorMessage + : $"You need to add at least one item before signing the {derivedFlow.Title}"; + errorLines.Add(errorMessage); + continue; + } + + var statuses = derivedCollectionFlowService.GetItemStatuses(derivedFlow.FieldId, state.FormData); + var unsignedItems = derivedItems + .Where(item => !statuses.ContainsKey(item.Id) || statuses[item.Id] != "Signed") + .ToList(); + + foreach (var item in unsignedItems) + { + var displayName = FormEngineDerivedItems.GetDisplayName( + derivedFlow, + item.Id, + state.FormData, + derivedCollectionFlowService); + var errorMessage = !string.IsNullOrEmpty(derivedFlow.UnsignedItemErrorMessage) + ? derivedFlow.UnsignedItemErrorMessage.Replace("{sourceName}", displayName) + : $"You need to sign the declaration for {displayName}"; + errorLines.Add(errorMessage); + } + } + } + + if (errorLines.Count > 0) + { + var errors = new List + { + new(string.Empty, "You cannot mark this section as complete:") + }; + errors.AddRange(errorLines.Select(line => new FormValidationError(string.Empty, line))); + + return FormEngineOutcome.Stay( + formState: FormState.DerivedCollectionFlowSummary, + errors: errors, + clearModelState: true, + isTaskCompleted: false, + reloadFormData: true); + } + } + + if (state.ApplicationId.HasValue && state.CurrentTask != null) + { + if (isCompleted) + { + await applicationStateService.SaveTaskStatusAsync( + state.ApplicationId.Value, + state.CurrentTask.TaskId, + Domain.Models.TaskStatus.Completed); + } + else + { + var hasAnyData = applicationStateService.CalculateTaskStatus( + state.CurrentTask.TaskId, + state.Template, + state.FormData, + state.ApplicationId, + state.ApplicationStatus) + != Domain.Models.TaskStatus.NotStarted; + var newStatus = hasAnyData ? Domain.Models.TaskStatus.InProgress : Domain.Models.TaskStatus.NotStarted; + await applicationStateService.SaveTaskStatusAsync( + state.ApplicationId.Value, + state.CurrentTask.TaskId, + newStatus); + } + } + + var taskListUrl = formNavigationService.GetTaskListUrl(state.ReferenceNumber); + return FormEngineOutcome.Redirect(taskListUrl); + } + + private bool IsExistingCollectionItem(string fieldId, string instanceId) + { + var accumulated = applicationResponseService.GetAccumulatedFormData(); + var items = FormEngineCollectionItems.Read(accumulated, fieldId); + return items.Any(item => item.TryGetValue("id", out var id) && id?.ToString() == instanceId); + } + + private void AppendCollectionItemToSession( + List pages, + string fieldId, + string instanceId, + Dictionary itemData) + { + var acc = applicationResponseService.GetAccumulatedFormData(); + var list = FormEngineCollectionItems.Read(acc, fieldId); + + var idx = list.FindIndex(x => x.TryGetValue("id", out var id) && id?.ToString() == instanceId); + Dictionary item; + + if (idx >= 0) + { + item = new Dictionary(list[idx]); + foreach (var kvp in itemData) + { + if (kvp.Value?.ToString() == FormEngineConstants.UploadFieldSessionPlaceholder + && item.TryGetValue(kvp.Key, out var existingVal) + && existingVal != null + && existingVal.ToString()!.StartsWith('[') + && existingVal.ToString()!.Contains("\"id\"")) + { + continue; + } + + item[kvp.Key] = kvp.Value; + } + } + else + { + item = new Dictionary(); + foreach (var page in pages) + { + foreach (var field in page.Fields) + { + var key = field.FieldId; + if (!itemData.TryGetValue(key, out var value)) + continue; + if (value?.ToString() == FormEngineConstants.UploadFieldSessionPlaceholder) + continue; + item[key] = value; + } + } + + item["id"] = instanceId; + } + + item["id"] = instanceId; + + if (idx >= 0) + list[idx] = item; + else + list.Add(item); + + var serialized = JsonSerializer.Serialize(list); + applicationResponseService.AccumulateFormData(new Dictionary { [fieldId] = serialized }); + } + + private void LogDataPreview(Dictionary data) + { + logger.LogInformation("[FLOW DEBUG] Data before calling GetNextPageAsync:"); + foreach (var kv in data.Take(10)) + logger.LogInformation("[FLOW DEBUG] Data[{Key}] = {Value}", kv.Key, kv.Value?.ToString() ?? "null"); + } + + private static bool ParseCompleted(string? value) => + !string.IsNullOrEmpty(value) + && (string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "on", StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/SubmitFormApplicationService.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/SubmitFormApplicationService.cs new file mode 100644 index 0000000..e10465d --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/SubmitFormApplicationService.cs @@ -0,0 +1,157 @@ +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Application.Validation; +using GovUK.Dfe.FlexForms.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +/// +/// Submits an application after task-completion and file-validation gates. +/// +public interface ISubmitFormApplication +{ + Task ExecuteAsync(FormEngineWorkState state, CancellationToken cancellationToken = default); +} + +public sealed class SubmitFormApplicationService( + IApplicationStateService applicationStateService, + IApplicationsClient applicationsClient, + IFormSessionStore sessionStore, + IConditionalLogicOrchestrator conditionalLogicOrchestrator, + ILogger logger) : ISubmitFormApplication +{ + public async Task ExecuteAsync( + FormEngineWorkState state, + CancellationToken cancellationToken = default) + { + if (!state.IsEditable) + { + return FormEngineOutcome.Stay( + formState: FormState.ApplicationPreview, + errors: [new FormValidationError(string.Empty, FormEngineMessages.NoWritePermission)]); + } + + if (!applicationStateService.AreAllTasksCompleted( + state.Template!, + state.FormData, + state.ApplicationId, + state.ApplicationStatus)) + { + logger.LogWarning("Cannot submit application {ReferenceNumber} - not all tasks completed", state.ReferenceNumber); + return FormEngineOutcome.Stay( + formState: FormState.ApplicationPreview, + errors: [new FormValidationError(string.Empty, FormEngineMessages.AllSectionsMustBeCompleted)]); + } + + var visibility = new FormEngineVisibilityEvaluator( + state.Template, + state.ConditionalState, + conditionalLogicOrchestrator, + state.CurrentPageId, + state.TaskId, + logger); + var tasksWithMissingFields = applicationStateService.ValidateAllRequiredFieldsForSubmission( + state.Template!, + state.FormData, + visibility.IsFieldHidden); + if (tasksWithMissingFields.Count > 0) + { + logger.LogWarning( + "Cannot submit application {ReferenceNumber} - {TaskCount} task(s) have missing required fields: {TaskIds}", + state.ReferenceNumber, + tasksWithMissingFields.Count, + string.Join(", ", tasksWithMissingFields.Keys)); + + var taskNames = tasksWithMissingFields.Keys + .Select(taskId => state.Template?.TaskGroups? + .SelectMany(g => g.Tasks) + .FirstOrDefault(t => t.TaskId == taskId)?.TaskName ?? taskId) + .ToList(); + + return FormEngineOutcome.Stay( + formState: FormState.ApplicationPreview, + errors: + [ + new FormValidationError( + string.Empty, + $"Some sections have missing required information and need to be completed again: {string.Join(", ", taskNames)}") + ]); + } + + if (!state.ApplicationId.HasValue) + { + logger.LogError("ApplicationId not found during submission for reference {ReferenceNumber}", state.ReferenceNumber); + return FormEngineOutcome.Stay( + errors: [new FormValidationError(string.Empty, FormEngineMessages.ApplicationNotFound)]); + } + + try + { + var gate = await applicationsClient.GetFileValidationGateAsync(state.ApplicationId.Value); + if (gate is { CanSubmit: false }) + { + var names = string.Join(", ", (gate.BlockingFiles ?? []).Select(f => f.OriginalFileName)); + return FormEngineOutcome.Stay( + formState: FormState.ApplicationPreview, + errors: + [ + new FormValidationError( + string.Empty, + $"Some uploaded files failed validation or are still being checked: {names}") + ], + fileValidationBlocksSubmit: true, + blockingFiles: gate.BlockingFiles ?? []); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not evaluate file validation gate for application {ApplicationId}", state.ApplicationId); + } + + try + { + logger.LogInformation( + "Attempting to submit application {ApplicationId} with reference {ReferenceNumber}", + state.ApplicationId.Value, + state.ReferenceNumber); + + var submittedApplication = await applicationsClient.SubmitApplicationAsync(state.ApplicationId.Value); + if (submittedApplication != null) + { + sessionStore.SetString( + $"ApplicationStatus_{state.ApplicationId.Value}", + submittedApplication.Status?.ToString() ?? "Submitted"); + logger.LogInformation( + "Successfully submitted application {ApplicationId} with reference {ReferenceNumber}", + state.ApplicationId.Value, + state.ReferenceNumber); + } + else + { + logger.LogWarning("Submit API returned null for application {ApplicationId}", state.ApplicationId.Value); + } + + return FormEngineOutcome.RedirectToPage( + "/Applications/ApplicationSubmitted", + new { referenceNumber = state.ReferenceNumber }); + } + catch (Exception ex) when (ex is not ExternalApplicationsException) + { + logger.LogError( + ex, + "Failed to submit application {ApplicationId} with reference {ReferenceNumber}", + state.ApplicationId.Value, + state.ReferenceNumber); + + return FormEngineOutcome.Stay( + formState: FormState.ApplicationPreview, + errors: + [ + new FormValidationError( + string.Empty, + $"An error occurred while submitting your application: {ex.Message}. Please try again.") + ]); + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/UploadFormFileService.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/UploadFormFileService.cs new file mode 100644 index 0000000..1ac8224 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/UploadFormFileService.cs @@ -0,0 +1,129 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Application.Validation; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +public sealed record UploadFormFileRequest( + Guid ApplicationId, + string FieldId, + string? ReturnUrl, + string? UploadDescription, + Stream Content, + string FileName, + string? ContentType, + string PageErrorContextKey, + bool HasFile); + +/// +/// Uploads a file into session-backed field storage without persisting it to the application response. +/// +public interface IUploadFormFile +{ + Task ExecuteAsync( + FormEngineWorkState state, + UploadFormFileRequest request, + CancellationToken cancellationToken = default); +} + +public sealed class UploadFormFileService( + IFormFileFieldService formFileFieldService, + IFileUploadService fileUploadService, + IInfectedUploadFilter infectedUploadFilter, + ILogger logger) : IUploadFormFile +{ + public async Task ExecuteAsync( + FormEngineWorkState state, + UploadFormFileRequest request, + CancellationToken cancellationToken = default) + { + if (state.Template == null) + state.Template = FormEngineConstants.CreateDummyTemplate(); + + var context = new FormFileFieldContext(request.ApplicationId, state.FlowId, state.InstanceId); + + if (!request.HasFile) + return FileRejected(request, context, FormEngineMessages.SelectAFile); + + if (formFileFieldService.ContainsFileName(context, request.FieldId, request.FileName)) + return FileRejected(request, context, FormEngineMessages.DuplicateFileName); + + var fileParam = new FileParameter(request.Content, request.FileName, request.ContentType); + var uploadedFile = await fileUploadService.UploadFileAsync( + request.ApplicationId, + request.FileName, + request.UploadDescription, + fileParam, + cancellationToken); + + var currentFieldFiles = formFileFieldService.GetFiles(context, request.FieldId).ToList(); + if (currentFieldFiles.All(cf => cf.Id != uploadedFile.Id)) + { + logger.LogInformation( + "Adding newly uploaded file {FileId} ({FileName}) to field {FieldId}", + uploadedFile.Id, + uploadedFile.OriginalFileName, + request.FieldId); + currentFieldFiles.Add(uploadedFile); + } + + currentFieldFiles = infectedUploadFilter.FilterList(currentFieldFiles, request.ApplicationId.ToString()); + formFileFieldService.SaveFiles(context, request.FieldId, currentFieldFiles); + + var successMessage = $"Your file '{request.FileName}' uploaded."; + logger.LogInformation( + "Cleared FormErrorStore (fieldKey: {FieldId}, contextKey: {PageContext}) and ModelState after successful upload", + request.FieldId, + request.PageErrorContextKey); + + string[] keysToClear = [request.FieldId, request.PageErrorContextKey]; + string[] modelStateKeys = [request.FieldId, $"Data[{request.FieldId}]"]; + + if (!string.IsNullOrEmpty(request.ReturnUrl)) + { + return FormEngineOutcome.Redirect( + request.ReturnUrl, + successMessage: successMessage, + errorStoreKeysToClear: keysToClear, + modelStateKeysToRemove: modelStateKeys, + notificationContext: $"file-upload|{uploadedFile.Id}"); + } + + return FormEngineOutcome.Stay( + successMessage: successMessage, + files: currentFieldFiles, + errorStoreKeysToClear: keysToClear, + modelStateKeysToRemove: modelStateKeys, + notificationContext: $"file-upload|{uploadedFile.Id}"); + } + + private FormEngineOutcome FileRejected( + UploadFormFileRequest request, + FormFileFieldContext context, + string message) + { + IReadOnlyList files = formFileFieldService.GetFiles(context, request.FieldId); + var errors = new[] { new FormValidationError("UploadFile", message) }; + var persistKey = string.IsNullOrEmpty(request.FieldId) ? null : request.FieldId; + + if (!string.IsNullOrEmpty(request.ReturnUrl)) + { + return FormEngineOutcome.Redirect( + request.ReturnUrl, + errors: errors, + persistErrors: persistKey != null, + errorContextKey: persistKey, + files: files, + errorMessage: message); + } + + return FormEngineOutcome.Stay( + errors: errors, + persistErrors: persistKey != null, + errorContextKey: persistKey, + errorMessage: message, + files: files); + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/GovUK.Dfe.FlexForms.Application.csproj b/src/GovUK.Dfe.FlexForms.Application/GovUK.Dfe.FlexForms.Application.csproj index fbca1d2..10db13b 100644 --- a/src/GovUK.Dfe.FlexForms.Application/GovUK.Dfe.FlexForms.Application.csproj +++ b/src/GovUK.Dfe.FlexForms.Application/GovUK.Dfe.FlexForms.Application.csproj @@ -7,11 +7,11 @@ + - - + diff --git a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IApiErrorParser.cs b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IApiErrorParser.cs deleted file mode 100644 index ef9cee1..0000000 --- a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IApiErrorParser.cs +++ /dev/null @@ -1,16 +0,0 @@ -//using GovUK.Dfe.FlexForms.Domain.Models; - -//namespace GovUK.Dfe.FlexForms.Application.Interfaces; - -///// -///// Provides parsing capabilities for API error responses -///// -//public interface IApiErrorParser -//{ -// /// -// /// Parses an exception to extract structured API error information -// /// -// /// The exception containing the API error -// /// Parsed API error information -// ApiErrorParsingResult ParseApiError(Exception exception); -//} \ No newline at end of file diff --git a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IApplicationResponseService.cs b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IApplicationResponseService.cs index 369db6b..346b897 100644 --- a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IApplicationResponseService.cs +++ b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IApplicationResponseService.cs @@ -1,18 +1,17 @@ using GovUK.Dfe.FlexForms.Domain.Models; -using Microsoft.AspNetCore.Http; using Task = System.Threading.Tasks.Task; namespace GovUK.Dfe.FlexForms.Application.Interfaces; public interface IApplicationResponseService { - Task SaveApplicationResponseAsync(Guid applicationId, Dictionary formData, ISession session, CancellationToken cancellationToken = default); + Task SaveApplicationResponseAsync(Guid applicationId, Dictionary formData, CancellationToken cancellationToken = default); string TransformToResponseJson(Dictionary formData, Dictionary taskStatusData, FormTemplate? template = null); - void AccumulateFormData(Dictionary newData, ISession session); - Dictionary GetAccumulatedFormData(ISession session); - void ClearAccumulatedFormData(ISession session); - Dictionary GetTaskStatusFromSession(Guid applicationId, ISession session); - void SaveTaskStatusToSession(Guid applicationId, string taskId, string status, ISession session); - void StoreFormDataInSession(Dictionary formData, ISession session); - void SetCurrentAccumulatedApplicationId(Guid applicationId, ISession session); -} \ No newline at end of file + void AccumulateFormData(Dictionary newData); + Dictionary GetAccumulatedFormData(); + void ClearAccumulatedFormData(); + Dictionary GetTaskStatusFromSession(Guid applicationId); + void SaveTaskStatusToSession(Guid applicationId, string taskId, string status); + void StoreFormDataInSession(Dictionary formData); + void SetCurrentAccumulatedApplicationId(Guid applicationId); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IApplicationStateService.cs b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IApplicationStateService.cs index 9e24364..442fc82 100644 --- a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IApplicationStateService.cs +++ b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IApplicationStateService.cs @@ -1,6 +1,5 @@ using GovUK.Dfe.FlexForms.Domain.Models; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; -using Microsoft.AspNetCore.Http; using Task = System.Threading.Tasks.Task; namespace GovUK.Dfe.FlexForms.Application.Interfaces @@ -14,17 +13,17 @@ public interface IApplicationStateService /// Loads the application from the API on every call (no session cache for authorization). /// /// When the application does not exist or the user cannot access it. - Task<(Guid? ApplicationId, ApplicationDto? Application)> EnsureApplicationIdAsync(string referenceNumber, ISession session); + Task<(Guid? ApplicationId, ApplicationDto? Application)> EnsureApplicationIdAsync(string referenceNumber); /// /// Loads response data from API into session /// - Task LoadResponseDataIntoSessionAsync(ApplicationDto application, ISession session); + Task LoadResponseDataIntoSessionAsync(ApplicationDto application); /// /// Gets application status from session or default /// - string GetApplicationStatus(Guid? applicationId, ISession session); + string GetApplicationStatus(Guid? applicationId); /// /// Checks if application is editable based on status @@ -34,17 +33,17 @@ public interface IApplicationStateService /// /// Calculates task status based on form data and explicit status /// - Domain.Models.TaskStatus CalculateTaskStatus(string taskId, FormTemplate template, Dictionary formData, Guid? applicationId, ISession session, string applicationStatus); + Domain.Models.TaskStatus CalculateTaskStatus(string taskId, FormTemplate template, Dictionary formData, Guid? applicationId, string applicationStatus); /// /// Saves task status to session and API /// - Task SaveTaskStatusAsync(Guid applicationId, string taskId, Domain.Models.TaskStatus status, ISession session); + Task SaveTaskStatusAsync(Guid applicationId, string taskId, Domain.Models.TaskStatus status); /// /// Checks if all tasks in the template are completed /// - bool AreAllTasksCompleted(FormTemplate template, Dictionary formData, Guid? applicationId, ISession session, string applicationStatus); + bool AreAllTasksCompleted(FormTemplate template, Dictionary formData, Guid? applicationId, string applicationStatus); /// /// Validates all required fields across all tasks for submission. @@ -62,4 +61,4 @@ public interface IApplicationStateService /// object GetJsonElementValue(System.Text.Json.JsonElement element); } -} \ No newline at end of file +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormConfigurationService.cs b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormConfigurationService.cs deleted file mode 100644 index 5b0fddc..0000000 --- a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormConfigurationService.cs +++ /dev/null @@ -1,83 +0,0 @@ -namespace GovUK.Dfe.FlexForms.Application.Interfaces -{ - /// - /// Manages form configuration and settings - /// - public interface IFormConfigurationService - { - /// - /// Gets the configuration for a specific form template - /// - /// The template ID - /// The form configuration - FormConfiguration GetFormConfiguration(string templateId); - - /// - /// Gets the configuration for a specific field type - /// - /// The field type - /// The field configuration - FieldConfiguration GetFieldConfiguration(string fieldType); - - /// - /// Gets the configuration for a specific validation type - /// - /// The validation type - /// The validation configuration - ValidationConfiguration GetValidationConfiguration(string validationType); - - /// - /// Gets the default form settings - /// - /// The default form settings - FormSettings GetDefaultFormSettings(); - } - - /// - /// Configuration for a form template - /// - public class FormConfiguration - { - public string TemplateId { get; set; } = string.Empty; - public string TemplateName { get; set; } = string.Empty; - public bool AllowPartialSaving { get; set; } = true; - public bool RequireAllTasksCompleted { get; set; } = false; - public int MaxFileUploadSize { get; set; } = 10 * 1024 * 1024; // 10MB - public string[] AllowedFileTypes { get; set; } = { ".pdf", ".doc", ".docx" }; - } - - /// - /// Configuration for a field type - /// - public class FieldConfiguration - { - public string FieldType { get; set; } = string.Empty; - public bool IsRequired { get; set; } = false; - public int MaxLength { get; set; } = 0; - public string DefaultValue { get; set; } = string.Empty; - public string[] ValidationRules { get; set; } = Array.Empty(); - } - - /// - /// Configuration for a validation type - /// - public class ValidationConfiguration - { - public string ValidationType { get; set; } = string.Empty; - public string ErrorMessage { get; set; } = string.Empty; - public object Rule { get; set; } = string.Empty; - public bool IsConditional { get; set; } = false; - } - - /// - /// Default form settings - /// - public class FormSettings - { - public bool EnableAutoSave { get; set; } = true; - public int AutoSaveInterval { get; set; } = 30000; // 30 seconds - public bool ShowProgressIndicator { get; set; } = true; - public bool EnableFieldValidation { get; set; } = true; - public string DefaultDateFormat { get; set; } = "dd/MM/yyyy"; - } -} diff --git a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormDataManager.cs b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormDataManager.cs deleted file mode 100644 index c1e0096..0000000 --- a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormDataManager.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace GovUK.Dfe.FlexForms.Application.Interfaces -{ - /// - /// Manages form data operations including loading, saving, and retrieving data - /// - public interface IFormDataManager - { - /// - /// Gets the data for a specific page - /// - /// The page ID - /// The application ID - /// The page data as a dictionary - Task> GetPageDataAsync(string pageId, string applicationId); - - /// - /// Saves the data for a specific page - /// - /// The page ID - /// The application ID - /// The data to save - /// A task representing the asynchronous operation - Task SavePageDataAsync(string pageId, string applicationId, Dictionary data); - - /// - /// Gets the data for a specific task - /// - /// The task ID - /// The application ID - /// The task data as a dictionary - Task> GetTaskDataAsync(string taskId, string applicationId); - - /// - /// Gets all data for an application - /// - /// The application ID - /// The application data as a dictionary - Task> GetApplicationDataAsync(string applicationId); - - /// - /// Accumulates form data in session storage - /// - /// The data to accumulate - /// The HTTP session - void AccumulateFormData(Dictionary data, ISession session); - - /// - /// Gets accumulated form data from session storage - /// - /// The HTTP session - /// The accumulated data as a dictionary - Dictionary GetAccumulatedFormData(ISession session); - - /// - /// Clears accumulated form data from session storage - /// - /// The HTTP session - void ClearAccumulatedFormData(ISession session); - } -} diff --git a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormSessionStore.cs b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormSessionStore.cs new file mode 100644 index 0000000..4dd3a7d --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormSessionStore.cs @@ -0,0 +1,16 @@ +namespace GovUK.Dfe.FlexForms.Application.Interfaces; + +/// +/// Application port for request-scoped form session state. +/// Implemented in Infrastructure against HTTP session. +/// +public interface IFormSessionStore +{ + string? GetString(string key); + + void SetString(string key, string value); + + void Remove(string key); + + IReadOnlyCollection Keys { get; } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormValidationOrchestrator.cs b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormValidationOrchestrator.cs index f8db195..240c9c7 100644 --- a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormValidationOrchestrator.cs +++ b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormValidationOrchestrator.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc.ModelBinding; +using GovUK.Dfe.FlexForms.Application.Validation; namespace GovUK.Dfe.FlexForms.Application.Interfaces { @@ -10,51 +10,26 @@ public interface IFormValidationOrchestrator /// /// Validates a single page /// - /// The page to validate - /// The form data - /// The model state to add errors to - /// Optional template for field requirement policy - /// True if validation passes - bool ValidatePage(Domain.Models.Page page, Dictionary data, ModelStateDictionary modelState, Domain.Models.FormTemplate? template = null); - + FormValidationResult ValidatePage(Domain.Models.Page page, Dictionary data, Domain.Models.FormTemplate? template = null); + /// /// Validates a single task /// - /// The task to validate - /// The form data - /// The model state to add errors to - /// Optional template for field requirement policy - /// True if validation passes - bool ValidateTask(Domain.Models.Task task, Dictionary data, ModelStateDictionary modelState, Domain.Models.FormTemplate? template = null); - + FormValidationResult ValidateTask(Domain.Models.Task task, Dictionary data, Domain.Models.FormTemplate? template = null); + /// /// Validates the entire application /// - /// The form template - /// The form data - /// The model state to add errors to - /// True if validation passes - bool ValidateApplication(Domain.Models.FormTemplate template, Dictionary data, ModelStateDictionary modelState); - + FormValidationResult ValidateApplication(Domain.Models.FormTemplate template, Dictionary data); + /// /// Validates a single field /// - /// The field to validate - /// The field value - /// The model state to add errors to - /// The field key for model state - /// True if validation passes - bool ValidateField(Domain.Models.Field field, object value, ModelStateDictionary modelState, string fieldKey); - + FormValidationResult ValidateField(Domain.Models.Field field, object value, string fieldKey); + /// /// Validates a single field with full form data context for conditional validation /// - /// The field to validate - /// The field value - /// The complete form data for conditional evaluation - /// The model state to add errors to - /// The field key for model state - /// True if validation passes - bool ValidateField(Domain.Models.Field field, object value, Dictionary? formData, ModelStateDictionary modelState, string fieldKey); + FormValidationResult ValidateField(Domain.Models.Field field, object value, Dictionary? formData, string fieldKey); } } diff --git a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IInfectedFileStore.cs b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IInfectedFileStore.cs new file mode 100644 index 0000000..a647022 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IInfectedFileStore.cs @@ -0,0 +1,12 @@ +namespace GovUK.Dfe.FlexForms.Application.Interfaces; + +/// +/// Application port for the malware-scan blacklist. +/// Implemented in Infrastructure against Redis. +/// +public interface IInfectedFileStore +{ + bool IsFileInfected(Guid fileId); + + bool IsFileNameInfected(string applicationId, string originalFileName); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IModelStateErrorHandler.cs b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IModelStateErrorHandler.cs deleted file mode 100644 index 33999af..0000000 --- a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IModelStateErrorHandler.cs +++ /dev/null @@ -1,26 +0,0 @@ -//using GovUK.Dfe.FlexForms.Domain.Models; -//using Microsoft.AspNetCore.Mvc.ModelBinding; - -//namespace GovUK.Dfe.FlexForms.Application.Interfaces; - -///// -///// Handles mapping of API errors to model state validation errors -///// -//public interface IModelStateErrorHandler -//{ -// /// -// /// Adds API validation errors to the model state with proper field mapping -// /// -// /// The model state to add errors to -// /// The parsed API error response -// /// Optional mapping of API field names to model property names -// void AddApiErrorsToModelState(ModelStateDictionary modelState, ApiErrorResponse apiError, -// Dictionary? fieldMappings = null); - -// /// -// /// Adds a general error message to the model state -// /// -// /// The model state to add the error to -// /// The error message to add -// void AddGeneralError(ModelStateDictionary modelState, string errorMessage); -//} \ No newline at end of file diff --git a/src/GovUK.Dfe.FlexForms.Application/Interfaces/INavigationHistoryService.cs b/src/GovUK.Dfe.FlexForms.Application/Interfaces/INavigationHistoryService.cs index 0347838..c338bc0 100644 --- a/src/GovUK.Dfe.FlexForms.Application/Interfaces/INavigationHistoryService.cs +++ b/src/GovUK.Dfe.FlexForms.Application/Interfaces/INavigationHistoryService.cs @@ -1,5 +1,3 @@ -using Microsoft.AspNetCore.Http; - namespace GovUK.Dfe.FlexForms.Application.Interfaces { /// @@ -11,34 +9,21 @@ public interface INavigationHistoryService /// /// Pushes a URL onto the navigation history stack for the given scope. /// - /// A unique key identifying the navigation scope (e.g. reference:task[:flow:instance]). - /// The URL to push. - /// The HTTP session to store history in. - void Push(string scopeKey, string url, ISession session); + void Push(string scopeKey, string url); /// /// Returns, without removing, the most recent URL for the scope, or null if none. /// - /// A unique key identifying the navigation scope. - /// The HTTP session to read from. - /// The last URL or null. - string? Peek(string scopeKey, ISession session); + string? Peek(string scopeKey); /// /// Pops and returns the most recent URL for the scope, or null if none. /// - /// A unique key identifying the navigation scope. - /// The HTTP session to read/write. - /// The popped URL or null. - string? Pop(string scopeKey, ISession session); + string? Pop(string scopeKey); /// /// Clears the navigation history for the scope. /// - /// A unique key identifying the navigation scope. - /// The HTTP session to clear from. - void Clear(string scopeKey, ISession session); + void Clear(string scopeKey); } } - - diff --git a/src/GovUK.Dfe.FlexForms.Application/Validation/FormValidationError.cs b/src/GovUK.Dfe.FlexForms.Application/Validation/FormValidationError.cs new file mode 100644 index 0000000..2206a41 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Validation/FormValidationError.cs @@ -0,0 +1,6 @@ +namespace GovUK.Dfe.FlexForms.Application.Validation; + +/// +/// A single field validation failure. Presentation maps this to ModelState. +/// +public sealed record FormValidationError(string FieldKey, string Message); diff --git a/src/GovUK.Dfe.FlexForms.Application/Validation/FormValidationResult.cs b/src/GovUK.Dfe.FlexForms.Application/Validation/FormValidationResult.cs new file mode 100644 index 0000000..1c0924d --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Validation/FormValidationResult.cs @@ -0,0 +1,18 @@ +namespace GovUK.Dfe.FlexForms.Application.Validation; + +/// +/// Outcome of form-engine validation without ASP.NET ModelState. +/// +public sealed class FormValidationResult +{ + public static FormValidationResult Success { get; } = new([]); + + public FormValidationResult(IReadOnlyList errors) + { + Errors = errors; + } + + public IReadOnlyList Errors { get; } + + public bool IsValid => Errors.Count == 0; +} diff --git a/src/GovUK.Dfe.FlexForms.Domain/Caching/FormSessionKeys.cs b/src/GovUK.Dfe.FlexForms.Domain/Caching/FormSessionKeys.cs new file mode 100644 index 0000000..0940064 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Domain/Caching/FormSessionKeys.cs @@ -0,0 +1,24 @@ +namespace GovUK.Dfe.FlexForms.Domain.Caching; + +/// +/// HTTP-session key names used by the form engine. +/// Keep these stable; existing in-flight applications depend on them. +/// +public static class FormSessionKeys +{ + public const string AccumulatedFormData = "AccumulatedFormData"; + public const string ApplicationId = "ApplicationId"; + public const string ApplicationReference = "ApplicationReference"; + public const string TemplateId = "TemplateId"; + public const string CurrentAccumulatedApplicationId = "CurrentAccumulatedApplicationId"; + public const string NavHistoryPrefix = "NavHistory_"; + + public static string FlowProgress(string flowId, string instanceId) => + $"FlowProgress_{flowId}_{instanceId}"; + + public static string FlowItemExisted(string flowId, string instanceId) => + $"FlowItemExisted_{flowId}_{instanceId}"; + + public static string UploadedFiles(Guid applicationId, string fieldId) => + $"UploadedFiles_{applicationId}_{fieldId}"; +} diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/CheckboxValueNormalizer.cs b/src/GovUK.Dfe.FlexForms.Domain/FormEngine/CheckboxValueNormalizer.cs similarity index 84% rename from src/GovUK.Dfe.FlexForms.Infrastructure/Services/CheckboxValueNormalizer.cs rename to src/GovUK.Dfe.FlexForms.Domain/FormEngine/CheckboxValueNormalizer.cs index 6481b5d..7039255 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/CheckboxValueNormalizer.cs +++ b/src/GovUK.Dfe.FlexForms.Domain/FormEngine/CheckboxValueNormalizer.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace GovUK.Dfe.FlexForms.Infrastructure.Services; +namespace GovUK.Dfe.FlexForms.Domain.FormEngine; /// /// Normalises checkbox values into a simple string collection regardless of source shape. @@ -25,7 +25,7 @@ public static IReadOnlyCollection Normalize(object? value) } var trimmed = s.Trim(); - if (trimmed.StartsWith("[") && trimmed.EndsWith("]")) + if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) { try { @@ -36,19 +36,19 @@ public static IReadOnlyCollection Normalize(object? value) .EnumerateArray() .Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()) .Where(v => !string.IsNullOrWhiteSpace(v)) - .ToArray(); + .ToArray()!; } } - catch + catch (JsonException) { // Fall through to return the raw string if parsing fails } } - return new[] { s }; + return [s]; } - if (value is IEnumerable stringEnumerable && value is not string) + if (value is IEnumerable stringEnumerable) { return stringEnumerable.Where(v => !string.IsNullOrWhiteSpace(v)).ToArray(); } @@ -61,13 +61,13 @@ public static IReadOnlyCollection Normalize(object? value) .EnumerateArray() .Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()) .Where(v => !string.IsNullOrWhiteSpace(v)) - .ToArray(); + .ToArray()!; } if (json.ValueKind == JsonValueKind.String) { var str = json.GetString(); - return string.IsNullOrWhiteSpace(str) ? Array.Empty() : new[] { str }; + return string.IsNullOrWhiteSpace(str) ? Array.Empty() : [str]; } } @@ -76,10 +76,10 @@ public static IReadOnlyCollection Normalize(object? value) return objEnumerable .Select(o => o?.ToString()) .Where(v => !string.IsNullOrWhiteSpace(v)) - .ToArray(); + .ToArray()!; } var fallback = value.ToString(); - return string.IsNullOrWhiteSpace(fallback) ? Array.Empty() : new[] { fallback }; + return string.IsNullOrWhiteSpace(fallback) ? Array.Empty() : [fallback]; } } diff --git a/src/GovUK.Dfe.FlexForms.Domain/FormEngine/FormRouteParser.cs b/src/GovUK.Dfe.FlexForms.Domain/FormEngine/FormRouteParser.cs new file mode 100644 index 0000000..7b17dda --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Domain/FormEngine/FormRouteParser.cs @@ -0,0 +1,73 @@ +namespace GovUK.Dfe.FlexForms.Domain.FormEngine; + +public readonly record struct CollectionFlowRoute(string FlowId, string InstanceId, string PageId); + +public readonly record struct DerivedFlowRoute(string FlowId, string ItemId, string PageId); + +/// +/// Parses form-engine page-id routes. Keep the path shapes stable; in-flight URLs depend on them. +/// +public static class FormRouteParser +{ + public const string CollectionFlowSegment = "flow"; + public const string DerivedFlowSegment = "derived"; + + /// + /// Collection flow: flow/{flowId}/{instanceId}/{pageId?} + /// + public static bool TryParseCollectionFlow(string? pageId, out CollectionFlowRoute route) + { + route = default; + if (string.IsNullOrEmpty(pageId)) + return false; + + var parts = pageId.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 3 || !parts[0].Equals(CollectionFlowSegment, StringComparison.OrdinalIgnoreCase)) + return false; + + route = new CollectionFlowRoute( + parts[1], + parts[2], + parts.Length > 3 ? parts[3] : string.Empty); + return true; + } + + /// + /// Derived collection flow: {flowId}/derived/{itemId}/{pageId?} + /// + public static bool TryParseDerivedFlow(string? pageId, out DerivedFlowRoute route) + { + route = default; + if (string.IsNullOrEmpty(pageId)) + return false; + + var parts = pageId.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 3 || !parts[1].Equals(DerivedFlowSegment, StringComparison.OrdinalIgnoreCase)) + return false; + + route = new DerivedFlowRoute( + parts[0], + parts[2], + parts.Length > 3 ? parts[3] : string.Empty); + return true; + } + + public static bool LooksLikeCollectionFlow(string? pageId) => + !string.IsNullOrEmpty(pageId) + && pageId.StartsWith($"{CollectionFlowSegment}/", StringComparison.OrdinalIgnoreCase); + + public static bool IsCollectionFlow(string? pageId) => TryParseCollectionFlow(pageId, out _); + + public static bool IsDerivedFlow(string? pageId) => TryParseDerivedFlow(pageId, out _); + + /// + /// Navigation-history scope: {reference}:{task} or {reference}:{task}:flow:{flowId}:{instanceId}. + /// + public static string HistoryScope(string referenceNumber, string taskId, string? pageId) + { + if (TryParseCollectionFlow(pageId, out var route)) + return $"{referenceNumber}:{taskId}:flow:{route.FlowId}:{route.InstanceId}"; + + return $"{referenceNumber}:{taskId}"; + } +} diff --git a/src/GovUK.Dfe.FlexForms.Domain/FormEngine/FormStepPolicy.cs b/src/GovUK.Dfe.FlexForms.Domain/FormEngine/FormStepPolicy.cs new file mode 100644 index 0000000..6579843 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Domain/FormEngine/FormStepPolicy.cs @@ -0,0 +1,87 @@ +using GovUK.Dfe.FlexForms.Domain.Models; +using TaskModel = GovUK.Dfe.FlexForms.Domain.Models.Task; + +namespace GovUK.Dfe.FlexForms.Domain.FormEngine; + +/// +/// Pure rules for which engine step to show and how to walk page lists. +/// +public static class FormStepPolicy +{ + public const string MultiCollectionFlowMode = "multiCollectionFlow"; + public const string DerivedCollectionFlowMode = "derivedCollectionFlow"; + + public static bool IsCollectionFlowPage(string? pageId) => FormRouteParser.LooksLikeCollectionFlow(pageId); + + public static bool IsFormPage(string? pageId) => + !string.IsNullOrEmpty(pageId) && !IsCollectionFlowPage(pageId); + + public static bool IsTaskSummary(string? taskId, string? pageId) => + !string.IsNullOrEmpty(taskId) && string.IsNullOrEmpty(pageId); + + public static bool IsTaskList(string? taskId, string? pageId) => + string.IsNullOrEmpty(taskId) && string.IsNullOrEmpty(pageId); + + public static bool IsApplicationPreview(string? pageId) => false; + + public static bool IsCollectionFlowSummary(TaskModel? task) => + task?.Summary?.Mode?.Equals(MultiCollectionFlowMode, StringComparison.OrdinalIgnoreCase) == true; + + public static bool IsDerivedCollectionFlowSummary(TaskModel? task) => + task?.Summary?.Mode?.Equals(DerivedCollectionFlowMode, StringComparison.OrdinalIgnoreCase) == true; + + public static bool IsInSubFlow(string flowId, string? pageId) => + !string.IsNullOrEmpty(pageId) + && pageId.StartsWith($"{FormRouteParser.CollectionFlowSegment}/{flowId}", StringComparison.OrdinalIgnoreCase); + + public static MultiCollectionFlowConfiguration? GetCollectionFlow(TaskModel? task, string flowId) => + task?.Summary?.Flows?.FirstOrDefault(f => f.FlowId == flowId); + + public static DerivedCollectionFlowConfiguration? GetDerivedFlow(TaskModel? task, string derivedFlowId) => + task?.Summary?.DerivedFlows?.FirstOrDefault(f => f.FlowId == derivedFlowId); + + public static IReadOnlyList? GetCollectionFlowPages(TaskModel? task, string flowId) => + GetCollectionFlow(task, flowId)?.Pages; + + public static string? GetCollectionFlowFieldId(TaskModel? task, string flowId) => + GetCollectionFlow(task, flowId)?.FieldId; + + public static Page? ResolvePage(IReadOnlyList? pages, string? pageId) + { + if (pages == null || pages.Count == 0) + return null; + + return string.IsNullOrEmpty(pageId) + ? pages[0] + : pages.FirstOrDefault(p => p.PageId == pageId); + } + + public static Page? GetNextPage(IReadOnlyList? pages, string currentPageId) + { + var index = IndexOfPage(pages, currentPageId); + if (index == -1 || pages is null || index >= pages.Count - 1) + return null; + + return pages[index + 1]; + } + + public static bool IsLastPage(IReadOnlyList? pages, string currentPageId) + { + var index = IndexOfPage(pages, currentPageId); + return pages == null || pages.Count == 0 || index == -1 || index >= pages.Count - 1; + } + + public static int IndexOfPage(IReadOnlyList? pages, string currentPageId) + { + if (pages == null) + return -1; + + for (var i = 0; i < pages.Count; i++) + { + if (pages[i].PageId == currentPageId) + return i; + } + + return -1; + } +} diff --git a/src/GovUK.Dfe.FlexForms.Domain/Templates/TemplateVersionPolicy.cs b/src/GovUK.Dfe.FlexForms.Domain/Templates/TemplateVersionPolicy.cs new file mode 100644 index 0000000..81d5535 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Domain/Templates/TemplateVersionPolicy.cs @@ -0,0 +1,29 @@ +namespace GovUK.Dfe.FlexForms.Domain.Templates; + +/// +/// Increments the patch segment of a template version string (for example 1.0.1 → 1.0.2). +/// +public static class TemplateVersionPolicy +{ + public static string IncrementPatch(string? version) + { + if (string.IsNullOrWhiteSpace(version)) + return "1.0.1"; + + var parts = version.Split('.'); + if (parts.Length == 0) + return "1.0.1"; + if (parts.Length == 1) + return $"{parts[0]}.0.1"; + if (parts.Length == 2) + return $"{parts[0]}.{parts[1]}.1"; + + if (int.TryParse(parts[2], out var patchVersion)) + { + patchVersion++; + return $"{parts[0]}.{parts[1]}.{patchVersion}"; + } + + return $"{parts[0]}.{parts[1]}.1"; + } +} diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/DependencyInjection.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..681be0d --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/DependencyInjection.cs @@ -0,0 +1,54 @@ +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Infrastructure.Parsers; +using GovUK.Dfe.FlexForms.Infrastructure.Providers; +using GovUK.Dfe.FlexForms.Infrastructure.Services; +using GovUK.Dfe.FlexForms.Infrastructure.Stores; +using Microsoft.Extensions.DependencyInjection; + +namespace GovUK.Dfe.FlexForms.Infrastructure; + +/// +/// Registers Infrastructure adapters. Call once from the composition root. +/// +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructureDependencyGroup(this IServiceCollection services) + { + services.AddHttpContextAccessor(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/GovUK.Dfe.FlexForms.Infrastructure.csproj b/src/GovUK.Dfe.FlexForms.Infrastructure/GovUK.Dfe.FlexForms.Infrastructure.csproj index aed020a..7312cf9 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/GovUK.Dfe.FlexForms.Infrastructure.csproj +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/GovUK.Dfe.FlexForms.Infrastructure.csproj @@ -6,13 +6,16 @@ enable + + + + - diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationResponseService.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationResponseService.cs index f1fbfe8..3420c74 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationResponseService.cs +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationResponseService.cs @@ -1,11 +1,9 @@ using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; using GovUK.Dfe.FlexForms.Application.Interfaces; -using GovUK.Dfe.FlexForms.Api.Client.Contracts; using GovUK.Dfe.FlexForms.Domain.Caching; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; using GovUK.Dfe.FlexForms.Domain.Models; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -using StackExchange.Redis; using System.Text.Json; using Task = System.Threading.Tasks.Task; @@ -13,26 +11,27 @@ namespace GovUK.Dfe.FlexForms.Infrastructure.Services; public class ApplicationResponseService( IApplicationsClient applicationsClient, - IConnectionMultiplexer redis, + IInfectedFileStore infectedFileStore, + IFormSessionStore sessionStore, IFormTemplateProvider formTemplateProvider, ILogger logger) : IApplicationResponseService { - private const string SessionKeyFormData = "AccumulatedFormData"; + private const string SessionKeyFormData = FormSessionKeys.AccumulatedFormData; - public async Task SaveApplicationResponseAsync(Guid applicationId, Dictionary formData, ISession session, CancellationToken cancellationToken = default) + public async Task SaveApplicationResponseAsync(Guid applicationId, Dictionary formData, CancellationToken cancellationToken = default) { try { // Accumulate the new data with existing data (infected files filtered by blacklist) - AccumulateFormData(formData, session); + AccumulateFormData(formData); // Get all accumulated data - var allFormData = GetAccumulatedFormData(session); + var allFormData = GetAccumulatedFormData(); - var taskStatusData = GetTaskStatusFromSession(applicationId, session); + var taskStatusData = GetTaskStatusFromSession(applicationId); - var template = await TryGetTemplateFromSessionAsync(session, cancellationToken); + var template = await TryGetTemplateFromSessionAsync(cancellationToken); var responseJson = TransformToResponseJson(allFormData, taskStatusData, template); @@ -43,7 +42,7 @@ public async Task SaveApplicationResponseAsync(Guid applicationId, Dictionary allFormData, ISession session, CancellationToken cancellationToken) + private async Task EnsureApplicationStatusIsInProgress(Guid applicationId, Dictionary allFormData, CancellationToken cancellationToken) { try { @@ -70,7 +69,7 @@ private async Task EnsureApplicationStatusIsInProgress(Guid applicationId, Dicti { // Get current application status from session var statusKey = $"ApplicationStatus_{applicationId}"; - var currentStatus = session.GetString(statusKey); + var currentStatus = sessionStore.GetString(statusKey); // Promote Created/empty session status to InProgress once data is saved. // Do not overwrite Submitted (or other terminal statuses). @@ -78,7 +77,7 @@ private async Task EnsureApplicationStatusIsInProgress(Guid applicationId, Dicti || currentStatus.Equals("Created", StringComparison.OrdinalIgnoreCase) || currentStatus.Equals("InProgress", StringComparison.OrdinalIgnoreCase)) { - session.SetString(statusKey, "InProgress"); + sessionStore.SetString(statusKey, "InProgress"); logger.LogInformation("Updated application {ApplicationId} status to InProgress due to form data being saved", applicationId); } } @@ -90,10 +89,10 @@ private async Task EnsureApplicationStatusIsInProgress(Guid applicationId, Dicti } } - public void AccumulateFormData(Dictionary newData, ISession session) + public void AccumulateFormData(Dictionary newData) { // Get existing data (infected files will be filtered by blacklist) - var existingData = GetAccumulatedFormData(session); + var existingData = GetAccumulatedFormData(); foreach (var kvp in newData) { @@ -116,7 +115,7 @@ public void AccumulateFormData(Dictionary newData, ISession sess } var jsonString = JsonSerializer.Serialize(existingData); - session.SetString(SessionKeyFormData, jsonString); + sessionStore.SetString(SessionKeyFormData, jsonString); } private bool AreEquivalentFieldNames(string fieldName1, string fieldName2) @@ -143,9 +142,9 @@ private string NormalizeFieldName(string fieldName) /// /// Gets accumulated form data with infected file filtering /// - public Dictionary GetAccumulatedFormData(ISession session) + public Dictionary GetAccumulatedFormData() { - var jsonString = session.GetString(SessionKeyFormData); + var jsonString = sessionStore.GetString(SessionKeyFormData); if (string.IsNullOrEmpty(jsonString)) { @@ -158,7 +157,7 @@ public Dictionary GetAccumulatedFormData(ISession session) ?? new Dictionary(); // Get applicationId from session for filename-based blacklist checking - var applicationId = session.GetString("ApplicationId"); + var applicationId = sessionStore.GetString(FormSessionKeys.ApplicationId); // Filter out any infected files from the data var filteredData = FilterInfectedFilesFromData(rawData, applicationId); @@ -220,9 +219,9 @@ private object CleanFormValue(object value) return value.ToString() ?? string.Empty; } - public void ClearAccumulatedFormData(ISession session) + public void ClearAccumulatedFormData() { - session.Remove(SessionKeyFormData); + sessionStore.Remove(SessionKeyFormData); logger.LogInformation("Cleared accumulated form data from session"); } @@ -235,7 +234,6 @@ private Dictionary FilterInfectedFilesFromData(Dictionary FilterInfectedFilesFromData(Dictionary FilterInfectedFilesFromData(Dictionary TryGetTemplateFromSessionAsync(ISession session, CancellationToken cancellationToken) + private async Task TryGetTemplateFromSessionAsync(CancellationToken cancellationToken) { try { - var templateId = session.GetString("TemplateId"); + var templateId = sessionStore.GetString(FormSessionKeys.TemplateId); if (string.IsNullOrWhiteSpace(templateId)) { logger.LogWarning("No TemplateId in session when saving application response; question/dataType will use runtime fallbacks only"); @@ -397,16 +389,16 @@ public string TransformToResponseJson( } } - public Dictionary GetTaskStatusFromSession(Guid applicationId, ISession session) + public Dictionary GetTaskStatusFromSession(Guid applicationId) { var taskStatusData = new Dictionary(); - var sessionKeys = session.Keys.Where(k => k.StartsWith($"TaskStatus_{applicationId}_")).ToList(); + var sessionKeys = sessionStore.Keys.Where(k => k.StartsWith($"TaskStatus_{applicationId}_")).ToList(); foreach (var sessionKey in sessionKeys) { var taskId = sessionKey.Substring($"TaskStatus_{applicationId}_".Length); - var statusValue = session.GetString(sessionKey); + var statusValue = sessionStore.GetString(sessionKey); if (!string.IsNullOrEmpty(statusValue)) { @@ -417,22 +409,22 @@ public Dictionary GetTaskStatusFromSession(Guid applicationId, I return taskStatusData; } - public void SaveTaskStatusToSession(Guid applicationId, string taskId, string status, ISession session) + public void SaveTaskStatusToSession(Guid applicationId, string taskId, string status) { var sessionKey = $"TaskStatus_{applicationId}_{taskId}"; - session.SetString(sessionKey, status); + sessionStore.SetString(sessionKey, status); } - public void StoreFormDataInSession(Dictionary formData, ISession session) + public void StoreFormDataInSession(Dictionary formData) { // Clear existing data and store new data - ClearAccumulatedFormData(session); - AccumulateFormData(formData, session); + ClearAccumulatedFormData(); + AccumulateFormData(formData); } - public void SetCurrentAccumulatedApplicationId(Guid applicationId, ISession session) + public void SetCurrentAccumulatedApplicationId(Guid applicationId) { - session.SetString("CurrentAccumulatedApplicationId", applicationId.ToString()); + sessionStore.SetString(FormSessionKeys.CurrentAccumulatedApplicationId, applicationId.ToString()); } } \ No newline at end of file diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationStateService.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationStateService.cs index 6ff860f..304837b 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationStateService.cs +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationStateService.cs @@ -1,10 +1,10 @@ using GovUK.Dfe.FlexForms.Application.Exceptions; using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Caching; using GovUK.Dfe.FlexForms.Domain.Models; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; using GovUK.Dfe.CoreLibs.Http.Models; using GovUK.Dfe.FlexForms.Api.Client.Contracts; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using System.Text.Json; using Task = System.Threading.Tasks.Task; @@ -18,17 +18,17 @@ public class ApplicationStateService( IApplicationsClient applicationsClient, IApplicationResponseService applicationResponseService, IFieldRequirementService fieldRequirementService, + IFormSessionStore sessionStore, ILogger logger) : IApplicationStateService { public async Task<(Guid? ApplicationId, ApplicationDto? Application)> EnsureApplicationIdAsync( - string referenceNumber, - ISession session) + string referenceNumber) { if (string.IsNullOrWhiteSpace(referenceNumber)) throw new ApplicationAccessException(referenceNumber ?? string.Empty); - ClearStaleSessionDataIfReferenceChanged(referenceNumber, session); + ClearStaleSessionDataIfReferenceChanged(referenceNumber); ApplicationDto application; try @@ -58,8 +58,8 @@ public class ApplicationStateService( throw new ApplicationAccessException(referenceNumber); } - PersistApplicationToSession(application, referenceNumber, session); - await LoadResponseDataIntoSessionAsync(application, session); + PersistApplicationToSession(application, referenceNumber); + await LoadResponseDataIntoSessionAsync(application); logger.LogDebug( "Loaded application {ApplicationId} from API for reference {ReferenceNumber}", @@ -69,13 +69,13 @@ public class ApplicationStateService( return (application.ApplicationId, application); } - public async Task LoadResponseDataIntoSessionAsync(ApplicationDto application, ISession session) + public async Task LoadResponseDataIntoSessionAsync(ApplicationDto application) { if (application.LatestResponse?.ResponseBody == null) { logger.LogInformation("No existing response data found for application {ApplicationReference}", application.ApplicationReference); - applicationResponseService.ClearAccumulatedFormData(session); - applicationResponseService.SetCurrentAccumulatedApplicationId(application.ApplicationId, session); + applicationResponseService.ClearAccumulatedFormData(); + applicationResponseService.SetCurrentAccumulatedApplicationId(application.ApplicationId); return; } @@ -119,7 +119,7 @@ public async Task LoadResponseDataIntoSessionAsync(ApplicationDto application, I if (!string.IsNullOrEmpty(statusValue)) { - applicationResponseService.SaveTaskStatusToSession(application.ApplicationId, taskId, statusValue, session); + applicationResponseService.SaveTaskStatusToSession(application.ApplicationId, taskId, statusValue); logger.LogDebug("Restored task status: {TaskId} = {Status}", taskId, statusValue); } } @@ -146,8 +146,8 @@ public async Task LoadResponseDataIntoSessionAsync(ApplicationDto application, I } // Store in session using the same key structure as form submission - applicationResponseService.StoreFormDataInSession(formDataDict, session); - applicationResponseService.SetCurrentAccumulatedApplicationId(application.ApplicationId, session); + applicationResponseService.StoreFormDataInSession(formDataDict); + applicationResponseService.SetCurrentAccumulatedApplicationId(application.ApplicationId); logger.LogInformation("Successfully loaded {FieldCount} fields from API into session for application {ApplicationReference}", formDataDict.Count, application.ApplicationReference); @@ -159,24 +159,24 @@ public async Task LoadResponseDataIntoSessionAsync(ApplicationDto application, I } } - private void ClearStaleSessionDataIfReferenceChanged(string referenceNumber, ISession session) + private void ClearStaleSessionDataIfReferenceChanged(string referenceNumber) { - var sessionReference = session.GetString("ApplicationReference"); + var sessionReference = sessionStore.GetString(FormSessionKeys.ApplicationReference); if (string.IsNullOrEmpty(sessionReference) || string.Equals(sessionReference, referenceNumber, StringComparison.OrdinalIgnoreCase)) { return; } - applicationResponseService.ClearAccumulatedFormData(session); - session.Remove("ApplicationId"); - session.Remove("ApplicationReference"); + applicationResponseService.ClearAccumulatedFormData(); + sessionStore.Remove(FormSessionKeys.ApplicationId); + sessionStore.Remove(FormSessionKeys.ApplicationReference); } - private void PersistApplicationToSession(ApplicationDto application, string referenceNumber, ISession session) + private void PersistApplicationToSession(ApplicationDto application, string referenceNumber) { - session.SetString("ApplicationId", application.ApplicationId.ToString()); - session.SetString("ApplicationReference", application.ApplicationReference ?? referenceNumber); + sessionStore.SetString(FormSessionKeys.ApplicationId, application.ApplicationId.ToString()); + sessionStore.SetString(FormSessionKeys.ApplicationReference, application.ApplicationReference ?? referenceNumber); var templateSchemaKey = $"TemplateSchema_{referenceNumber}"; var templateVersionIdKey = $"TemplateVersionId_{referenceNumber}"; @@ -184,37 +184,37 @@ private void PersistApplicationToSession(ApplicationDto application, string refe if (application.TemplateSchema?.JsonSchema != null) { - session.SetString(templateSchemaKey, application.TemplateSchema.JsonSchema); - session.SetString(templateVersionIdKey, application.TemplateVersionId.ToString()); - session.SetString(templateVersionNoKey, application.TemplateSchema.VersionNumber ?? string.Empty); + sessionStore.SetString(templateSchemaKey, application.TemplateSchema.JsonSchema); + sessionStore.SetString(templateVersionIdKey, application.TemplateVersionId.ToString()); + sessionStore.SetString(templateVersionNoKey, application.TemplateSchema.VersionNumber ?? string.Empty); } if (application.Status != null) { var statusKey = $"ApplicationStatus_{application.ApplicationId}"; - session.SetString(statusKey, application.Status.ToString()); + sessionStore.SetString(statusKey, application.Status.ToString()); } if (application.CreatedBy != null) { - session.SetString($"ApplicationLeadApplicantName_{application.ApplicationId}", application.CreatedBy.Name); - session.SetString($"ApplicationLeadApplicantEmail_{application.ApplicationId}", application.CreatedBy.Email); - session.SetString($"ApplicationLeadApplicantUserId_{application.ApplicationId}", application.CreatedBy.UserId.ToString()); + sessionStore.SetString($"ApplicationLeadApplicantName_{application.ApplicationId}", application.CreatedBy.Name ?? string.Empty); + sessionStore.SetString($"ApplicationLeadApplicantEmail_{application.ApplicationId}", application.CreatedBy.Email ?? string.Empty); + sessionStore.SetString($"ApplicationLeadApplicantUserId_{application.ApplicationId}", application.CreatedBy.UserId.ToString()); } - session.SetString( + sessionStore.SetString( $"ApplicationFormVersion_{application.ApplicationId}", string.IsNullOrEmpty(application.TemplateSchema?.VersionNumber) ? "N/A" : application.TemplateSchema.VersionNumber); } - public string GetApplicationStatus(Guid? applicationId, ISession session) + public string GetApplicationStatus(Guid? applicationId) { if (applicationId.HasValue) { var statusKey = $"ApplicationStatus_{applicationId.Value}"; - return session.GetString(statusKey) ?? "InProgress"; + return sessionStore.GetString(statusKey) ?? "InProgress"; } return "InProgress"; } @@ -227,7 +227,7 @@ public bool IsApplicationEditable(string applicationStatus) || applicationStatus.Equals("Created", StringComparison.OrdinalIgnoreCase); } - public Domain.Models.TaskStatus CalculateTaskStatus(string taskId, FormTemplate template, Dictionary formData, Guid? applicationId, ISession session, string applicationStatus) + public Domain.Models.TaskStatus CalculateTaskStatus(string taskId, FormTemplate template, Dictionary formData, Guid? applicationId, string applicationStatus) { // If application is submitted, all tasks are completed if (applicationStatus.Equals("Submitted", StringComparison.OrdinalIgnoreCase)) @@ -239,7 +239,7 @@ public Domain.Models.TaskStatus CalculateTaskStatus(string taskId, FormTemplate if (applicationId.HasValue) { var sessionKey = $"TaskStatus_{applicationId.Value}_{taskId}"; - var statusString = session.GetString(sessionKey); + var statusString = sessionStore.GetString(sessionKey); if (!string.IsNullOrEmpty(statusString) && Enum.TryParse(statusString, out var explicitStatus) && @@ -309,17 +309,17 @@ public Domain.Models.TaskStatus CalculateTaskStatus(string taskId, FormTemplate return Domain.Models.TaskStatus.NotStarted; } - public async Task SaveTaskStatusAsync(Guid applicationId, string taskId, Domain.Models.TaskStatus status, ISession session) + public async Task SaveTaskStatusAsync(Guid applicationId, string taskId, Domain.Models.TaskStatus status) { // Save task status to session - applicationResponseService.SaveTaskStatusToSession(applicationId, taskId, status.ToString(), session); + applicationResponseService.SaveTaskStatusToSession(applicationId, taskId, status.ToString()); // Save all accumulated data (including task status) to API var formData = new Dictionary(); // Empty form data since we're just updating task status - await applicationResponseService.SaveApplicationResponseAsync(applicationId, formData, session); + await applicationResponseService.SaveApplicationResponseAsync(applicationId, formData); } - public bool AreAllTasksCompleted(FormTemplate template, Dictionary formData, Guid? applicationId, ISession session, string applicationStatus) + public bool AreAllTasksCompleted(FormTemplate template, Dictionary formData, Guid? applicationId, string applicationStatus) { if (template?.TaskGroups == null) { @@ -329,7 +329,7 @@ public bool AreAllTasksCompleted(FormTemplate template, Dictionary g.Tasks).ToList(); return allTasks.All(task => - CalculateTaskStatus(task.TaskId, template, formData, applicationId, session, applicationStatus) == Domain.Models.TaskStatus.Completed); + CalculateTaskStatus(task.TaskId, template, formData, applicationId, applicationStatus) == Domain.Models.TaskStatus.Completed); } public object GetJsonElementValue(JsonElement element) diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FieldFormattingService.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FieldFormattingService.cs index 51b0775..3c463d8 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FieldFormattingService.cs +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FieldFormattingService.cs @@ -1,4 +1,5 @@ using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.FormEngine; using GovUK.Dfe.FlexForms.Domain.Models; using System.Globalization; using System.Linq; diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormConfigurationService.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormConfigurationService.cs deleted file mode 100644 index 921417f..0000000 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormConfigurationService.cs +++ /dev/null @@ -1,81 +0,0 @@ -using GovUK.Dfe.FlexForms.Application.Interfaces; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; - -namespace GovUK.Dfe.FlexForms.Infrastructure.Services; - -/// -/// Provides form settings from the effective request configuration. -/// -public sealed class FormConfigurationService( - IRequestAppConfiguration requestConfiguration, - ILogger logger) : IFormConfigurationService -{ - /// - public FormConfiguration GetFormConfiguration(string templateId) - { - var configuration = requestConfiguration.Current; - var config = new FormConfiguration - { - TemplateId = templateId, - TemplateName = templateId, - AllowPartialSaving = configuration.GetValue("FormEngine:AllowPartialSaving", true), - RequireAllTasksCompleted = configuration.GetValue("FormEngine:RequireAllTasksCompleted", false), - MaxFileUploadSize = configuration.GetValue("FormEngine:MaxFileUploadSize", 10 * 1024 * 1024), - AllowedFileTypes = configuration.GetSection("FormEngine:AllowedFileTypes").Get() ?? [".pdf", ".doc", ".docx"] - }; - - logger.LogDebug("Retrieved form configuration for template {TemplateId}", templateId); - return config; - } - - /// - public FieldConfiguration GetFieldConfiguration(string fieldType) - { - var configuration = requestConfiguration.Current; - var config = new FieldConfiguration - { - FieldType = fieldType, - IsRequired = configuration.GetValue($"FormEngine:FieldTypes:{fieldType}:IsRequired", false), - MaxLength = configuration.GetValue($"FormEngine:FieldTypes:{fieldType}:MaxLength", 0), - DefaultValue = configuration.GetValue($"FormEngine:FieldTypes:{fieldType}:DefaultValue", string.Empty), - ValidationRules = configuration.GetSection($"FormEngine:FieldTypes:{fieldType}:ValidationRules").Get() ?? [] - }; - - logger.LogDebug("Retrieved field configuration for type {FieldType}", fieldType); - return config; - } - - /// - public ValidationConfiguration GetValidationConfiguration(string validationType) - { - var configuration = requestConfiguration.Current; - var config = new ValidationConfiguration - { - ValidationType = validationType, - ErrorMessage = configuration.GetValue($"FormEngine:ValidationTypes:{validationType}:ErrorMessage", "Validation failed"), - Rule = configuration.GetValue($"FormEngine:ValidationTypes:{validationType}:Rule", string.Empty), - IsConditional = configuration.GetValue($"FormEngine:ValidationTypes:{validationType}:IsConditional", false) - }; - - logger.LogDebug("Retrieved validation configuration for type {ValidationType}", validationType); - return config; - } - - /// - public FormSettings GetDefaultFormSettings() - { - var configuration = requestConfiguration.Current; - var settings = new FormSettings - { - EnableAutoSave = configuration.GetValue("FormEngine:EnableAutoSave", true), - AutoSaveInterval = configuration.GetValue("FormEngine:AutoSaveInterval", 30000), - ShowProgressIndicator = configuration.GetValue("FormEngine:ShowProgressIndicator", true), - EnableFieldValidation = configuration.GetValue("FormEngine:EnableFieldValidation", true), - DefaultDateFormat = configuration.GetValue("FormEngine:DefaultDateFormat", "dd/MM/yyyy") - }; - - logger.LogDebug("Retrieved default form settings"); - return settings; - } -} diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormDataManager.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormDataManager.cs deleted file mode 100644 index 9f314ac..0000000 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormDataManager.cs +++ /dev/null @@ -1,117 +0,0 @@ -using GovUK.Dfe.FlexForms.Application.Interfaces; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; - -namespace GovUK.Dfe.FlexForms.Infrastructure.Services -{ - /// - /// Implementation of the form data manager that handles data operations - /// - public class FormDataManager : IFormDataManager - { - private readonly IApplicationResponseService _applicationResponseService; - private readonly ILogger _logger; - - public FormDataManager( - IApplicationResponseService applicationResponseService, - ILogger logger) - { - _applicationResponseService = applicationResponseService; - _logger = logger; - } - - /// - /// Gets the data for a specific page - /// - /// The page ID - /// The application ID - /// The page data as a dictionary - public async Task> GetPageDataAsync(string pageId, string applicationId) - { - // This would need to be implemented based on how page-specific data is stored - // For now, we'll return an empty dictionary - _logger.LogDebug("Getting page data for page {PageId} and application {ApplicationId}", pageId, applicationId); - return new Dictionary(); - } - - /// - /// Saves the data for a specific page - /// - /// The page ID - /// The application ID - /// The data to save - /// A task representing the asynchronous operation - public async Task SavePageDataAsync(string pageId, string applicationId, Dictionary data) - { - if (Guid.TryParse(applicationId, out var appId)) - { - await _applicationResponseService.SaveApplicationResponseAsync(appId, data, null); - _logger.LogInformation("Saved page data for page {PageId} and application {ApplicationId}", pageId, applicationId); - } - else - { - _logger.LogWarning("Invalid application ID format: {ApplicationId}", applicationId); - } - } - - /// - /// Gets the data for a specific task - /// - /// The task ID - /// The application ID - /// The task data as a dictionary - public async Task> GetTaskDataAsync(string taskId, string applicationId) - { - // This would need to be implemented based on how task-specific data is stored - // For now, we'll return an empty dictionary - _logger.LogDebug("Getting task data for task {TaskId} and application {ApplicationId}", taskId, applicationId); - return new Dictionary(); - } - - /// - /// Gets all data for an application - /// - /// The application ID - /// The application data as a dictionary - public async Task> GetApplicationDataAsync(string applicationId) - { - // This would need to be implemented based on how application data is stored - // For now, we'll return an empty dictionary - _logger.LogDebug("Getting application data for application {ApplicationId}", applicationId); - return new Dictionary(); - } - - /// - /// Accumulates form data in session storage - /// - /// The data to accumulate - /// The HTTP session - public void AccumulateFormData(Dictionary data, ISession session) - { - _applicationResponseService.AccumulateFormData(data, session); - _logger.LogDebug("Accumulated {Count} form data entries in session", data.Count); - } - - /// - /// Gets accumulated form data from session storage - /// - /// The HTTP session - /// The accumulated data as a dictionary - public Dictionary GetAccumulatedFormData(ISession session) - { - var data = _applicationResponseService.GetAccumulatedFormData(session); - _logger.LogDebug("Retrieved {Count} accumulated form data entries from session", data.Count); - return data; - } - - /// - /// Clears accumulated form data from session storage - /// - /// The HTTP session - public void ClearAccumulatedFormData(ISession session) - { - _applicationResponseService.ClearAccumulatedFormData(session); - _logger.LogDebug("Cleared accumulated form data from session"); - } - } -} diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormNavigationService.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormNavigationService.cs index 140243e..b349940 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormNavigationService.cs +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormNavigationService.cs @@ -1,5 +1,5 @@ using GovUK.Dfe.FlexForms.Application.Interfaces; -using Microsoft.AspNetCore.Http; +using GovUK.Dfe.FlexForms.Domain.FormEngine; namespace GovUK.Dfe.FlexForms.Infrastructure.Services { @@ -9,12 +9,10 @@ namespace GovUK.Dfe.FlexForms.Infrastructure.Services public class FormNavigationService : IFormNavigationService { private readonly INavigationHistoryService _history; - private readonly IHttpContextAccessor _httpContextAccessor; - public FormNavigationService(INavigationHistoryService history, IHttpContextAccessor httpContextAccessor) + public FormNavigationService(INavigationHistoryService history) { _history = history; - _httpContextAccessor = httpContextAccessor; } /// /// Gets the URL for the next page in the form @@ -98,10 +96,9 @@ public string GetBackLinkUrl(string currentPageId, string taskId, string referen { // Build scope: reference:task[:flow:instance] var scope = BuildScope(referenceNumber, taskId, currentPageId); - var session = _httpContextAccessor.HttpContext?.Session; // Prefer history when available - var last = session != null ? _history.Peek(scope, session) : null; + var last = _history.Peek(scope); if (!string.IsNullOrEmpty(last)) { // Append nav=back so GET can pop @@ -142,7 +139,7 @@ public string GetNextNavigationTargetAfterSave(Domain.Models.Page currentPage, D } // Find the next page in the same task - var nextPage = GetNextPageInTask(currentPage, currentTask); + var nextPage = FormStepPolicy.GetNextPage(currentTask.Pages, currentPage.PageId); if (nextPage != null) { return $"/applications/{referenceNumber}/{currentTask.TaskId}/{nextPage.PageId}"; @@ -152,31 +149,6 @@ public string GetNextNavigationTargetAfterSave(Domain.Models.Page currentPage, D return GetTaskSummaryUrl(currentTask.TaskId, referenceNumber); } - /// - /// Gets the next page in the same task, or null if there is no next page - /// - /// The current page - /// The current task - /// The next page, or null if there is no next page - private Domain.Models.Page? GetNextPageInTask(Domain.Models.Page currentPage, Domain.Models.Task currentTask) - { - if (currentTask.Pages == null || !currentTask.Pages.Any()) - { - return null; - } - - // Find the current page index - var currentPageIndex = currentTask.Pages.FindIndex(p => p.PageId == currentPage.PageId); - if (currentPageIndex == -1 || currentPageIndex >= currentTask.Pages.Count - 1) - { - // Current page not found or it's the last page - return null; - } - - // Return the next page - return currentTask.Pages[currentPageIndex + 1]; - } - // Sub-flow helpers public string GetCollectionFlowSummaryUrl(string taskId, string referenceNumber) { @@ -194,26 +166,10 @@ public string GetSubFlowPageUrl(string taskId, string referenceNumber, string fl return $"/applications/{referenceNumber}/{taskId}/flow/{flowId}/{instanceId}/{pageId}"; } - private static bool IsSubFlowPage(string currentPageId) - { - return !string.IsNullOrEmpty(currentPageId) && currentPageId.StartsWith("flow/", StringComparison.OrdinalIgnoreCase); - } + private static bool IsSubFlowPage(string currentPageId) => + FormStepPolicy.IsCollectionFlowPage(currentPageId); - private static string BuildScope(string referenceNumber, string taskId, string currentPageId) - { - if (string.IsNullOrEmpty(currentPageId)) - { - return $"{referenceNumber}:{taskId}"; - } - // Extract flow/instance if present: flow/{flowId}/{instanceId}/... - var parts = currentPageId.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 3 && string.Equals(parts[0], "flow", StringComparison.OrdinalIgnoreCase)) - { - var flowId = parts[1]; - var instanceId = parts[2]; - return $"{referenceNumber}:{taskId}:flow:{flowId}:{instanceId}"; - } - return $"{referenceNumber}:{taskId}"; - } + private static string BuildScope(string referenceNumber, string taskId, string currentPageId) => + FormRouteParser.HistoryScope(referenceNumber, taskId, currentPageId); } } diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormStateManager.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormStateManager.cs index 0576666..5beff3d 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormStateManager.cs +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormStateManager.cs @@ -1,88 +1,42 @@ using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.FormEngine; namespace GovUK.Dfe.FlexForms.Infrastructure.Services { /// - /// Implementation of the form state manager that determines which view should be rendered + /// Application adapter over . /// public class FormStateManager : IFormStateManager { - /// - /// Gets the current form state based on the provided parameters - /// - /// The application reference number - /// The current task ID (optional) - /// The current page ID (optional) - /// The current form state public FormState GetCurrentState(string referenceNumber, string taskId, string pageId) { - // Sub-flow routing pattern: pageId may include "flow/flowId/..." segment when mapped in the Razor Page - if (!string.IsNullOrEmpty(pageId) && pageId.StartsWith("flow/", StringComparison.OrdinalIgnoreCase)) - { + if (FormStepPolicy.IsCollectionFlowPage(pageId)) return FormState.SubFlowPage; - } - // If we have a pageId, we're showing a specific form page - if (!string.IsNullOrEmpty(pageId)) - { + + if (FormStepPolicy.IsFormPage(pageId)) return FormState.FormPage; - } - - // If we have a taskId but no pageId, we're showing the task summary - if (!string.IsNullOrEmpty(taskId)) - { + + if (FormStepPolicy.IsTaskSummary(taskId, pageId)) return FormState.TaskSummary; - } - - // If we have neither taskId nor pageId, we're showing the task list + return FormState.TaskList; } - - /// - /// Determines if the task list should be shown - /// - /// The current page ID - /// True if task list should be shown - public bool ShouldShowTaskList(string pageId) - { - return string.IsNullOrEmpty(pageId); - } - - /// - /// Determines if the task summary should be shown - /// - /// The current task ID - /// The current page ID - /// True if task summary should be shown - public bool ShouldShowTaskSummary(string taskId, string pageId) - { - return !string.IsNullOrEmpty(taskId) && string.IsNullOrEmpty(pageId); - } - - /// - /// Determines if the application preview should be shown - /// - /// The current page ID - /// True if application preview should be shown - public bool ShouldShowApplicationPreview(string pageId) - { - // This would be determined by specific routing logic - // For now, we'll return false as this is handled by separate pages - return false; - } - public bool ShouldShowCollectionFlowSummary(Domain.Models.Task task) - { - return task?.Summary?.Mode?.Equals("multiCollectionFlow", StringComparison.OrdinalIgnoreCase) == true; - } + public bool ShouldShowTaskList(string pageId) => string.IsNullOrEmpty(pageId); - public bool ShouldShowDerivedCollectionFlowSummary(Domain.Models.Task task) - { - return task?.Summary?.Mode?.Equals("derivedCollectionFlow", StringComparison.OrdinalIgnoreCase) == true; - } + public bool ShouldShowTaskSummary(string taskId, string pageId) => + FormStepPolicy.IsTaskSummary(taskId, pageId); - public bool IsInSubFlow(string flowId, string pageId) - { - return !string.IsNullOrEmpty(pageId) && pageId.StartsWith($"flow/{flowId}", StringComparison.OrdinalIgnoreCase); - } + public bool ShouldShowApplicationPreview(string pageId) => + FormStepPolicy.IsApplicationPreview(pageId); + + public bool ShouldShowCollectionFlowSummary(Domain.Models.Task task) => + FormStepPolicy.IsCollectionFlowSummary(task); + + public bool ShouldShowDerivedCollectionFlowSummary(Domain.Models.Task task) => + FormStepPolicy.IsDerivedCollectionFlowSummary(task); + + public bool IsInSubFlow(string flowId, string pageId) => + FormStepPolicy.IsInSubFlow(flowId, pageId); } } diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormValidationOrchestrator.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormValidationOrchestrator.cs index cdb4565..d098c3b 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormValidationOrchestrator.cs +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormValidationOrchestrator.cs @@ -1,6 +1,8 @@ +using GovUK.Dfe.FlexForms.Application.FormEngine; using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Application.Validation; +using GovUK.Dfe.FlexForms.Domain.FormEngine; using GovUK.Dfe.FlexForms.Domain.Models; -using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; using System.ComponentModel.DataAnnotations; using System.Globalization; @@ -30,144 +32,79 @@ public FormValidationOrchestrator( _fieldRequirementService = fieldRequirementService; } - /// - /// Validates a single page - /// - /// The page to validate - /// The form data - /// The model state to add errors to - /// Optional template for field requirement policy - /// True if validation passes - public bool ValidatePage(Page page, Dictionary data, ModelStateDictionary modelState, FormTemplate? template = null) + public FormValidationResult ValidatePage(Page page, Dictionary data, FormTemplate? template = null) { - if (page?.Fields == null) - { - return true; - } - - var isValid = true; - foreach (var field in page.Fields) - { - var key = field.FieldId; - data.TryGetValue(key, out var rawValue); + var errors = new List(); + CollectPageErrors(page, data, errors, template); + return new FormValidationResult(errors); + } + public FormValidationResult ValidateTask(Task task, Dictionary data, FormTemplate? template = null) + { + var errors = new List(); + CollectTaskErrors(task, data, errors, template); + return new FormValidationResult(errors); + } - if (field.Type == "checkboxes") { - if (!ValidateField(field, rawValue ?? string.Empty, data, modelState, key, template)) - { - isValid = false; - } - } - else - { - var value = rawValue?.ToString() ?? string.Empty; - if (!ValidateField(field, value, data, modelState, key, template)) - { - isValid = false; - } - } + public FormValidationResult ValidateApplication(FormTemplate template, Dictionary data) + { + var errors = new List(); + if (template?.TaskGroups == null) + return FormValidationResult.Success; - + foreach (var group in template.TaskGroups) + { + foreach (var task in group.Tasks) + CollectTaskErrors(task, data, errors); } - return isValid; + return new FormValidationResult(errors); } - /// - /// Validates a single task - /// - /// The task to validate - /// The form data - /// The model state to add errors to - /// Optional template for field requirement policy - /// True if validation passes - public bool ValidateTask(Task task, Dictionary data, ModelStateDictionary modelState, FormTemplate? template = null) + public FormValidationResult ValidateField(Field field, object value, string fieldKey) { - if (task?.Pages == null) - { - return true; - } + return ValidateField(field, value, null, fieldKey, null); + } - var isValid = true; - foreach (var page in task.Pages) - { - if (!ValidatePage(page, data, modelState, template)) - { - isValid = false; - } - } + public FormValidationResult ValidateField(Field field, object value, Dictionary? formData, string fieldKey) + { + return ValidateField(field, value, formData, fieldKey, null); + } - return isValid; + public FormValidationResult ValidateField(Field field, object value, Dictionary? formData, string fieldKey, FormTemplate? template) + { + var errors = new List(); + ValidateFieldCore(field, value, formData, errors, fieldKey, template); + return new FormValidationResult(errors); } - /// - /// Validates the entire application - /// - /// The form template - /// The form data - /// The model state to add errors to - /// True if validation passes - public bool ValidateApplication(FormTemplate template, Dictionary data, ModelStateDictionary modelState) + private void CollectPageErrors(Page page, Dictionary data, List errors, FormTemplate? template = null) { - if (template?.TaskGroups == null) - { - return true; - } + if (page?.Fields == null) + return; - var isValid = true; - foreach (var group in template.TaskGroups) + foreach (var field in page.Fields) { - foreach (var task in group.Tasks) - { - if (!ValidateTask(task, data, modelState)) - { - isValid = false; - } - } - } + var key = field.FieldId; + data.TryGetValue(key, out var rawValue); - return isValid; + if (field.Type == "checkboxes") + ValidateFieldCore(field, rawValue ?? string.Empty, data, errors, key, template); + else + ValidateFieldCore(field, rawValue?.ToString() ?? string.Empty, data, errors, key, template); + } } - /// - /// Validates a single field - /// - /// The field to validate - /// The field value - /// The model state to add errors to - /// The field key for model state - /// True if validation passes - public bool ValidateField(Field field, object value, ModelStateDictionary modelState, string fieldKey) + private void CollectTaskErrors(Task task, Dictionary data, List errors, FormTemplate? template = null) { - // Call the overloaded method with null data and template for backward compatibility - return ValidateField(field, value, null, modelState, fieldKey, null); - } + if (task?.Pages == null) + return; - /// - /// Validates a single field with full form data context for conditional validation - /// - /// The field to validate - /// The field value - /// The complete form data for conditional evaluation - /// The model state to add errors to - /// The field key for model state - /// True if validation passes - public bool ValidateField(Field field, object value, Dictionary? formData, ModelStateDictionary modelState, string fieldKey) - { - return ValidateField(field, value, formData, modelState, fieldKey, null); + foreach (var page in task.Pages) + CollectPageErrors(page, data, errors, template); } - /// - /// Validates a single field with full form data context, conditional validation, and template-based requirement policy - /// - /// The field to validate - /// The field value - /// The complete form data for conditional evaluation - /// The model state to add errors to - /// The field key for model state - /// The template containing the default field requirement policy - /// True if validation passes - public bool ValidateField(Field field, object value, Dictionary? formData, ModelStateDictionary modelState, string fieldKey, FormTemplate? template) + private bool ValidateFieldCore(Field field, object value, Dictionary? formData, List errors, string fieldKey, FormTemplate? template) { var normalizedCheckboxValues = field.Type == "checkboxes" ? CheckboxValueNormalizer.Normalize(value) @@ -182,7 +119,7 @@ public bool ValidateField(Field field, object value, Dictionary? if (field.Type == "complexField" && field.ComplexField != null) { // Pass template to complex field validation so it can check global required policy - return ValidateComplexField(field, value, formData, modelState, fieldKey, template); + return ValidateComplexField(field, value, formData, errors, fieldKey, template); } // Check if field is required based on template policy (before explicit validation rules) @@ -199,7 +136,7 @@ public bool ValidateField(Field field, object value, Dictionary? if (string.IsNullOrWhiteSpace(stringValue)) { var fieldLabel = field.Label?.Value ?? field.FieldId; - modelState.AddModelError(fieldKey, $"{fieldLabel} is required"); + errors.Add(new FormValidationError(fieldKey, $"{fieldLabel} is required")); isValid = false; } } @@ -226,13 +163,13 @@ public bool ValidateField(Field field, object value, Dictionary? if (missingParts) { - modelState.AddModelError(fieldKey, $"{validationLabel} must include a day, month and year"); + errors.Add(new FormValidationError(fieldKey, $"{validationLabel} must include a day, month and year")); isValid = false; } else if (!DateTime.TryParseExact(stringValue, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _)) { // All parts present and numeric but not a real calendar date - modelState.AddModelError(fieldKey, $"{validationLabel} must be a real date"); + errors.Add(new FormValidationError(fieldKey, $"{validationLabel} must be a real date")); isValid = false; } } @@ -246,7 +183,7 @@ public bool ValidateField(Field field, object value, Dictionary? var emailAttr = new EmailAddressAttribute(); if (!emailAttr.IsValid(stringValue)) { - modelState.AddModelError(fieldKey, "Enter an email address in the correct format, for example, name@example.com"); + errors.Add(new FormValidationError(fieldKey, "Enter an email address in the correct format, for example, name@example.com")); isValid = false; } } @@ -263,7 +200,7 @@ public bool ValidateField(Field field, object value, Dictionary? if (!isValidOption) { var message = GetCustomRequiredMessage(field) ?? "Select an option from the list"; - modelState.AddModelError(fieldKey, message); + errors.Add(new FormValidationError(fieldKey, message)); isValid = false; break; } @@ -276,7 +213,7 @@ public bool ValidateField(Field field, object value, Dictionary? if (!isValidOption) { var message = GetCustomRequiredMessage(field) ?? "Select an option from the list"; - modelState.AddModelError(fieldKey, message); + errors.Add(new FormValidationError(fieldKey, message)); isValid = false; } @@ -319,7 +256,7 @@ public bool ValidateField(Field field, object value, Dictionary? case "required": if (string.IsNullOrWhiteSpace(stringValue)) { - modelState.AddModelError(fieldKey, rule.Message); + errors.Add(new FormValidationError(fieldKey, rule.Message)); isValid = false; } break; @@ -330,7 +267,7 @@ public bool ValidateField(Field field, object value, Dictionary? var regexMatch = Regex.IsMatch(stringValue, pattern, RegexOptions.None, TimeSpan.FromMilliseconds(200)); if (!regexMatch) { - modelState.AddModelError(fieldKey, rule.Message); + errors.Add(new FormValidationError(fieldKey, rule.Message)); isValid = false; } } @@ -343,7 +280,7 @@ public bool ValidateField(Field field, object value, Dictionary? var plainTextForMaxLengthValidation = FormSanitisedTextNormalizer.ToPlainTextForCharacterCountValidation(stringValue); if (plainTextForMaxLengthValidation.Length > maxLength) { - modelState.AddModelError(fieldKey, rule.Message); + errors.Add(new FormValidationError(fieldKey, rule.Message)); isValid = false; } } @@ -351,7 +288,7 @@ public bool ValidateField(Field field, object value, Dictionary? case "maxWords": if (!ValidateWordCount(stringValue, rule)) { - modelState.AddModelError(fieldKey, rule.Message); + errors.Add(new FormValidationError(fieldKey, rule.Message)); isValid = false; } break; @@ -373,11 +310,11 @@ public bool ValidateField(Field field, object value, Dictionary? /// The complex field to validate /// The field value /// The complete form data for conditional evaluation - /// The model state to add errors to + /// The model state to add errors to /// The field key for model state /// The template containing the default field requirement policy /// True if validation passes - private bool ValidateComplexField(Field field, object? value, Dictionary? formData, ModelStateDictionary modelState, string fieldKey, FormTemplate? template = null) + private bool ValidateComplexField(Field field, object? value, Dictionary? formData, List errors, string fieldKey, FormTemplate? template = null) { var stringValue = value?.ToString() ?? string.Empty; var isValid = true; @@ -400,7 +337,7 @@ private bool ValidateComplexField(Field field, object? value, Dictionary maxLength) { - modelState.AddModelError(fieldKey, rule.Message); + errors.Add(new FormValidationError(fieldKey, rule.Message)); isValid = false; } } @@ -519,7 +456,7 @@ private bool ValidateComplexField(Field field, object? value, Dictionary - public class NavigationHistoryService(ILogger logger) : INavigationHistoryService + public class NavigationHistoryService( + IFormSessionStore sessionStore, + ILogger logger) : INavigationHistoryService { - private const string SessionPrefix = "NavHistory_"; + private const string SessionPrefix = FormSessionKeys.NavHistoryPrefix; private const int MaxDepth = 25; - public void Push(string scopeKey, string url, ISession session) + public void Push(string scopeKey, string url) { if (string.IsNullOrWhiteSpace(scopeKey) || string.IsNullOrWhiteSpace(url)) return; var key = SessionPrefix + scopeKey; - var stack = Load(session, key); + var stack = Load(key); // Avoid pushing duplicates of the latest entry if (stack.Count == 0 || !string.Equals(stack[^1], url, StringComparison.OrdinalIgnoreCase)) @@ -26,40 +28,39 @@ public void Push(string scopeKey, string url, ISession session) stack.Add(url); if (stack.Count > MaxDepth) { - // Trim oldest stack.RemoveAt(0); } - Save(session, key, stack); + Save(key, stack); } } - public string? Peek(string scopeKey, ISession session) + public string? Peek(string scopeKey) { if (string.IsNullOrWhiteSpace(scopeKey)) return null; var key = SessionPrefix + scopeKey; - var stack = Load(session, key); + var stack = Load(key); return stack.Count > 0 ? stack[^1] : null; } - public string? Pop(string scopeKey, ISession session) + public string? Pop(string scopeKey) { if (string.IsNullOrWhiteSpace(scopeKey)) return null; var key = SessionPrefix + scopeKey; - var stack = Load(session, key); + var stack = Load(key); if (stack.Count == 0) return null; var last = stack[^1]; stack.RemoveAt(stack.Count - 1); - Save(session, key, stack); + Save(key, stack); return last; } - public void Clear(string scopeKey, ISession session) + public void Clear(string scopeKey) { if (string.IsNullOrWhiteSpace(scopeKey)) return; var key = SessionPrefix + scopeKey; try { - session.Remove(key); + sessionStore.Remove(key); } catch (Exception ex) { @@ -67,13 +68,12 @@ public void Clear(string scopeKey, ISession session) } } - private static List Load(ISession session, string key) + private List Load(string key) { try { - var bytes = session.Get(key); - if (bytes == null) return new List(); - var json = System.Text.Encoding.UTF8.GetString(bytes); + var json = sessionStore.GetString(key); + if (string.IsNullOrEmpty(json)) return new List(); var list = JsonSerializer.Deserialize>(json); return list ?? new List(); } @@ -83,13 +83,11 @@ private static List Load(ISession session, string key) } } - private static void Save(ISession session, string key, List values) + private void Save(string key, List values) { try { - var json = JsonSerializer.Serialize(values); - var bytes = System.Text.Encoding.UTF8.GetBytes(json); - session.Set(key, bytes); + sessionStore.SetString(key, JsonSerializer.Serialize(values)); } catch { @@ -98,5 +96,3 @@ private static void Save(ISession session, string key, List values) } } } - - diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Stores/HttpFormSessionStore.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Stores/HttpFormSessionStore.cs new file mode 100644 index 0000000..61c894d --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Stores/HttpFormSessionStore.cs @@ -0,0 +1,22 @@ +using GovUK.Dfe.FlexForms.Application.Interfaces; +using Microsoft.AspNetCore.Http; + +namespace GovUK.Dfe.FlexForms.Infrastructure.Stores; + +/// +/// HTTP-session adapter for . +/// +public sealed class HttpFormSessionStore(IHttpContextAccessor httpContextAccessor) : IFormSessionStore +{ + private ISession Session => + httpContextAccessor.HttpContext?.Session + ?? throw new InvalidOperationException("HTTP session is not available."); + + public string? GetString(string key) => Session.GetString(key); + + public void SetString(string key, string value) => Session.SetString(key, value); + + public void Remove(string key) => Session.Remove(key); + + public IReadOnlyCollection Keys => Session.Keys.ToList(); +} diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Stores/RedisInfectedFileStore.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Stores/RedisInfectedFileStore.cs new file mode 100644 index 0000000..f87bae0 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Stores/RedisInfectedFileStore.cs @@ -0,0 +1,49 @@ +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Caching; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; + +namespace GovUK.Dfe.FlexForms.Infrastructure.Stores; + +/// +/// Redis adapter for the malware-scan file blacklist. +/// +public sealed class RedisInfectedFileStore( + IConnectionMultiplexer redis, + ILogger logger) : IInfectedFileStore +{ + public bool IsFileInfected(Guid fileId) + { + try + { + var key = $"{FlexFormsCacheKeys.InfectedFilePrefix}{fileId}"; + return redis.GetDatabase().KeyExists(key); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to check infected-file blacklist for {FileId}", fileId); + return false; + } + } + + public bool IsFileNameInfected(string applicationId, string originalFileName) + { + if (string.IsNullOrWhiteSpace(applicationId) || string.IsNullOrWhiteSpace(originalFileName)) + return false; + + try + { + var key = $"{FlexFormsCacheKeys.InfectedFileNamePrefix}{applicationId}:{originalFileName}"; + return redis.GetDatabase().KeyExists(key); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Failed to check infected-filename blacklist for {ApplicationId}/{FileName}", + applicationId, + originalFileName); + return false; + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/Extensions/FormCollectionExtensions.cs b/src/GovUK.Dfe.FlexForms.Web/Extensions/FormCollectionExtensions.cs new file mode 100644 index 0000000..73476f8 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/Extensions/FormCollectionExtensions.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Http; + +namespace GovUK.Dfe.FlexForms.Web.Extensions; + +public static class FormCollectionExtensions +{ + public static IReadOnlyDictionary> ToPostedFields(this IFormCollection form) + { + var fields = new Dictionary>(StringComparer.Ordinal); + foreach (var key in form.Keys) + fields[key] = form[key].ToArray(); + + return fields; + } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/Extensions/FormValidationResultExtensions.cs b/src/GovUK.Dfe.FlexForms.Web/Extensions/FormValidationResultExtensions.cs new file mode 100644 index 0000000..608ea9c --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/Extensions/FormValidationResultExtensions.cs @@ -0,0 +1,21 @@ +using GovUK.Dfe.FlexForms.Application.Validation; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace GovUK.Dfe.FlexForms.Web.Extensions; + +/// +/// Maps Application validation results onto ASP.NET ModelState. +/// +public static class FormValidationResultExtensions +{ + public static bool ApplyTo(this FormValidationResult result, ModelStateDictionary modelState) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(modelState); + + foreach (var error in result.Errors) + modelState.AddModelError(error.FieldKey, error.Message); + + return result.IsValid; + } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/Extensions/ServiceCollectionExtensions.cs b/src/GovUK.Dfe.FlexForms.Web/Extensions/ServiceCollectionExtensions.cs index 3302144..dc03b6d 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Extensions/ServiceCollectionExtensions.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Extensions/ServiceCollectionExtensions.cs @@ -1,11 +1,12 @@ +using GovUK.Dfe.FlexForms.Application.Admin; +using GovUK.Dfe.FlexForms.Application.Dashboard; +using GovUK.Dfe.FlexForms.Application.FormEngine; using GovUK.Dfe.FlexForms.Application.Interfaces; -using GovUK.Dfe.FlexForms.Infrastructure.Parsers; -using GovUK.Dfe.FlexForms.Infrastructure.Providers; -using GovUK.Dfe.FlexForms.Infrastructure.Services; -using GovUK.Dfe.FlexForms.Infrastructure.Stores; +using GovUK.Dfe.FlexForms.Infrastructure; using GovUK.Dfe.FlexForms.Web.Configuration; using GovUK.Dfe.FlexForms.Web.Interfaces; using GovUK.Dfe.FlexForms.Web.Services; +using GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; using GovUK.Dfe.FlexForms.Api.Client; using GovUK.Dfe.FlexForms.Api.Client.Contracts; using GovUK.Dfe.FlexForms.Api.Client.Extensions; @@ -47,48 +48,44 @@ public static IServiceCollection AddExternalApplicationsApiClients( public static IServiceCollection AddWebLayerServices(this IServiceCollection services) { - // Web layer services + services.AddInfrastructureDependencyGroup(); + services.AddScoped(); services.AddScoped(); - - // Infrastructure/application services used by web - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddSingleton(); - services.AddSingleton(); - services.AddScoped(); - - // Form Engine Services - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddHttpContextAccessor(); + services.AddScoped(); - // Confirmation Services - services.AddScoped(); - services.AddScoped(); - - // Feedback services - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); return services; } } } - diff --git a/src/GovUK.Dfe.FlexForms.Web/GovUK.Dfe.FlexForms.Web.csproj b/src/GovUK.Dfe.FlexForms.Web/GovUK.Dfe.FlexForms.Web.csproj index d2c0e6e..bc92406 100644 --- a/src/GovUK.Dfe.FlexForms.Web/GovUK.Dfe.FlexForms.Web.csproj +++ b/src/GovUK.Dfe.FlexForms.Web/GovUK.Dfe.FlexForms.Web.csproj @@ -5,8 +5,8 @@ enable enable 8051c984-585b-4a5e-b6d7-833e5dd4afe7 - 2.0.0 - 2.0.0 + 2.1.0 + 2.1.0 false Linux ..\.. diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/Admin.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/Admin.cshtml.cs index 6b6f89f..bc15cb9 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/Admin.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/Admin.cshtml.cs @@ -1,34 +1,29 @@ using GovUK.Dfe.FlexForms.Web.Security; using GovUK.Dfe.CoreLibs.Caching.Helpers; using GovUK.Dfe.CoreLibs.Caching.Interfaces; -using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Application.Admin; using GovUK.Dfe.FlexForms.Domain.Models; -using GovUK.Dfe.FlexForms.Web.Services; -using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; -using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Api.Client.Security; +using GovUK.Dfe.FlexForms.Web.Services; +using GovUK.Dfe.FlexForms.Web.Tenancy; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNetCore.Authentication; using Task = System.Threading.Tasks.Task; -using GovUK.Dfe.FlexForms.Api.Client.Security; -using GovUK.Dfe.FlexForms.Web.Tenancy; namespace GovUK.Dfe.FlexForms.Web.Pages.Admin { [ExcludeFromCodeCoverage] [Authorize(Policy = AdminAccessHelper.CanAccessAdminAreaPolicy)] public class AdminModel( - IFormTemplateProvider templateProvider, - ITemplatesClient templatesClient, - ITemplateSelectionService templateSelectionService, + IAdminHome adminHome, ICacheService cacheService, IHttpContextAccessor httpContextAccessor, IInternalUserTokenStore tokenStore, - ITenantAdminClient tenantAdminClient, + ITemplateSelectionService templateSelectionService, ITenantRequestContext tenantRequestContext, ILogger logger) : PageModel @@ -92,12 +87,13 @@ public async Task OnGetAsync() } DsiToken = await httpContextAccessor.HttpContext?.GetTokenAsync("id_token")!; - + UserToken = tokenStore.GetToken(); - await LoadTenantTemplatesAsync(); - await LoadTemplateInformationAsync(); - await LoadTenantConfigurationSummaryAsync(); + CaptureSessionTemplate(); + var state = CaptureWorkState(); + await adminHome.LoadAsync(state); + ApplyWorkState(state); return Page(); } @@ -109,7 +105,7 @@ public async Task OnPostClearAllAsync() try { HttpContext.Session.Clear(); - + if (!string.IsNullOrEmpty(TemplateCacheKey)) { cacheService.Remove(TemplateCacheKey); @@ -118,7 +114,7 @@ public async Task OnPostClearAllAsync() ShowSuccess = true; SuccessMessage = "Successfully cleared all sessions and caches."; - + logger.LogInformation("Admin cleared all sessions and caches"); } catch (Exception ex) @@ -128,7 +124,6 @@ public async Task OnPostClearAllAsync() ErrorMessage = "Failed to clear sessions and caches. Please try again."; } - await LoadTemplateInformationAsync(true); return RedirectToPage("/Applications/Dashboard"); } @@ -143,30 +138,13 @@ private async Task SetTemplateLiveAsync(Guid templateId, bool isL if (!CanManageTemplates) return Forbid(); - try - { - logger.LogInformation( - "Setting template {TemplateId} live status to {IsLive}", - templateId, - isLive); - - await templatesClient.SetTemplateLiveAsync( - templateId, - new SetTemplateLiveRequest { IsLive = isLive }); - - TempData["AdminSuccess"] = isLive - ? "Template is now live for end users." - : "Template is no longer live for end users."; - } - catch (Exception ex) - { - logger.LogError( - ex, - "Failed to set live status to {IsLive} for template {TemplateId}", - isLive, - templateId); - TempData["AdminError"] = "Failed to update template live status. Please try again."; - } + var outcome = await adminHome.SetTemplateLiveAsync(templateId, isLive); + + if (outcome.SuccessMessage != null) + TempData["AdminSuccess"] = outcome.SuccessMessage; + + if (outcome.ErrorMessage != null) + TempData["AdminError"] = outcome.ErrorMessage; return RedirectToPage(); } @@ -176,115 +154,29 @@ public async Task OnPostOpenTemplateAsync(Guid templateId) if (!CanManageTemplates) return Forbid(); - try - { - var templates = await templateSelectionService.GetSelectableTemplatesAsync(); - if (templates.All(t => t.TemplateId != templateId)) - { - HasError = true; - ErrorMessage = "Template was not found in the tenant catalogue."; - await LoadTenantTemplatesAsync(); - await LoadTemplateInformationAsync(); - return Page(); - } + CaptureSessionTemplate(); + var state = CaptureWorkState(); + var outcome = await adminHome.OpenTemplateAsync(state, templateId); + ApplyWorkState(state); - var template = templates.First(t => t.TemplateId == templateId); - await templateSelectionService.SelectTemplateAsync(HttpContext, template); - return RedirectToPage("/Applications/Dashboard"); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to open template {TemplateId}", templateId); - HasError = true; - ErrorMessage = "Failed to open template. Please try again."; - await LoadTenantTemplatesAsync(); - await LoadTemplateInformationAsync(); + if (outcome.Kind == AdminPageOutcomeKind.StayOnPage) return Page(); - } - } - - private async Task LoadTenantTemplatesAsync() - { - try - { - TenantTemplates = await templateSelectionService.GetSelectableTemplatesAsync(); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to load tenant templates for admin page"); - TenantTemplates = []; - } - } - - private async Task LoadTemplateInformationAsync(bool afterSessionClear = false) - { - try - { - TestToken = HttpContext.Session.GetString("TestAuth:Token"); - - TemplateId = HttpContext.Session.GetString("TemplateId"); - - if (afterSessionClear) - return; - if (string.IsNullOrEmpty(TemplateId)) - { - return; - } - - TemplateCacheKey = $"FormTemplate_{CacheKeyHelper.GenerateHashedCacheKey(TemplateId)}"; - - var template = await templateProvider.GetTemplateAsync(TemplateId); - if (template != null) - { - TemplateName = template.TemplateName; - TemplateDescription = template.Description; - TaskGroupCount = template.TaskGroups?.Count ?? 0; - } - - var templateResponse = await templatesClient.GetLatestTemplateSchemaAsync(new Guid(TemplateId)); - CurrentTemplateVersion = templateResponse?.VersionNumber; - - logger.LogDebug("Loaded admin information for template {TemplateId}", TemplateId); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to load template information for admin page"); - HasError = true; - ErrorMessage = "Failed to load template information. Please try again."; - } - } - - private async Task LoadTenantConfigurationSummaryAsync() - { - if (!CanViewTenantConfigurationSummary) - { - return; - } - - if (tenantRequestContext.TenantId is not { } tenantId || tenantId == Guid.Empty) - { - return; - } + if (state.TemplateToOpen is null) + return Page(); - try - { - TenantConfigurationSummary = await tenantAdminClient.GetEffectiveConfigurationAsync(tenantId); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to load tenant configuration summary for admin dashboard"); - } + await templateSelectionService.SelectTemplateAsync(HttpContext, state.TemplateToOpen); + return RedirectToPage("/Applications/Dashboard"); } public string GetSessionKeysInfo() { var sessionKeys = new List(); - + var commonKeys = new[] { "TemplateId", - "ApplicationId", + "ApplicationId", "ApplicationReference", "CurrentAccumulatedApplicationId" }; @@ -311,7 +203,7 @@ public async Task GetCacheStatusAsync() try { var factoryCalled = false; - + await cacheService.GetOrAddAsync( TemplateCacheKey, async () => @@ -328,5 +220,37 @@ await cacheService.GetOrAddAsync( return "Unable to determine cache status"; } } + + private void CaptureSessionTemplate() + { + TestToken = HttpContext.Session.GetString("TestAuth:Token"); + TemplateId = HttpContext.Session.GetString("TemplateId"); + if (!string.IsNullOrEmpty(TemplateId)) + TemplateCacheKey = $"FormTemplate_{CacheKeyHelper.GenerateHashedCacheKey(TemplateId)}"; + } + + private AdminHomeWorkState CaptureWorkState() => + new() + { + TenantId = tenantRequestContext.TenantId, + IncludeTenantConfigurationSummary = CanViewTenantConfigurationSummary, + TemplateId = TemplateId + }; + + private void ApplyWorkState(AdminHomeWorkState state) + { + TemplateId = state.TemplateId ?? TemplateId; + TemplateName = state.TemplateName; + TemplateDescription = state.TemplateDescription; + TaskGroupCount = state.TaskGroupCount; + CurrentTemplateVersion = state.CurrentTemplateVersion; + TenantTemplates = state.TenantTemplates; + TenantConfigurationSummary = state.TenantConfigurationSummary; + if (state.HasError) + { + HasError = true; + ErrorMessage = state.ErrorMessage; + } + } } } diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/ContributorManagement.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/ContributorManagement.cshtml.cs index bdedad0..d0f7b68 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/ContributorManagement.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/ContributorManagement.cshtml.cs @@ -1,7 +1,6 @@ using System.ComponentModel.DataAnnotations; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; -using GovUK.Dfe.CoreLibs.Http.Models; -using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; using GovUK.Dfe.FlexForms.Web.Security; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -13,9 +12,7 @@ namespace GovUK.Dfe.FlexForms.Web.Pages.Admin; /// Lookup application contributors by reference number for tenant admins. /// [Authorize(Policy = AdminAccessHelper.CanManageUsersPolicy)] -public sealed class ContributorManagementModel( - IApplicationsClient applicationsClient, - ILogger logger) : PageModel +public sealed class ContributorManagementModel(IContributorManagementAdmin contributorManagementAdmin) : PageModel { public bool HasError { get; private set; } @@ -47,53 +44,23 @@ public async Task OnPostAsync(CancellationToken cancellationToken if (!ModelState.IsValid) return Page(); - LookupPerformed = true; - ApplicationReference = ReferenceNumber; - - try - { - var application = await applicationsClient.GetApplicationByReferenceAsync( - ReferenceNumber, - cancellationToken); - - ApplicationId = application.ApplicationId; - ApplicationReference = string.IsNullOrWhiteSpace(application.ApplicationReference) - ? ReferenceNumber - : application.ApplicationReference; - TemplateName = application.TemplateName; - - var contributors = await applicationsClient.GetContributorsAsync( - application.ApplicationId, - includePermissionDetails: false, - cancellationToken); - - Contributors = contributors? - .OrderBy(c => c.Name) - .ThenBy(c => c.Email) - .ToList() ?? []; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to look up contributors for {ReferenceNumber}", ReferenceNumber); - HasError = true; - ErrorMessage = GetErrorMessage(ex, "Could not find that application or load its contributors."); - Contributors = []; - } - + var state = new ContributorManagementWorkState { ReferenceNumber = ReferenceNumber }; + await contributorManagementAdmin.LookupAsync(state, cancellationToken); + ApplyWorkState(state); return Page(); } - internal static string GetErrorMessage(Exception ex, string fallback) + private void ApplyWorkState(ContributorManagementWorkState state) { - if (ex is ExternalApplicationsException apiEx - && !string.IsNullOrWhiteSpace(apiEx.Result?.Message)) + LookupPerformed = state.LookupPerformed; + ApplicationReference = state.ApplicationReference; + ApplicationId = state.ApplicationId; + TemplateName = state.TemplateName; + Contributors = state.Contributors; + if (state.HasError) { - return apiEx.Result.Message; + HasError = true; + ErrorMessage = state.ErrorMessage; } - - if (ex is ExternalApplicationsException clientEx && clientEx.StatusCode > 0) - return $"{fallback} (HTTP {clientEx.StatusCode})"; - - return fallback; } } diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/CustomStatusLabelOverrides.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/CustomStatusLabelOverrides.cshtml.cs index c441173..82a5fcd 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/CustomStatusLabelOverrides.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/CustomStatusLabelOverrides.cshtml.cs @@ -1,13 +1,10 @@ using GovUK.Dfe.FlexForms.Web.Security; -using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Application.Admin; using GovUK.Dfe.FlexForms.Domain.Models; -using GovUK.Dfe.FlexForms.Web.Services; using GovUK.Dfe.CoreLibs.Caching.Helpers; using GovUK.Dfe.CoreLibs.Caching.Interfaces; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; -using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; -using GovUK.Dfe.FlexForms.Api.Client.Contracts; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; @@ -20,13 +17,8 @@ namespace GovUK.Dfe.FlexForms.Web.Pages.Admin [ExcludeFromCodeCoverage] [Authorize(Policy = AdminAccessHelper.CanManageTemplatesPolicy)] public class CustomStatusLabelOverridesModel( - IApplicationStatusService applicationStatusService, - IFormTemplateProvider formTemplateProvider, - ICacheService cacheService, - ITemplatesClient templatesClient, - ITemplateSelectionService templateSelectionService, - ILogger logger) - : PageModel + ICustomStatusLabelOverridesAdmin customStatusLabelOverridesAdmin, + ICacheService cacheService) : PageModel { public bool ShowSuccess { get; set; } public bool HasError { get; set; } @@ -52,35 +44,40 @@ public async Task OnGetAsync( ApplicationStatus status = ApplicationStatus.Created) { ShowSuccess = success; - await LoadAvailableTemplatesAsync(); + var state = CaptureWorkState(); + await customStatusLabelOverridesAdmin.LoadAvailableTemplatesAsync(state); var templateId = ResolveTemplateId(); if (templateId == null) { + ApplyWorkState(state); return Page(); } SelectedTemplateId = templateId.Value; - await LoadTemplateDataAsync(templateId.Value); + state.SelectedTemplateId = templateId.Value; + await customStatusLabelOverridesAdmin.LoadTemplateDataAsync(state, templateId.Value); SelectedBaseStatus = status; - BaseStatuses = applicationStatusService.GetBaseApplicationStatuses().OrderBy(x => x.Key); - var statuses = await applicationStatusService.GetCustomApplicationStatusesAsync(templateId.Value); - BaseStatusOverrideValue = applicationStatusService.GetStatusLabel(status, statuses); + await customStatusLabelOverridesAdmin.LoadStatusOverrideAsync(state, templateId.Value, status); + ApplyWorkState(state); return Page(); } public async Task OnPostAsync() { - await LoadAvailableTemplatesAsync(); + var state = CaptureWorkState(); + await customStatusLabelOverridesAdmin.LoadAvailableTemplatesAsync(state); var templateId = ResolveTemplateId(); if (templateId == null) { - ModelState.AddModelError(nameof(SelectedTemplateId), "Please select a template."); + ModelState.AddModelError(nameof(SelectedTemplateId), CustomStatusLabelOverridesMessages.SelectTemplate); + ApplyWorkState(state); return Page(); } SelectedTemplateId = templateId.Value; + state.SelectedTemplateId = templateId.Value; ApplicationStatus appStatus = ApplicationStatus.Created; var query = HttpContext.Request.Query; @@ -96,19 +93,17 @@ public async Task OnPostAsync() if (!ValidateInput()) { - await LoadTemplateDataAsync(templateId.Value); - BaseStatuses = applicationStatusService.GetBaseApplicationStatuses().OrderBy(x => x.Key); + await customStatusLabelOverridesAdmin.LoadTemplateDataAsync(state, templateId.Value); + customStatusLabelOverridesAdmin.PopulateBaseStatuses(state); + ApplyWorkState(state); return Page(); } - await applicationStatusService.OverrideApplicationStatusLabels(templateId.Value, - new CustomApplicationStatusRequest - { - Label = BaseStatusOverrideValue, - ApplicationStatus = SelectedBaseStatus - }); + await customStatusLabelOverridesAdmin.OverrideAsync( + templateId.Value, + SelectedBaseStatus, + BaseStatusOverrideValue); - logger.LogInformation("Successfully overridden application status for {TemplateId}", templateId); cacheService.Remove( $"CustomApplicationStatuses_{CacheKeyHelper.GenerateHashedCacheKey(templateId.Value.ToString())}"); @@ -141,31 +136,31 @@ private bool ValidateInput() if (string.IsNullOrWhiteSpace(BaseStatusOverrideValue)) { ModelState.AddModelError(nameof(BaseStatusOverrideValue), - "An override value is required and cannot be empty"); + CustomStatusLabelOverridesMessages.OverrideRequired); return false; } return true; } - private async Task LoadAvailableTemplatesAsync() - { - try - { - AvailableTemplates = await templateSelectionService.GetSelectableTemplatesAsync(); - } - catch (Exception ex) + private CustomStatusLabelOverridesWorkState CaptureWorkState() => + new() { - logger.LogWarning(ex, "Failed to load available templates for custom status page"); - AvailableTemplates = []; - } - } + SelectedTemplateId = SelectedTemplateId, + SelectedBaseStatus = SelectedBaseStatus, + BaseStatusOverrideValue = BaseStatusOverrideValue + }; - private async Task LoadTemplateDataAsync(Guid templateId) + private void ApplyWorkState(CustomStatusLabelOverridesWorkState state) { - var apiResponse = await templatesClient.GetLatestTemplateSchemaAsync(templateId); - CurrentVersionNumber = apiResponse.VersionNumber; - CurrentTemplate = await formTemplateProvider.GetTemplateAsync(templateId.ToString()); + SelectedTemplateId = state.SelectedTemplateId; + CurrentTemplate = state.CurrentTemplate; + CurrentVersionNumber = state.CurrentVersionNumber; + AvailableTemplates = state.AvailableTemplates; + if (state.BaseStatuses.Count > 0) + BaseStatuses = state.BaseStatuses; + if (!string.IsNullOrEmpty(state.BaseStatusOverrideValue)) + BaseStatusOverrideValue = state.BaseStatusOverrideValue; } } } diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/DuplicateTenant.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/DuplicateTenant.cshtml.cs index f7030eb..209b1c2 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/DuplicateTenant.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/DuplicateTenant.cshtml.cs @@ -1,11 +1,5 @@ using System.ComponentModel.DataAnnotations; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; -using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; -using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; using GovUK.Dfe.FlexForms.Web.Security; using GovUK.Dfe.FlexForms.Web.Tenancy; using Microsoft.AspNetCore.Authorization; @@ -19,16 +13,9 @@ namespace GovUK.Dfe.FlexForms.Web.Pages.Admin; /// [Authorize(Policy = AdminAccessHelper.CanManagePlatformTenantsPolicy)] public sealed class DuplicateTenantModel( - ITenantAdminClient tenantAdminClient, - ITenantRequestContext tenantRequestContext, - ILogger logger) : PageModel + IDuplicateTenantAdmin duplicateTenantAdmin, + ITenantRequestContext tenantRequestContext) : PageModel { - private static readonly JsonSerializerOptions PayloadSerializerOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - public Guid SourceTenantId { get; private set; } public string SourceTenantName { get; private set; } = string.Empty; @@ -70,13 +57,13 @@ public sealed class DuplicateTenantModel( [Required(ErrorMessage = "Enter an Authorization secret key")] [MinLength(32, ErrorMessage = "Authorization secret key must be at least 32 characters")] [Display(Name = "Authorization SecretKey (API)")] - public string AuthorizationApiSecretKey { get; set; } = GenerateSecretKey(); + public string AuthorizationApiSecretKey { get; set; } = DuplicateTenantAdminService.GenerateSecretKey(); [BindProperty] [Required(ErrorMessage = "Enter an InternalServiceAuth secret key")] [MinLength(32, ErrorMessage = "InternalServiceAuth secret key must be at least 32 characters")] [Display(Name = "InternalServiceAuth SecretKey (API and Web)")] - public string InternalServiceAuthSecretKey { get; set; } = GenerateSecretKey(); + public string InternalServiceAuthSecretKey { get; set; } = DuplicateTenantAdminService.GenerateSecretKey(); /// /// One editable ApiKey per InternalServiceAuth Services[] email from the source tenant. @@ -103,7 +90,9 @@ public async Task OnGetAsync(CancellationToken cancellationToken) ?? string.Empty; } - await LoadInternalServiceAuthServicesAsync(cancellationToken); + var state = CaptureWorkState(); + await duplicateTenantAdmin.LoadInternalServiceAuthServicesAsync(state, cancellationToken); + ApplyWorkState(state); EnsureSecretsPopulated(); return Page(); } @@ -119,12 +108,16 @@ public async Task OnPostRegenerateSecretsAsync(CancellationToken // Keep service emails from the posted form when present; otherwise reload from source. if (InternalServiceAuthServiceApiKeys.Count == 0) - await LoadInternalServiceAuthServicesAsync(cancellationToken); + { + var state = CaptureWorkState(); + await duplicateTenantAdmin.LoadInternalServiceAuthServicesAsync(state, cancellationToken); + ApplyWorkState(state); + } - AuthorizationApiSecretKey = GenerateSecretKey(); - InternalServiceAuthSecretKey = GenerateSecretKey(); + AuthorizationApiSecretKey = DuplicateTenantAdminService.GenerateSecretKey(); + InternalServiceAuthSecretKey = DuplicateTenantAdminService.GenerateSecretKey(); foreach (var service in InternalServiceAuthServiceApiKeys) - service.ApiKey = GenerateSecretKey(); + service.ApiKey = DuplicateTenantAdminService.GenerateSecretKey(); ModelState.Clear(); return Page(); @@ -151,185 +144,95 @@ public async Task OnPostAsync(CancellationToken cancellationToken var service = InternalServiceAuthServiceApiKeys[i]; service.Email = service.Email?.Trim() ?? string.Empty; service.ApiKey = service.ApiKey?.Trim() ?? string.Empty; - - if (string.IsNullOrWhiteSpace(service.Email)) - ModelState.AddModelError( - $"{nameof(InternalServiceAuthServiceApiKeys)}[{i}].{nameof(InternalServiceAuthServiceSecretInput.Email)}", - "Service email is required."); - - if (string.IsNullOrWhiteSpace(service.ApiKey) || service.ApiKey.Length < 32) - ModelState.AddModelError( - $"{nameof(InternalServiceAuthServiceApiKeys)}[{i}].{nameof(InternalServiceAuthServiceSecretInput.ApiKey)}", - "Enter an ApiKey of at least 32 characters."); } + var state = CaptureWorkState(); + foreach (var validationError in duplicateTenantAdmin.ValidateInput(state)) + ModelState.AddModelError(validationError.FieldKey, validationError.Message); + if (!ModelState.IsValid) return Page(); - if (NewTenantId == Guid.Empty) - { - ModelState.AddModelError(nameof(NewTenantId), "Enter a valid tenant id."); - return Page(); - } + var outcome = await duplicateTenantAdmin.CloneAsync(state, cancellationToken); + ApplyWorkState(state); - if (NewTenantId == SourceTenantId) - { - ModelState.AddModelError(nameof(NewTenantId), "New tenant id must be different from the current tenant."); - return Page(); - } + foreach (var validationError in outcome.Errors) + ModelState.AddModelError(validationError.FieldKey, validationError.Message); - try - { - // WAF-safe: hostname, frontendOrigin, serviceName, and secrets live only inside Base64 payloadJson - // so Application Gateway does not see cleartext https:// ARGS (rule 931130 RFI). - var secretsPayload = new CloneTenantSecretsPayload - { - Hostname = Hostname, - FrontendOrigin = FrontendOrigin, - AuthorizationApiSecretKey = AuthorizationApiSecretKey, - InternalServiceAuthSecretKey = InternalServiceAuthSecretKey, - InternalServiceAuthServiceApiKeys = InternalServiceAuthServiceApiKeys - .Select(s => new CloneTenantServiceApiKeyPayload - { - Email = s.Email, - ApiKey = s.ApiKey - }) - .ToList() - }; - - var payloadNode = JsonSerializer.SerializeToNode(secretsPayload, PayloadSerializerOptions)!.AsObject(); - payloadNode["serviceName"] = ServiceName; - - var body = new CloneTenantRequest( - NewTenantId, - NewTenantName, - ToBase64Utf8(payloadNode.ToJsonString(PayloadSerializerOptions))); - - var response = await tenantAdminClient.CloneTenantAsync(SourceTenantId, body, cancellationToken); - - TempData["AdminSuccess"] = - $"Created tenant '{response.NewTenantName}' ({response.NewTenantId}). " + - $"Copied {response.SettingsCopied} setting(s). Hostname: {response.Hostname}. " + - "Authorization and InternalServiceAuth secrets (SecretKey + service ApiKeys) were applied. " + - "Create a template for this tenant before users can access the dashboard."; - - return RedirectToPage("/Admin/Admin"); - } - catch (Exception ex) - { - logger.LogError( - ex, - "Failed to duplicate tenant {SourceTenantId} to {NewTenantId}", - SourceTenantId, - NewTenantId); - HasError = true; - ErrorMessage = GetCloneErrorMessage(ex); - return Page(); - } - } - - private async Task LoadInternalServiceAuthServicesAsync(CancellationToken cancellationToken) - { - try + if (outcome.Kind == AdminPageOutcomeKind.StayOnPage) { - var response = await tenantAdminClient.GetTenantSettingsAsync(SourceTenantId, cancellationToken); - var template = response.Settings - .Where(s => string.Equals(s.Category, "InternalServiceAuth", StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(s => string.Equals(s.Target, "Api", StringComparison.OrdinalIgnoreCase)) - .ThenBy(s => s.Target, StringComparer.OrdinalIgnoreCase) - .FirstOrDefault(); - - if (template is null || string.IsNullOrWhiteSpace(template.SettingsJson)) + if (outcome.ErrorMessage != null) { - InternalServiceAuthServiceApiKeys = []; - return; + HasError = true; + ErrorMessage = outcome.ErrorMessage; } - InternalServiceAuthServiceApiKeys = ParseServiceEmails(template.SettingsJson) - .Select(email => new InternalServiceAuthServiceSecretInput - { - Email = email, - ApiKey = GenerateSecretKey() - }) - .ToList(); - } - catch (Exception ex) - { - logger.LogWarning( - ex, - "Could not load InternalServiceAuth services for tenant {TenantId}. Service ApiKey fields will be empty.", - SourceTenantId); - InternalServiceAuthServiceApiKeys = []; + return Page(); } - } - - private static IReadOnlyList ParseServiceEmails(string settingsJson) - { - try - { - if (JsonNode.Parse(settingsJson) is not JsonObject root || - root["Services"] is not JsonArray services) - { - return []; - } - return services - .OfType() - .Select(s => s["Email"]?.GetValue()?.Trim() ?? string.Empty) - .Where(email => !string.IsNullOrWhiteSpace(email)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - } - catch (JsonException) - { - return []; - } + TempData["AdminSuccess"] = outcome.SuccessMessage; + return RedirectToPage("/Admin/Admin"); } private void EnsureSecretsPopulated() { if (string.IsNullOrWhiteSpace(AuthorizationApiSecretKey)) - AuthorizationApiSecretKey = GenerateSecretKey(); + AuthorizationApiSecretKey = DuplicateTenantAdminService.GenerateSecretKey(); if (string.IsNullOrWhiteSpace(InternalServiceAuthSecretKey)) - InternalServiceAuthSecretKey = GenerateSecretKey(); + InternalServiceAuthSecretKey = DuplicateTenantAdminService.GenerateSecretKey(); foreach (var service in InternalServiceAuthServiceApiKeys) { if (string.IsNullOrWhiteSpace(service.ApiKey)) - service.ApiKey = GenerateSecretKey(); + service.ApiKey = DuplicateTenantAdminService.GenerateSecretKey(); } } - private static string GenerateSecretKey(int byteLength = 48) => - Convert.ToBase64String(RandomNumberGenerator.GetBytes(byteLength)); - - internal static string ToBase64Utf8(string value) => - Convert.ToBase64String(Encoding.UTF8.GetBytes(value ?? string.Empty)); + private DuplicateTenantWorkState CaptureWorkState() => + new() + { + SourceTenantId = SourceTenantId, + SourceTenantName = SourceTenantName, + NewTenantId = NewTenantId, + NewTenantName = NewTenantName, + ServiceName = ServiceName, + Hostname = Hostname, + FrontendOrigin = FrontendOrigin, + AuthorizationApiSecretKey = AuthorizationApiSecretKey, + InternalServiceAuthSecretKey = InternalServiceAuthSecretKey, + InternalServiceAuthServiceApiKeys = InternalServiceAuthServiceApiKeys + .Select(s => new DuplicateTenantServiceSecret + { + Email = s.Email, + ApiKey = s.ApiKey + }) + .ToList() + }; - private static string GetCloneErrorMessage(Exception ex) + private void ApplyWorkState(DuplicateTenantWorkState state) { - if (ex is ExternalApplicationsException clientEx) - { - var body = clientEx.Response?.TrimStart() ?? string.Empty; - if (clientEx.StatusCode == 403 && body.StartsWith('<')) + SourceTenantId = state.SourceTenantId; + SourceTenantName = state.SourceTenantName; + InternalServiceAuthServiceApiKeys = state.InternalServiceAuthServiceApiKeys + .Select(s => new InternalServiceAuthServiceSecretInput { - return "Clone was blocked with HTTP 403 (HTML response). " - + "This usually means Front Door / WAF rejected the request before the API. " - + "Check WAF logs for POST /v1/admin/tenants/.../clone."; - } + Email = s.Email, + ApiKey = s.ApiKey + }) + .ToList(); - if (clientEx.StatusCode > 0) - return $"Could not duplicate tenant. (HTTP {clientEx.StatusCode})"; + if (state.HasError) + { + HasError = true; + ErrorMessage = state.ErrorMessage; } - - return TenantSettingsModel.GetErrorMessage(ex, "Could not duplicate tenant."); } private bool TryResolveSourceTenant(out string? error) { if (tenantRequestContext.TenantId is not { } tenantId || tenantId == Guid.Empty) { - error = "Tenant context is not available for this request."; + error = DuplicateTenantMessages.TenantContextMissing; return false; } diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/EventMappings.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/EventMappings.cshtml.cs index bc7d184..6bddd36 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/EventMappings.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/EventMappings.cshtml.cs @@ -1,12 +1,4 @@ -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; -using GovUK.Dfe.CoreLibs.Http.Models; -using GovUK.Dfe.FlexForms.Api.Client.Contracts; -using GovUK.Dfe.FlexForms.Application.Interfaces; -using GovUK.Dfe.FlexForms.Application.Options; -using GovUK.Dfe.FlexForms.Domain.Models.EventMapping; +using GovUK.Dfe.FlexForms.Application.Admin; using GovUK.Dfe.FlexForms.Web.Security; using GovUK.Dfe.FlexForms.Web.Services.Tenant; using GovUK.Dfe.FlexForms.Web.Tenancy; @@ -23,52 +15,11 @@ namespace GovUK.Dfe.FlexForms.Web.Pages.Admin; /// [Authorize(Policy = AdminAccessHelper.CanManageEventMappingsPolicy)] public sealed class EventMappingsModel( - ITenantAdminClient tenantAdminClient, + IEventMappingsAdmin eventMappingsAdmin, ITenantRequestContext tenantRequestContext, ITenantConfigurationCache tenantConfigurationCache, - ITenantIdResolver tenantIdResolver, - ITemplatesClient templatesClient, - IEventTypeRegistry eventTypeRegistry, - ISchemaEventDefinitionProvider schemaEventDefinitionProvider, - ILogger logger) : PageModel + ITenantIdResolver tenantIdResolver) : PageModel { - /// - /// These categories are read by the API at runtime, so they are stored against the Shared - /// target rather than the Web-only target. - /// - private const string TargetShared = "Shared"; - - /// Pre-migration target; still read so existing tenants keep working until their next save. - private const string TargetWeb = "Web"; - - private const string CategoryEventMappings = "EventMappings"; - private const string CategorySchemaEvents = "SchemaEvents"; - private const string CategoryEventTriggers = "EventTriggers"; - - /// Published by the API for every upload; never bindable as a tenant trigger. - private const string SystemOnlyEventType = "ScanRequestedEvent"; - - private static readonly string[] TriggerNames = ["ApplicationSubmitted", "FileUploaded"]; - - private static readonly JsonSerializerOptions JsonWriteOptions = new() - { - PropertyNameCaseInsensitive = true, - WriteIndented = true, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull - }; - - private static readonly JsonSerializerOptions JsonReadOptions = new() - { - PropertyNameCaseInsensitive = true - }; - - /// Used when persisting mappings so null optional properties do not break API config flatten/round-trip. - private static readonly JsonSerializerOptions JsonPersistOptions = new() - { - PropertyNameCaseInsensitive = true, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull - }; - public Guid TenantId { get; private set; } public string TenantName { get; private set; } = string.Empty; @@ -175,115 +126,24 @@ public async Task OnGetAsync(CancellationToken cancellationToken) { ApplyTempData(); if (!TryResolveTenant(out var error)) - { - HasError = true; - ErrorMessage = error; - return Page(); - } + return StayWithTenantError(error); - await LoadPageDataAsync(cancellationToken); - await LoadSavedTypedMappingsAsync(cancellationToken); - await LoadSavedTriggersAsync(cancellationToken); - await LoadSelectedMappingAsync(cancellationToken); - await LoadSelectedSchemaDefinitionAsync(cancellationToken); + var state = CaptureWorkState(); + await eventMappingsAdmin.LoadAsync(state, cancellationToken); + ApplyWorkState(state); return Page(); } public async Task OnPostSaveTriggerAsync(CancellationToken cancellationToken) { - // Ignore the mapping/schema editors when saving a trigger binding. ModelState.Remove(nameof(MappingJson)); ModelState.Remove(nameof(SchemaDefinitionJson)); ModelState.Remove(nameof(NewSchemaEventType)); if (!TryResolveTenant(out var error)) - { - HasError = true; - ErrorMessage = error; - return Page(); - } - - await LoadPageDataAsync(cancellationToken); - await LoadSavedTypedMappingsAsync(cancellationToken); - await LoadSavedTriggersAsync(cancellationToken); - await LoadSelectedMappingAsync(cancellationToken); - await LoadSelectedSchemaDefinitionAsync(cancellationToken); - - var trigger = TriggerName?.Trim(); - var eventType = TriggerEventType?.Trim(); - var mappingId = TriggerMappingId?.Trim(); - var eventKind = string.IsNullOrWhiteSpace(TriggerEventKind) - ? EventPublishKind.Typed - : TriggerEventKind.Trim(); - - if (string.IsNullOrWhiteSpace(trigger) - || !TriggerNames.Contains(trigger, StringComparer.OrdinalIgnoreCase)) - { - ModelState.AddModelError(nameof(TriggerName), "Select a trigger."); - } + return StayWithTenantError(error); - if (string.IsNullOrWhiteSpace(eventType)) - { - ModelState.AddModelError(nameof(TriggerEventType), "Select an event type."); - } - else if (string.Equals(eventType, SystemOnlyEventType, StringComparison.OrdinalIgnoreCase)) - { - ModelState.AddModelError( - nameof(TriggerEventType), - $"{SystemOnlyEventType} is published by the platform for every upload and cannot be configured here."); - } - - if (string.IsNullOrWhiteSpace(mappingId)) - ModelState.AddModelError(nameof(TriggerMappingId), "Enter the mapping ID to use."); - - if (!string.Equals(eventKind, EventPublishKind.Typed, StringComparison.OrdinalIgnoreCase) - && !string.Equals(eventKind, EventPublishKind.Schema, StringComparison.OrdinalIgnoreCase)) - { - ModelState.AddModelError(nameof(TriggerEventKind), "Event kind must be Typed or Schema."); - } - - if (!ModelState.IsValid) - return Page(); - - try - { - var root = await LoadCategoryRootAsync(CategoryEventTriggers, cancellationToken); - var bindings = root[trigger!] as JsonArray ?? new JsonArray(); - - // One binding per event type per trigger: saving the same event type replaces it. - var replaced = false; - for (var i = 0; i < bindings.Count; i++) - { - if (bindings[i] is not JsonObject existing) - continue; - - if (!string.Equals(ReadBindingValue(existing, "eventType"), eventType, StringComparison.OrdinalIgnoreCase)) - continue; - - bindings[i] = BuildBindingNode(eventKind, eventType!, mappingId!); - replaced = true; - break; - } - - if (!replaced) - bindings.Add(BuildBindingNode(eventKind, eventType!, mappingId!)); - - root[trigger!] = bindings; - - await UpsertCategoryAsync(CategoryEventTriggers, root, cancellationToken); - await RefreshCachesAsync(cancellationToken); - - TempData["EventMappingsSuccess"] = - $"Saved {eventType} on the {trigger} trigger."; - return RedirectToPage(new { SelectedTemplateId, SelectedEventType, SelectedSchemaEventType }); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to save EventTriggers for {TenantId}", TenantId); - HasError = true; - ErrorMessage = GetErrorMessage(ex, "Could not save event trigger."); - return Page(); - } + return await DispatchAsync(state => eventMappingsAdmin.SaveTriggerAsync(state, cancellationToken)); } public async Task OnPostDeleteTriggerAsync(CancellationToken cancellationToken) @@ -291,61 +151,13 @@ public async Task OnPostDeleteTriggerAsync(CancellationToken canc ModelState.Clear(); if (!TryResolveTenant(out var error)) - { - HasError = true; - ErrorMessage = error; - return Page(); - } - - var trigger = TriggerName?.Trim(); - var eventType = TriggerEventType?.Trim(); + return StayWithTenantError(error); - if (string.IsNullOrWhiteSpace(trigger) || string.IsNullOrWhiteSpace(eventType)) - { - TempData["EventMappingsError"] = "Could not identify the trigger binding to remove."; - return RedirectToPage(new { SelectedTemplateId, SelectedEventType, SelectedSchemaEventType }); - } - - try - { - var root = await LoadCategoryRootAsync(CategoryEventTriggers, cancellationToken); - if (root[trigger] is JsonArray bindings) - { - var remaining = new JsonArray(); - foreach (var binding in bindings) - { - if (binding is JsonObject obj - && string.Equals(ReadBindingValue(obj, "eventType"), eventType, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - remaining.Add(binding?.DeepClone()); - } - - if (remaining.Count == 0) - root.Remove(trigger); - else - root[trigger] = remaining; - - await UpsertCategoryAsync(CategoryEventTriggers, root, cancellationToken); - await RefreshCachesAsync(cancellationToken); - } - - TempData["EventMappingsSuccess"] = $"Removed {eventType} from the {trigger} trigger."; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to delete EventTriggers binding for {TenantId}", TenantId); - TempData["EventMappingsError"] = GetErrorMessage(ex, "Could not remove event trigger."); - } - - return RedirectToPage(new { SelectedTemplateId, SelectedEventType, SelectedSchemaEventType }); + return await DispatchAsync(state => eventMappingsAdmin.DeleteTriggerAsync(state, cancellationToken)); } public async Task OnPostSaveMappingAsync(CancellationToken cancellationToken) { - // Ignore schema-form fields when saving a typed/schema field mapping. ModelState.Remove(nameof(SchemaDefinitionJson)); ModelState.Remove(nameof(NewSchemaEventType)); @@ -353,721 +165,144 @@ public async Task OnPostSaveMappingAsync(CancellationToken cancel { HasError = true; ErrorMessage = error; - await LoadPageDataAsync(cancellationToken); - await LoadSavedTypedMappingsAsync(cancellationToken); - await LoadSavedTriggersAsync(cancellationToken); - await LoadSelectedSchemaDefinitionAsync(cancellationToken); - return Page(); - } - - await LoadPageDataAsync(cancellationToken); - await LoadSavedTypedMappingsAsync(cancellationToken); - await LoadSavedTriggersAsync(cancellationToken); - await LoadSelectedSchemaDefinitionAsync(cancellationToken); - - SelectedTemplateId = SelectedTemplateId?.Trim(); - SelectedEventType = SelectedEventType?.Trim(); - - if (string.IsNullOrWhiteSpace(SelectedTemplateId)) - ModelState.AddModelError(nameof(SelectedTemplateId), "Select a template."); - else if (!await IsTemplateAllowedForCurrentTenantAsync(SelectedTemplateId, cancellationToken)) - { - ModelState.AddModelError( - nameof(SelectedTemplateId), - "Select a template that belongs to this tenant."); - } - - if (string.IsNullOrWhiteSpace(SelectedEventType)) - ModelState.AddModelError(nameof(SelectedEventType), "Select an event type."); - - if (string.IsNullOrWhiteSpace(MappingJson)) - ModelState.AddModelError(nameof(MappingJson), "Enter mapping JSON."); - - if (!ModelState.IsValid) + var state = CaptureWorkState(); + await eventMappingsAdmin.LoadAsync(state, cancellationToken); + ApplyWorkState(state); return Page(); - - EventFieldMapping? mapping; - try - { - mapping = JsonSerializer.Deserialize(MappingJson!, JsonReadOptions); } - catch (JsonException ex) - { - ModelState.AddModelError(nameof(MappingJson), $"Invalid JSON: {ex.Message}"); - return Page(); - } - - if (mapping is null) - { - ModelState.AddModelError(nameof(MappingJson), "Mapping JSON could not be parsed."); - return Page(); - } - - if (string.IsNullOrWhiteSpace(mapping.MappingId)) - ModelState.AddModelError(nameof(MappingJson), "mappingId is required."); - - if (string.IsNullOrWhiteSpace(mapping.EventType)) - mapping.EventType = SelectedEventType!; - else if (!string.Equals(mapping.EventType, SelectedEventType, StringComparison.OrdinalIgnoreCase)) - ModelState.AddModelError(nameof(MappingJson), "eventType in JSON must match the selected event type."); - - if (mapping.FieldMappings is null || mapping.FieldMappings.Count == 0) - ModelState.AddModelError(nameof(MappingJson), "fieldMappings must contain at least one property."); - - var catalogueItem = Catalogue.FirstOrDefault(c => - string.Equals(c.EventTypeName, SelectedEventType, StringComparison.OrdinalIgnoreCase)); - if (catalogueItem is { Kind: EventPublishKind.Typed, Properties.Count: > 0 } - && mapping.FieldMappings is { Count: > 0 }) - { - var known = catalogueItem.Properties.Select(p => p).ToHashSet(StringComparer.OrdinalIgnoreCase); - var unknown = mapping.FieldMappings.Keys - .Where(k => !known.Contains(k)) - .OrderBy(k => k, StringComparer.OrdinalIgnoreCase) - .ToList(); - if (unknown.Count > 0) - { - ValidationWarnings = unknown - .Select(k => $"Property '{k}' is not on {SelectedEventType}.") - .ToList(); - } - } - - if (!ModelState.IsValid) - return Page(); - - try - { - var root = await LoadCategoryRootAsync(CategoryEventMappings, cancellationToken); - var mappingJson = JsonSerializer.Serialize(mapping, JsonPersistOptions); - var templateKeys = await ResolveTemplateMappingKeysAsync(SelectedTemplateId!, cancellationToken); - - foreach (var templateKey in templateKeys) - { - var templateNode = root[templateKey] as JsonObject ?? new JsonObject(); - root[templateKey] = templateNode; - - var mappingNode = JsonNode.Parse(mappingJson) - ?? throw new InvalidOperationException("Failed to serialise mapping."); - templateNode[SelectedEventType!] = mappingNode; - } - - await UpsertCategoryAsync(CategoryEventMappings, root, cancellationToken); - await RefreshCachesAsync(cancellationToken); - var keysLabel = string.Join(", ", templateKeys); - TempData["EventMappingsSuccess"] = - $"Saved mapping for template key(s) [{keysLabel}] / {SelectedEventType}."; - return RedirectToPage(new { SelectedTemplateId, SelectedEventType, SelectedSchemaEventType }); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to save EventMappings for {TenantId}", TenantId); - HasError = true; - ErrorMessage = GetErrorMessage(ex, "Could not save event mapping."); - return Page(); - } + return await DispatchAsync(state => eventMappingsAdmin.SaveMappingAsync(state, cancellationToken)); } public async Task OnPostSaveSchemaAsync(CancellationToken cancellationToken) { - // Ignore mapping-form fields when saving a schema event definition. ModelState.Remove(nameof(MappingJson)); if (!TryResolveTenant(out var error)) { HasError = true; ErrorMessage = error; - await LoadPageDataAsync(cancellationToken); - await LoadSavedTypedMappingsAsync(cancellationToken); - await LoadSavedTriggersAsync(cancellationToken); - await LoadSelectedMappingAsync(cancellationToken); - return Page(); - } - - await LoadPageDataAsync(cancellationToken); - await LoadSavedTypedMappingsAsync(cancellationToken); - await LoadSavedTriggersAsync(cancellationToken); - await LoadSelectedMappingAsync(cancellationToken); - - var schemaKey = (NewSchemaEventType ?? SelectedSchemaEventType)?.Trim(); - if (string.IsNullOrWhiteSpace(schemaKey)) - { - ModelState.AddModelError(nameof(NewSchemaEventType), "Enter a schema event type name."); - return Page(); - } - - if (Catalogue.Any(c => - string.Equals(c.Kind, EventPublishKind.Typed, StringComparison.OrdinalIgnoreCase) - && string.Equals(c.EventTypeName, schemaKey, StringComparison.OrdinalIgnoreCase))) - { - ModelState.AddModelError( - nameof(NewSchemaEventType), - $"'{schemaKey}' is a platform typed event. Choose a different name for schema events."); - return Page(); - } - - if (string.IsNullOrWhiteSpace(SchemaDefinitionJson)) - { - ModelState.AddModelError(nameof(SchemaDefinitionJson), "Enter schema definition JSON."); + var state = CaptureWorkState(); + await eventMappingsAdmin.LoadAsync(state, cancellationToken); + ApplyWorkState(state); return Page(); } - JsonNode? definitionNode; - try - { - definitionNode = JsonNode.Parse(SchemaDefinitionJson); - } - catch (JsonException ex) - { - ModelState.AddModelError(nameof(SchemaDefinitionJson), $"Invalid JSON: {ex.Message}"); - return Page(); - } - - if (definitionNode is not JsonObject defObj) - { - ModelState.AddModelError(nameof(SchemaDefinitionJson), "Schema definition must be a JSON object."); - return Page(); - } - - if (defObj["topicName"] is null && defObj["TopicName"] is null) - ModelState.AddModelError(nameof(SchemaDefinitionJson), "topicName is required."); - - if (defObj["jsonSchema"] is null && defObj["JsonSchema"] is null) - ModelState.AddModelError(nameof(SchemaDefinitionJson), "jsonSchema is required."); - - if (!ModelState.IsValid) - return Page(); - - try - { - var root = await LoadCategoryRootAsync(CategorySchemaEvents, cancellationToken); - root[schemaKey] = definitionNode; - await UpsertCategoryAsync(CategorySchemaEvents, root, cancellationToken); - await RefreshCachesAsync(cancellationToken); - - TempData["EventMappingsSuccess"] = $"Saved schema event '{schemaKey}'."; - return RedirectToPage(new - { - SelectedTemplateId, - SelectedEventType = schemaKey, - SelectedSchemaEventType = schemaKey - }); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to save SchemaEvents for {TenantId}", TenantId); - HasError = true; - ErrorMessage = GetErrorMessage(ex, "Could not save schema event."); - return Page(); - } - } - - private async Task LoadPageDataAsync(CancellationToken cancellationToken) - { - await LoadCatalogueAsync(cancellationToken); - await LoadTemplateOptionsAsync(cancellationToken); - - SchemaEvents = schemaEventDefinitionProvider.GetAll() - .OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase) - .Select(kv => new SchemaEventRow( - kv.Key, - kv.Value.TopicName, - kv.Value.Version, - kv.Value.Description)) - .ToList(); - - var eventOptions = Catalogue - .Select(e => new SelectListItem( - $"{e.EventTypeName} ({e.Kind})", - e.EventTypeName, - string.Equals(e.EventTypeName, SelectedEventType, StringComparison.OrdinalIgnoreCase))) - .ToList(); - - foreach (var schema in SchemaEvents) - { - if (eventOptions.Any(o => string.Equals(o.Value, schema.MessageType, StringComparison.OrdinalIgnoreCase))) - continue; - eventOptions.Add(new SelectListItem( - $"{schema.MessageType} (Schema)", - schema.MessageType, - string.Equals(schema.MessageType, SelectedEventType, StringComparison.OrdinalIgnoreCase))); - } - - EventTypeOptions = eventOptions - .OrderBy(o => o.Text, StringComparer.OrdinalIgnoreCase) - .ToList(); - - TriggerOptions = TriggerNames - .Select(t => new SelectListItem(t, t, string.Equals(t, TriggerName, StringComparison.OrdinalIgnoreCase))) - .ToList(); - - TriggerEventTypeOptions = EventTypeOptions - .Where(o => !string.Equals(o.Value, SystemOnlyEventType, StringComparison.OrdinalIgnoreCase)) - .Select(o => new SelectListItem( - o.Text, - o.Value, - string.Equals(o.Value, TriggerEventType, StringComparison.OrdinalIgnoreCase))) - .ToList(); - - if (!string.IsNullOrWhiteSpace(SelectedEventType)) - { - var item = Catalogue.FirstOrDefault(c => - string.Equals(c.EventTypeName, SelectedEventType, StringComparison.OrdinalIgnoreCase)); - ClrPropertyHints = item?.Properties ?? []; - } - } - - private async Task LoadCatalogueAsync(CancellationToken cancellationToken) - { - try - { - var response = await tenantAdminClient.GetEventCatalogueAsync(cancellationToken); - Catalogue = (response.Events ?? []) - .Select(e => new EventCatalogueRow( - e.EventTypeName, - e.TopicName ?? "(no topic resolved)", - e.ClrTypeName, - e.Description, - e.Version, - string.IsNullOrWhiteSpace(e.Kind) ? EventPublishKind.Typed : e.Kind, - (e.Properties ?? []).Select(p => p.Name).ToList())) - .ToList(); - CatalogueSource = "API"; - return; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Event catalogue API unavailable; falling back to local registry."); - } - - Catalogue = eventTypeRegistry.GetCatalogue() - .Select(e => new EventCatalogueRow( - e.EventTypeName, - e.TopicName ?? "(no topic resolved)", - e.ClrType.FullName ?? e.ClrType.Name, - Description: null, - Version: "local", - Kind: EventPublishKind.Typed, - Properties: e.ClrType.GetProperties().Select(p => p.Name).ToList())) - .ToList(); - CatalogueSource = "local registry (API unavailable)"; - } - - private async Task LoadTemplateOptionsAsync(CancellationToken cancellationToken) - { - try - { - var templates = await templatesClient.GetAccessibleTemplatesAsync(cancellationToken) ?? []; - var allowed = new HashSet(StringComparer.OrdinalIgnoreCase); - - TemplateOptions = templates - .Where(t => t.TemplateId != Guid.Empty) - .OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase) - .Select(t => - { - var id = t.TemplateId.ToString(); - allowed.Add(id); - var label = string.IsNullOrWhiteSpace(t.Name) ? id : $"{t.Name} ({id})"; - return new SelectListItem( - label, - id, - string.Equals(id, SelectedTemplateId, StringComparison.OrdinalIgnoreCase)); - }) - .ToList(); - - // Include schema-embedded template ids (e.g. form-001) for this tenant only. - foreach (var template in templates.Where(t => t.TemplateId != Guid.Empty)) - { - foreach (var key in await ResolveTemplateMappingKeysAsync( - template.TemplateId.ToString(), - cancellationToken)) - { - allowed.Add(key); - } - } - - AllowedTemplateKeys = allowed; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Could not load templates for EventMappings editor"); - TemplateOptions = []; - AllowedTemplateKeys = new HashSet(StringComparer.OrdinalIgnoreCase); - } + return await DispatchAsync(state => eventMappingsAdmin.SaveSchemaAsync(state, cancellationToken)); } - private async Task IsTemplateAllowedForCurrentTenantAsync( - string templateId, - CancellationToken cancellationToken) + private async Task DispatchAsync(Func> execute) { - if (AllowedTemplateKeys.Count == 0) - await LoadTemplateOptionsAsync(cancellationToken); - - return AllowedTemplateKeys.Contains(templateId); + var state = CaptureWorkState(); + var outcome = await execute(state); + ApplyWorkState(state); + return MapOutcome(outcome); } - /// - /// Returns TenantConfig keys to write the mapping under: the selected key plus the - /// schema-embedded templateId (e.g. form-001) when the selection is an API GUID. - /// Submissions look up by the schema TemplateId, so both must exist for SaaS. - /// - private async Task> ResolveTemplateMappingKeysAsync( - string selectedTemplateId, - CancellationToken cancellationToken) + private IActionResult StayWithTenantError(string? error) { - var keys = new List { selectedTemplateId }; - - if (!Guid.TryParse(selectedTemplateId, out var templateGuid)) - return keys; - - try - { - var schema = await templatesClient.GetLatestTemplateSchemaAsync(templateGuid, cancellationToken); - if (string.IsNullOrWhiteSpace(schema?.JsonSchema)) - return keys; - - // JsonSchema may be raw JSON or base64-encoded JSON depending on store. - var schemaText = schema.JsonSchema.Trim(); - if (!schemaText.StartsWith('{') && !schemaText.StartsWith('[')) - { - try - { - schemaText = Encoding.UTF8.GetString(Convert.FromBase64String(schemaText)); - } - catch (FormatException) - { - return keys; - } - } - - using var doc = JsonDocument.Parse(schemaText); - if (doc.RootElement.TryGetProperty("templateId", out var embeddedId) - && embeddedId.ValueKind == JsonValueKind.String - && !string.IsNullOrWhiteSpace(embeddedId.GetString())) - { - var schemaTemplateId = embeddedId.GetString()!.Trim(); - if (!keys.Contains(schemaTemplateId, StringComparer.OrdinalIgnoreCase)) - keys.Add(schemaTemplateId); - } - } - catch (Exception ex) - { - logger.LogWarning( - ex, - "Could not resolve schema templateId alias for EventMappings key {TemplateId}", - selectedTemplateId); - } - - return keys; - } - - private async Task LoadSavedTypedMappingsAsync(CancellationToken cancellationToken) - { - try - { - var root = await LoadCategoryRootAsync(CategoryEventMappings, cancellationToken); - var schemaNames = SchemaEvents - .Select(s => s.MessageType) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - var rows = new List(); - foreach (var templateProperty in root) - { - if (string.Equals(templateProperty.Key, "BasePath", StringComparison.OrdinalIgnoreCase)) - continue; - - if (templateProperty.Value is not JsonObject templateNode) - continue; - - foreach (var eventProperty in templateNode) - { - if (eventProperty.Value is not JsonObject mappingNode) - continue; - - var eventType = eventProperty.Key; - if (schemaNames.Contains(eventType)) - continue; - - var mappingId = mappingNode["mappingId"]?.GetValue() - ?? mappingNode["MappingId"]?.GetValue() - ?? "—"; - var description = mappingNode["description"]?.GetValue() - ?? mappingNode["Description"]?.GetValue(); - - rows.Add(new SavedMappingRow( - templateProperty.Key, - eventType, - mappingId, - description)); - } - } - - SavedTypedMappings = rows - .Where(r => AllowedTemplateKeys.Count == 0 || AllowedTemplateKeys.Contains(r.TemplateId)) - .GroupBy( - r => $"{r.TemplateId}|{r.EventType}", - StringComparer.OrdinalIgnoreCase) - .Select(g => g.First()) - .OrderBy(r => r.EventType, StringComparer.OrdinalIgnoreCase) - .ThenBy(r => r.TemplateId, StringComparer.OrdinalIgnoreCase) - .ToList(); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Could not load saved typed EventMappings"); - SavedTypedMappings = []; - } + HasError = true; + ErrorMessage = error; + return Page(); } - private static JsonObject BuildBindingNode(string eventKind, string eventType, string mappingId) => + private EventMappingsWorkState CaptureWorkState() => new() { - ["eventKind"] = eventKind, - ["eventType"] = eventType, - ["mappingId"] = mappingId + TenantId = TenantId, + TenantName = TenantName, + SelectedTemplateId = SelectedTemplateId, + SelectedEventType = SelectedEventType, + SelectedSchemaEventType = SelectedSchemaEventType, + MappingJson = MappingJson, + SchemaDefinitionJson = SchemaDefinitionJson, + NewSchemaEventType = NewSchemaEventType, + TriggerName = TriggerName, + TriggerEventKind = TriggerEventKind, + TriggerEventType = TriggerEventType, + TriggerMappingId = TriggerMappingId }; - private static string? ReadBindingValue(JsonObject binding, string camelCaseName) - { - var pascalCaseName = char.ToUpperInvariant(camelCaseName[0]) + camelCaseName[1..]; - var node = binding[camelCaseName] ?? binding[pascalCaseName]; - return node?.GetValue(); - } - - private async Task LoadSavedTriggersAsync(CancellationToken cancellationToken) + private void ApplyWorkState(EventMappingsWorkState state) { - try - { - var root = await LoadCategoryRootAsync(CategoryEventTriggers, cancellationToken); - var rows = new List(); - - foreach (var triggerProperty in root) - { - if (triggerProperty.Value is not JsonArray bindings) - continue; - - foreach (var binding in bindings) - { - if (binding is not JsonObject obj) - continue; - - var eventType = ReadBindingValue(obj, "eventType"); - if (string.IsNullOrWhiteSpace(eventType)) - continue; - - rows.Add(new TriggerBindingRow( - triggerProperty.Key, - ReadBindingValue(obj, "eventKind") ?? EventPublishKind.Typed, - eventType, - ReadBindingValue(obj, "mappingId") ?? "—")); - } - } - - SavedTriggers = rows - .OrderBy(r => r.Trigger, StringComparer.OrdinalIgnoreCase) - .ThenBy(r => r.EventType, StringComparer.OrdinalIgnoreCase) - .ToList(); - } - catch (Exception ex) + TenantId = state.TenantId; + TenantName = state.TenantName; + TemplateOptions = ToSelectList(state.TemplateOptions); + EventTypeOptions = ToSelectList(state.EventTypeOptions); + Catalogue = state.Catalogue; + SchemaEvents = state.SchemaEvents; + SavedTypedMappings = state.SavedTypedMappings; + TriggerOptions = ToSelectList(state.TriggerOptions); + TriggerEventTypeOptions = ToSelectList(state.TriggerEventTypeOptions); + SavedTriggers = state.SavedTriggers; + AllowedTemplateKeys = state.AllowedTemplateKeys; + ClrPropertyHints = state.ClrPropertyHints; + ValidationWarnings = state.ValidationWarnings; + CatalogueSource = state.CatalogueSource; + SelectedTemplateId = state.SelectedTemplateId; + SelectedEventType = state.SelectedEventType; + SelectedSchemaEventType = state.SelectedSchemaEventType; + MappingJson = state.MappingJson; + SchemaDefinitionJson = state.SchemaDefinitionJson; + NewSchemaEventType = state.NewSchemaEventType; + if (state.HasError) { - logger.LogWarning(ex, "Could not load saved EventTriggers"); - SavedTriggers = []; + HasError = true; + ErrorMessage = state.ErrorMessage; } } - private async Task LoadSelectedSchemaDefinitionAsync(CancellationToken cancellationToken) + private IActionResult MapOutcome(AdminPageOutcome outcome) { - var key = SelectedSchemaEventType?.Trim(); - if (string.IsNullOrWhiteSpace(key)) - { - if (string.IsNullOrWhiteSpace(SchemaDefinitionJson)) - SchemaDefinitionJson = GetEmptySchemaTemplate(); - return; - } + foreach (var key in outcome.ModelStateKeysToRemove) + ModelState.Remove(key); - // Replace editor contents with the saved definition (edit = replace). - NewSchemaEventType = key; - SelectedSchemaEventType = key; + if (outcome.ClearModelState) + ModelState.Clear(); - try + if (outcome.Errors.Count > 0) { - var root = await LoadCategoryRootAsync(CategorySchemaEvents, cancellationToken); - JsonNode? definitionNode = null; - foreach (var property in root) - { - if (string.Equals(property.Key, key, StringComparison.OrdinalIgnoreCase)) - { - definitionNode = property.Value; - NewSchemaEventType = property.Key; - SelectedSchemaEventType = property.Key; - break; - } - } - - if (definitionNode is not null) - { - SchemaDefinitionJson = definitionNode.ToJsonString(JsonWriteOptions); - return; - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Could not load SchemaEvents definition for {SchemaKey}", key); + foreach (var error in outcome.Errors) + ModelState.AddModelError(error.FieldKey, error.Message); } - // Provider fallback (request-scoped overlay). - var def = schemaEventDefinitionProvider.GetDefinition(key); - if (def is not null) + if (outcome.RefreshLocalCaches) { - SchemaDefinitionJson = JsonSerializer.Serialize(new - { - topicName = def.TopicName, - version = def.Version, - description = def.Description, - jsonSchema = def.JsonSchema - ?? new Dictionary { ["type"] = "object", ["properties"] = new { } } - }, JsonWriteOptions); - return; + tenantConfigurationCache.Invalidate(TenantId); + tenantIdResolver.InvalidateHostnameCache(); } - SchemaDefinitionJson = GetEmptySchemaTemplate(); - } + if (outcome.SuccessMessage != null) + TempData["EventMappingsSuccess"] = outcome.SuccessMessage; - private async Task LoadSelectedMappingAsync(CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(SelectedTemplateId) || string.IsNullOrWhiteSpace(SelectedEventType)) + if (outcome.ErrorMessage != null && outcome.Kind == AdminPageOutcomeKind.RedirectToPage) + TempData["EventMappingsError"] = outcome.ErrorMessage; + else if (outcome.ErrorMessage != null) { - MappingJson = GetEmptyMappingTemplate(SelectedEventType); - return; + HasError = true; + ErrorMessage = outcome.ErrorMessage; } - try + return outcome.Kind switch { - var root = await LoadCategoryRootAsync(CategoryEventMappings, cancellationToken); - var lookupKeys = await ResolveTemplateMappingKeysAsync(SelectedTemplateId, cancellationToken); - foreach (var key in lookupKeys) - { - if (root[key] is JsonObject template - && template[SelectedEventType] is JsonNode mappingNode) - { - MappingJson = mappingNode.ToJsonString(JsonWriteOptions); - return; - } - } - - // Fall back: any template key that already has this event mapping. - foreach (var property in root) + AdminPageOutcomeKind.RedirectToPage => RedirectToPage(new { - if (property.Value is JsonObject template - && template[SelectedEventType] is JsonNode mappingNode) - { - MappingJson = mappingNode.ToJsonString(JsonWriteOptions); - return; - } - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Could not load existing EventMappings for editor"); - } - - MappingJson = GetEmptyMappingTemplate(SelectedEventType); - } - - private async Task LoadCategoryRootAsync(string category, CancellationToken cancellationToken) - { - var response = await tenantAdminClient.GetSafeTenantSettingsAsync(TenantId, cancellationToken); - TenantName = response.TenantName ?? TenantName; - - var candidates = (response.Settings ?? []) - .Where(s => string.Equals(s.Category, category, StringComparison.OrdinalIgnoreCase)) - .ToList(); - - // Shared is where the API reads from; a leftover Web row is only a migration fallback. - var setting = candidates.FirstOrDefault(s => string.Equals(s.Target, TargetShared, StringComparison.OrdinalIgnoreCase)) - ?? candidates.FirstOrDefault(s => string.Equals(s.Target, TargetWeb, StringComparison.OrdinalIgnoreCase)) - ?? candidates.FirstOrDefault(); - - if (string.IsNullOrWhiteSpace(setting?.SettingsJson)) - return new JsonObject(); - - try - { - return JsonNode.Parse(setting.SettingsJson) as JsonObject ?? new JsonObject(); - } - catch (JsonException) - { - return new JsonObject(); - } - } - - private async Task UpsertCategoryAsync(string category, JsonObject root, CancellationToken cancellationToken) - { - var payloadJson = root.ToJsonString(new JsonSerializerOptions { WriteIndented = false }); - await tenantAdminClient.UpsertSafeTenantSettingAsync( - TenantId, - new UpsertTenantSettingRequest( - category, - TargetShared, - ToBase64SettingsJson(payloadJson), - IsSecret: false), - cancellationToken); - } - - private static string GetEmptyMappingTemplate(string? eventType) - { - var mapping = new EventFieldMapping - { - MappingId = string.IsNullOrWhiteSpace(eventType) - ? "mapping-v1" - : $"{ToKebab(eventType)}-v1", - EventType = eventType ?? string.Empty, - Description = null, - FieldMappings = new Dictionary() + SelectedTemplateId = outcome.RouteValues.GetValueOrDefault("SelectedTemplateId"), + SelectedEventType = outcome.RouteValues.GetValueOrDefault("SelectedEventType"), + SelectedSchemaEventType = outcome.RouteValues.GetValueOrDefault("SelectedSchemaEventType") + }), + _ => Page() }; - - return JsonSerializer.Serialize(mapping, JsonWriteOptions); } - private static string GetEmptySchemaTemplate() => - JsonSerializer.Serialize(new - { - topicName = "my-custom-topic", - version = "1.0", - description = "Tenant-defined schema event", - jsonSchema = new - { - type = "object", - properties = new { } - } - }, JsonWriteOptions); - - private static string ToKebab(string value) - { - var sb = new StringBuilder(); - for (var i = 0; i < value.Length; i++) - { - var c = value[i]; - if (char.IsUpper(c) && i > 0) - sb.Append('-'); - sb.Append(char.ToLowerInvariant(c)); - } - - return sb.ToString(); - } - - private async Task RefreshCachesAsync(CancellationToken cancellationToken) - { - await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken); - tenantConfigurationCache.Invalidate(TenantId); - tenantIdResolver.InvalidateHostnameCache(); - } + private static IReadOnlyList ToSelectList(IReadOnlyList options) => + options.Select(o => new SelectListItem(o.Text, o.Value, o.Selected)).ToList(); private bool TryResolveTenant(out string? error) { if (tenantRequestContext.TenantId is not { } tenantId || tenantId == Guid.Empty) { - error = "Tenant context is not available for this request."; + error = EventMappingsMessages.TenantContextMissing; return false; } @@ -1091,50 +326,4 @@ private void ApplyTempData() ErrorMessage = err; } } - - private static string ToBase64SettingsJson(string settingsJson) => - Convert.ToBase64String(Encoding.UTF8.GetBytes(settingsJson)); - - private static string GetErrorMessage(Exception ex, string fallback) - { - if (ex is ExternalApplicationsException apiEx - && !string.IsNullOrWhiteSpace(apiEx.Result?.Message)) - { - return apiEx.Result.Message; - } - - if (ex is ExternalApplicationsException clientEx && clientEx.StatusCode > 0) - return $"{fallback} (HTTP {clientEx.StatusCode})"; - - return fallback; - } - - public sealed record EventCatalogueRow( - string EventTypeName, - string TopicName, - string ClrTypeName, - string? Description, - string Version, - string Kind, - IReadOnlyList Properties); - - public sealed record SchemaEventRow( - string MessageType, - string TopicName, - string Version, - string? Description); - - public sealed record SavedMappingRow( - string TemplateId, - string EventType, - string MappingId, - string? Description); - - public sealed record TriggerBindingRow( - string Trigger, - string EventKind, - string EventType, - string MappingId); - - public sealed record MetadataKeyHint(string Key, string Description); } diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/OrganisationSettings.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/OrganisationSettings.cshtml.cs index fdd139e..1ebe7a1 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/OrganisationSettings.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/OrganisationSettings.cshtml.cs @@ -1,9 +1,5 @@ using System.ComponentModel.DataAnnotations; -using System.Text; -using System.Text.Json; -using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; -using GovUK.Dfe.CoreLibs.Http.Models; -using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; using GovUK.Dfe.FlexForms.Web.Security; using GovUK.Dfe.FlexForms.Web.Services.Tenant; using GovUK.Dfe.FlexForms.Web.Tenancy; @@ -18,23 +14,11 @@ namespace GovUK.Dfe.FlexForms.Web.Pages.Admin; /// [Authorize(Roles = AdminAccessHelper.AuthorizeRoles)] public sealed class OrganisationSettingsModel( - ITenantAdminClient tenantAdminClient, + IOrganisationSettingsAdmin organisationSettingsAdmin, ITenantRequestContext tenantRequestContext, ITenantConfigurationCache tenantConfigurationCache, - ITenantIdResolver tenantIdResolver, - ILogger logger) : PageModel + ITenantIdResolver tenantIdResolver) : PageModel { - private const string TargetWeb = "Web"; - private const string CategoryTerminology = "ApplicationTerminology"; - private const string CategoryBanner = "NotificationBanner"; - private const string CategoryDashboard = "Dashboard"; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNameCaseInsensitive = true, - WriteIndented = false - }; - public Guid TenantId { get; private set; } public string TenantName { get; private set; } = string.Empty; @@ -85,7 +69,9 @@ public async Task OnGetAsync(CancellationToken cancellationToken) return Page(); } - await LoadSettingsAsync(cancellationToken); + var state = CaptureWorkState(); + await organisationSettingsAdmin.LoadAsync(state, cancellationToken); + ApplyWorkState(state); return Page(); } @@ -106,168 +92,72 @@ public async Task OnPostAsync(CancellationToken cancellationToken BannerHeading = BannerHeading?.Trim() ?? "Important"; BannerMessage = BannerMessage?.Trim() ?? string.Empty; - try - { - await UpsertCategoryAsync( - CategoryTerminology, - new { Singular = TerminologySingular, Plural = TerminologyPlural }, - cancellationToken); - - await UpsertCategoryAsync( - CategoryBanner, - new { Enabled = BannerEnabled, Heading = BannerHeading, Message = BannerMessage }, - cancellationToken); - - await UpsertCategoryAsync( - CategoryDashboard, - new { PageSize = DashboardPageSize, EnableApplicationFilters = DashboardEnableFilters }, - cancellationToken); - - await RefreshCachesAsync(cancellationToken); - - TempData["OrganisationSettingsSuccess"] = "Organisation settings saved."; - return RedirectToPage(); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to save organisation settings for {TenantId}", TenantId); - HasError = true; - ErrorMessage = GetErrorMessage(ex, "Could not save organisation settings."); - return Page(); - } - } - - private async Task UpsertCategoryAsync(string category, object payload, CancellationToken cancellationToken) - { - var json = JsonSerializer.Serialize(payload, JsonOptions); - await tenantAdminClient.UpsertSafeTenantSettingAsync( - TenantId, - new UpsertTenantSettingRequest(category, TargetWeb, ToBase64SettingsJson(json), IsSecret: false), - cancellationToken); + var outcome = await organisationSettingsAdmin.SaveAsync(CaptureWorkState(), cancellationToken); + return MapOutcome(outcome); } - private async Task LoadSettingsAsync(CancellationToken cancellationToken) + private IActionResult MapOutcome(AdminPageOutcome outcome) { - try + if (outcome.RefreshLocalCaches) { - var response = await tenantAdminClient.GetSafeTenantSettingsAsync(TenantId, cancellationToken); - TenantName = response.TenantName; - - foreach (var setting in response.Settings ?? []) - { - ApplySettingJson(setting.Category, setting.SettingsJson); - } - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to load organisation settings for {TenantId}", TenantId); - HasError = true; - ErrorMessage = GetErrorMessage(ex, "Could not load organisation settings."); + tenantConfigurationCache.Invalidate(TenantId); + tenantIdResolver.InvalidateHostnameCache(); } - } - private void ApplySettingJson(string category, string? settingsJson) - { - if (string.IsNullOrWhiteSpace(settingsJson)) - return; + if (outcome.SuccessMessage != null) + TempData["OrganisationSettingsSuccess"] = outcome.SuccessMessage; - try + if (outcome.Kind == AdminPageOutcomeKind.StayOnPage) { - using var doc = JsonDocument.Parse(settingsJson); - var root = doc.RootElement; - - if (string.Equals(category, CategoryTerminology, StringComparison.OrdinalIgnoreCase)) - { - if (TryGetString(root, "Singular", out var singular)) - TerminologySingular = singular; - if (TryGetString(root, "Plural", out var plural)) - TerminologyPlural = plural; - } - else if (string.Equals(category, CategoryBanner, StringComparison.OrdinalIgnoreCase)) - { - if (TryGetBool(root, "Enabled", out var enabled)) - BannerEnabled = enabled; - if (TryGetString(root, "Heading", out var heading)) - BannerHeading = heading; - if (TryGetString(root, "Message", out var message)) - BannerMessage = message; - } - else if (string.Equals(category, CategoryDashboard, StringComparison.OrdinalIgnoreCase)) + if (outcome.ErrorMessage != null) { - if (TryGetInt(root, "PageSize", out var pageSize)) - DashboardPageSize = pageSize; - if (TryGetBool(root, "EnableApplicationFilters", out var filters)) - DashboardEnableFilters = filters; + HasError = true; + ErrorMessage = outcome.ErrorMessage; } - } - catch (JsonException ex) - { - logger.LogWarning(ex, "Could not parse settings JSON for category {Category}", category); - } - } - - private static bool TryGetString(JsonElement root, string name, out string value) - { - value = string.Empty; - if (!TryGetProperty(root, name, out var prop) || prop.ValueKind != JsonValueKind.String) - return false; - value = prop.GetString() ?? string.Empty; - return true; - } - - private static bool TryGetBool(JsonElement root, string name, out bool value) - { - value = false; - if (!TryGetProperty(root, name, out var prop)) - return false; - if (prop.ValueKind == JsonValueKind.True || prop.ValueKind == JsonValueKind.False) - { - value = prop.GetBoolean(); - return true; + return Page(); } - return false; - } - - private static bool TryGetInt(JsonElement root, string name, out int value) - { - value = 0; - if (!TryGetProperty(root, name, out var prop) || prop.ValueKind != JsonValueKind.Number) - return false; - return prop.TryGetInt32(out value); + return RedirectToPage(); } - private static bool TryGetProperty(JsonElement root, string name, out JsonElement property) + private OrganisationSettingsWorkState CaptureWorkState() => + new() + { + TenantId = TenantId, + TenantName = TenantName, + TerminologySingular = TerminologySingular, + TerminologyPlural = TerminologyPlural, + BannerEnabled = BannerEnabled, + BannerHeading = BannerHeading, + BannerMessage = BannerMessage, + DashboardPageSize = DashboardPageSize, + DashboardEnableFilters = DashboardEnableFilters + }; + + private void ApplyWorkState(OrganisationSettingsWorkState state) { - if (root.TryGetProperty(name, out property)) - return true; - - foreach (var p in root.EnumerateObject()) + TenantId = state.TenantId; + TenantName = state.TenantName; + TerminologySingular = state.TerminologySingular; + TerminologyPlural = state.TerminologyPlural; + BannerEnabled = state.BannerEnabled; + BannerHeading = state.BannerHeading; + BannerMessage = state.BannerMessage; + DashboardPageSize = state.DashboardPageSize; + DashboardEnableFilters = state.DashboardEnableFilters; + if (state.HasError) { - if (string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) - { - property = p.Value; - return true; - } + HasError = true; + ErrorMessage = state.ErrorMessage; } - - property = default; - return false; - } - - private async Task RefreshCachesAsync(CancellationToken cancellationToken) - { - await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken); - tenantConfigurationCache.Invalidate(TenantId); - tenantIdResolver.InvalidateHostnameCache(); } private bool TryResolveTenant(out string? error) { if (tenantRequestContext.TenantId is not { } tenantId || tenantId == Guid.Empty) { - error = "Tenant context is not available for this request."; + error = OrganisationSettingsMessages.TenantContextMissing; return false; } @@ -291,21 +181,4 @@ private void ApplyTempData() ErrorMessage = error; } } - - internal static string ToBase64SettingsJson(string settingsJson) => - Convert.ToBase64String(Encoding.UTF8.GetBytes(settingsJson)); - - internal static string GetErrorMessage(Exception ex, string fallback) - { - if (ex is ExternalApplicationsException apiEx - && !string.IsNullOrWhiteSpace(apiEx.Result?.Message)) - { - return apiEx.Result.Message; - } - - if (ex is ExternalApplicationsException clientEx && clientEx.StatusCode > 0) - return $"{fallback} (HTTP {clientEx.StatusCode})"; - - return fallback; - } } diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/RoleManager.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/RoleManager.cshtml.cs index e3bb3f2..1c8a0a7 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/RoleManager.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/RoleManager.cshtml.cs @@ -1,8 +1,6 @@ using System.ComponentModel.DataAnnotations; -using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; -using GovUK.Dfe.CoreLibs.Http.Models; -using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; using GovUK.Dfe.FlexForms.Web.Security; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -14,9 +12,7 @@ namespace GovUK.Dfe.FlexForms.Web.Pages.Admin; /// Lists and creates tenant roles. /// [Authorize(Roles = AdminAccessHelper.AuthorizeRoles)] -public sealed class RoleManagerModel( - IRolesClient rolesClient, - ILogger logger) : PageModel +public sealed class RoleManagerModel(IRoleManagerAdmin roleManagerAdmin) : PageModel { public IReadOnlyList Roles { get; private set; } = []; @@ -36,75 +32,40 @@ public sealed class RoleManagerModel( public async Task OnGetAsync(CancellationToken cancellationToken) { ApplyTempData(); - await LoadRolesAsync(cancellationToken); + var state = CaptureWorkState(); + await roleManagerAdmin.LoadAsync(state, cancellationToken); + ApplyWorkState(state); } public async Task OnPostCreateAsync(CancellationToken cancellationToken) { - await LoadRolesAsync(cancellationToken); + var state = CaptureWorkState(); + await roleManagerAdmin.LoadAsync(state, cancellationToken); + ApplyWorkState(state); if (!ModelState.IsValid) return Page(); - try - { - var created = await rolesClient.CreateAsync( - new CreateTenantRoleRequest { Name = NewRoleName.Trim() }, - cancellationToken); - - TempData["RoleManagerSuccess"] = $"Role '{created.Name}' has been created."; - return RedirectToPage(); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to create role {Name}", NewRoleName); - ModelState.AddModelError(string.Empty, GetErrorMessage(ex, "Could not create the role.")); - return Page(); - } + var outcome = await roleManagerAdmin.CreateAsync(state, cancellationToken); + ApplyWorkState(state); + return MapOutcome(outcome); } public async Task OnPostCreateFromTemplateAsync( string templateKey, CancellationToken cancellationToken) { - templateKey = templateKey?.Trim() ?? string.Empty; - if (string.IsNullOrWhiteSpace(templateKey)) - { - TempData["RoleManagerError"] = "Choose a role template."; - return RedirectToPage(); - } - - try - { - var created = await rolesClient.CreateFromTemplateAsync( - new CreateTenantRoleFromTemplateRequest(templateKey), - cancellationToken); - TempData["RoleManagerSuccess"] = - $"Role '{created.Name}' has been created from the {templateKey} template."; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to create role from template {TemplateKey}", templateKey); - TempData["RoleManagerError"] = GetErrorMessage(ex, "Could not create the role from template."); - } - - return RedirectToPage(); + var outcome = await roleManagerAdmin.CreateFromTemplateAsync( + CaptureWorkState(), + templateKey, + cancellationToken); + return MapOutcome(outcome); } public async Task OnPostDeleteAsync(Guid roleId, CancellationToken cancellationToken) { - try - { - await rolesClient.DeleteAsync(roleId, cancellationToken); - TempData["RoleManagerSuccess"] = "Role deleted."; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to delete role {RoleId}", roleId); - TempData["RoleManagerError"] = GetErrorMessage(ex, "Could not delete the role."); - } - - return RedirectToPage(); + var outcome = await roleManagerAdmin.DeleteAsync(CaptureWorkState(), roleId, cancellationToken); + return MapOutcome(outcome); } public async Task OnPostRenameAsync( @@ -112,27 +73,38 @@ public async Task OnPostRenameAsync( string name, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(name)) - { - TempData["RoleManagerError"] = "Enter a role name."; - return RedirectToPage(); - } + var outcome = await roleManagerAdmin.RenameAsync(CaptureWorkState(), roleId, name, cancellationToken); + return MapOutcome(outcome); + } - try - { - await rolesClient.RenameAsync( - roleId, - new RenameTenantRoleRequest { Name = name.Trim() }, - cancellationToken); - TempData["RoleManagerSuccess"] = "Role renamed."; - } - catch (Exception ex) + private RoleManagerWorkState CaptureWorkState() => + new() { NewRoleName = NewRoleName }; + + private void ApplyWorkState(RoleManagerWorkState state) + { + Roles = state.Roles; + NewRoleName = state.NewRoleName; + if (state.HasError) { - logger.LogError(ex, "Failed to rename role {RoleId}", roleId); - TempData["RoleManagerError"] = GetErrorMessage(ex, "Could not rename the role."); + HasError = true; + ErrorMessage = state.ErrorMessage; } + } + + private IActionResult MapOutcome(AdminPageOutcome outcome) + { + foreach (var error in outcome.Errors) + ModelState.AddModelError(error.FieldKey, error.Message); + + if (outcome.SuccessMessage != null) + TempData["RoleManagerSuccess"] = outcome.SuccessMessage; + + if (outcome.ErrorMessage != null) + TempData["RoleManagerError"] = outcome.ErrorMessage; - return RedirectToPage(); + return outcome.Kind == AdminPageOutcomeKind.RedirectToPage + ? RedirectToPage() + : Page(); } private void ApplyTempData() @@ -149,34 +121,4 @@ private void ApplyTempData() ErrorMessage = error; } } - - private async Task LoadRolesAsync(CancellationToken cancellationToken) - { - try - { - var roles = await rolesClient.ListAsync(cancellationToken); - Roles = roles? - .OrderBy(r => r.IsSystem ? 0 : 1) - .ThenBy(r => r.Name) - .ToList() ?? []; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to load tenant roles"); - HasError = true; - ErrorMessage = GetErrorMessage(ex, "Could not load roles for this tenant."); - Roles = []; - } - } - - internal static string GetErrorMessage(Exception ex, string fallback) - { - if (ex is ExternalApplicationsException apiEx - && !string.IsNullOrWhiteSpace(apiEx.Result?.Message)) - { - return apiEx.Result.Message; - } - - return fallback; - } } diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/RoleManagerPermissions.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/RoleManagerPermissions.cshtml.cs index 0aa6dba..593ce4f 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/RoleManagerPermissions.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/RoleManagerPermissions.cshtml.cs @@ -1,6 +1,5 @@ using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; -using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; -using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; using GovUK.Dfe.FlexForms.Web.Security; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -12,11 +11,9 @@ namespace GovUK.Dfe.FlexForms.Web.Pages.Admin; /// Sets RolePermissions for a custom tenant role (ResourceType + ResourceKey + AccessType). /// [Authorize(Roles = AdminAccessHelper.AuthorizeRoles)] -public sealed class RoleManagerPermissionsModel( - IRolesClient rolesClient, - ILogger logger) : PageModel +public sealed class RoleManagerPermissionsModel(IRoleManagerPermissionsAdmin roleManagerPermissionsAdmin) : PageModel { - public const string AnyResourceKey = "Any"; + public const string AnyResourceKey = AdminPermissionGrants.AnyResourceKey; [BindProperty(SupportsGet = true)] public Guid RoleId { get; set; } @@ -46,50 +43,18 @@ public sealed class RoleManagerPermissionsModel( public async Task OnGetAsync(CancellationToken cancellationToken) { - var loaded = await LoadAsync(cancellationToken); - return loaded ? Page() : RedirectToPage("/Admin/RoleManager"); + var state = CaptureWorkState(); + var outcome = await roleManagerPermissionsAdmin.LoadAsync(state, cancellationToken); + ApplyWorkState(state); + return MapOutcome(outcome); } public async Task OnPostAddAsync(CancellationToken cancellationToken) { - if (!await LoadRoleMetaAsync(cancellationToken)) - return RedirectToPage("/Admin/RoleManager"); - - if (IsSystemRole) - { - TempData["RoleManagerError"] = "System role permissions cannot be changed."; - return RedirectToPage("/Admin/RoleManager"); - } - - SelectedGrants = NormalizeGrants(SelectedGrants); - - var resourceKey = NewResourceKey?.Trim() ?? string.Empty; - if (string.IsNullOrWhiteSpace(resourceKey)) - { - ModelState.AddModelError(nameof(NewResourceKey), "Enter a resource key."); - return Page(); - } - - var validationError = ValidateGrant(NewResourceType, resourceKey, NewAccessType); - if (validationError is not null) - { - ModelState.AddModelError(nameof(NewResourceKey), validationError); - return Page(); - } - - var key = EncodeGrantKey(NewResourceType, resourceKey, NewAccessType); - if (SelectedGrants.Contains(key, StringComparer.OrdinalIgnoreCase)) - { - ModelState.AddModelError( - string.Empty, - $"{NewResourceType} / {resourceKey} / {NewAccessType} is already in the list."); - return Page(); - } - - SelectedGrants.Add(key); - SelectedGrants = NormalizeGrants(SelectedGrants); - - return await SaveAndReloadAsync(cancellationToken); + var state = CaptureWorkState(); + var outcome = await roleManagerPermissionsAdmin.AddGrantAsync(state, cancellationToken); + ApplyWorkState(state); + return MapOutcome(outcome); } public async Task OnPostRemoveAsync(string grantKey, CancellationToken cancellationToken) @@ -98,210 +63,55 @@ public async Task OnPostRemoveAsync(string grantKey, Cancellation ModelState.Remove(nameof(NewResourceType)); ModelState.Remove(nameof(NewAccessType)); - if (!await LoadRoleMetaAsync(cancellationToken)) - return RedirectToPage("/Admin/RoleManager"); - - if (IsSystemRole) - { - TempData["RoleManagerError"] = "System role permissions cannot be changed."; - return RedirectToPage("/Admin/RoleManager"); - } - - SelectedGrants = NormalizeGrants(SelectedGrants); - SelectedGrants.RemoveAll(g => string.Equals(g, grantKey, StringComparison.OrdinalIgnoreCase)); - - return await SaveAndReloadAsync(cancellationToken); - } - - private async Task SaveAndReloadAsync(CancellationToken cancellationToken) - { - foreach (var grant in SelectedGrants.Select(ParseGrantKey).Where(g => g is not null)) - { - var error = ValidateGrant(grant!.Value.ResourceType, grant.Value.ResourceKey, grant.Value.AccessType); - if (error is not null) - { - ModelState.AddModelError(string.Empty, error); - return Page(); - } - } - - try - { - var grants = SelectedGrants - .Select(ParseGrantKey) - .Where(g => g is not null) - .Select(g => g!) - .Select(g => new RolePermissionGrantDto - { - ResourceType = g.Value.ResourceType, - ResourceKey = g.Value.ResourceKey, - AccessType = g.Value.AccessType - }) - .ToList(); - - await rolesClient.SetPermissionsAsync( - RoleId, - new SetRolePermissionsRequest { Permissions = grants }, - cancellationToken); - - NewResourceKey = string.Empty; - await LoadPermissionsAsync(cancellationToken); - return Page(); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to set permissions for role {RoleId}", RoleId); - ModelState.AddModelError(string.Empty, RoleManagerModel.GetErrorMessage(ex, "Could not save permissions.")); - await LoadPermissionsAsync(cancellationToken); - return Page(); - } - } - - private async Task LoadAsync(CancellationToken cancellationToken) - { - if (!await LoadRoleMetaAsync(cancellationToken)) - return false; - - if (IsSystemRole) - { - TempData["RoleManagerError"] = "System role permissions cannot be changed. Create a custom role instead."; - return false; - } - - try - { - await LoadPermissionsAsync(cancellationToken); - return true; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to load permissions for role {RoleId}", RoleId); - TempData["RoleManagerError"] = RoleManagerModel.GetErrorMessage(ex, "Could not load role permissions."); - return false; - } - } - - private async Task LoadRoleMetaAsync(CancellationToken cancellationToken) - { - try - { - var roles = await rolesClient.ListAsync(cancellationToken); - var role = roles?.FirstOrDefault(r => r.RoleId == RoleId); - if (role is null) - { - TempData["RoleManagerError"] = "Role not found."; - return false; - } - - RoleName = role.Name; - IsSystemRole = role.IsSystem; - return true; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to load role {RoleId}", RoleId); - TempData["RoleManagerError"] = RoleManagerModel.GetErrorMessage(ex, "Could not load role."); - return false; - } - } - - private async Task LoadPermissionsAsync(CancellationToken cancellationToken) - { - var existing = await rolesClient.GetPermissionsAsync(RoleId, cancellationToken); - SelectedGrants = NormalizeGrants( - existing? - .Select(p => EncodeGrantKey(p.ResourceType, p.ResourceKey, p.AccessType)) - .ToList() ?? []); + var state = CaptureWorkState(); + var outcome = await roleManagerPermissionsAdmin.RemoveGrantAsync(state, grantKey, cancellationToken); + ApplyWorkState(state); + return MapOutcome(outcome); } public static string EncodeGrantKey(ResourceType resourceType, string resourceKey, AccessType accessType) => - $"{resourceType}|{resourceKey.Trim()}|{accessType}"; + AdminPermissionGrants.EncodeGrantKey(resourceType, resourceKey, accessType); - public static string FormatGrant(string key) - { - var parsed = ParseGrantKey(key); - return parsed is null - ? key - : $"{parsed.Value.ResourceType} / {parsed.Value.ResourceKey} / {parsed.Value.AccessType}"; - } + public static string FormatGrant(string key) => + AdminPermissionGrants.FormatGrant(key); /// /// Mirrors API RolePermissionGrantRules. /// - public static string? ValidateGrant(ResourceType resourceType, string resourceKey, AccessType accessType) - { - var key = resourceKey.Trim(); - if (accessType == AccessType.Manage) - { - if (resourceType != ResourceType.Template && resourceType != ResourceType.User) - { - return "Access type 'Manage' is only allowed for Template or User permissions."; - } + public static string? ValidateGrant(ResourceType resourceType, string resourceKey, AccessType accessType) => + AdminPermissionGrants.ValidateGrant(resourceType, resourceKey, accessType); - if (string.Equals(key, AnyResourceKey, StringComparison.OrdinalIgnoreCase)) - return null; - } - else if (string.Equals(key, AnyResourceKey, StringComparison.OrdinalIgnoreCase)) + private RoleManagerPermissionsWorkState CaptureWorkState() => + new() { - if ((resourceType == ResourceType.Template && accessType == AccessType.Write) - || (resourceType == ResourceType.Template && accessType == AccessType.Manage) - || (resourceType == ResourceType.User && accessType == AccessType.Manage) - || (resourceType == ResourceType.Application && accessType == AccessType.Read) - || (resourceType == ResourceType.ApplicationFiles && accessType == AccessType.Read) - || (resourceType == ResourceType.FileValidation && accessType == AccessType.Write)) - { - return null; - } - - return $"Resource key '{AnyResourceKey}' is only allowed for Template — Write, " + - "Template — Manage, User — Manage, Application — Read, " + - "ApplicationFiles — Read, or FileValidation — Write. " + - "For other combinations, use a specific resource id or email."; - } - - return resourceType switch - { - ResourceType.Application or ResourceType.ApplicationFiles or ResourceType.Template - or ResourceType.File or ResourceType.FileValidation or ResourceType.Task or ResourceType.TaskGroup - or ResourceType.Page or ResourceType.Field - when !Guid.TryParse(key, out var id) || id == Guid.Empty - => $"{resourceType} resource key must be a valid non-empty GUID (the resource id) or 'Any' (where allowed).", - - ResourceType.User or ResourceType.Notifications - when !key.Contains('@', StringComparison.Ordinal) && !Guid.TryParse(key, out _) - => $"{resourceType} resource key must be a user email (or a service client id).", - - _ => null + RoleId = RoleId, + SelectedGrants = SelectedGrants, + NewResourceType = NewResourceType, + NewResourceKey = NewResourceKey, + NewAccessType = NewAccessType }; - } - private static List NormalizeGrants(IEnumerable? grants) => - (grants ?? []) - .Select(ParseGrantKey) - .Where(g => g is not null) - .Select(g => EncodeGrantKey(g!.Value.ResourceType, g.Value.ResourceKey, g.Value.AccessType)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(g => g, StringComparer.OrdinalIgnoreCase) - .ToList(); - - private static (ResourceType ResourceType, string ResourceKey, AccessType AccessType)? ParseGrantKey(string? key) + private void ApplyWorkState(RoleManagerPermissionsWorkState state) { - if (string.IsNullOrWhiteSpace(key)) - return null; - - var parts = key.Split('|', 3); - if (parts.Length != 3) - return null; - - if (!Enum.TryParse(parts[0], ignoreCase: true, out var resourceType)) - return null; + RoleId = state.RoleId; + RoleName = state.RoleName; + IsSystemRole = state.IsSystemRole; + SelectedGrants = state.SelectedGrants; + NewResourceType = state.NewResourceType; + NewResourceKey = state.NewResourceKey; + NewAccessType = state.NewAccessType; + } - if (string.IsNullOrWhiteSpace(parts[1])) - return null; + private IActionResult MapOutcome(AdminPageOutcome outcome) + { + foreach (var error in outcome.Errors) + ModelState.AddModelError(error.FieldKey, error.Message); - if (!Enum.TryParse(parts[2], ignoreCase: true, out var accessType)) - return null; + if (outcome.ErrorMessage != null) + TempData["RoleManagerError"] = outcome.ErrorMessage; - return (resourceType, parts[1].Trim(), accessType); + return outcome.Kind == AdminPageOutcomeKind.RedirectToPage + ? RedirectToPage("/Admin/RoleManager") + : Page(); } } diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/TemplateManager.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/TemplateManager.cshtml.cs index c2028cf..f354be2 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/TemplateManager.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/TemplateManager.cshtml.cs @@ -1,17 +1,13 @@ using GovUK.Dfe.FlexForms.Web.Security; +using GovUK.Dfe.FlexForms.Application.Admin; using GovUK.Dfe.FlexForms.Application.Interfaces; using GovUK.Dfe.FlexForms.Domain.Models; -using GovUK.Dfe.FlexForms.Domain.Templates; using GovUK.Dfe.FlexForms.Web.Services; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; -using GovUK.Dfe.FlexForms.Api.Client.Contracts; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -using System.Text; -using System.Text.Json; using System.ComponentModel.DataAnnotations; -using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; using Task = System.Threading.Tasks.Task; using GovUK.Dfe.CoreLibs.Caching.Interfaces; using GovUK.Dfe.CoreLibs.Caching.Helpers; @@ -23,21 +19,13 @@ namespace GovUK.Dfe.FlexForms.Web.Pages.Admin; [RequestFormLimits(ValueLengthLimit = 52_428_800, ValueCountLimit = 1000)] public class TemplateManagerModel( IFormTemplateProvider formTemplateProvider, - ITemplatesClient templatesClient, + ITemplateManagerAdmin templateManagerAdmin, ITemplateSelectionService templateSelectionService, ICacheService cacheService, - ITemplateValidationService templateValidationService, ILogger logger) : PageModel { private const string TemplateVersionSessionKey = "TemplateVersionNumber"; - private readonly IFormTemplateProvider _formTemplateProvider = formTemplateProvider; - private readonly ITemplatesClient _templatesClient = templatesClient; - private readonly ITemplateSelectionService _templateSelectionService = templateSelectionService; - private readonly ICacheService _cacheService = cacheService; - private readonly ITemplateValidationService _templateValidationService = templateValidationService; - private readonly ILogger _logger = logger; - public FormTemplate? CurrentTemplate { get; set; } public string? CurrentVersionNumber { get; set; } public string? LatestVersionNumber { get; set; } @@ -80,7 +68,7 @@ public async Task OnGetAsync( { try { - _logger.LogInformation("TemplateManager GET started. Memory: {MemoryMB} MB", + logger.LogInformation("TemplateManager GET started. Memory: {MemoryMB} MB", GC.GetTotalMemory(false) / 1024 / 1024); ShowAddVersionForm = showForm; @@ -97,28 +85,29 @@ public async Task OnGetAsync( await LoadTenantTemplatesAsync(); var templateId = await ResolveSelectedTemplateIdAsync(); if (templateId is null) - { return Page(); - } - await LoadTemplateDataAsync(templateId.Value); + var state = CaptureWorkState(); + await templateManagerAdmin.LoadTemplateDataAsync(state, templateId.Value); + ApplyWorkState(state); + PersistSessionVersion(state); if (!string.IsNullOrEmpty(suggestedVersion)) { NewVersion = suggestedVersion; - _logger.LogInformation("Pre-populated NewVersion field with suggested version: {SuggestedVersion}", suggestedVersion); + logger.LogInformation("Pre-populated NewVersion field with suggested version: {SuggestedVersion}", suggestedVersion); } - PrefillNewSchemaIfEmpty(templateId.Value); + PrefillNewSchema(templateId.Value); - _logger.LogInformation("TemplateManager GET completed successfully. Memory: {MemoryMB} MB", + logger.LogInformation("TemplateManager GET completed successfully. Memory: {MemoryMB} MB", GC.GetTotalMemory(false) / 1024 / 1024); return Page(); } catch (Exception ex) { - _logger.LogError(ex, "CRITICAL ERROR in TemplateManager OnGetAsync. Memory: {MemoryMB} MB, Exception Type: {ExceptionType}", + logger.LogError(ex, "CRITICAL ERROR in TemplateManager OnGetAsync. Memory: {MemoryMB} MB, Exception Type: {ExceptionType}", GC.GetTotalMemory(false) / 1024 / 1024, ex.GetType().FullName); throw; } @@ -130,29 +119,28 @@ public async Task OnPostAsync() var templateId = await ResolveSelectedTemplateIdAsync(); if (templateId is null) { - ModelState.AddModelError(string.Empty, "Select a template."); + ModelState.AddModelError(string.Empty, TemplateManagerMessages.SelectTemplate); return Page(); } - if (!ValidateInput()) + var state = CaptureWorkState(); + var validation = templateManagerAdmin.ValidateNewVersion(state); + if (validation.Errors.Count > 0) { + ApplyValidationErrors(validation); ShowAddVersionForm = true; - await LoadTemplateDataAsync(templateId.Value); - PrefillNewSchemaIfEmpty(templateId.Value); + await templateManagerAdmin.LoadTemplateDataAsync(state, templateId.Value); + ApplyWorkState(state); + PrefillNewSchema(templateId.Value); return Page(); } - await CreateNewTemplateVersionAsync(templateId.Value.ToString()); - + await templateManagerAdmin.CreateVersionAsync(state, templateId.Value); await InvalidateTemplateCacheAsync(templateId.Value.ToString()); - // After create, open the newly saved version. HttpContext.Session.SetString(TemplateVersionSessionKey, NewVersion!); await HttpContext.Session.CommitAsync(); - _logger.LogInformation("Successfully created template version {NewVersion} for {TemplateId}", - NewVersion, templateId); - return RedirectToPage(new { success = true }); } @@ -163,12 +151,12 @@ public async Task OnPostSelectTemplateAsync(CancellationToken can if (SelectedTemplateId is null || TenantTemplates.All(template => template.TemplateId != SelectedTemplateId.Value)) { - ModelState.AddModelError(nameof(SelectedTemplateId), "Select a template for this tenant."); + ModelState.AddModelError(nameof(SelectedTemplateId), TemplateManagerMessages.SelectTenantTemplate); return Page(); } var template = TenantTemplates.First(item => item.TemplateId == SelectedTemplateId.Value); - await _templateSelectionService.SelectTemplateAsync(HttpContext, template, cancellationToken); + await templateSelectionService.SelectTemplateAsync(HttpContext, template, cancellationToken); HttpContext.Session.Remove(TemplateVersionSessionKey); await HttpContext.Session.CommitAsync(cancellationToken); return RedirectToPage(); @@ -180,14 +168,16 @@ public async Task OnPostSelectVersionAsync(CancellationToken canc var templateId = await ResolveSelectedTemplateIdAsync(cancellationToken); if (templateId is null) { - ModelState.AddModelError(string.Empty, "Select a template."); + ModelState.AddModelError(string.Empty, TemplateManagerMessages.SelectTemplate); return Page(); } if (string.IsNullOrWhiteSpace(SelectedVersionNumber)) { - ModelState.AddModelError(nameof(SelectedVersionNumber), "Select a template version."); - await LoadTemplateDataAsync(templateId.Value); + ModelState.AddModelError(nameof(SelectedVersionNumber), TemplateManagerMessages.SelectVersion); + var state = CaptureWorkState(); + await templateManagerAdmin.LoadTemplateDataAsync(state, templateId.Value, cancellationToken); + ApplyWorkState(state); return Page(); } @@ -202,14 +192,15 @@ public async Task OnPostShowAddFormAsync() var templateId = await ResolveSelectedTemplateIdAsync(); if (templateId is not null) { - await LoadTemplateDataAsync(templateId.Value); + var state = CaptureWorkState(); + await templateManagerAdmin.LoadTemplateDataAsync(state, templateId.Value); + ApplyWorkState(state); - // Suggest the next version from the latest published version, not the selected older one. var baseVersion = LatestVersionNumber ?? CurrentVersionNumber; if (!string.IsNullOrEmpty(baseVersion)) { - var incrementedVersion = IncrementPatchVersion(baseVersion); - _logger.LogInformation( + var incrementedVersion = templateManagerAdmin.SuggestNextVersion(LatestVersionNumber, CurrentVersionNumber); + logger.LogInformation( "Auto-incremented version from {LatestVersion} to {NewVersion} (selected schema version {SelectedVersion})", baseVersion, incrementedVersion, CurrentVersionNumber); @@ -227,203 +218,126 @@ public async Task OnPostGrantToAllUsersAsync(CancellationToken ca if (templateId is null) { HasError = true; - ErrorMessage = "Select a template before granting access to all users."; + ErrorMessage = TemplateManagerMessages.GrantRequiresTemplate; return Page(); } - try - { - var result = await _templatesClient.GrantTemplateAccessToAllUsersAsync( - templateId.Value, - cancellationToken); - - var summary = - $"Granted to {result.UsersGranted} user(s). " + - $"{result.UsersAlreadyHadAccess} already had access. " + - $"Total tenant users checked: {result.TotalUsers}."; - - _logger.LogInformation( - "Granted template {TemplateId} to all tenant users. Granted={Granted}, AlreadyHad={AlreadyHad}, Total={Total}", - templateId, - result.UsersGranted, - result.UsersAlreadyHadAccess, - result.TotalUsers); - - TempData["TemplateManagerGrantSummary"] = summary; - return RedirectToPage(); - } - catch (Exception ex) + var state = CaptureWorkState(); + var outcome = await templateManagerAdmin.GrantToAllUsersAsync(state, templateId.Value, cancellationToken); + ApplyWorkState(state); + + if (outcome.Kind == AdminPageOutcomeKind.StayOnPage) { - _logger.LogError(ex, "Failed to grant template {TemplateId} to all tenant users", templateId); HasError = true; - ErrorMessage = "Failed to grant this template to all users in the tenant."; - await LoadTemplateDataAsync(templateId.Value); + ErrorMessage = outcome.ErrorMessage ?? TemplateManagerMessages.GrantFailed; return Page(); } - } - - /// - /// Increments the patch version of a semantic version string (e.g., 1.0.1 -> 1.0.2) - /// - private static string IncrementPatchVersion(string version) - { - try - { - var parts = version.Split('.'); - if (parts.Length == 0) - { - return "1.0.1"; - } - else if (parts.Length == 1) - { - return $"{parts[0]}.0.1"; - } - else if (parts.Length == 2) - { - return $"{parts[0]}.{parts[1]}.1"; - } - else - { - if (int.TryParse(parts[2], out var patchVersion)) - { - patchVersion++; - return $"{parts[0]}.{parts[1]}.{patchVersion}"; - } - else - { - return $"{parts[0]}.{parts[1]}.1"; - } - } - } - catch - { - return "1.0.1"; - } - } - - public IActionResult OnPostCancelAdd() - { + TempData["TemplateManagerGrantSummary"] = state.GrantToAllUsersSummary ?? outcome.SuccessMessage; return RedirectToPage(); } + public IActionResult OnPostCancelAdd() => RedirectToPage(); + public async Task OnPostClearAllAsync() { try { var templateId = HttpContext.Session.GetString("TemplateId"); - HttpContext.Session.Clear(); if (!string.IsNullOrEmpty(templateId)) { var cacheKey = $"FormTemplate_{CacheKeyHelper.GenerateHashedCacheKey(templateId)}"; - _cacheService.Remove(cacheKey); - _logger.LogInformation("Cleared template cache for key: {CacheKey}", cacheKey); + cacheService.Remove(cacheKey); + logger.LogInformation("Cleared template cache for key: {CacheKey}", cacheKey); } - _logger.LogInformation("Successfully cleared all sessions and caches from TemplateManager"); - + logger.LogInformation("Successfully cleared all sessions and caches from TemplateManager"); return RedirectToPage("/Applications/Dashboard"); } catch (Exception ex) { - _logger.LogError(ex, "Error clearing sessions and caches from TemplateManager"); + logger.LogError(ex, "Error clearing sessions and caches from TemplateManager"); HasError = true; - ErrorMessage = "Failed to clear sessions and caches."; + ErrorMessage = TemplateManagerMessages.ClearFailed; return Page(); } } - private async Task LoadTemplateDataAsync(Guid templateId) - { - try + private TemplateManagerWorkState CaptureWorkState() => + new() { - _logger.LogDebug("Loading template data for {TemplateId}", templateId); - - SelectedTemplate = TenantTemplates.First(template => template.TemplateId == templateId); - SelectedTemplateId = templateId; - LatestVersionNumber = SelectedTemplate.LatestVersionNumber; - - var versions = await _templatesClient.GetTemplateVersionsAsync(templateId); - AvailableVersions = versions.ToList(); - - if (AvailableVersions.Count == 0) - { - CurrentVersionNumber = null; - SelectedVersionNumber = null; - CurrentTemplate = null; - CurrentTemplateJson = null; - return; - } - - var requestedVersion = SelectedVersionNumber - ?? HttpContext.Session.GetString(TemplateVersionSessionKey); - - var selectedVersion = AvailableVersions.FirstOrDefault(v => - !string.IsNullOrWhiteSpace(requestedVersion) && - string.Equals(v.VersionNumber, requestedVersion, StringComparison.OrdinalIgnoreCase)) - ?? AvailableVersions[0]; - - SelectedVersionNumber = selectedVersion.VersionNumber; - CurrentVersionNumber = selectedVersion.VersionNumber; - LatestVersionNumber = AvailableVersions[0].VersionNumber; - - HttpContext.Session.SetString(TemplateVersionSessionKey, selectedVersion.VersionNumber); - - var apiResponse = await _templatesClient.GetTemplateSchemaByVersionAsync( - templateId, - selectedVersion.VersionNumber); + SelectedTemplateId = SelectedTemplateId, + SelectedVersionNumber = SelectedVersionNumber, + NewVersion = NewVersion, + NewSchema = NewSchema, + AcknowledgeReportingImpact = AcknowledgeReportingImpact, + ShowAddVersionForm = ShowAddVersionForm, + TenantTemplates = TenantTemplates, + SessionVersionNumber = HttpContext.Session.GetString(TemplateVersionSessionKey) + }; - var schemaJson = apiResponse.JsonSchema; - if (string.IsNullOrWhiteSpace(schemaJson)) - { - CurrentTemplate = null; - CurrentTemplateJson = null; - return; - } - - var options = new JsonSerializerOptions { WriteIndented = true, PropertyNameCaseInsensitive = true }; - CurrentTemplate = JsonSerializer.Deserialize(schemaJson, options); - CurrentTemplateJson = CurrentTemplate != null - ? JsonSerializer.Serialize(CurrentTemplate, options) - : PrettyPrintJson(schemaJson); - - _logger.LogDebug( - "Loaded template {TemplateId} version {VersionNumber} with {TaskGroupCount} task groups", - templateId, - CurrentVersionNumber, - CurrentTemplate?.TaskGroups?.Count ?? 0); - } - catch (Exception ex) + private void ApplyWorkState(TemplateManagerWorkState state) + { + SelectedTemplateId = state.SelectedTemplateId; + SelectedVersionNumber = state.SelectedVersionNumber; + NewVersion = state.NewVersion ?? NewVersion; + NewSchema = state.NewSchema ?? NewSchema; + CurrentTemplate = state.CurrentTemplate; + CurrentVersionNumber = state.CurrentVersionNumber; + LatestVersionNumber = state.LatestVersionNumber; + CurrentTemplateJson = state.CurrentTemplateJson; + AvailableVersions = state.AvailableVersions; + SelectedTemplate = state.SelectedTemplate; + if (state.HasError) { - _logger.LogError(ex, "Error loading template data for {TemplateId}", templateId); HasError = true; - ErrorMessage = "There was an error loading the template data."; + ErrorMessage = state.ErrorMessage; } } - private static string PrettyPrintJson(string json) + private void PersistSessionVersion(TemplateManagerWorkState state) { - try - { - using var document = JsonDocument.Parse(json); - return JsonSerializer.Serialize(document.RootElement, new JsonSerializerOptions { WriteIndented = true }); - } - catch + if (!string.IsNullOrWhiteSpace(state.SessionVersionNumber)) + HttpContext.Session.SetString(TemplateVersionSessionKey, state.SessionVersionNumber); + } + + private void PrefillNewSchema(Guid templateId) + { + var state = CaptureWorkState(); + state.CurrentTemplateJson = CurrentTemplateJson; + state.SelectedTemplate = SelectedTemplate; + templateManagerAdmin.PrefillNewSchemaIfEmpty(state, templateId); + NewSchema = state.NewSchema; + NewVersion = state.NewVersion ?? NewVersion; + if (ShowAddVersionForm) + ModelState.Remove(nameof(NewSchema)); + } + + private void ApplyValidationErrors(AdminPageOutcome validation) + { + foreach (var error in validation.Errors) { - return json; + if (error.FieldKey == nameof(NewSchema) + && error.Message == TemplateManagerMessages.SchemaRequired + && ModelState[nameof(NewSchema)]?.Errors.Count > 0) + { + continue; + } + + ModelState.AddModelError(error.FieldKey, error.Message); } } private async Task LoadTenantTemplatesAsync(CancellationToken cancellationToken = default) { - TenantTemplates = await _templateSelectionService.GetSelectableTemplatesAsync(cancellationToken); + TenantTemplates = await templateSelectionService.GetSelectableTemplatesAsync(cancellationToken); } private async Task ResolveSelectedTemplateIdAsync(CancellationToken cancellationToken = default) { - var sessionTemplateId = _templateSelectionService.GetSelectedTemplateId(HttpContext); + var sessionTemplateId = templateSelectionService.GetSelectedTemplateId(HttpContext); if (Guid.TryParse(sessionTemplateId, out var selectedId) && TenantTemplates.Any(template => template.TemplateId == selectedId)) { @@ -434,125 +348,39 @@ private async Task LoadTenantTemplatesAsync(CancellationToken cancellationToken var firstTemplate = TenantTemplates.FirstOrDefault(); if (firstTemplate is null) - { return null; - } - await _templateSelectionService.SelectTemplateAsync(HttpContext, firstTemplate, cancellationToken); + await templateSelectionService.SelectTemplateAsync(HttpContext, firstTemplate, cancellationToken); SelectedTemplateId = firstTemplate.TemplateId; SelectedTemplate = firstTemplate; return firstTemplate.TemplateId; } - private void PrefillNewSchemaIfEmpty(Guid templateId) - { - if (!ShowAddVersionForm || !string.IsNullOrWhiteSpace(NewSchema)) - { - return; - } - - if (!string.IsNullOrWhiteSpace(CurrentTemplateJson)) - { - NewSchema = CurrentTemplateJson; - } - else - { - NewSchema = StarterFormTemplateSchema.CreateJson( - templateId.ToString(), - SelectedTemplate?.Name ?? "New template"); - NewVersion ??= StarterFormTemplateSchema.DefaultVersionNumber; - } - - ModelState.Remove(nameof(NewSchema)); - } - - private bool ValidateInput() - { - var isValid = true; - - if (string.IsNullOrWhiteSpace(NewVersion)) - { - ModelState.AddModelError(nameof(NewVersion), "Version number is required"); - isValid = false; - } - - if (string.IsNullOrWhiteSpace(NewSchema)) - { - if (ModelState[nameof(NewSchema)]?.Errors.Count is null or 0) - { - ModelState.AddModelError(nameof(NewSchema), "JSON schema is required"); - } - - isValid = false; - } - else - { - var (templateIsValid, validationErrors) = _templateValidationService.ValidateTemplateJson(NewSchema); - - if (!templateIsValid) - { - _logger.LogWarning("Template validation failed with {ErrorCount} errors", validationErrors.Count); - - foreach (var error in validationErrors) - { - ModelState.AddModelError(nameof(NewSchema), error); - } - - isValid = false; - } - else - { - _logger.LogInformation("Template validation passed successfully"); - } - } - - if (!AcknowledgeReportingImpact) - { - ModelState.AddModelError(nameof(AcknowledgeReportingImpact), - "You must confirm that you understand the reporting impact before saving."); - isValid = false; - } - - return isValid; - } - - private async Task CreateNewTemplateVersionAsync(string templateId) - { - var base64Schema = Convert.ToBase64String(Encoding.UTF8.GetBytes(NewSchema!)); - await _templatesClient.CreateTemplateVersionAsync(new Guid(templateId), - new CreateTemplateVersionRequest(VersionNumber: NewVersion!, JsonSchema: base64Schema)); - } - private async Task InvalidateTemplateCacheAsync(string templateId) { try { var cacheKey = $"FormTemplate_{CacheKeyHelper.GenerateHashedCacheKey(templateId)}"; - _logger.LogInformation("Attempting to invalidate cache for template {TemplateId} with key {CacheKey}", + logger.LogInformation("Attempting to invalidate cache for template {TemplateId} with key {CacheKey}", templateId, cacheKey); - _cacheService.Remove(cacheKey); - _logger.LogInformation("Successfully invalidated cache for template {TemplateId} with key {CacheKey}", + cacheService.Remove(cacheKey); + logger.LogInformation("Successfully invalidated cache for template {TemplateId} with key {CacheKey}", templateId, cacheKey); - await VerifyNewTemplateVersionAsync(templateId); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to invalidate cache for template {TemplateId}", templateId); - } - } - - private async Task VerifyNewTemplateVersionAsync(string templateId) - { - try - { - await _formTemplateProvider.GetTemplateAsync(templateId); - _logger.LogDebug("Successfully verified new template version is available for {TemplateId}", templateId); + try + { + await formTemplateProvider.GetTemplateAsync(templateId); + logger.LogDebug("Successfully verified new template version is available for {TemplateId}", templateId); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to verify new template version for {TemplateId}", templateId); + } } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to verify new template version for {TemplateId}", templateId); + logger.LogWarning(ex, "Failed to invalidate cache for template {TemplateId}", templateId); } } } diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/TenantSettings.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/TenantSettings.cshtml.cs index 13756e1..6d41c01 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/TenantSettings.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/TenantSettings.cshtml.cs @@ -1,8 +1,5 @@ -using System.Text; -using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; -using GovUK.Dfe.CoreLibs.Http.Models; -using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; using GovUK.Dfe.FlexForms.Web.Security; using GovUK.Dfe.FlexForms.Web.Services.Tenant; using GovUK.Dfe.FlexForms.Web.Tenancy; @@ -17,13 +14,12 @@ namespace GovUK.Dfe.FlexForms.Web.Pages.Admin; /// [Authorize(Policy = AdminAccessHelper.CanManageTenantSettingsPolicy)] public sealed class TenantSettingsModel( - ITenantAdminClient tenantAdminClient, + ITenantSettingsAdmin tenantSettingsAdmin, ITenantRequestContext tenantRequestContext, ITenantConfigurationCache tenantConfigurationCache, - ITenantIdResolver tenantIdResolver, - ILogger logger) : PageModel + ITenantIdResolver tenantIdResolver) : PageModel { - public static readonly string[] ValidTargets = ["Shared", "Api", "Web"]; + public static readonly string[] ValidTargets = TenantSettingsAdminService.ValidTargets; public Guid TenantId { get; private set; } @@ -71,16 +67,11 @@ public async Task OnGetAsync(CancellationToken cancellationToken) { ApplyTempData(); if (!TryResolveTenant(out var error)) - { - HasError = true; - ErrorMessage = error; - return Page(); - } + return StayWithTenantError(error); - await LoadSettingsAsync(cancellationToken); - await LoadHealthAsync(cancellationToken); - await LoadCookbookAsync(cancellationToken); - await LoadAuditLogAsync(cancellationToken); + var state = CaptureWorkState(); + await tenantSettingsAdmin.LoadAsync(state, cancellationToken); + ApplyWorkState(state); return Page(); } @@ -93,130 +84,42 @@ public async Task OnPostValidateAsync( { ApplyTempData(); if (!TryResolveTenant(out var error)) - { - HasError = true; - ErrorMessage = error; - return Page(); - } - - category = category?.Trim() ?? string.Empty; - target = target?.Trim() ?? string.Empty; - settingsJson = settingsJson?.Trim() ?? string.Empty; - ValidationCategory = category; - ValidationTarget = target; - ValidationIsSecret = isSecret; - - await LoadSettingsAsync(cancellationToken); - await LoadHealthAsync(cancellationToken); - await LoadCookbookAsync(cancellationToken); - await LoadAuditLogAsync(cancellationToken); - - if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(settingsJson)) - { - HasError = true; - ErrorMessage = "Category and settings JSON are required to validate."; - return Page(); - } - - try - { - ValidationPreview = await tenantAdminClient.ValidateTenantSettingAsync( - TenantId, - new ValidateTenantSettingRequest(category, target, ToBase64SettingsJson(settingsJson), isSecret), - cancellationToken); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to validate tenant setting {Category}/{Target}", category, target); - HasError = true; - ErrorMessage = GetErrorMessage(ex, "Could not validate setting."); - } + return StayWithTenantError(error); + var state = CaptureWorkState(); + await tenantSettingsAdmin.ValidateAsync(state, category, target, settingsJson, isSecret, cancellationToken); + ApplyWorkState(state); return Page(); } public Task OnPostValidateNewAsync(CancellationToken cancellationToken) => OnPostValidateAsync(NewCategory, NewTarget, NewSettingsJson, NewIsSecret, cancellationToken); - public async Task OnPostDeleteAsync( - string category, - string target, - CancellationToken cancellationToken) - { - if (!TryResolveTenant(out var error)) - { - TempData["TenantSettingsError"] = error; - return RedirectToPage(); - } - - category = category?.Trim() ?? string.Empty; - target = target?.Trim() ?? string.Empty; + public Task OnPostDeleteAsync(string category, string target, CancellationToken cancellationToken) + => DispatchMutationAsync(state => tenantSettingsAdmin.DeleteAsync(state, category, target, cancellationToken)); - try - { - await tenantAdminClient.DeleteTenantSettingAsync(TenantId, category, target, cancellationToken); - await RefreshCachesAsync(cancellationToken); - TempData["TenantSettingsSuccess"] = $"Deleted '{category}' ({target})."; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to delete tenant setting {Category}/{Target}", category, target); - TempData["TenantSettingsError"] = GetErrorMessage(ex, "Could not delete setting."); - } - - return RedirectToPage(); - } - - public async Task OnPostUpdateAsync( + public Task OnPostUpdateAsync( string category, string target, string settingsJson, bool isSecret, CancellationToken cancellationToken) - { - if (!TryResolveTenant(out var error)) - { - TempData["TenantSettingsError"] = error; - return RedirectToPage(); - } - - category = category?.Trim() ?? string.Empty; - target = target?.Trim() ?? string.Empty; - settingsJson = settingsJson?.Trim() ?? string.Empty; - - if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(settingsJson)) - { - TempData["TenantSettingsError"] = "Category and settings JSON are required."; - return RedirectToPage(); - } - - if (!ValidTargets.Contains(target, StringComparer.OrdinalIgnoreCase)) - { - TempData["TenantSettingsError"] = "Target must be Shared, Api, or Web."; - return RedirectToPage(); - } - - try - { - await tenantAdminClient.UpsertTenantSettingAsync( - TenantId, - new UpsertTenantSettingRequest(category, target, ToBase64SettingsJson(settingsJson), isSecret), - cancellationToken); - - await RefreshCachesAsync(cancellationToken); + => DispatchMutationAsync(state => + tenantSettingsAdmin.UpdateAsync(state, category, target, settingsJson, isSecret, cancellationToken)); - TempData["TenantSettingsSuccess"] = $"Updated '{category}' ({target})."; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to update tenant setting {Category}/{Target}", category, target); - TempData["TenantSettingsError"] = GetErrorMessage(ex, "Could not update setting."); - } + public Task OnPostAddAsync(CancellationToken cancellationToken) + => DispatchMutationAsync(state => tenantSettingsAdmin.AddAsync( + state, + NewCategory, + NewTarget, + NewSettingsJson, + NewIsSecret, + cancellationToken)); - return RedirectToPage(); - } + public Task OnPostExportAsync(CancellationToken cancellationToken) + => DispatchMutationAsync(state => tenantSettingsAdmin.ExportAsync(state, cancellationToken)); - public async Task OnPostAddAsync(CancellationToken cancellationToken) + public async Task OnPostImportAsync(IFormFile? importFile, CancellationToken cancellationToken) { if (!TryResolveTenant(out var error)) { @@ -224,55 +127,27 @@ public async Task OnPostAddAsync(CancellationToken cancellationTo return RedirectToPage(); } - NewCategory = NewCategory?.Trim() ?? string.Empty; - NewTarget = NewTarget?.Trim() ?? "Shared"; - NewSettingsJson = NewSettingsJson?.Trim() ?? string.Empty; - - if (string.IsNullOrWhiteSpace(NewCategory)) - { - TempData["TenantSettingsError"] = "Enter a category name."; - return RedirectToPage(); - } - - if (NewCategory.Length > 50) - { - TempData["TenantSettingsError"] = "Category must not exceed 50 characters."; - return RedirectToPage(); - } - - if (!ValidTargets.Contains(NewTarget, StringComparer.OrdinalIgnoreCase)) - { - TempData["TenantSettingsError"] = "Target must be Shared, Api, or Web."; - return RedirectToPage(); - } - - if (string.IsNullOrWhiteSpace(NewSettingsJson)) + if (importFile is null || importFile.Length == 0) { - TempData["TenantSettingsError"] = "Enter settings JSON."; + TempData["TenantSettingsError"] = TenantSettingsMessages.ImportFileRequired; return RedirectToPage(); } - try - { - await tenantAdminClient.UpsertTenantSettingAsync( - TenantId, - new UpsertTenantSettingRequest(NewCategory, NewTarget, ToBase64SettingsJson(NewSettingsJson), NewIsSecret), - cancellationToken); + using var reader = new StreamReader(importFile.OpenReadStream()); + var json = await reader.ReadToEndAsync(cancellationToken); + return await MapOutcomeAsync(await tenantSettingsAdmin.ImportAsync(CaptureWorkState(), json, cancellationToken)); + } - await RefreshCachesAsync(cancellationToken); + public Task OnPostRefreshAsync(CancellationToken cancellationToken) + => DispatchMutationAsync(state => tenantSettingsAdmin.RefreshAsync(state, cancellationToken)); - TempData["TenantSettingsSuccess"] = $"Added '{NewCategory}' ({NewTarget})."; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to add tenant setting {Category}/{Target}", NewCategory, NewTarget); - TempData["TenantSettingsError"] = GetErrorMessage(ex, "Could not add setting."); - } + internal static string ToBase64SettingsJson(string settingsJson) => + AdminSettingsEncoding.ToBase64(settingsJson); - return RedirectToPage(); - } + internal static string GetErrorMessage(Exception ex, string fallback) => + AdminApiErrorMapper.Format(ex, fallback, includeGatewayHint: true); - public async Task OnPostExportAsync(CancellationToken cancellationToken) + private async Task DispatchMutationAsync(Func> execute) { if (!TryResolveTenant(out var error)) { @@ -280,125 +155,75 @@ public async Task OnPostExportAsync(CancellationToken cancellatio return RedirectToPage(); } - try - { - var export = await tenantAdminClient.ExportConfigurationAsync(TenantId, cancellationToken); - var json = System.Text.Json.JsonSerializer.Serialize(export, - new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); - var bytes = Encoding.UTF8.GetBytes(json); - return File(bytes, "application/json", $"tenant-config-{TenantId:N}-{DateTime.UtcNow:yyyyMMddHHmmss}.json"); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to export tenant configuration for {TenantId}", TenantId); - TempData["TenantSettingsError"] = GetErrorMessage(ex, "Could not export configuration."); - return RedirectToPage(); - } + return await MapOutcomeAsync(await execute(CaptureWorkState())); } - public async Task OnPostImportAsync(IFormFile? importFile, CancellationToken cancellationToken) + private IActionResult StayWithTenantError(string? error) { - if (!TryResolveTenant(out var error)) - { - TempData["TenantSettingsError"] = error; - return RedirectToPage(); - } - - if (importFile is null || importFile.Length == 0) - { - TempData["TenantSettingsError"] = "Select a JSON file to import."; - return RedirectToPage(); - } + HasError = true; + ErrorMessage = error; + return Page(); + } - try + private TenantSettingsWorkState CaptureWorkState() => + new() { - using var reader = new StreamReader(importFile.OpenReadStream()); - var json = await reader.ReadToEndAsync(cancellationToken); - var exportBundle = System.Text.Json.JsonSerializer.Deserialize(json, - new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + TenantId = TenantId, + TenantName = TenantName + }; - if (exportBundle?.Settings is null || exportBundle.Settings.Count == 0) - { - TempData["TenantSettingsError"] = "The import file contains no settings."; - return RedirectToPage(); - } - - var importItems = exportBundle.Settings - .Select(s => new TenantSettingImportItemDto(s.Category, s.Target, s.SettingsJson, s.IsSecret)) - .ToList(); - - var bundle = new ImportTenantConfigurationDto(importItems, SkipSecretPlaceholders: true); - - var result = await tenantAdminClient.ImportConfigurationAsync(TenantId, bundle, cancellationToken); - await RefreshCachesAsync(cancellationToken); - - TempData["TenantSettingsSuccess"] = - $"Imported {result.AppliedCount} settings ({result.SkippedCount} secret placeholders skipped)."; - } - catch (System.Text.Json.JsonException) - { - TempData["TenantSettingsError"] = "The file is not valid JSON."; - } - catch (Exception ex) + private void ApplyWorkState(TenantSettingsWorkState state) + { + TenantId = state.TenantId; + TenantName = state.TenantName; + Settings = state.Settings; + EffectiveConfig = state.EffectiveConfig; + TenantHealth = state.TenantHealth; + Cookbook = state.Cookbook; + AuditEntries = state.AuditEntries; + ValidationPreview = state.ValidationPreview; + ValidationCategory = state.ValidationCategory; + ValidationTarget = state.ValidationTarget; + ValidationIsSecret = state.ValidationIsSecret; + if (state.HasError) { - logger.LogError(ex, "Failed to import tenant configuration for {TenantId}", TenantId); - TempData["TenantSettingsError"] = GetErrorMessage(ex, "Could not import configuration."); + HasError = true; + ErrorMessage = state.ErrorMessage; } - - return RedirectToPage(); } - public async Task OnPostRefreshAsync(CancellationToken cancellationToken) + private Task MapOutcomeAsync(AdminPageOutcome outcome) { - if (!TryResolveTenant(out var error)) + if (outcome.RefreshLocalCaches) { - TempData["TenantSettingsError"] = error; - return RedirectToPage(); + tenantConfigurationCache.Invalidate(TenantId); + tenantIdResolver.InvalidateHostnameCache(); } - try - { - await RefreshCachesAsync(cancellationToken); - TempData["TenantSettingsSuccess"] = "Tenant configuration cache refreshed."; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to refresh tenant configuration for {TenantId}", TenantId); - TempData["TenantSettingsError"] = GetErrorMessage(ex, "Could not refresh settings."); - } + if (outcome.SuccessMessage != null) + TempData["TenantSettingsSuccess"] = outcome.SuccessMessage; - return RedirectToPage(); - } + if (outcome.ErrorMessage != null) + TempData["TenantSettingsError"] = outcome.ErrorMessage; - private async Task RefreshCachesAsync(CancellationToken cancellationToken) - { - await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken); - tenantConfigurationCache.Invalidate(TenantId); - tenantIdResolver.InvalidateHostnameCache(); - } - - private async Task LoadSettingsAsync(CancellationToken cancellationToken) - { - try + IActionResult result = outcome.Kind switch { - var response = await tenantAdminClient.GetTenantSettingsAsync(TenantId, cancellationToken); - TenantName = response.TenantName; - Settings = response.Settings?.ToList() ?? []; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to load tenant settings for {TenantId}", TenantId); - HasError = true; - ErrorMessage = GetErrorMessage(ex, "Could not load tenant settings."); - Settings = []; - } + AdminPageOutcomeKind.FileDownload => File( + outcome.FileBytes!, + outcome.FileContentType!, + outcome.FileDownloadName), + AdminPageOutcomeKind.StayOnPage => Page(), + _ => RedirectToPage() + }; + + return Task.FromResult(result); } private bool TryResolveTenant(out string? error) { if (tenantRequestContext.TenantId is not { } tenantId || tenantId == Guid.Empty) { - error = "Tenant context is not available for this request."; + error = TenantSettingsMessages.TenantContextMissing; return false; } @@ -422,82 +247,4 @@ private void ApplyTempData() ErrorMessage = error; } } - - /// - /// Encodes settings JSON as Base64 for the API (WAF-safe; mirrors template schema transport). - /// - internal static string ToBase64SettingsJson(string settingsJson) => - Convert.ToBase64String(Encoding.UTF8.GetBytes(settingsJson)); - - private async Task LoadHealthAsync(CancellationToken cancellationToken) - { - try - { - TenantHealth = await tenantAdminClient.GetTenantHealthAsync(TenantId, cancellationToken); - EffectiveConfig = TenantHealth.EffectiveConfiguration; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to load tenant health for {TenantId}", TenantId); - try - { - EffectiveConfig = await tenantAdminClient.GetEffectiveConfigurationAsync(TenantId, cancellationToken); - } - catch (Exception inner) - { - logger.LogWarning(inner, "Failed to load effective configuration for {TenantId}", TenantId); - } - } - } - - private async Task LoadCookbookAsync(CancellationToken cancellationToken) - { - try - { - var response = await tenantAdminClient.GetCategoryCookbookAsync(cancellationToken); - Cookbook = response.Categories?.ToList() ?? []; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to load category cookbook"); - } - } - - private async Task LoadAuditLogAsync(CancellationToken cancellationToken) - { - try - { - var log = await tenantAdminClient.GetSettingAuditLogAsync(TenantId, 20, cancellationToken); - AuditEntries = log?.Entries?.ToList() ?? []; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to load audit log for {TenantId}", TenantId); - } - } - - internal static string GetErrorMessage(Exception ex, string fallback) - { - if (ex is ExternalApplicationsException apiEx - && !string.IsNullOrWhiteSpace(apiEx.Result?.Message)) - { - return apiEx.Result.Message; - } - - if (ex is ExternalApplicationsException clientEx) - { - var body = clientEx.Response?.TrimStart() ?? string.Empty; - if (clientEx.StatusCode == 403 && body.StartsWith('<')) - { - return "Save was blocked with HTTP 403 (HTML response). " - + "This usually means an Azure gateway/WAF rejected the request before the API. " - + "Check Front Door / App Gateway logs for /v1/admin/tenants/.../settings."; - } - - if (clientEx.StatusCode > 0) - return $"{fallback} (HTTP {clientEx.StatusCode})"; - } - - return fallback; - } } diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/UserManager.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/UserManager.cshtml.cs index aad6527..c7ea448 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/UserManager.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/UserManager.cshtml.cs @@ -1,6 +1,5 @@ using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; -using GovUK.Dfe.CoreLibs.Http.Models; -using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; using GovUK.Dfe.FlexForms.Web.Security; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -12,9 +11,7 @@ namespace GovUK.Dfe.FlexForms.Web.Pages.Admin; /// Lists users with form access in the current tenant. /// [Authorize(Policy = AdminAccessHelper.CanManageUsersPolicy)] -public sealed class UserManagerModel( - IUsersClient usersClient, - ILogger logger) : PageModel +public sealed class UserManagerModel(IUserManagerAdmin userManagerAdmin) : PageModel { public IReadOnlyList Users { get; private set; } = []; @@ -34,82 +31,53 @@ public sealed class UserManagerModel( public async Task OnGetAsync(CancellationToken cancellationToken) { - if (TempData["UserManagerSuccess"] is string success) - { - ShowSuccess = true; - SuccessMessage = success; - } - - if (TempData["UserManagerError"] is string error) - { - HasError = true; - ErrorMessage = error; - } - - await LoadUsersAsync(cancellationToken); - await LoadAccessAuditLogAsync(cancellationToken); + ApplyTempData(); + var state = new UserManagerWorkState(); + await userManagerAdmin.LoadAsync(state, cancellationToken); + ApplyWorkState(state); } public async Task OnPostRemoveAsync(Guid userId, CancellationToken cancellationToken) { - try - { - await usersClient.RemoveUserFromTenantAsync(userId, cancellationToken); - TempData["UserManagerSuccess"] = "User removed from this tenant."; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to remove user {UserId} from tenant", userId); - TempData["UserManagerError"] = GetErrorMessage(ex, "Could not remove the user from this tenant."); - } - - return RedirectToPage(); + return MapOutcome(await userManagerAdmin.RemoveAsync(new UserManagerWorkState(), userId, cancellationToken)); } - private async Task LoadUsersAsync(CancellationToken cancellationToken) + private void ApplyWorkState(UserManagerWorkState state) { - try + Users = state.Users; + AccessAuditEntries = state.AccessAuditEntries; + AuditLogLoadFailed = state.AuditLogLoadFailed; + AuditLogLoadErrorMessage = state.AuditLogLoadErrorMessage; + if (state.HasError) { - var users = await usersClient.GetTenantUsersAsync(cancellationToken); - Users = users?.OrderBy(u => u.Name).ToList() ?? []; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to load tenant users"); HasError = true; - ErrorMessage = GetErrorMessage(ex, "Could not load users for this tenant."); - Users = []; + ErrorMessage = state.ErrorMessage; } } - private async Task LoadAccessAuditLogAsync(CancellationToken cancellationToken) + private IActionResult MapOutcome(AdminPageOutcome outcome) { - try - { - var log = await usersClient.GetAccessAuditLogAsync(take: 50, cancellationToken); - AccessAuditEntries = log?.Entries? - .OrderByDescending(e => e.OccurredAtUtc) - .ToList() ?? []; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to load tenant access audit log"); - AccessAuditEntries = []; - AuditLogLoadFailed = true; - AuditLogLoadErrorMessage = GetErrorMessage( - ex, - "Could not load the access audit trail. Ensure the API is up to date and database migrations have been applied."); - } + if (outcome.SuccessMessage != null) + TempData["UserManagerSuccess"] = outcome.SuccessMessage; + + if (outcome.ErrorMessage != null) + TempData["UserManagerError"] = outcome.ErrorMessage; + + return RedirectToPage(); } - internal static string GetErrorMessage(Exception ex, string fallback) + private void ApplyTempData() { - if (ex is ExternalApplicationsException apiEx - && !string.IsNullOrWhiteSpace(apiEx.Result?.Message)) + if (TempData["UserManagerSuccess"] is string success) { - return apiEx.Result.Message; + ShowSuccess = true; + SuccessMessage = success; } - return fallback; + if (TempData["UserManagerError"] is string error) + { + HasError = true; + ErrorMessage = error; + } } } diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/UserManagerAdd.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/UserManagerAdd.cshtml.cs index 3a93d02..34498e7 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/UserManagerAdd.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/UserManagerAdd.cshtml.cs @@ -1,7 +1,6 @@ using System.ComponentModel.DataAnnotations; -using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; -using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; using GovUK.Dfe.FlexForms.Web.Security; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -13,11 +12,7 @@ namespace GovUK.Dfe.FlexForms.Web.Pages.Admin; /// Registers a user into the tenant with a role and optional form access. /// [Authorize(Policy = AdminAccessHelper.CanManageUsersPolicy)] -public sealed class UserManagerAddModel( - IUsersClient usersClient, - ITemplatesClient templatesClient, - IRolesClient rolesClient, - ILogger logger) : PageModel +public sealed class UserManagerAddModel(IUserManagerAddAdmin userManagerAddAdmin) : PageModel { [BindProperty] [Required(ErrorMessage = "Enter the user's name")] @@ -42,109 +37,62 @@ public sealed class UserManagerAddModel( public async Task OnGetAsync(CancellationToken cancellationToken) { - await LoadLookupsAsync(cancellationToken); + var state = CaptureWorkState(); + await userManagerAddAdmin.LoadAsync(state, cancellationToken); + ApplyWorkState(state); + ApplyErrors(state); } public async Task OnPostAsync(CancellationToken cancellationToken) { - await LoadLookupsAsync(cancellationToken); + var state = CaptureWorkState(); + await userManagerAddAdmin.LoadAsync(state, cancellationToken); + ApplyWorkState(state); + ApplyErrors(state); if (!ModelState.IsValid) return Page(); - if (!AssignableRoles.Contains(Role, StringComparer.OrdinalIgnoreCase)) - { - ModelState.AddModelError(nameof(Role), "Select a valid role for this tenant."); - return Page(); - } - - var isSystemUserRole = string.Equals(Role, "User", StringComparison.OrdinalIgnoreCase); - if (isSystemUserRole && (SelectedTemplateIds is null || SelectedTemplateIds.Count == 0)) - { - ModelState.AddModelError(nameof(SelectedTemplateIds), "Select at least one form for the User role."); - return Page(); - } - - try - { - var existingUsers = await usersClient.GetTenantUsersAsync(cancellationToken); - if (existingUsers?.Any(u => - string.Equals(u.Email, Email.Trim(), StringComparison.OrdinalIgnoreCase)) == true) - { - ModelState.AddModelError( - nameof(Email), - "A user with this email address already exists in this tenant."); - return Page(); - } - - var created = await usersClient.AssignUserRoleAsync( - new AssignUserRoleRequest - { - Name = Name.Trim(), - Email = Email.Trim(), - Role = Role, - TemplateIds = SelectedTemplateIds - }, - createOnly: true, - cancellationToken); - - if (created?.UserId is Guid userId && SelectedTemplateIds is { Count: > 0 }) - { - await usersClient.UpdateUserTemplateAccessAsync( - userId, - new UpdateUserTemplateAccessRequest { TemplateIds = SelectedTemplateIds }, - cancellationToken); - } - - TempData["UserManagerSuccess"] = $"User {Email.Trim()} has been added with role {Role}."; - return RedirectToPage("/Admin/UserManager"); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to add user {Email}", Email); - ModelState.AddModelError(string.Empty, UserManagerModel.GetErrorMessage(ex, "Could not add the user.")); - return Page(); - } + return MapOutcome(await userManagerAddAdmin.AddAsync(state, cancellationToken), state); } - private async Task LoadLookupsAsync(CancellationToken cancellationToken) + private UserManagerAddWorkState CaptureWorkState() => + new() + { + Name = Name, + Email = Email, + Role = Role, + SelectedTemplateIds = SelectedTemplateIds, + IncludeTenantAdmin = AdminAccessHelper.IsSuperAdmin(User) + }; + + private void ApplyWorkState(UserManagerAddWorkState state) { - await LoadTemplatesAsync(cancellationToken); - await LoadRolesAsync(cancellationToken); + Name = state.Name; + Email = state.Email; + Role = state.Role; + SelectedTemplateIds = state.SelectedTemplateIds; + AvailableTemplates = state.AvailableTemplates; + AssignableRoles = state.AssignableRoles; } - private async Task LoadTemplatesAsync(CancellationToken cancellationToken) + private void ApplyErrors(UserManagerAddWorkState state) { - try - { - var templates = await templatesClient.GetAccessibleTemplatesAsync(cancellationToken); - AvailableTemplates = templates?.OrderBy(t => t.Name).ToList() ?? []; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to load templates for add user"); - ModelState.AddModelError(string.Empty, UserManagerModel.GetErrorMessage(ex, "Could not load available forms.")); - AvailableTemplates = []; - } + foreach (var error in state.Errors) + ModelState.AddModelError(error.FieldKey, error.Message); } - private async Task LoadRolesAsync(CancellationToken cancellationToken) + private IActionResult MapOutcome(AdminPageOutcome outcome, UserManagerAddWorkState state) { - try - { - var roles = await rolesClient.ListAsync(cancellationToken); - AssignableRoles = AdminAccessHelper.GetUserManagerAssignableRoles( - User, - roles?.Select(r => (r.Name, r.IsSystem))); - - if (string.IsNullOrWhiteSpace(Role) || !AssignableRoles.Contains(Role, StringComparer.OrdinalIgnoreCase)) - Role = AssignableRoles.FirstOrDefault() ?? "User"; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to load roles for add user"); - ModelState.AddModelError(string.Empty, UserManagerModel.GetErrorMessage(ex, "Could not load available roles.")); - AssignableRoles = AdminAccessHelper.GetUserManagerAssignableRoles(User, null); - } + ApplyWorkState(state); + foreach (var error in outcome.Errors) + ModelState.AddModelError(error.FieldKey, error.Message); + + if (outcome.SuccessMessage != null) + TempData["UserManagerSuccess"] = outcome.SuccessMessage; + + return outcome.Kind == AdminPageOutcomeKind.RedirectToPage + ? RedirectToPage("/Admin/UserManager") + : Page(); } } diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/UserManagerEdit.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/UserManagerEdit.cshtml.cs index 915ba78..e0f9984 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/UserManagerEdit.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/UserManagerEdit.cshtml.cs @@ -1,9 +1,8 @@ using System.ComponentModel.DataAnnotations; using System.Security.Claims; -using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; -using GovUK.Dfe.FlexForms.Api.Client.Contracts; using GovUK.Dfe.FlexForms.Api.Client.Security; +using GovUK.Dfe.FlexForms.Application.Admin; using GovUK.Dfe.FlexForms.Web.Security; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -17,12 +16,9 @@ namespace GovUK.Dfe.FlexForms.Web.Pages.Admin; /// [Authorize(Policy = AdminAccessHelper.CanManageUsersPolicy)] public sealed class UserManagerEditModel( - IUsersClient usersClient, - ITemplatesClient templatesClient, - IRolesClient rolesClient, + IUserManagerEditAdmin userManagerEditAdmin, IInternalUserTokenStore tokenStore, - IMemoryCache memoryCache, - ILogger logger) : PageModel + IMemoryCache memoryCache) : PageModel { [BindProperty(SupportsGet = true)] public Guid UserId { get; set; } @@ -44,136 +40,79 @@ public sealed class UserManagerEditModel( public async Task OnGetAsync(CancellationToken cancellationToken) { - var loaded = await LoadAsync(cancellationToken); - return loaded ? Page() : RedirectToPage("/Admin/UserManager"); + var state = CaptureWorkState(); + var outcome = await userManagerEditAdmin.LoadAsync(state, cancellationToken); + ApplyWorkState(state); + return MapOutcome(outcome); } public async Task OnPostAsync(CancellationToken cancellationToken) { - await LoadLookupsAsync(cancellationToken); + var state = CaptureWorkState(); + var loaded = await userManagerEditAdmin.LoadForUpdateAsync(state, cancellationToken); + ApplyWorkState(state); + if (loaded.Kind == AdminPageOutcomeKind.RedirectToPage) + return MapOutcome(loaded); - var users = await usersClient.GetTenantUsersAsync(cancellationToken); - var user = users?.FirstOrDefault(u => u.UserId == UserId); - if (user is null) - { - TempData["UserManagerError"] = "User not found in this tenant."; - return RedirectToPage("/Admin/UserManager"); - } - - UserName = user.Name; - UserEmail = user.Email; + foreach (var error in loaded.Errors) + ModelState.AddModelError(error.FieldKey, error.Message); if (!ModelState.IsValid) return Page(); - if (!AssignableRoles.Contains(Role, StringComparer.OrdinalIgnoreCase)) - { - ModelState.AddModelError(nameof(Role), "Select a valid role for this tenant."); - return Page(); - } + var outcome = await userManagerEditAdmin.UpdateAsync(state, cancellationToken); + ApplyWorkState(state); - try - { - await usersClient.AssignUserRoleAsync( - new AssignUserRoleRequest - { - Name = UserName, - Email = UserEmail, - Role = Role, - TemplateIds = SelectedTemplateIds - }, - createOnly: false, - cancellationToken); - - await usersClient.UpdateUserTemplateAccessAsync( - UserId, - new UpdateUserTemplateAccessRequest { TemplateIds = SelectedTemplateIds ?? [] }, - cancellationToken); - - // If the admin edited their own role, drop the cached OBO JWT and web permission claims - // immediately so the next request re-exchanges with the new membership. - var actingEmail = User.FindFirstValue(ClaimTypes.Email); - if (!string.IsNullOrWhiteSpace(actingEmail) - && string.Equals(actingEmail, UserEmail, StringComparison.OrdinalIgnoreCase)) - { - tokenStore.ClearToken(); - UserPermissionsCache.Invalidate(memoryCache, User); - } - - TempData["UserManagerSuccess"] = "User role and form access updated."; - return RedirectToPage("/Admin/UserManager"); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to update user {UserId}", UserId); - ModelState.AddModelError(string.Empty, UserManagerModel.GetErrorMessage(ex, "Could not update the user.")); - return Page(); - } + if (outcome.Kind == AdminPageOutcomeKind.RedirectToPage && outcome.SuccessMessage != null) + InvalidateActorSessionIfSelf(state.UserEmail); + + return MapOutcome(outcome); } - private async Task LoadAsync(CancellationToken cancellationToken) - { - try - { - await LoadLookupsAsync(cancellationToken); - - var users = await usersClient.GetTenantUsersAsync(cancellationToken); - var user = users?.FirstOrDefault(u => u.UserId == UserId); - if (user is null) - { - TempData["UserManagerError"] = "User not found in this tenant."; - return false; - } - - UserName = user.Name; - UserEmail = user.Email; - Role = user.Role; - - if (!AssignableRoles.Contains(Role, StringComparer.OrdinalIgnoreCase) - && !string.IsNullOrWhiteSpace(Role)) - { - AssignableRoles = AssignableRoles.Append(Role).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(r => r).ToList(); - } - - if (SelectedTemplateIds.Count == 0) - SelectedTemplateIds = user.Templates.Select(t => t.TemplateId).ToList(); - - return true; - } - catch (Exception ex) + private UserManagerEditWorkState CaptureWorkState() => + new() { - logger.LogError(ex, "Failed to load user {UserId} for edit", UserId); - TempData["UserManagerError"] = UserManagerModel.GetErrorMessage(ex, "Could not load user details."); - return false; - } + UserId = UserId, + Role = Role, + SelectedTemplateIds = SelectedTemplateIds, + IncludeTenantAdmin = AdminAccessHelper.IsSuperAdmin(User) + }; + + private void ApplyWorkState(UserManagerEditWorkState state) + { + UserId = state.UserId; + UserName = state.UserName; + UserEmail = state.UserEmail; + Role = state.Role; + SelectedTemplateIds = state.SelectedTemplateIds; + AvailableTemplates = state.AvailableTemplates; + AssignableRoles = state.AssignableRoles; } - private async Task LoadLookupsAsync(CancellationToken cancellationToken) + private IActionResult MapOutcome(AdminPageOutcome outcome) { - try - { - var templates = await templatesClient.GetAccessibleTemplatesAsync(cancellationToken); - AvailableTemplates = templates?.OrderBy(t => t.Name).ToList() ?? []; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to load templates for edit user"); - ModelState.AddModelError(string.Empty, UserManagerModel.GetErrorMessage(ex, "Could not load available forms.")); - AvailableTemplates = []; - } + foreach (var error in outcome.Errors) + ModelState.AddModelError(error.FieldKey, error.Message); - try - { - var roles = await rolesClient.ListAsync(cancellationToken); - AssignableRoles = AdminAccessHelper.GetUserManagerAssignableRoles( - User, - roles?.Select(r => (r.Name, r.IsSystem))); - } - catch (Exception ex) + if (outcome.SuccessMessage != null) + TempData["UserManagerSuccess"] = outcome.SuccessMessage; + + if (outcome.ErrorMessage != null) + TempData["UserManagerError"] = outcome.ErrorMessage; + + return outcome.Kind == AdminPageOutcomeKind.RedirectToPage + ? RedirectToPage("/Admin/UserManager") + : Page(); + } + + private void InvalidateActorSessionIfSelf(string userEmail) + { + var actingEmail = User.FindFirstValue(ClaimTypes.Email); + if (!string.IsNullOrWhiteSpace(actingEmail) + && string.Equals(actingEmail, userEmail, StringComparison.OrdinalIgnoreCase)) { - logger.LogError(ex, "Failed to load roles for edit user"); - ModelState.AddModelError(string.Empty, UserManagerModel.GetErrorMessage(ex, "Could not load available roles.")); - AssignableRoles = AdminAccessHelper.GetUserManagerAssignableRoles(User, null); + tokenStore.ClearToken(); + UserPermissionsCache.Invalidate(memoryCache, User); } } } diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/UserManagerPermissions.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/UserManagerPermissions.cshtml.cs index c1b5720..2b4f27a 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/UserManagerPermissions.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Admin/UserManagerPermissions.cshtml.cs @@ -1,6 +1,5 @@ using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; -using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; -using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; using GovUK.Dfe.FlexForms.Web.Security; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -13,11 +12,9 @@ namespace GovUK.Dfe.FlexForms.Web.Pages.Admin; /// Does not affect permissions inherited from the user's role. /// [Authorize(Policy = AdminAccessHelper.CanManageUsersPolicy)] -public sealed class UserManagerPermissionsModel( - IUsersClient usersClient, - ILogger logger) : PageModel +public sealed class UserManagerPermissionsModel(IUserManagerPermissionsAdmin userManagerPermissionsAdmin) : PageModel { - public const string AnyResourceKey = RoleManagerPermissionsModel.AnyResourceKey; + public const string AnyResourceKey = AdminPermissionGrants.AnyResourceKey; [BindProperty(SupportsGet = true)] public Guid UserId { get; set; } @@ -48,206 +45,63 @@ public sealed class UserManagerPermissionsModel( public async Task OnGetAsync(CancellationToken cancellationToken) { - var loaded = await LoadAsync(cancellationToken); - return loaded ? Page() : RedirectToPage("/Admin/UserManager"); + var state = CaptureWorkState(); + var outcome = await userManagerPermissionsAdmin.LoadAsync(state, cancellationToken); + ApplyWorkState(state); + return MapOutcome(outcome); } public async Task OnPostAddAsync(CancellationToken cancellationToken) { - if (!await LoadUserMetaAsync(cancellationToken)) - return RedirectToPage("/Admin/UserManager"); - - SelectedGrants = NormalizeGrants(SelectedGrants); - - var resourceKey = NewResourceKey?.Trim() ?? string.Empty; - if (string.IsNullOrWhiteSpace(resourceKey)) - { - ModelState.AddModelError(nameof(NewResourceKey), "Enter a resource key."); - return Page(); - } - - var validationError = ValidateUserGrant(NewResourceType, resourceKey, NewAccessType); - if (validationError is not null) - { - ModelState.AddModelError(nameof(NewResourceKey), validationError); - return Page(); - } - - var key = RoleManagerPermissionsModel.EncodeGrantKey(NewResourceType, resourceKey, NewAccessType); - if (SelectedGrants.Contains(key, StringComparer.OrdinalIgnoreCase)) - { - ModelState.AddModelError( - string.Empty, - $"{NewResourceType} / {resourceKey} / {NewAccessType} is already in the list."); - return Page(); - } - - SelectedGrants.Add(key); - SelectedGrants = NormalizeGrants(SelectedGrants); - - return await SaveAndReloadAsync(cancellationToken); + var state = CaptureWorkState(); + var outcome = await userManagerPermissionsAdmin.AddGrantAsync(state, cancellationToken); + ApplyWorkState(state); + return MapOutcome(outcome); } public async Task OnPostRemoveAsync(string grantKey, CancellationToken cancellationToken) { - // Remove form does not post Add fields; clear implicit required errors for them. ModelState.Remove(nameof(NewResourceKey)); ModelState.Remove(nameof(NewResourceType)); ModelState.Remove(nameof(NewAccessType)); - if (!await LoadUserMetaAsync(cancellationToken)) - return RedirectToPage("/Admin/UserManager"); - - SelectedGrants = NormalizeGrants(SelectedGrants); - SelectedGrants.RemoveAll(g => string.Equals(g, grantKey, StringComparison.OrdinalIgnoreCase)); - - return await SaveAndReloadAsync(cancellationToken); + var state = CaptureWorkState(); + var outcome = await userManagerPermissionsAdmin.RemoveGrantAsync(state, grantKey, cancellationToken); + ApplyWorkState(state); + return MapOutcome(outcome); } - private async Task SaveAndReloadAsync(CancellationToken cancellationToken) - { - foreach (var grant in SelectedGrants.Select(ParseGrantKey).Where(g => g is not null)) - { - var error = ValidateUserGrant( - grant!.Value.ResourceType, - grant.Value.ResourceKey, - grant.Value.AccessType); - if (error is not null) - { - ModelState.AddModelError(string.Empty, error); - return Page(); - } - } - - try + private UserManagerPermissionsWorkState CaptureWorkState() => + new() { - var grants = SelectedGrants - .Select(ParseGrantKey) - .Where(g => g is not null) - .Select(g => g!) - .Select(g => new RolePermissionGrantDto - { - ResourceType = g.Value.ResourceType, - ResourceKey = g.Value.ResourceKey, - AccessType = g.Value.AccessType - }) - .ToList(); - - await usersClient.SetUserPermissionsAsync( - UserId, - new SetUserPermissionsRequest { Permissions = grants }, - cancellationToken); - - NewResourceKey = string.Empty; - // Always re-load from the API so the list reflects tenant-filtered grants, - // not the posted form (which can contain stale cross-tenant entries). - await LoadPermissionsAsync(cancellationToken); - return Page(); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to save permissions for user {UserId}", UserId); - ModelState.AddModelError(string.Empty, UserManagerModel.GetErrorMessage(ex, "Could not save permissions.")); - await LoadPermissionsAsync(cancellationToken); - return Page(); - } - } - - private async Task LoadAsync(CancellationToken cancellationToken) + UserId = UserId, + SelectedGrants = SelectedGrants, + NewResourceType = NewResourceType, + NewResourceKey = NewResourceKey, + NewAccessType = NewAccessType + }; + + private void ApplyWorkState(UserManagerPermissionsWorkState state) { - if (!await LoadUserMetaAsync(cancellationToken)) - return false; - - try - { - await LoadPermissionsAsync(cancellationToken); - return true; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to load permissions for user {UserId}", UserId); - TempData["UserManagerError"] = UserManagerModel.GetErrorMessage(ex, "Could not load user permissions."); - return false; - } + UserId = state.UserId; + UserName = state.UserName; + UserEmail = state.UserEmail; + SelectedGrants = state.SelectedGrants; + NewResourceType = state.NewResourceType; + NewResourceKey = state.NewResourceKey; + NewAccessType = state.NewAccessType; } - private async Task LoadPermissionsAsync(CancellationToken cancellationToken) + private IActionResult MapOutcome(AdminPageOutcome outcome) { - var existing = await usersClient.GetUserPermissionsAsync(UserId, cancellationToken); - SelectedGrants = NormalizeGrants( - existing? - .Select(p => RoleManagerPermissionsModel.EncodeGrantKey(p.ResourceType, p.ResourceKey, p.AccessType)) - .ToList() ?? []); - } - - private async Task LoadUserMetaAsync(CancellationToken cancellationToken) - { - try - { - var users = await usersClient.GetTenantUsersAsync(cancellationToken); - var user = users?.FirstOrDefault(u => u.UserId == UserId); - if (user is null) - { - TempData["UserManagerError"] = "User not found."; - return false; - } - - UserName = user.Name; - UserEmail = user.Email; - return true; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to load user {UserId}", UserId); - TempData["UserManagerError"] = UserManagerModel.GetErrorMessage(ex, "Could not load user."); - return false; - } - } - - private static List NormalizeGrants(IEnumerable? grants) => - (grants ?? []) - .Select(ParseGrantKey) - .Where(g => g is not null) - .Select(g => RoleManagerPermissionsModel.EncodeGrantKey( - g!.Value.ResourceType, - g.Value.ResourceKey, - g.Value.AccessType)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(g => g, StringComparer.OrdinalIgnoreCase) - .ToList(); - - private static (ResourceType ResourceType, string ResourceKey, AccessType AccessType)? ParseGrantKey(string? key) - { - if (string.IsNullOrWhiteSpace(key)) - return null; - - var parts = key.Split('|', 3); - if (parts.Length != 3) - return null; + foreach (var error in outcome.Errors) + ModelState.AddModelError(error.FieldKey, error.Message); - if (!Enum.TryParse(parts[0], ignoreCase: true, out var resourceType)) - return null; - - if (string.IsNullOrWhiteSpace(parts[1])) - return null; - - if (!Enum.TryParse(parts[2], ignoreCase: true, out var accessType)) - return null; - - return (resourceType, parts[1].Trim(), accessType); - } - - /// - /// Same shape rules as role grants, but Manage is never allowed on an individual user. - /// - internal static string? ValidateUserGrant(ResourceType resourceType, string resourceKey, AccessType accessType) - { - if (accessType == AccessType.Manage) - { - return "Access type 'Manage' cannot be granted to an individual user. " + - "Assign Manage via a tenant role instead."; - } + if (outcome.ErrorMessage != null) + TempData["UserManagerError"] = outcome.ErrorMessage; - return RoleManagerPermissionsModel.ValidateGrant(resourceType, resourceKey, accessType); + return outcome.Kind == AdminPageOutcomeKind.RedirectToPage + ? RedirectToPage("/Admin/UserManager") + : Page(); } } diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Contributors-Invite.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Contributors-Invite.cshtml.cs index 6122174..c2890ad 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Contributors-Invite.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Contributors-Invite.cshtml.cs @@ -19,8 +19,6 @@ public class ContributorsInviteModel( IApplicationStateService applicationStateService, IContributorPatternService contributorPatternService, IRequestAppConfiguration requestConfiguration, - //IApiErrorParser apiErrorParser, - //IModelStateErrorHandler errorHandler, ILogger logger) : PageModel { [BindProperty(SupportsGet = true, Name = "referenceNumber")] @@ -48,7 +46,7 @@ public async Task OnGetAsync() { // Ensure we have a valid application ID - var (applicationId, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber, HttpContext.Session); + var (applicationId, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber); var redirect = await RedirectIfContributorPatternDisabledAsync(application); if (redirect != null) @@ -65,7 +63,7 @@ public async Task OnGetAsync() /// public async Task OnPostSendInviteAsync() { - var (applicationId, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber, HttpContext.Session); + var (applicationId, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber); var redirect = await RedirectIfContributorPatternDisabledAsync(application); if (redirect != null) @@ -105,7 +103,7 @@ public async Task OnPostCancel() { logger.LogInformation("User cancelled contributor invitation for application reference {ReferenceNumber}", ReferenceNumber); - var (_, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber, HttpContext.Session); + var (_, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber); var redirect = await RedirectIfContributorPatternDisabledAsync(application); if (redirect != null) { diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Contributors.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Contributors.cshtml.cs index 1049916..7dfe5b8 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Contributors.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Contributors.cshtml.cs @@ -34,7 +34,7 @@ public class ContributorsModel( /// public async Task OnGetAsync() { - var (applicationId, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber, HttpContext.Session); + var (applicationId, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber); var redirect = await RedirectIfContributorPatternDisabledAsync(application); if (redirect != null) @@ -66,7 +66,7 @@ public IActionResult OnPostProceedToForm() /// public async Task OnPostAddContributor() { - var (_, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber, HttpContext.Session); + var (_, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber); var redirect = await RedirectIfContributorPatternDisabledAsync(application); if (redirect != null) { @@ -86,7 +86,7 @@ public async Task OnPostRemoveContributorAsync(Guid contributorId { if (!ApplicationId.HasValue) { - var (applicationId, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber, HttpContext.Session); + var (applicationId, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber); ApplicationId = applicationId; var redirect = await RedirectIfContributorPatternDisabledAsync(application); @@ -169,7 +169,7 @@ public async Task OnGetRemoveContributorAsync() if (!ApplicationId.HasValue) { - var (applicationId, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber, HttpContext.Session); + var (applicationId, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber); ApplicationId = applicationId; var redirect = await RedirectIfContributorPatternDisabledAsync(application); diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Dashboard.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Dashboard.cshtml.cs index 8ea24cd..4982c35 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Dashboard.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Dashboard.cshtml.cs @@ -5,7 +5,6 @@ using GovUK.Dfe.FlexForms.Web.Security; using GovUK.Dfe.FlexForms.Web.Services; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; -using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; using GovUK.Dfe.FlexForms.Api.Client.Contracts; using Microsoft.AspNetCore.Authorization; @@ -25,12 +24,9 @@ namespace GovUK.Dfe.FlexForms.Web.Pages.Applications public class DashboardModel( ILogger logger, IApplicationStatusService applicationStatusService, - IApplicationsClient applicationsClient, + IDashboardApplications dashboardApplications, IUsersClient usersClient, - IHttpContextAccessor httpContextAccessor, IApplicationResponseService applicationResponseService, - IContributorPatternService contributorPatternService, - IFormTemplateProvider formTemplateProvider, IMemoryCache memoryCache, IOptions dashboardOptions) : PageModel @@ -88,20 +84,6 @@ public class DashboardModel( public bool IsSearchActive => FiltersEnabled && SearchFilters.HasActiveFilters; public bool ShowFiltersPanel => IsSearchActive; - public class ApplicationWithCalculatedStatus - { - public ApplicationDto Application { get; set; } = null!; - public KeyValuePair CalculatedStatus { get; set; } - public IReadOnlyDictionary CustomColumnValues { get; set; } = - new Dictionary(StringComparer.OrdinalIgnoreCase); - - public Guid ApplicationId => Application.ApplicationId; - public string ApplicationReference => Application.ApplicationReference; - public string TemplateName => Application.TemplateName; - public DateTime DateCreated => Application.DateCreated; - public DateTime? DateSubmitted => Application.DateSubmitted; - } - public async SystemTask OnGetAsync(ApplicationStatus? status = null) { var statusFilters = new List>(); @@ -115,8 +97,8 @@ public async SystemTask OnGetAsync(ApplicationStatus? status = null) StatusFilters = statusFilters.OrderBy(x => x.Key).ToList(); SelectedStatusFilter = status; ValidateSearchFilters(); - await LoadDashboardColumnsAsync(); - await LoadUserDetailsAsync(); + Columns = await dashboardApplications.ResolveColumnsAsync(ResolveTemplateId()); + LoadUserDetails(); await LoadApplicationsAsync(); } @@ -157,16 +139,13 @@ public async Task OnPostCreateApplicationAsync() if (!templateGuid.HasValue) { HasError = true; - ErrorMessage = "Template is not configured. Please refresh the page."; + ErrorMessage = DashboardMessages.TemplateNotConfigured; logger.LogWarning("TemplateId not available when creating application"); return Page(); } - var response = await applicationsClient.CreateApplicationAsync(new CreateApplicationRequest - { - InitialResponseBody = "{}", - TemplateId = templateGuid.Value - }); + var created = await dashboardApplications.CreateAsync(templateGuid.Value); + var response = created.Application; HttpContext.Session.SetString("ApplicationId", response.ApplicationId.ToString()); HttpContext.Session.SetString("ApplicationReference", response.ApplicationReference); @@ -188,8 +167,7 @@ public async Task OnPostCreateApplicationAsync() HttpContext.Session.SetString($"ApplicationLeadApplicantUserId_{response.ApplicationId}", currentUserId); } - // Clear any existing accumulated form data when starting a new application - applicationResponseService.ClearAccumulatedFormData(HttpContext.Session); + applicationResponseService.ClearAccumulatedFormData(); HttpContext.Session.SetString("CurrentAccumulatedApplicationId", response.ApplicationId.ToString()); if (User.Identity?.IsAuthenticated == true) @@ -200,9 +178,7 @@ public async Task OnPostCreateApplicationAsync() logger.LogInformation("Created new application {ApplicationId} and cleared accumulated form data", response.ApplicationId); - // Note: Token management now handled automatically by TokenManagementMiddleware - var templateId = templateGuid.Value.ToString(); - if (await contributorPatternService.IsEnabledAsync(templateId)) + if (created.ContributorsEnabled) { return RedirectToPage("/Applications/Contributors", new { referenceNumber = response.ApplicationReference }); } @@ -210,26 +186,6 @@ public async Task OnPostCreateApplicationAsync() return RedirectToPage("/FormEngine/RenderForm", new { referenceNumber = response.ApplicationReference }); } - private async SystemTask LoadDashboardColumnsAsync() - { - Columns = DashboardColumnResolver.DefaultColumns; - - var templateGuid = ResolveTemplateId(); - if (!templateGuid.HasValue) - return; - - try - { - var template = await formTemplateProvider.GetTemplateAsync(templateGuid.Value.ToString()); - Columns = DashboardColumnResolver.Resolve(template); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to load dashboard columns from latest template {TemplateId}; using defaults", templateGuid); - Columns = DashboardColumnResolver.DefaultColumns; - } - } - private async SystemTask LoadApplicationsAsync() { if (!ModelState.IsValid) @@ -241,48 +197,32 @@ private async SystemTask LoadApplicationsAsync() var templateGuid = ResolveTemplateId(); if (!templateGuid.HasValue) { - // Try again on next request; show empty state instead of erroring logger.LogWarning("TemplateId not available when loading applications; rendering empty dashboard"); Applications = Array.Empty(); return; } - var pageSize = dashboardOptions.Value.PageSize; var filters = FiltersEnabled ? SearchFilters : new DashboardApplicationSearch(); - var result = await applicationsClient.GetMyApplicationsAsync( - templateId: templateGuid.Value, - pageNumber: CurrentPage, - pageSize: pageSize, - applicationReference: string.IsNullOrWhiteSpace(filters.SearchReference) ? null : filters.SearchReference, - dateStartedFrom: filters.DateStartedFrom, - dateStartedTo: filters.DateStartedTo, - dateSubmittedFrom: filters.DateSubmittedFrom, - dateSubmittedTo: filters.DateSubmittedTo, - status: filters.Status); - - TotalPages = result.TotalPages; - CurrentPage = Math.Clamp(CurrentPage, 1, Math.Max(1, TotalPages)); - - var fieldColumns = Columns.Where(c => c.Kind == DashboardColumnKind.Field).ToList(); - - var applicationTasks = result.Items.AsEnumerable().Select(async app => + var result = await dashboardApplications.ListAsync(new DashboardApplicationListQuery { - var formData = DashboardAnswerReader.ParseFormData(app.LatestResponse?.ResponseBody); - var customValues = fieldColumns.ToDictionary( - c => c.Key, - c => DashboardAnswerReader.GetDisplayValue(c.FieldId!, formData), - StringComparer.OrdinalIgnoreCase); - - return new ApplicationWithCalculatedStatus - { - Application = app, - CalculatedStatus = applicationStatusService.GetCalculatedApplicationStatusAsync(app, CustomStatuses), - CustomColumnValues = customValues - }; + TemplateId = templateGuid.Value, + CurrentPage = CurrentPage, + PageSize = dashboardOptions.Value.PageSize, + Scope = DashboardApplicationListScope.Mine, + IncludeCustomColumns = true, + Columns = Columns, + CustomStatuses = CustomStatuses, + SearchReference = filters.SearchReference, + DateStartedFrom = filters.DateStartedFrom, + DateStartedTo = filters.DateStartedTo, + DateSubmittedFrom = filters.DateSubmittedFrom, + DateSubmittedTo = filters.DateSubmittedTo, + Status = filters.Status }); - Applications = [..(await SystemTask.WhenAll(applicationTasks)) - .OrderByDescending(a => a.DateCreated)]; + Applications = result.Applications; + TotalPages = result.TotalPages; + CurrentPage = result.CurrentPage; } private Guid? ResolveTemplateId() @@ -295,12 +235,10 @@ private async SystemTask LoadApplicationsAsync() return guid; } - // Fallback to configuration var configuration = HttpContext.RequestServices.GetService(typeof(IRequestAppConfiguration)) as IRequestAppConfiguration; var configured = configuration?["Template:Id"]; if (Guid.TryParse(configured, out var cfgGuid)) { - // Persist into session for subsequent requests HttpContext.Session.SetString("TemplateId", cfgGuid.ToString()); return cfgGuid; } @@ -313,7 +251,7 @@ private async SystemTask LoadApplicationsAsync() return null; } - private SystemTask LoadUserDetailsAsync() + private void LoadUserDetails() { Email = User.FindFirst(ClaimTypes.Email)?.Value ?? User.FindFirst("email")?.Value; @@ -337,8 +275,6 @@ private SystemTask LoadUserDetailsAsync() OrganisationName = null; } } - - return SystemTask.CompletedTask; } } } diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Index.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Index.cshtml.cs index d001d71..314e784 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Index.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Index.cshtml.cs @@ -1,21 +1,20 @@ using GovUK.Dfe.FlexForms.Web.Security; +using GovUK.Dfe.FlexForms.Application.Dashboard; using GovUK.Dfe.FlexForms.Application.Options; using GovUK.Dfe.FlexForms.Web.Models.Applications; using GovUK.Dfe.FlexForms.Web.Services; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; -using GovUK.Dfe.FlexForms.Api.Client.Contracts; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Options; -using static GovUK.Dfe.FlexForms.Web.Pages.Applications.DashboardModel; namespace GovUK.Dfe.FlexForms.Web.Pages.Applications; [Authorize(Policy = AdminAccessHelper.CanReadAnyApplicationPolicy)] public class IndexModel( - IApplicationsClient applicationsClient, + IDashboardApplications dashboardApplications, IApplicationStatusService applicationStatusService, IOptions dashboardOptions, ILogger logger) : PageModel @@ -115,35 +114,30 @@ private async Task LoadApplicationsAsync() if (!TemplateId.HasValue) { - // Try again on next request; show empty state instead of erroring logger.LogWarning("TemplateId not available when loading applications; rendering empty dashboard"); Applications = Array.Empty(); return; } - var pageSize = dashboardOptions.Value.PageSize; var filters = FiltersEnabled ? SearchFilters : new DashboardApplicationSearch(); - var result = await applicationsClient.GetApplicationsByTemplateAsync( - templateId: TemplateId.Value, - pageNumber: CurrentPage, - pageSize: pageSize, - applicationReference: string.IsNullOrWhiteSpace(filters.SearchReference) ? null : filters.SearchReference, - dateStartedFrom: filters.DateStartedFrom, - dateStartedTo: filters.DateStartedTo, - dateSubmittedFrom: filters.DateSubmittedFrom, - dateSubmittedTo: filters.DateSubmittedTo, - status: filters.Status); - - TotalPages = result.TotalPages; - CurrentPage = Math.Clamp(CurrentPage, 1, Math.Max(1, TotalPages)); - - var applicationTasks = result.Items.AsEnumerable().Select(async app => new ApplicationWithCalculatedStatus + var result = await dashboardApplications.ListAsync(new DashboardApplicationListQuery { - Application = app, - CalculatedStatus = applicationStatusService.GetCalculatedApplicationStatusAsync(app, CustomStatuses) + TemplateId = TemplateId.Value, + CurrentPage = CurrentPage, + PageSize = dashboardOptions.Value.PageSize, + Scope = DashboardApplicationListScope.AllForTemplate, + IncludeCustomColumns = false, + CustomStatuses = CustomStatuses, + SearchReference = filters.SearchReference, + DateStartedFrom = filters.DateStartedFrom, + DateStartedTo = filters.DateStartedTo, + DateSubmittedFrom = filters.DateSubmittedFrom, + DateSubmittedTo = filters.DateSubmittedTo, + Status = filters.Status }); - Applications = [..(await Task.WhenAll(applicationTasks)) - .OrderByDescending(a => a.DateCreated)]; + Applications = result.Applications; + TotalPages = result.TotalPages; + CurrentPage = result.CurrentPage; } } diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/BaseFormEngineModel.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/BaseFormEngineModel.cs index 6649731..bfba5a4 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/BaseFormEngineModel.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/BaseFormEngineModel.cs @@ -3,7 +3,6 @@ using GovUK.Dfe.FlexForms.Web.Pages.Shared; using GovUK.Dfe.FlexForms.Web.Services; using Microsoft.AspNetCore.Mvc; -using System.Diagnostics.CodeAnalysis; using Task = System.Threading.Tasks.Task; namespace GovUK.Dfe.FlexForms.Web.Pages.FormEngine @@ -11,7 +10,6 @@ namespace GovUK.Dfe.FlexForms.Web.Pages.FormEngine /// /// Base class for form engine page models containing common functionality /// - [ExcludeFromCodeCoverage] public abstract class BaseFormEngineModel( IFieldRendererService renderer, IApplicationResponseService applicationResponseService, @@ -20,41 +18,25 @@ public abstract class BaseFormEngineModel( IApplicationStateService applicationStateService, IFormStateManager formStateManager, IFormNavigationService formNavigationService, - IFormDataManager formDataManager, - IFormValidationOrchestrator formValidationOrchestrator, - IFormConfigurationService formConfigurationService, ILogger logger) : BaseFormPageModel(renderer, applicationResponseService, fieldFormattingService, templateManagementService, applicationStateService, logger) { - // Form Engine Services protected readonly IFormStateManager _formStateManager = formStateManager; protected readonly IFormNavigationService _formNavigationService = formNavigationService; - protected readonly IFormDataManager _formDataManager = formDataManager; - protected readonly IFormValidationOrchestrator _formValidationOrchestrator = formValidationOrchestrator; - protected readonly IFormConfigurationService _formConfigurationService = formConfigurationService; - // Common Properties public FormState CurrentFormState { get; set; } public TaskGroup CurrentGroup { get; set; } public Domain.Models.Task CurrentTask { get; set; } public Domain.Models.Page CurrentPage { get; set; } - - // URL Parameters (to be set by derived classes) + [BindProperty(SupportsGet = true, Name = "taskId")] public string TaskId { get; set; } = string.Empty; [BindProperty(SupportsGet = true, Name = "pageId")] public string CurrentPageId { get; set; } = string.Empty; - // Optional sub-flow tokens; nullable so model binding does not require them on normal pages [BindProperty(SupportsGet = true, Name = "flowId")] public string? FlowId { get; set; } [BindProperty(SupportsGet = true, Name = "instanceId")] public string? InstanceId { get; set; } - /// - /// Gets the current form state based on the URL parameters - /// - /// The current form state - protected FormState GetCurrentFormState() - { - return _formStateManager.GetCurrentState(ReferenceNumber, TaskId, CurrentPageId); - } + protected FormState GetCurrentFormState() => + _formStateManager.GetCurrentState(ReferenceNumber, TaskId, CurrentPageId); /// /// Ensures is populated when returning from an exception filter. @@ -62,150 +44,38 @@ protected FormState GetCurrentFormState() public void EnsureFormStateForErrorDisplay() { if (CurrentFormState == default) - { CurrentFormState = GetCurrentFormState(); - } } - /// - /// Gets the back link URL for the current context - /// - /// The back link URL - protected string GetBackLinkUrl() - { - return _formNavigationService.GetBackLinkUrl(CurrentPageId, TaskId, ReferenceNumber); - } + protected string GetBackLinkUrl() => + _formNavigationService.GetBackLinkUrl(CurrentPageId, TaskId, ReferenceNumber); /// /// Exposes the back link URL to Razor views that cannot call protected methods. /// public string BackLinkUrl => GetBackLinkUrl(); - /// - /// Gets the task summary URL for the current task - /// - /// The task summary URL - protected string GetTaskSummaryUrl() - { - return _formNavigationService.GetTaskSummaryUrl(TaskId, ReferenceNumber); - } - - /// - /// Gets the application preview URL - /// - /// The application preview URL - protected string GetApplicationPreviewUrl() - { - return _formNavigationService.GetApplicationPreviewUrl(ReferenceNumber); - } - - /// - /// Gets the task list URL - /// - /// The task list URL - protected string GetTaskListUrl() - { - return _formNavigationService.GetTaskListUrl(ReferenceNumber); - } - - /// - /// Validates the current page using the validation orchestrator - /// - /// The page to validate - /// The form data - /// True if validation passes - protected bool ValidateCurrentPage(Domain.Models.Page page, Dictionary data) - { - return _formValidationOrchestrator.ValidatePage(page, data, ModelState, Template); - } - - /// - /// Validates the current task using the validation orchestrator - /// - /// The task to validate - /// The form data - /// True if validation passes - protected bool ValidateCurrentTask(Domain.Models.Task task, Dictionary data) - { - return _formValidationOrchestrator.ValidateTask(task, data, ModelState, Template); - } - - /// - /// Gets the form configuration for the current template - /// - /// The form configuration - protected FormConfiguration GetFormConfiguration() - { - return _formConfigurationService.GetFormConfiguration(TemplateId); - } - - /// - /// Gets the default form settings - /// - /// The default form settings - protected FormSettings GetDefaultFormSettings() - { - return _formConfigurationService.GetDefaultFormSettings(); - } - - /// - /// Common initialization for form engine pages - /// - /// A task representing the asynchronous operation protected async Task CommonFormEngineInitializationAsync() { - - try { await CommonInitializationAsync(); - } catch (Exception ex) { logger.LogError(ex, "CommonFormEngineInitializationAsync - Error in CommonInitializationAsync"); throw; } - + try { CurrentFormState = GetCurrentFormState(); - } catch (Exception ex) { logger.LogError(ex, "CommonFormEngineInitializationAsync - Error getting current form state"); throw; } - - - } - - /// - /// Checks if the current form state should show the task list - /// - /// True if task list should be shown - protected bool ShouldShowTaskList() - { - return _formStateManager.ShouldShowTaskList(CurrentPageId); - } - - /// - /// Checks if the current form state should show the task summary - /// - /// True if task summary should be shown - protected bool ShouldShowTaskSummary() - { - return _formStateManager.ShouldShowTaskSummary(TaskId, CurrentPageId); - } - - /// - /// Checks if the current form state should show the application preview - /// - /// True if application preview should be shown - protected bool ShouldShowApplicationPreview() - { - return _formStateManager.ShouldShowApplicationPreview(CurrentPageId); } } } diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/DisplayHelpers.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/DisplayHelpers.cs index 3887687..51087cc 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/DisplayHelpers.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/DisplayHelpers.cs @@ -1,7 +1,7 @@ -using System.Text.Encodings.Web; using System.Text.Json; using System.Text.RegularExpressions; using System.Web; +using GovUK.Dfe.FlexForms.Application.FormEngine; using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Mvc.Rendering; @@ -125,12 +125,7 @@ private static string GetDisplayNameFromItemData(Dictionary? ite /// /// The input string containing potentially unsafe text. /// A sanitised string with HTML encoded content and normalised line breaks. - public static string SanitiseHtmlInput(string input) - { - var lines = input.Split("\r\n").SelectMany(s => s.Split('\r')).SelectMany(s => s.Split('\n')); - - return string.Join("
", lines.Select(HtmlEncoder.Default.Encode)); - } + public static string SanitiseHtmlInput(string input) => HtmlInputSanitiser.Sanitise(input); /// /// Converts a sanitised HTML input string back to its original form by decoding HTML entities diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RemoveFieldItem.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RemoveFieldItem.cshtml.cs index 773a2af..0d76d51 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RemoveFieldItem.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RemoveFieldItem.cshtml.cs @@ -20,7 +20,7 @@ public async Task OnPostRemoveFieldItemAsync(string referenceNumb return BadRequest("Field ID and valid index are required"); } - var acc = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); + var acc = _applicationResponseService.GetAccumulatedFormData(); if (acc.TryGetValue(fieldId, out var existing)) { var json = existing?.ToString() ?? "[]"; @@ -31,7 +31,7 @@ public async Task OnPostRemoveFieldItemAsync(string referenceNumb { list.RemoveAt(index); var updated = JsonSerializer.Serialize(list); - _applicationResponseService.AccumulateFormData(new Dictionary { [fieldId] = updated }, HttpContext.Session); + _applicationResponseService.AccumulateFormData(new Dictionary { [fieldId] = updated }); } } catch (Exception ex) diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.Support.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.Support.cs new file mode 100644 index 0000000..7cb3120 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.Support.cs @@ -0,0 +1,301 @@ +using GovUK.Dfe.FlexForms.Application.FormEngine; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Application.Notifications; +using GovUK.Dfe.FlexForms.Application.Validation; +using GovUK.Dfe.FlexForms.Domain.Caching; +using GovUK.Dfe.FlexForms.Domain.FormEngine; +using GovUK.Dfe.FlexForms.Domain.Models; +using GovUK.Dfe.FlexForms.Web.Extensions; +using GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; +using Task = System.Threading.Tasks.Task; + +namespace GovUK.Dfe.FlexForms.Web.Pages.FormEngine; + +public partial class RenderFormModel +{ + public static string BuildHistoryScope(string referenceNumber, string taskId, string currentPageId) => + FormRouteParser.HistoryScope(referenceNumber, taskId, currentPageId); + + private FormEngineWorkState CaptureWorkState() => + new() + { + ReferenceNumber = ReferenceNumber, + TaskId = TaskId, + CurrentPageId = CurrentPageId, + ApplicationId = ApplicationId, + ApplicationStatus = ApplicationStatus, + Template = Template, + FormData = FormData, + Data = Data, + CurrentFormState = CurrentFormState, + CurrentGroup = CurrentGroup, + CurrentTask = CurrentTask, + CurrentPage = CurrentPage, + FlowId = FlowId, + InstanceId = InstanceId, + FlowPageId = FlowPageId, + DerivedFlowId = DerivedFlowId, + DerivedItemId = DerivedItemId, + DerivedPageId = DerivedPageId, + ConditionalState = ConditionalState, + IsEditable = IsApplicationEditable(), + IsTaskCompleted = IsTaskCompleted + }; + + private void ApplyWorkState(FormEngineWorkState state) + { + ReferenceNumber = state.ReferenceNumber; + TaskId = state.TaskId; + CurrentPageId = state.CurrentPageId; + ApplicationId = state.ApplicationId; + ApplicationStatus = state.ApplicationStatus; + Template = state.Template; + FormData = state.FormData; + Data = state.Data; + CurrentFormState = state.CurrentFormState; + CurrentGroup = state.CurrentGroup; + CurrentTask = state.CurrentTask; + CurrentPage = state.CurrentPage; + FlowId = state.FlowId; + InstanceId = state.InstanceId; + FlowPageId = state.FlowPageId; + DerivedFlowId = state.DerivedFlowId; + DerivedItemId = state.DerivedItemId; + DerivedPageId = state.DerivedPageId; + ConditionalState = state.ConditionalState; + IsTaskCompleted = state.IsTaskCompleted; + _visibility = null; + } + + private IActionResult MapOutcome(FormEngineOutcome outcome) + { + foreach (var key in outcome.ModelStateKeysToRemove) + ModelState.Remove(key); + + if (outcome.ClearModelState) + ModelState.Clear(); + + if (outcome.Errors.Count > 0) + new FormValidationResult(outcome.Errors).ApplyTo(ModelState); + + if (outcome.FormState is { } formState) + CurrentFormState = formState; + + if (outcome.IsTaskCompleted is { } completed) + IsTaskCompleted = completed; + + if (outcome.SuccessMessage != null) + SuccessMessage = outcome.SuccessMessage; + + if (outcome.ErrorMessage != null) + ErrorMessage = outcome.ErrorMessage; + + if (outcome.Files != null) + Files = outcome.Files; + + FileValidationBlocksSubmit = outcome.FileValidationBlocksSubmit; + if (outcome.BlockingFiles.Count > 0) + FileValidationBlockingFiles = outcome.BlockingFiles; + + if (outcome.ConditionalState != null) + ConditionalState = outcome.ConditionalState; + + if (outcome.ReloadFormData) + LoadFormDataFromSession(); + + foreach (var key in outcome.ErrorStoreKeysToClear) + _formErrorStore.Clear(key); + + if (outcome.PersistErrors && !string.IsNullOrEmpty(outcome.ErrorContextKey)) + _formErrorStore.Save(outcome.ErrorContextKey, ModelState); + + return outcome.Kind switch + { + FormEngineOutcomeKind.StayOnPage => Page(), + FormEngineOutcomeKind.Redirect => Redirect(outcome.RedirectUrl!), + FormEngineOutcomeKind.RedirectToPage => RedirectToPage(outcome.PageName, outcome.RouteValues), + FormEngineOutcomeKind.NotFound => NotFound(), + FormEngineOutcomeKind.BadRequest => BadRequest(outcome.ErrorMessage), + FormEngineOutcomeKind.FileDownload => File(outcome.FileStream!, outcome.FileContentType!, outcome.FileDownloadName), + _ => Page() + }; + } + + private void RestoreConfirmedFormData() + { + if (!Request.Query.ContainsKey("confirmed") || Request.Query["confirmed"] != "true") + return; + + var confirmedDataJson = TempData["ConfirmedFormData"]?.ToString(); + var confirmedHandler = TempData["ConfirmedHandler"]?.ToString(); + if (string.IsNullOrEmpty(confirmedDataJson)) + return; + + try + { + var confirmedData = System.Text.Json.JsonSerializer.Deserialize>(confirmedDataJson); + if (confirmedData == null) + return; + + foreach (var kvp in confirmedData) + Data[kvp.Key] = kvp.Value; + _logger.LogInformation("Restored {Count} confirmed form fields for handler {Handler}", + confirmedData.Count, confirmedHandler); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to deserialize confirmed form data"); + } + } + + private void BuildPresentationViewModels() + { + if (Template == null) + return; + + var presentationContext = CreatePresentationContext(); + + if (CurrentFormState == FormState.ApplicationPreview) + Preview = _formEnginePresentationComposer.BuildPreview(presentationContext); + + if (CurrentFormState == FormState.TaskSummary + && CurrentTask != null + && FormStepPolicy.IsCollectionFlowSummary(CurrentTask)) + { + CollectionFlows = _formEnginePresentationComposer.BuildCollectionFlows(presentationContext, CurrentTask); + } + } + + private FormEnginePresentationContext CreatePresentationContext() + { + var submitDisabled = _requestConfiguration.GetSection("Layout:SubmitAppDisabled").Exists(); + return new FormEnginePresentationContext + { + Template = Template, + FormData = FormData, + ReferenceNumber = ReferenceNumber, + TaskId = TaskId, + ApplicationId = ApplicationId, + InfectedFilterApplicationId = ApplicationId?.ToString() + ?? HttpContext.Session.GetString(FormSessionKeys.ApplicationId), + IsEditable = IsApplicationEditable(), + IsLeadApplicant = IsCurrentUserLeadApplicant(), + SubmitDisabledByConfig = submitDisabled, + SubmitDisabledBannerText = submitDisabled + ? _requestConfiguration["Layout:SubmitAppDisabled:BannerText"] + : null, + SubmitDisabledHelpText = submitDisabled + ? _requestConfiguration["Layout:SubmitAppDisabled:HelpText"] + : null, + FileValidationBlocksSubmit = FileValidationBlocksSubmit, + BlockingFiles = FileValidationBlockingFiles, + IncludePreviewQuery = Request.Query.ContainsKey("preview"), + EnsureItemFieldVisibility = EnsureItemFieldVisibility, + IsFieldHiddenForItem = IsFieldHiddenForItem, + IsFieldHidden = IsFieldHidden + }; + } + + private new bool IsCurrentUserLeadApplicant() + { + var applicationId = HttpContext.Session.GetString(FormSessionKeys.ApplicationId); + var leadApplicantEmail = HttpContext.Session.GetString($"ApplicationLeadApplicantEmail_{applicationId}"); + var currentUserEmail = User.FindFirst(ClaimTypes.Email)?.Value + ?? User.FindFirst("email")?.Value + ?? User.FindFirst("sub")?.Value + ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? User.Identity?.Name; + + return string.Equals( + currentUserEmail?.Trim(), + leadApplicantEmail?.Trim(), + StringComparison.InvariantCultureIgnoreCase); + } + + private FormEngineVisibilityEvaluator Visibility => + _visibility ??= new FormEngineVisibilityEvaluator( + Template, + ConditionalState, + _conditionalLogicOrchestrator, + CurrentPageId, + TaskId, + _logger); + + public bool IsFieldHidden(string fieldId) => Visibility.IsFieldHidden(fieldId); + + public bool IsPageHidden(string pageId) => Visibility.IsPageHidden(pageId); + + public void EnsureItemFieldVisibility(Dictionary itemData, IEnumerable fieldIds) => + Visibility.EnsureItemFieldVisibility(itemData, fieldIds); + + public bool IsFieldHiddenForItem(string fieldId, Dictionary itemData) => + Visibility.IsFieldHiddenForItem(fieldId, itemData); + + private async Task TryNotifyFileOperationAsync(FormEngineOutcome outcome) + { + if (string.IsNullOrEmpty(outcome.SuccessMessage) || string.IsNullOrEmpty(outcome.NotificationContext)) + return; + + await TryCreateFileNotificationAsync(new AddNotificationRequest + { + Message = outcome.SuccessMessage, + Category = "file-upload", + Context = outcome.NotificationContext, + Type = NotificationType.Success, + AutoDismiss = false, + AutoDismissSeconds = outcome.NotificationContext.StartsWith("file-upload|", StringComparison.Ordinal) + ? 5 + : 0, + ReplaceExistingContext = false + }); + } + + private async Task TryCreateFileNotificationAsync(AddNotificationRequest addRequest) + { + try + { + addRequest.Context = NotificationScopeContext.PrefixDetail(ApplicationContext, addRequest.Context); + await _notificationsClient.CreateNotificationAsync(addRequest); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "File operation succeeded but notification could not be created"); + } + } + + private void RestoreFormErrors() + { + try + { + var contextKey = $"{ReferenceNumber}_{TaskId}_{CurrentPageId}"; + var (fieldErrors, generalError) = _formErrorStore.Load(contextKey, clearAfterRead: true); + + if (fieldErrors.Count > 0) + { + foreach (var kvp in fieldErrors) + { + foreach (var error in kvp.Value) + ModelState.AddModelError(kvp.Key, error); + } + _logger.LogInformation( + "DEBUG: Restored {ErrorCount} field errors from FormErrorStore with key: {ContextKey}", + fieldErrors.Sum(x => x.Value.Count), + contextKey); + } + + if (!string.IsNullOrEmpty(generalError)) + { + ModelState.AddModelError("", generalError); + _logger.LogInformation("DEBUG: Restored general error from FormErrorStore: {GeneralError}", generalError); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to restore form errors from session"); + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml index a9d86ce..0fe399a 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml @@ -1,11 +1,10 @@ @page "/applications/{referenceNumber}/{taskId?}/{*pageId}" -@using GovUK.Dfe.FlexForms.Infrastructure.Services @using GovUK.Dfe.FlexForms.Application.Interfaces +@using GovUK.Dfe.FlexForms.Domain.FormEngine @using GovUk.Frontend.AspNetCore.TagHelpers @model RenderFormModel @{ ViewData["Title"] = "New application"; - var allTasksCompleted = Model.AreAllTasksCompleted(); } @section BeforeContent { @@ -38,8 +37,7 @@ @await Html.PartialAsync("FormEngine/_FormPage", Model) break; case FormState.TaskSummary: - // If this task declares a multiCollectionFlow summary, render the custom summary - if (Model.CurrentTask?.Summary?.Mode?.ToLowerInvariant() == "multicollectionflow") + if (FormStepPolicy.IsCollectionFlowSummary(Model.CurrentTask)) { @await Html.PartialAsync("FormEngine/_CollectionFlowSummary", Model) } @@ -54,4 +52,4 @@ case FormState.ApplicationPreview: @await Html.PartialAsync("FormEngine/_ApplicationPreview", Model) break; -} \ No newline at end of file +} diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml.cs index 75aaadb..859f91d 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml.cs @@ -1,32 +1,27 @@ using GovUK.Dfe.FlexForms.Application.Exceptions; +using GovUK.Dfe.FlexForms.Application.FormEngine; using GovUK.Dfe.FlexForms.Application.Interfaces; using GovUK.Dfe.FlexForms.Application.Notifications; +using GovUK.Dfe.FlexForms.Application.Validation; using GovUK.Dfe.FlexForms.Domain.Caching; +using GovUK.Dfe.FlexForms.Domain.FormEngine; using GovUK.Dfe.FlexForms.Domain.Models; -using GovUK.Dfe.FlexForms.Infrastructure.Services; -using GovUK.Dfe.FlexForms.Web.Constants; +using GovUK.Dfe.FlexForms.Web.Extensions; using GovUK.Dfe.FlexForms.Web.Interfaces; -using GovUK.Dfe.FlexForms.Web.Pages.Shared; using GovUK.Dfe.FlexForms.Web.Services; +using GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; using GovUK.Dfe.FlexForms.Api.Client.Contracts; -using Microsoft.AspNetCore.DataProtection.KeyManagement; using Microsoft.AspNetCore.Mvc; -using StackExchange.Redis; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; -using System.Text.RegularExpressions; -using System.Threading; -using static GovUK.Dfe.FlexForms.Web.Pages.FormEngine.DisplayHelpers; +using Microsoft.AspNetCore.Mvc.Filters; +using System.Security.Claims; using Task = System.Threading.Tasks.Task; namespace GovUK.Dfe.FlexForms.Web.Pages.FormEngine { - [ExcludeFromCodeCoverage] - public class RenderFormModel( + public partial class RenderFormModel( IFieldRendererService renderer, IApplicationResponseService applicationResponseService, IFieldFormattingService fieldFormattingService, @@ -34,35 +29,38 @@ public class RenderFormModel( IApplicationStateService applicationStateService, IFormStateManager formStateManager, IFormNavigationService formNavigationService, - IFormDataManager formDataManager, - IFormValidationOrchestrator formValidationOrchestrator, - IFormConfigurationService formConfigurationService, IAutocompleteService autocompleteService, - IFileUploadService fileUploadService, - IApplicationsClient applicationsClient, IConditionalLogicOrchestrator conditionalLogicOrchestrator, INotificationsClient notificationsClient, IFormErrorStore formErrorStore, - IComplexFieldConfigurationService complexFieldConfigurationService, - IDerivedCollectionFlowService derivedCollectionFlowService, - IFieldRequirementService fieldRequirementService, - IConnectionMultiplexer redis, + IFormEnginePresentationComposer formEnginePresentationComposer, + ICompleteFormTask completeFormTask, + ISubmitFormApplication submitFormApplication, + IPrepareFormEngineGet prepareFormEngineGet, + ISaveFormPage saveFormPage, + IRemoveCollectionItem removeCollectionItem, + IUploadFormFile uploadFormFile, + IDeleteFormFile deleteFormFile, + IDownloadFormFile downloadFormFile, ILogger logger, - INavigationHistoryService navigationHistoryService, IRequestAppConfiguration requestConfiguration) : BaseFormEngineModel(renderer, applicationResponseService, fieldFormattingService, templateManagementService, - applicationStateService, formStateManager, formNavigationService, formDataManager, formValidationOrchestrator, formConfigurationService, logger) + applicationStateService, formStateManager, formNavigationService, logger) { - private readonly IApplicationsClient _applicationsClient = applicationsClient; private readonly IConditionalLogicOrchestrator _conditionalLogicOrchestrator = conditionalLogicOrchestrator; private readonly INotificationsClient _notificationsClient = notificationsClient; private readonly IFormErrorStore _formErrorStore = formErrorStore; - private readonly IComplexFieldConfigurationService _complexFieldConfigurationService = complexFieldConfigurationService; - private readonly IDerivedCollectionFlowService _derivedCollectionFlowService = derivedCollectionFlowService; - private readonly IConnectionMultiplexer _redis = redis; - private readonly IFieldRequirementService _fieldRequirementService = fieldRequirementService; - private readonly INavigationHistoryService _navigationHistoryService = navigationHistoryService; + private readonly IFormEnginePresentationComposer _formEnginePresentationComposer = formEnginePresentationComposer; + private readonly ICompleteFormTask _completeFormTask = completeFormTask; + private readonly ISubmitFormApplication _submitFormApplication = submitFormApplication; + private readonly IPrepareFormEngineGet _prepareFormEngineGet = prepareFormEngineGet; + private readonly ISaveFormPage _saveFormPage = saveFormPage; + private readonly IRemoveCollectionItem _removeCollectionItem = removeCollectionItem; + private readonly IUploadFormFile _uploadFormFile = uploadFormFile; + private readonly IDeleteFormFile _deleteFormFile = deleteFormFile; + private readonly IDownloadFormFile _downloadFormFile = downloadFormFile; private readonly IRequestAppConfiguration _requestConfiguration = requestConfiguration; + private FormEngineVisibilityEvaluator? _visibility; private string ApplicationContext => _requestConfiguration["ApplicationName"] ?? _requestConfiguration["TenantName"] @@ -70,315 +68,79 @@ public class RenderFormModel( [BindProperty(SupportsGet = false)] public Dictionary Data { get; set; } = new(); - public string BackLinkUrl => GetBackLinkUrl(); + public new string BackLinkUrl => GetBackLinkUrl(); [BindProperty] public bool IsTaskCompleted { get; set; } - - // Collection flow properties from form submission + [BindProperty] public new string? FlowId { get; set; } [BindProperty] public new string? InstanceId { get; set; } [BindProperty] public string? FlowPageId { get; set; } - - // Derived collection flow properties + [BindProperty] public string? DerivedFlowId { get; set; } [BindProperty] public string? DerivedItemId { get; set; } [BindProperty] public string? DerivedPageId { get; set; } - - // Calculate IsCollectionFlow automatically based on FlowId and InstanceId presence + private bool IsCollectionFlow => !string.IsNullOrEmpty(FlowId) && !string.IsNullOrEmpty(InstanceId); - - // Calculate IsDerivedFlow automatically based on DerivedFlowId and DerivedItemId presence - private bool IsDerivedFlow => !string.IsNullOrEmpty(DerivedFlowId) && !string.IsNullOrEmpty(DerivedItemId); - // Success message for collection operations [TempData] public string? SuccessMessage { get; set; } - - // Error message for upload operations + [TempData] public string? ErrorMessage { get; set; } - - // Files property for upload field (matches original UploadFile.cshtml.cs) + public IReadOnlyList Files { get; set; } = new List(); public bool FileValidationBlocksSubmit { get; set; } public IReadOnlyList FileValidationBlockingFiles { get; set; } = []; - // Conditional logic state for the current form - public FormConditionalState? ConditionalState { get; set; } + public ApplicationPreviewViewModel? Preview { get; private set; } - /// - /// Per-request cache: one lean field-visibility evaluation per collection item dictionary - /// (preview/summary previously re-ran the whole template for every column). - /// - private readonly Dictionary _itemConditionalStateCache = - new(ReferenceEqualityComparer.Instance); + public IReadOnlyList CollectionFlows { get; private set; } = []; - private HashSet? _fieldsWithConditionalVisibility; + public FormConditionalState? ConditionalState { get; set; } - public async Task OnGetAsync() + public async Task OnGetAsync() { - - - try - { - await CommonFormEngineInitializationAsync(); - - } - catch (ApplicationAccessException) - { - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in CommonFormEngineInitializationAsync for ReferenceNumber: {ReferenceNumber}", ReferenceNumber); - throw; - } - - // Ensure Template is not null to prevent NullReferenceException - if (Template == null) - { - _logger.LogError("Template is null after CommonFormEngineInitializationAsync for ReferenceNumber: {ReferenceNumber}", ReferenceNumber); - Template = new FormTemplate - { - TemplateId = "dummy", - TemplateName = "dummy", - Description = "dummy", - TaskGroups = new List() - }; - } - else - { - - } - - // Check if this is a preview request - if (Request.Query.ContainsKey("preview")) - { - // Override the form state for preview requests - CurrentFormState = FormState.ApplicationPreview; - CurrentGroup = null; - CurrentTask = null; - CurrentPage = null; - - // Clear all validation errors for preview since we don't need validation on preview page - ModelState.Clear(); - await RefreshFileValidationGateAsync(); - } - else - { - // Detect sub-flow route segments inside pageId via route value parsing if needed in future - // If application is not editable and trying to access a specific page, redirect to preview - if (!IsApplicationEditable() && !string.IsNullOrEmpty(CurrentPageId)) - { - Response.Redirect($"~/applications/{ReferenceNumber}"); - return; - } - - if (!string.IsNullOrEmpty(CurrentPageId)) - { - if (TryParseFlowRoute(CurrentPageId, out var flowId, out var instanceId, out var flowPageId)) - { - - FlowId = flowId; - InstanceId = instanceId; - FlowPageId = flowPageId; - - // Sub-flow: initialize task and resolve page from task's pages - var (group, task) = InitializeCurrentTask(TaskId); - CurrentGroup = group; - CurrentTask = task; - - // Find the correct flow and its pages - var flowPages = GetFlowPages(task, flowId); - var flowFieldId = GetFlowFieldId(task, flowId); - - // Record whether the item existed before this flow started so we can choose the correct - // success message even after partial autosaves add the item to the session. - if (!string.IsNullOrEmpty(flowFieldId)) - { - var existenceKey = GetFlowItemExistenceSessionKey(flowId, instanceId); - if (HttpContext.Session.GetString(existenceKey) == null) - { - var existed = IsExistingCollectionItem(flowFieldId, instanceId); - HttpContext.Session.SetString(existenceKey, existed ? "true" : "false"); - } - } - if (flowPages != null) - { - var page = string.IsNullOrEmpty(flowPageId) ? flowPages.FirstOrDefault() : flowPages.FirstOrDefault(p => p.PageId == flowPageId); - if (page != null) - { - CurrentPage = page; - CurrentFormState = FormState.FormPage; // Render as a normal page - - // If editing existing item, load its data into form fields - // This must happen AFTER LoadAccumulatedDataFromSession is skipped for sub-flows - LoadExistingFlowItemData(flowId, instanceId); - - // Also load any in-progress data for this specific flow instance - // IMPORTANT: Progress data takes priority over existing item data as it contains the latest user changes - var progressData = LoadFlowProgress(flowId, instanceId); - foreach (var kvp in progressData) - { - Data[kvp.Key] = kvp.Value; // Always overwrite with progress data (latest changes) - } - - - - - - } - } - } - else if (TryParseDerivedFlowRoute(CurrentPageId, out var derivedFlowId, out var derivedItemId, out var derivedPageId)) - { - // Derived flow: initialize task and resolve page from derived flow configuration - var (group, task) = InitializeCurrentTask(TaskId); - CurrentGroup = group; - CurrentTask = task; - - // Set derived flow properties - DerivedFlowId = derivedFlowId; - DerivedItemId = derivedItemId; - DerivedPageId = derivedPageId; - - // Find the derived flow configuration - var derivedConfig = GetDerivedFlowConfiguration(task, derivedFlowId); - if (derivedConfig != null) - { - // Get the page to render (default to first page if no specific page) - var page = string.IsNullOrEmpty(derivedPageId) ? derivedConfig.Pages.FirstOrDefault() : derivedConfig.Pages.FirstOrDefault(p => p.PageId == derivedPageId); - if (page != null) - { - CurrentPage = page; - CurrentFormState = FormState.FormPage; - - // Load pre-filled data for this derived item - LoadDerivedItemData(derivedConfig, derivedItemId); - - // Replace placeholders in page metadata with the item's display name - var displayName = GetDerivedItemDisplayName(derivedConfig, derivedItemId); - if (!string.IsNullOrEmpty(CurrentPage.Title)) - { - CurrentPage.Title = CurrentPage.Title - .Replace("{displayName}", displayName) - .Replace("{name}", displayName); - } - if (!string.IsNullOrEmpty(CurrentPage.Description)) - { - CurrentPage.Description = CurrentPage.Description - .Replace("{displayName}", displayName) - .Replace("{name}", displayName); - } - } - } - } - else - { - var (group, task, page) = InitializeCurrentPage(CurrentPageId); - CurrentGroup = group; - CurrentTask = task; - CurrentPage = page; - } - } - else if (!string.IsNullOrEmpty(TaskId)) - { - var (group, task) = InitializeCurrentTask(TaskId); - CurrentGroup = group; - CurrentTask = task; - CurrentPage = null; // No specific page for task summary - - // If task requests collectionFlow summary, switch state accordingly - if (_formStateManager.ShouldShowCollectionFlowSummary(CurrentTask)) - { - CurrentFormState = FormState.TaskSummary; // view chooses partial - } - // If task requests derivedCollectionFlow summary, switch state accordingly - else if (_formStateManager.ShouldShowDerivedCollectionFlowSummary(CurrentTask)) - { - CurrentFormState = FormState.DerivedCollectionFlowSummary; - } - } - } - - // Check if we need to clear session data for a new application - CheckAndClearSessionForNewApplication(); - - await LoadAccumulatedDataFromSessionAsync(); - MergeFlowProgressIntoFormDataForSummary(); - - // For derived flow pages, re-apply declaration data AFTER accumulated data. - // The accumulated session may contain stale top-level keys (e.g. "chairName-joining") - // that were saved before derived-flow isolation. The declaration data in - // "fieldId_data_itemId" holds the authoritative values and must take priority. - if (!string.IsNullOrEmpty(DerivedFlowId) && !string.IsNullOrEmpty(DerivedItemId) && CurrentTask != null) - { - var derivedConfig = GetDerivedFlowConfiguration(CurrentTask, DerivedFlowId); - if (derivedConfig != null) - { - LoadDerivedItemData(derivedConfig, DerivedItemId); - } - } - - // For upload fields, populate Data from session so they display on GET - // This ensures files appear in the list after upload - await PopulateUploadFieldsFromSessionAsync(); - - await ApplyConditionalLogicAsync(); - ModelState.Clear(); - RestoreFormErrors(); - - ViewData["ValidationErrors"] = ModelState.Where(m => m.Value.Errors.Any()) - .ToDictionary(m => m.Key, m => m.Value.Errors.Select(e => e.ErrorMessage).ToList()); - - // Initialize task completion status for summaries (standard or derived) - if (CurrentTask != null) - { - var isSummary = CurrentFormState == FormState.TaskSummary - || _formStateManager.ShouldShowDerivedCollectionFlowSummary(CurrentTask); - if (isSummary) - { - var taskStatus = GetTaskStatusFromSession(CurrentTask.TaskId); - IsTaskCompleted = taskStatus == Domain.Models.TaskStatus.Completed; - - // Clear any validation errors when viewing task summary on GET - // Task completion validation errors should only appear after POST, not on initial load - ModelState.Clear(); - } - } - // If this GET was reached via back navigation, pop history entry for the current scope try { - if (Request.Query.ContainsKey("nav") && string.Equals(Request.Query["nav"], "back", StringComparison.OrdinalIgnoreCase)) - { - var scope = BuildHistoryScope(ReferenceNumber, TaskId, CurrentPageId); - _navigationHistoryService.Pop(scope, HttpContext.Session); - } + await CommonFormEngineInitializationAsync(); } - catch { } - } - - public static string BuildHistoryScope(string referenceNumber, string taskId, string currentPageId) - { - if (string.IsNullOrEmpty(currentPageId)) + catch (ApplicationAccessException) { - return $"{referenceNumber}:{taskId}"; + throw; } - var parts = currentPageId.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 3 && string.Equals(parts[0], "flow", StringComparison.OrdinalIgnoreCase)) + catch (Exception ex) { - var flowId = parts[1]; - var instanceId = parts[2]; - return $"{referenceNumber}:{taskId}:flow:{flowId}:{instanceId}"; + _logger.LogError(ex, "Error in CommonFormEngineInitializationAsync for ReferenceNumber: {ReferenceNumber}", ReferenceNumber); + throw; } - return $"{referenceNumber}:{taskId}"; + + var state = CaptureWorkState(); + var isPreview = Request.Query.ContainsKey("preview"); + var isBackNav = Request.Query.ContainsKey("nav") + && string.Equals(Request.Query["nav"], "back", StringComparison.OrdinalIgnoreCase); + var outcome = await _prepareFormEngineGet.ExecuteAsync(state, isPreview, isBackNav, IsApplicationEditable()); + ApplyWorkState(state); + + if (outcome.Kind is FormEngineOutcomeKind.Redirect or FormEngineOutcomeKind.RedirectToPage) + return MapOutcome(outcome); + + var result = MapOutcome(outcome); + RestoreFormErrors(); + ViewData["ValidationErrors"] = ModelState.Where(m => m.Value != null && m.Value.Errors.Any()) + .ToDictionary(m => m.Key, m => m.Value!.Errors.Select(e => e.ErrorMessage).ToList()); + return result; + } + + public override void OnPageHandlerExecuted(PageHandlerExecutedContext context) + { + BuildPresentationViewModels(); + base.OnPageHandlerExecuted(context); } public async Task OnPostTaskSummaryAsync() { await CommonFormEngineInitializationAsync(); - // Initialize the current task for task summary if (!string.IsNullOrEmpty(TaskId)) { var (group, task) = InitializeCurrentTask(TaskId); @@ -387,1350 +149,60 @@ public async Task OnPostTaskSummaryAsync() CurrentPage = null; } - // Task summary POST does not submit form field data, so Data is empty. - // We need to apply conditional logic using FormData (session data) for accurate validation. - // Create a custom conditional logic evaluation using FormData instead of Data. - try - { - if (Template?.ConditionalLogic != null && Template.ConditionalLogic.Any()) - { - var context = new ConditionalLogicContext - { - CurrentPageId = CurrentPageId, - CurrentTaskId = TaskId, - IsClientSide = false, - Trigger = "task_summary_validation" - }; - - //Use FormData (session data) instead of Data (empty on task summary POST) - ConditionalState = await _conditionalLogicOrchestrator.ApplyConditionalLogicAsync(Template, FormData, context); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error applying conditional logic in task summary validation"); - // Continue with empty conditional state - better than failing - ConditionalState = new FormConditionalState(); - } - - // Log conditional state for debugging - _logger.LogInformation("ConditionalState after ApplyConditionalLogicAsync: Fields={FieldCount}, HiddenFields={HiddenFields}", - ConditionalState?.FieldVisibility?.Count ?? 0, - string.Join(", ", ConditionalState?.FieldVisibility?.Where(kv => !kv.Value).Select(kv => kv.Key) ?? new List())); - - // Handle task completion checkbox state - if (CurrentTask != null && ApplicationId.HasValue) - { - if (IsTaskCompleted) - { - // Use new method that returns custom error messages - var missingFieldsWithMessages = _fieldRequirementService.GetMissingRequiredFieldsWithMessages(CurrentTask, Template, FormData, IsFieldHidden); - var errorLines = new List(); - - if (missingFieldsWithMessages.Any()) - { - foreach (var errorMessage in missingFieldsWithMessages.Values) - { - errorLines.Add(errorMessage); - } - } - - // Additional validation for multi-collection flow tasks - if (CurrentTask.Summary?.Mode?.Equals("multiCollectionFlow", StringComparison.OrdinalIgnoreCase) == true && - CurrentTask.Summary.Flows != null && CurrentTask.Summary.Flows.Any()) - { - foreach (var flow in CurrentTask.Summary.Flows) - { - var items = ReadCollectionItemsFromFormData(flow.FieldId); - var itemCount = items.Count; - - var requiredMin = flow.MinItems ?? 1; // default to at least one item - if (itemCount < requiredMin) - { - var flowTitle = string.IsNullOrWhiteSpace(flow.Title) - ? (string.IsNullOrWhiteSpace(CurrentTask?.TaskName) ? "this section" : CurrentTask!.TaskName) - : flow.Title; - errorLines.Add($"• Add at least {requiredMin} item(s) to {flowTitle}"); - _logger.LogInformation("Collection flow '{FlowId}' requires at least {MinItems} items but has {Count}", flow.FlowId, requiredMin, itemCount); - } - - // Check each collection item has all required fields completed - if (flow.Pages != null && items.Any()) - { - foreach (var item in items) - { - bool itemHasMissingFields = false; - var requiredFieldIds = flow.Pages - .Where(p => p?.Fields != null) - .SelectMany(p => p.Fields) - .Where(f => _fieldRequirementService.IsFieldRequired(f, Template)) - .Select(f => f.FieldId) - .ToList(); - EnsureItemFieldVisibility(item, requiredFieldIds); - - foreach (var page in flow.Pages) - { - if (page?.Fields == null) continue; - foreach (var field in page.Fields) - { - if (!_fieldRequirementService.IsFieldRequired(field, Template)) continue; - - if (IsFieldHiddenForItem(field.FieldId, item)) continue; - - var hasValue = item.TryGetValue(field.FieldId, out var val) - && val != null - && !string.IsNullOrWhiteSpace(val.ToString()); - if (!hasValue) - { - itemHasMissingFields = true; - break; - } - } - if (itemHasMissingFields) break; - } - - if (itemHasMissingFields) - { - var flowTitle = string.IsNullOrWhiteSpace(flow.Title) - ? (string.IsNullOrWhiteSpace(CurrentTask?.TaskName) ? "this section" : CurrentTask!.TaskName) - : flow.Title; - errorLines.Add($"Complete all required questions for each item in {flowTitle}"); - _logger.LogInformation("Collection flow '{FlowId}' has an item with incomplete required fields", flow.FlowId); - break; - } - } - } - } - } - - if (errorLines.Any()) - { - // Cannot complete task - required fields are missing - ModelState.Clear(); - - // Create error message with bullet points - var errorMessage = "You cannot mark this section as complete because some required questions have not been answered:\n" + - string.Join("\n", errorLines); - - ModelState.AddModelError(string.Empty, errorMessage); - - IsTaskCompleted = false; // Reset the checkbox state - - // Set CurrentFormState so the view knows to render the task summary - CurrentFormState = FormState.TaskSummary; - - // DON'T save ModelState errors to FormErrorStore - they should only appear once - // on this immediate response, not persist to next GET request - return Page(); - } - - // Mark the task as completed in session and API - await _applicationStateService.SaveTaskStatusAsync(ApplicationId.Value, CurrentTask.TaskId, Domain.Models.TaskStatus.Completed, HttpContext.Session); - } - else - { - // Task was unchecked - set it back to in progress if it has data, otherwise not started - var currentStatus = _applicationStateService.CalculateTaskStatus(CurrentTask.TaskId, Template, FormData, ApplicationId, HttpContext.Session, ApplicationStatus); - if (currentStatus == Domain.Models.TaskStatus.Completed) - { - // Only override if it was explicitly marked as completed - revert to calculated status - var calculatedStatus = HasAnyTaskData(CurrentTask) ? Domain.Models.TaskStatus.InProgress : Domain.Models.TaskStatus.NotStarted; - await _applicationStateService.SaveTaskStatusAsync(ApplicationId.Value, CurrentTask.TaskId, calculatedStatus, HttpContext.Session); - } - } - } - - // Redirect to the task list page - return Redirect($"/applications/{ReferenceNumber}"); + var state = CaptureWorkState(); + var outcome = await _completeFormTask.ExecuteAsync(state); + ApplyWorkState(state); + return MapOutcome(outcome); } public async Task OnPostSubmitApplicationAsync() { - // Clear any model state errors for route parameters since they're not relevant for preview submission ModelState.Remove(nameof(TaskId)); ModelState.Remove(nameof(CurrentPageId)); ModelState.Remove("TaskId"); ModelState.Remove("CurrentPageId"); ModelState.Remove("pageId"); ModelState.Remove("taskId"); - - // Initialize common form engine data first (loads Template, FormData, etc.) - await CommonFormEngineInitializationAsync(); - - // Prevent submission if application is not editable - if (!IsApplicationEditable()) - { - CurrentFormState = FormState.ApplicationPreview; - ModelState.AddModelError("", ApplicationAccessMessages.NoWritePermission); - return Page(); - } - - // Check if all tasks are completed before allowing submission - if (!AreAllTasksCompleted()) - { - _logger.LogWarning("Cannot submit application {ReferenceNumber} - not all tasks completed", ReferenceNumber); - - // Override the form state for preview with errors - CurrentFormState = FormState.ApplicationPreview; - - ModelState.AddModelError("", "All sections must be completed before you can submit your application."); - return Page(); - } - - // Additional validation: Check that all required fields actually have values - // This catches cases where files were removed by virus scanner after task was marked complete - var tasksWithMissingFields = ValidateAllRequiredFieldsForSubmission(IsFieldHidden); - if (tasksWithMissingFields.Any()) - { - _logger.LogWarning( - "Cannot submit application {ReferenceNumber} - {TaskCount} task(s) have missing required fields: {TaskIds}", - ReferenceNumber, - tasksWithMissingFields.Count, - string.Join(", ", tasksWithMissingFields.Keys)); - - // Override the form state for preview with errors - CurrentFormState = FormState.ApplicationPreview; - - // Find task names for better error message - var taskNames = tasksWithMissingFields.Keys - .Select(taskId => Template?.TaskGroups? - .SelectMany(g => g.Tasks) - .FirstOrDefault(t => t.TaskId == taskId)?.TaskName ?? taskId) - .ToList(); - - ModelState.AddModelError("", - $"Some sections have missing required information and need to be completed again: {string.Join(", ", taskNames)}"); - return Page(); - } - - if (!ApplicationId.HasValue) - { - _logger.LogError("ApplicationId not found during submission for reference {ReferenceNumber}", ReferenceNumber); - ModelState.AddModelError("", "Application not found. Please try again."); - return Page(); - } - - await RefreshFileValidationGateAsync(); - if (FileValidationBlocksSubmit) - { - CurrentFormState = FormState.ApplicationPreview; - var names = string.Join(", ", FileValidationBlockingFiles.Select(f => f.OriginalFileName)); - ModelState.AddModelError("", - $"Some uploaded files failed validation or are still being checked: {names}"); - return Page(); - } - try - { - _logger.LogInformation("Attempting to submit application {ApplicationId} with reference {ReferenceNumber}", - ApplicationId.Value, ReferenceNumber); + await CommonFormEngineInitializationAsync(); - // Submit the application via API - var submittedApplication = await _applicationsClient.SubmitApplicationAsync(ApplicationId.Value); - - // Update session with new application status - if (submittedApplication != null) - { - var statusKey = $"ApplicationStatus_{ApplicationId.Value}"; - HttpContext.Session.SetString(statusKey, submittedApplication.Status?.ToString() ?? "Submitted"); - // Outbound mapped events are published by the API from its - // ApplicationSubmitted domain event, using the tenant's EventTriggers. - _logger.LogInformation("Successfully submitted application {ApplicationId} with reference {ReferenceNumber}", - ApplicationId.Value, ReferenceNumber); - } - else - { - _logger.LogWarning("Submit API returned null for application {ApplicationId}", ApplicationId.Value); - } - - return RedirectToPage("/Applications/ApplicationSubmitted", new { referenceNumber = ReferenceNumber }); - } - catch (ExternalApplicationsException) - { - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to submit application {ApplicationId} with reference {ReferenceNumber}", - ApplicationId.Value, ReferenceNumber); - - ModelState.AddModelError("", $"An error occurred while submitting your application: {ex.Message}. Please try again."); - CurrentFormState = FormState.ApplicationPreview; - return Page(); - } + var state = CaptureWorkState(); + state.IsEditable = IsApplicationEditable(); + var outcome = await _submitFormApplication.ExecuteAsync(state); + ApplyWorkState(state); + return MapOutcome(outcome); } public async Task OnPostPageAsync() { - _logger.LogInformation("POST: OnPostPageAsync called - ReferenceNumber='{ReferenceNumber}', TaskId='{TaskId}', CurrentPageId='{CurrentPageId}'", + _logger.LogInformation("POST: OnPostPageAsync called - ReferenceNumber='{ReferenceNumber}', TaskId='{TaskId}', CurrentPageId='{CurrentPageId}'", ReferenceNumber, TaskId, CurrentPageId); _logger.LogInformation("POST: Request URL: {Url}", $"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}"); _logger.LogInformation("POST: Form data keys: {Keys}", string.Join(", ", Request.Form.Keys)); - - // This handler is also used by task summary pages which do not post a pageId. - // Non-nullable reference types are implicitly required in MVC, so clear any implicit - // model state error for missing pageId to avoid short-circuiting to Page(). + ModelState.Remove(nameof(CurrentPageId)); ModelState.Remove("pageId"); - - // Check if this is a confirmed action coming back from confirmation page - if (Request.Query.ContainsKey("confirmed") && Request.Query["confirmed"] == "true") - { - // Restore the original form data from TempData - var confirmedDataJson = TempData["ConfirmedFormData"]?.ToString(); - var confirmedHandler = TempData["ConfirmedHandler"]?.ToString(); - - if (!string.IsNullOrEmpty(confirmedDataJson)) - { - try - { - var confirmedData = System.Text.Json.JsonSerializer.Deserialize>(confirmedDataJson); - if (confirmedData != null) - { - // Merge confirmed data into current Data - foreach (var kvp in confirmedData) - { - Data[kvp.Key] = kvp.Value; - } - _logger.LogInformation("Restored {Count} confirmed form fields for handler {Handler}", - confirmedData.Count, confirmedHandler); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to deserialize confirmed form data"); - } - } - } + + RestoreConfirmedFormData(); await CommonFormEngineInitializationAsync(); if (!string.IsNullOrEmpty(CurrentPageId)) - { CurrentPageId = System.Web.HttpUtility.UrlDecode(CurrentPageId); - } - - if (!string.IsNullOrEmpty(CurrentPageId)) - { - if (TryParseFlowRoute(CurrentPageId, out var flowId, out var instanceId, out var flowPageId)) - { - - - var (group, task) = InitializeCurrentTask(TaskId); - CurrentGroup = group; - CurrentTask = task; - - // Find the correct flow and its pages - var flowPages = GetFlowPages(task, flowId); - if (flowPages != null) - { - var page = string.IsNullOrEmpty(flowPageId) ? flowPages.FirstOrDefault() : flowPages.FirstOrDefault(p => p.PageId == flowPageId); - if (page != null) - { - CurrentPage = page; - } - } - } - else if (TryParseDerivedFlowRoute(CurrentPageId, out var dFlowId, out var dItemId, out var dPageId)) - { - var (group, task) = InitializeCurrentTask(TaskId); - CurrentGroup = group; - CurrentTask = task; - - var derivedConfig = GetDerivedFlowConfiguration(task, dFlowId); - if (derivedConfig != null) - { - var page = string.IsNullOrEmpty(dPageId) - ? derivedConfig.Pages?.FirstOrDefault() - : derivedConfig.Pages?.FirstOrDefault(p => p.PageId == dPageId); - if (page != null) - { - CurrentPage = page; - } - } - } - else - { - var (group, task, page) = InitializeCurrentPage(CurrentPageId); - CurrentGroup = group; - CurrentTask = task; - CurrentPage = page; - } - } - else if (!string.IsNullOrEmpty(TaskId)) - { - // No pageId posted (e.g., task summary/derived summary). Initialize the task context. - var (group, task) = InitializeCurrentTask(TaskId); - CurrentGroup = group; - CurrentTask = task; - CurrentPage = null; - _logger.LogInformation("POST: Initialized CurrentTask '{TaskId}' for summary POST (no pageId)", CurrentTask?.TaskId); - } - else if (!string.IsNullOrEmpty(TaskId)) - { - var (group, task) = InitializeCurrentTask(TaskId); - CurrentGroup = group; - CurrentTask = task; - CurrentPage = null; // No specific page for task summary - } - - if (!IsApplicationEditable()) - { - ModelState.AddModelError("", ApplicationAccessMessages.NoWritePermission); - return Page(); - } - - // Removed verbose debug logging of posted keys - - // Collect date parts for fields rendered with GOV.UK date input - var dateParts = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var key in Request.Form.Keys) - { - var match = Regex.Match(key, @"^Data\[(.+?)\]$", RegexOptions.None, TimeSpan.FromMilliseconds(200)); - - if (match.Success) - { - var fieldId = match.Groups[1].Value; - // Normalise autocomplete ids like Data_trustsSearch to trustsSearch - var normalisedFieldId = fieldId.StartsWith("Data_", StringComparison.Ordinal) ? fieldId.Substring(5) : fieldId; - var formValue = Request.Form[key]; - - _logger.LogInformation("DEBUG: Processing form field - Key: '{Key}', FieldId: '{FieldId}', FormValue: '{FormValue}'", - key, fieldId, formValue.ToString()); - - // Convert StringValues to a simple string or array based on count - if (formValue.Count == 1) - { - var val = SanitiseHtmlInput(formValue.ToString()); - Data[fieldId] = val; - if (!string.Equals(fieldId, normalisedFieldId, StringComparison.Ordinal)) - { - Data[normalisedFieldId] = val; - } - _logger.LogInformation("DEBUG: Added to Data - FieldId: '{FieldId}', Value: '{Value}'", fieldId, val); - } - else if (formValue.Count > 1) - { - var arr = formValue.Select(SanitiseHtmlInput).ToArray(); - Data[fieldId] = arr; - if (!string.Equals(fieldId, normalisedFieldId, StringComparison.Ordinal)) - { - Data[normalisedFieldId] = arr; - } - } - else - { - Data[fieldId] = string.Empty; - if (!string.Equals(fieldId, normalisedFieldId, StringComparison.Ordinal)) - { - Data[normalisedFieldId] = string.Empty; - } - } - } - else - { - // Match date inputs like Data[fieldId].Day / Data[fieldId]-day (support both dot and hyphen) - var dateMatch = Regex.Match(key, @"^Data\[(.+?)\](?:[.\-](day|month|year))$", RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(200)); - if (dateMatch.Success) - { - var fieldId = dateMatch.Groups[1].Value; - var part = dateMatch.Groups[2].Value.ToLowerInvariant(); - var formValue = Request.Form[key].ToString(); - - if (!dateParts.TryGetValue(fieldId, out var parts)) - { - parts = (null, null, null); - } - - switch (part) - { - case "day": - parts.Day = formValue; - break; - case "month": - parts.Month = formValue; - break; - case "year": - parts.Year = formValue; - break; - } - - dateParts[fieldId] = parts; - } - } - } - - // Apply conditional logic after processing form data changes - - - - - - // Handle upload fields that use session data instead of form data to avoid truncation - if (IsCollectionFlow) - { - var flowProgress = LoadFlowProgress(FlowId, InstanceId); - var accumulatedData = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); - - foreach (var key in Data.Keys.ToList()) - { - if (Data[key]?.ToString() == "UPLOAD_FIELD_SESSION_DATA") - { - // FIX: Try session first, then fall back to database - // Filter infected files BEFORE saving to database - if (flowProgress.TryGetValue(key, out var sessionValue)) - { - // Filter infected files from session data before saving - var filteredValue = FilterInfectedFilesFromUploadData(sessionValue?.ToString()); - Data[key] = filteredValue; - _logger.LogInformation("Collection flow: Replaced upload placeholder for field {FieldId} with filtered session data", key); - } - else - { - // Fall back to database data if session is empty - // This handles the case where user clicks Continue without making changes - _logger.LogWarning("Collection flow: Session empty for field {FieldId}, falling back to database", key); - - // Try to get from accumulated data (database) - // Need to look inside the collection items - try - { - foreach (var kvp in accumulatedData) - { - var collectionJson = kvp.Value?.ToString(); - if (string.IsNullOrWhiteSpace(collectionJson)) - continue; - - var items = JsonSerializer.Deserialize>>(collectionJson); - if (items == null) continue; - - var existingItem = items.FirstOrDefault(item => item.TryGetValue("id", out var idVal) && idVal?.ToString() == InstanceId); - if (existingItem != null && existingItem.TryGetValue(key, out var fieldValue)) - { - var fieldValueStr = fieldValue?.ToString(); - if (!string.IsNullOrWhiteSpace(fieldValueStr)) - { - // Filter infected files from database data before saving - var filteredValue = FilterInfectedFilesFromUploadData(fieldValueStr); - Data[key] = filteredValue; - _logger.LogInformation("Collection flow: Replaced upload placeholder for field {FieldId} with filtered database data", key); - break; - } - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Collection flow: Error getting database data for field {FieldId}", key); - } - } - } - } - } - else - { - // For regular (non-collection) forms, also replace upload placeholders with session data - foreach (var key in Data.Keys.ToList()) - { - if (Data[key]?.ToString() == "UPLOAD_FIELD_SESSION_DATA") - { - // Read from upload-specific session key, not from AccumulatedFormData - // Uploads are stored in: UploadedFiles_{appId}_{fieldId} - var sessionKey = $"UploadedFiles_{ApplicationId}_{key}"; - var sessionFilesJson = HttpContext.Session.GetString(sessionKey); - - if (!string.IsNullOrWhiteSpace(sessionFilesJson)) - { - // Filter infected files before saving - var filteredValue = FilterInfectedFilesFromUploadData(sessionFilesJson); - Data[key] = filteredValue; - _logger.LogInformation("Replaced upload placeholder for field {FieldId} with filtered session data from upload key", key); - } - else - { - // No session data means no files uploaded yet - keep placeholder so validation can detect it - _logger.LogInformation("No session data found for upload field {FieldId} - validation will detect empty field", key); - } - } - } - } - - await ApplyConditionalLogicAsync("change"); - - // Compose collected date parts into a single ISO date string so summaries recognise an answer - if (dateParts.Count > 0) - { - foreach (var kvp in dateParts) - { - var fieldId = kvp.Key; - var parts = kvp.Value; - var anyEntered = !string.IsNullOrWhiteSpace(parts.Day) || !string.IsNullOrWhiteSpace(parts.Month) || !string.IsNullOrWhiteSpace(parts.Year); - - if (!anyEntered) - { - continue; - } - - if (int.TryParse(parts.Year, out var y) && int.TryParse(parts.Month, out var m) && int.TryParse(parts.Day, out var d)) - { - try - { - // Enforce four-digit year: if not 4 digits, do not normalise to ISO, - // leave as joined parts so validation can raise an error - var yearText = parts.Year?.Trim() ?? string.Empty; - if (yearText.Length != 4) - { - var joinedInvalid = $"{parts.Year}-{parts.Month}-{parts.Day}"; - var normalisedFieldId = fieldId.StartsWith("Data_", StringComparison.Ordinal) ? fieldId.Substring(5) : fieldId; - Data[fieldId] = joinedInvalid; - if (!string.Equals(fieldId, normalisedFieldId, StringComparison.Ordinal)) Data[normalisedFieldId] = joinedInvalid; - } - else - { - var dt = new DateTime(y, m, d); - var iso = dt.ToString("yyyy-MM-dd"); - var normalisedFieldId = fieldId.StartsWith("Data_", StringComparison.Ordinal) ? fieldId.Substring(5) : fieldId; - Data[fieldId] = iso; - if (!string.Equals(fieldId, normalisedFieldId, StringComparison.Ordinal)) Data[normalisedFieldId] = iso; - } - } - catch - { - // Invalid date combo: set a joined value so validator can produce a message and retain the parts - var joined = $"{parts.Year}-{parts.Month}-{parts.Day}"; - var normalisedFieldId = fieldId.StartsWith("Data_", StringComparison.Ordinal) ? fieldId.Substring(5) : fieldId; - Data[fieldId] = joined; - if (!string.Equals(fieldId, normalisedFieldId, StringComparison.Ordinal)) Data[normalisedFieldId] = joined; - } - } - else - { - // Partial or non-numeric: set a joined value so validator can produce a message - var joined = $"{parts.Year}-{parts.Month}-{parts.Day}"; - var normalisedFieldId = fieldId.StartsWith("Data_", StringComparison.Ordinal) ? fieldId.Substring(5) : fieldId; - Data[fieldId] = joined; - if (!string.Equals(fieldId, normalisedFieldId, StringComparison.Ordinal)) Data[normalisedFieldId] = joined; - } - } - } - - bool isDerivedFlowRoute = TryParseDerivedFlowRoute(CurrentPageId, out var _, out var _, out var _); - if (!isDerivedFlowRoute && CurrentPage != null) - { - ValidateCurrentPage(CurrentPage, Data); - } - - if (!ModelState.IsValid) - { - _logger.LogWarning("ModelState invalid on POST Page"); - - // (Reverted) Do not accumulate general invalid form data to session; sub-flow persistence below is sufficient - - // For sub-flow pages, persist latest values to flow progress prior to redirect - try - { - if (TryParseFlowRoute(CurrentPageId, out var fId, out var instId, out _)) - { - SaveFlowProgress(fId, instId, Data); - _logger.LogInformation("Saved in-progress flow data for flow {FlowId}, instance {InstanceId} with {Count} fields due to validation errors.", fId, instId, Data?.Count ?? 0); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to save flow progress on validation failure."); - } - - var contextKey = GetFormErrorContextKey(); - _formErrorStore.Save(contextKey, ModelState); - - if (TryParseDerivedFlowRoute(CurrentPageId, out _, out _, out _)) - { - var selfUrl = $"/applications/{ReferenceNumber}/{TaskId}/{CurrentPageId}"; - return Redirect(selfUrl); - } - - if (TryParseFlowRoute(CurrentPageId, out _, out _, out _)) - { - var selfUrl = $"/applications/{ReferenceNumber}/{TaskId}/{CurrentPageId}"; - return Redirect(selfUrl); - } - - return Page(); - } - - - // When AllowMultiple is true for an autocomplete complex field, append new selection - // to any existing array value instead of replacing it - if (CurrentPage != null) - { - try - { - foreach (var field in CurrentPage.Fields.Where(f => f.Type == "complexField" && f.ComplexField != null)) - { - var cfg = _complexFieldConfigurationService.GetConfiguration(field.ComplexField.Id); - if (!string.Equals(cfg.FieldType, "autocomplete", StringComparison.OrdinalIgnoreCase) || !cfg.AllowMultiple) - { - continue; - } - - var key = field.FieldId; - if (!Data.TryGetValue(key, out var newValObj)) - { - continue; - } - - var newVal = newValObj?.ToString(); - if (string.IsNullOrWhiteSpace(newVal)) - { - continue; - } - - // Load existing selections from accumulated session - var acc = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); - var list = new List(); - if (acc.TryGetValue(key, out var existing) && !string.IsNullOrWhiteSpace(existing?.ToString())) - { - var existingText = existing!.ToString()!; - var addedExisting = false; - // Try parse as array of objects - try - { - var parsedArray = JsonSerializer.Deserialize>(existingText); - if (parsedArray != null) - { - list = parsedArray; - addedExisting = true; - } - } - catch { } - - // If not an array, try parse as single object and add it as first element - if (!addedExisting) - { - try - { - using var doc = JsonDocument.Parse(existingText); - if (doc.RootElement.ValueKind == JsonValueKind.Object) - { - list.Add(doc.RootElement.Clone()); - addedExisting = true; - } - } - catch { } - } - - // If still not added and it's a non-empty string, include as string element - if (!addedExisting && !string.IsNullOrWhiteSpace(existingText)) - { - list.Add(existingText); - } - } - - // Avoid duplicates by comparing JSON string - bool exists = false; - try - { - var newJson = newVal; - exists = list.Any(x => (x?.ToString() ?? "") == newJson); - } - catch { } - - if (!exists) - { - try - { - using var newDoc = JsonDocument.Parse(newVal); - if (newDoc.RootElement.ValueKind == JsonValueKind.Object || newDoc.RootElement.ValueKind == JsonValueKind.Array) - { - list.Add(newDoc.RootElement.Clone()); - } - else if (newDoc.RootElement.ValueKind == JsonValueKind.String) - { - list.Add(newDoc.RootElement.GetString() ?? string.Empty); - } - else - { - list.Add(newDoc.RootElement.ToString()); - } - } - catch - { - // If not JSON, store as string value - list.Add(newVal); - } - } - - var updatedJson = JsonSerializer.Serialize(list); - // Update both normalized and Data_ forms to be safe - Data[key] = updatedJson; - Data[$"Data_{key}"] = updatedJson; - _applicationResponseService.AccumulateFormData(new Dictionary { [key] = updatedJson }, HttpContext.Session); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to merge multi-select autocomplete values"); - } - } - - // Save the current page data to the API (skip for sub-flows and derived flows as they accumulate data differently) - bool isSubFlow = TryParseFlowRoute(CurrentPageId, out _, out _, out _); - bool isDerivedFlowSave = TryParseDerivedFlowRoute(CurrentPageId, out _, out _, out _); - if (ApplicationId.HasValue && Data.Any() && !isSubFlow && !isDerivedFlowSave) - { - await _applicationResponseService.SaveApplicationResponseAsync(ApplicationId.Value, Data, HttpContext.Session); - _logger.LogInformation("Successfully saved response for Application {ApplicationId}, Page {PageId}", - ApplicationId.Value, CurrentPageId); - } - - // Before deciding where to go, push current page URL to navigation history so Back returns here - try - { - if (!string.IsNullOrEmpty(CurrentPageId)) - { - var scope = RenderFormModel.BuildHistoryScope(ReferenceNumber, TaskId, CurrentPageId); - var currentUrl = $"/applications/{ReferenceNumber}/{TaskId}/{CurrentPageId}"; - _navigationHistoryService.Push(scope, currentUrl, HttpContext.Session); - } - else if (!string.IsNullOrEmpty(TaskId)) - { - var scope = RenderFormModel.BuildHistoryScope(ReferenceNumber, TaskId, CurrentPageId); - var currentUrl = $"/applications/{ReferenceNumber}/{TaskId}"; - _navigationHistoryService.Push(scope, currentUrl, HttpContext.Session); - } - } - catch { } - - // Use the new navigation logic to determine where to go after saving - if (CurrentTask != null && CurrentPage != null) - { - // If this is a sub-flow route, compute next page within the flow - if (TryParseFlowRoute(CurrentPageId, out var flowId, out var instanceId, out var flowPageId)) - { - // Find the correct flow and its pages - var flowPages = GetFlowPages(CurrentTask, flowId); - var flowFieldId = GetFlowFieldId(CurrentTask, flowId); - - if (flowPages != null && !string.IsNullOrEmpty(flowFieldId)) - { - // Use the existence flag captured when the flow was first opened (fallback to current check) - var existenceKey = GetFlowItemExistenceSessionKey(flowId, instanceId); - bool itemExistedBeforeSave = HttpContext.Session.GetString(existenceKey) is { } existedValue && - bool.TryParse(existedValue, out var parsed) - ? parsed - : IsExistingCollectionItem(flowFieldId, instanceId); - - // Persist in-progress sub-flow data for this instance - SaveFlowProgress(flowId, instanceId, Data); - - // Also persist partial collection item to the database on every page - if (ApplicationId.HasValue) - { - var accumulatedProgress = LoadFlowProgress(flowId, instanceId); - AppendCollectionItemToSession(flowPages, flowFieldId, instanceId, accumulatedProgress); - - var accData = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); - if (accData.TryGetValue(flowFieldId, out var collectionValue)) - { - await _applicationResponseService.SaveApplicationResponseAsync( - ApplicationId.Value, - new Dictionary { [flowFieldId] = collectionValue }, - HttpContext.Session); - _logger.LogInformation("Saved partial collection item to database for flow {FlowId}, instance {InstanceId}, page {PageId}", - flowId, instanceId, CurrentPageId); - } - } - - var index = flowPages.FindIndex(p => p.PageId == CurrentPage.PageId); - var isLast = index == -1 || index >= flowPages.Count - 1; - if (!isLast) - { - // Find the next visible page using conditional logic - string? nextPageId = null; - - // Check if we have conditional logic to determine next page - if (ConditionalState != null) - { - _logger.LogDebug("Sub-flow navigation: checking conditional logic for pages. Current page: {CurrentPageId}, Flow: {FlowId}", CurrentPage.PageId, flowId); - - // Re-evaluate conditional logic with complete flow data for navigation - var mergedData = LoadFlowProgress(FlowId, InstanceId); - foreach (var kvp in Data) - { - mergedData[kvp.Key] = kvp.Value; - } - - - var navContext = new ConditionalLogicContext - { - CurrentPageId = CurrentPageId, - CurrentTaskId = TaskId, - IsClientSide = false, - Trigger = "change" - }; - - // Re-compute conditional state with complete data - var updatedConditionalState = await _conditionalLogicOrchestrator.ApplyConditionalLogicAsync(Template, mergedData, navContext); - - - // Look for the next visible page after current page using updated state - for (int i = index + 1; i < flowPages.Count; i++) - { - var candidatePage = flowPages[i]; - - // Check if this page should be skipped due to conditional logic using updated state - var isHidden = updatedConditionalState.PageVisibility.TryGetValue(candidatePage.PageId, out var isVisible) && !isVisible; - var isSkipped = updatedConditionalState.SkippedPages.Contains(candidatePage.PageId); - - - if (!isHidden && !isSkipped) - { - nextPageId = candidatePage.PageId; - break; - } - } - } - else - { - // Fallback to simple next page logic if no conditional logic - nextPageId = flowPages[index + 1].PageId; - } - - - if (!string.IsNullOrEmpty(nextPageId)) - { - var nextUrl = _formNavigationService.GetSubFlowPageUrl(CurrentTask.TaskId, ReferenceNumber, flowId, instanceId, nextPageId); - return Redirect(nextUrl); - } - - // If no valid next page found, treat as last page and complete the flow - // Fall through to flow completion logic below - } - - // Flow completion logic - execute when no next page is found - // Flow complete: append item to collection and go back to collection summary - if (!string.IsNullOrEmpty(flowFieldId)) - { - // Merge accumulated progress with final page data - var accumulated = LoadFlowProgress(flowId, instanceId); - - foreach (var kv in Data) - { - // Do not overwrite existing upload data with placeholder token - if (kv.Value?.ToString() == "UPLOAD_FIELD_SESSION_DATA" && accumulated.ContainsKey(kv.Key)) - { - continue; - } - accumulated[kv.Key] = kv.Value; - } - - AppendCollectionItemToSession(flowPages, flowFieldId, instanceId, accumulated); - - // Generate simple, consistent success message - var flow = CurrentTask.Summary?.Flows?.FirstOrDefault(f => f.FlowId == flowId); - var taskTitle = CurrentTask?.TaskName ?? flow?.Title ?? "Item"; - SuccessMessage = $"{taskTitle} updated"; - - if (ApplicationId.HasValue) - { - // Trigger save for the collection field - var acc = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); - if (acc.TryGetValue(flowFieldId, out var collectionValue)) - { - await _applicationResponseService.SaveApplicationResponseAsync(ApplicationId.Value, new Dictionary { [flowFieldId] = collectionValue }, HttpContext.Session); - } - } - // Clear the in-progress cache for this instance - ClearFlowProgress(flowId, instanceId); - - // Clear navigation history - var scope = BuildHistoryScope(ReferenceNumber, TaskId, CurrentPageId); - _navigationHistoryService.Clear(scope, HttpContext.Session); - } - var backToSummary = _formNavigationService.GetCollectionFlowSummaryUrl(CurrentTask.TaskId, ReferenceNumber); - return Redirect(backToSummary); - } - } - - _logger.LogInformation("POST: Checking if CurrentPageId '{CurrentPageId}' is a derived flow route", CurrentPageId); - - // Handle derived collection flow form submissions - if (TryParseDerivedFlowRoute(CurrentPageId, out var derivedFlowId, out var derivedItemId, out var derivedPageId)) - { - _logger.LogInformation("POST: Detected derived flow route - flowId='{FlowId}', itemId='{ItemId}', pageId='{PageId}'", - derivedFlowId, derivedItemId, derivedPageId); - } - else - { - _logger.LogInformation("POST: CurrentPageId '{CurrentPageId}' is NOT a derived flow route", CurrentPageId); - } - - if (TryParseDerivedFlowRoute(CurrentPageId, out derivedFlowId, out derivedItemId, out derivedPageId)) - { - var correctTask = Template?.TaskGroups?.SelectMany(g => g.Tasks)?.FirstOrDefault(t => t.TaskId == TaskId); - var derivedConfig = GetDerivedFlowConfiguration(correctTask, derivedFlowId); - if (derivedConfig != null) - { - var currentDerivedPage = string.IsNullOrEmpty(derivedPageId) - ? derivedConfig.Pages?.FirstOrDefault() - : derivedConfig.Pages?.FirstOrDefault(p => p.PageId == derivedPageId); - - if (currentDerivedPage != null) - { - ValidateCurrentPage(currentDerivedPage, Data); - } - - if (!ModelState.IsValid) - { - var contextKey = GetFormErrorContextKey(); - _formErrorStore.Save(contextKey, ModelState); - var selfUrl = $"/applications/{ReferenceNumber}/{TaskId}/{CurrentPageId}"; - return Redirect(selfUrl); - } - } - - if (derivedConfig != null) - { - // Save the declaration data and mark as signed - _derivedCollectionFlowService.SaveItemDeclaration( - derivedConfig.FieldId, - derivedItemId, - Data, - "Signed", - FormData); - - // Save to API — only pass the declaration keys that were changed, - // not the entire stale FormData snapshot loaded at init time. - // This prevents overwriting the current session state with stale data - // which caused edits to not persist for existing (API-loaded) applications. - if (ApplicationId.HasValue) - { - var statusKey = $"{derivedConfig.FieldId}_status_{derivedItemId}"; - var dataKey = $"{derivedConfig.FieldId}_data_{derivedItemId}"; - var derivedUpdates = new Dictionary - { - [statusKey] = FormData[statusKey], - [dataKey] = FormData[dataKey] - }; - await _applicationResponseService.SaveApplicationResponseAsync(ApplicationId.Value, derivedUpdates, HttpContext.Session); - } - else - { - _logger.LogWarning("DerivedFlow POST: No ApplicationId found, skipping API save"); - } - - // Generate success message - var displayName = GetDerivedItemDisplayName(derivedConfig, derivedItemId); - var templateMessage = derivedConfig.SignedMessage ?? "Declaration for {displayName} has been signed"; - SuccessMessage = templateMessage - .Replace("{displayName}", displayName) - .Replace("{name}", displayName); - - - - // Redirect back to derived collection summary - var redirectUrl = $"/applications/{ReferenceNumber}/{TaskId}"; - - return Redirect(redirectUrl); - } - else - { - _logger.LogError("DerivedFlow POST: Could not find derived config for flowId='{FlowId}'", derivedFlowId); - } - } - else if (_formStateManager.ShouldShowDerivedCollectionFlowSummary(CurrentTask)) - { - // Handle POST from derived collection flow summary page (Continue button) - - - // Handle task completion checkbox and redirect to task list - var completedValue = Request.Form["IsTaskCompleted"].ToString(); - var isCompleted = !string.IsNullOrEmpty(completedValue) && - (string.Equals(completedValue, "true", StringComparison.OrdinalIgnoreCase) || - string.Equals(completedValue, "on", StringComparison.OrdinalIgnoreCase)); - - - if (isCompleted) - { - // Persist a flag so API has an audit of completion action - await _applicationResponseService.SaveApplicationResponseAsync(ApplicationId.Value, new Dictionary - { - [$"{TaskId}_completed"] = true - }, HttpContext.Session); - - // Also set the task status to Completed (matches TaskSummary behaviour) - if (CurrentTask != null) - { - await _applicationStateService.SaveTaskStatusAsync( - ApplicationId.Value, - CurrentTask.TaskId, - Domain.Models.TaskStatus.Completed, - HttpContext.Session); - } - - _logger.LogInformation("POST: About to redirect to task list using RedirectToPage with ReferenceNumber: {ReferenceNumber}", ReferenceNumber); - return RedirectToPage("/FormEngine/RenderForm", new { referenceNumber = ReferenceNumber }); - } - else - { - - - // If unchecked: set task status based on calculated state (in progress if any data exists, else not started) - if (CurrentTask != null && ApplicationId.HasValue) - { - var hasAnyData = _applicationStateService.CalculateTaskStatus(CurrentTask.TaskId, Template, FormData, ApplicationId, HttpContext.Session, ApplicationStatus) - != Domain.Models.TaskStatus.NotStarted; - var newStatus = hasAnyData ? Domain.Models.TaskStatus.InProgress : Domain.Models.TaskStatus.NotStarted; - await _applicationStateService.SaveTaskStatusAsync(ApplicationId.Value, CurrentTask.TaskId, newStatus, HttpContext.Session); - - } - - // Use RedirectToPage to ensure proper page model initialization - return RedirectToPage("/FormEngine/RenderForm", new { referenceNumber = ReferenceNumber }); - } - } - else - { - // First check if returnToSummaryPage is true and should be respected - if (CurrentPage.ReturnToSummaryPage) - { - - - - // Check if conditional logic suggests a different next page (override returnToSummaryPage) - string? conditionalNextPageId = null; - bool hasConditionalTrigger = false; - - if (ConditionalState != null && Template != null) - { - // FIXED: Check if conditional rules specifically show/reveal new pages, not just any trigger - hasConditionalTrigger = HasConditionalLogicShowingPages(); - - _logger.LogInformation("[FLOW DEBUG] ReturnToSummaryPage=true path - hasConditionalTrigger: {HasTrigger}, currentPageId: {PageId}", hasConditionalTrigger, CurrentPage.PageId); - - if (hasConditionalTrigger) - { - _logger.LogInformation("[FLOW DEBUG] Data before calling GetNextPageAsync:"); - foreach (var kv in Data.Take(10)) - { - _logger.LogInformation("[FLOW DEBUG] Data[{Key}] = {Value}", kv.Key, kv.Value?.ToString() ?? "null"); - } - - var context = new ConditionalLogicContext - { - CurrentPageId = CurrentPageId, - CurrentTaskId = TaskId, - IsClientSide = false, - Trigger = "change" - }; - - conditionalNextPageId = await _conditionalLogicOrchestrator.GetNextPageAsync(Template, Data, CurrentPage.PageId, context); - _logger.LogInformation("[FLOW DEBUG] GetNextPageAsync returned: {NextPageId}", conditionalNextPageId ?? "null"); - } - } - - // If conditional logic found a next page AND was triggered, navigate there (override returnToSummaryPage) - if (hasConditionalTrigger && !string.IsNullOrEmpty(conditionalNextPageId)) - { - var nextUrl = $"/applications/{ReferenceNumber}/{CurrentTask.TaskId}/{conditionalNextPageId}"; - - return Redirect(nextUrl); - } - - // No conditional override - respect returnToSummaryPage - var summaryScope = RenderFormModel.BuildHistoryScope(ReferenceNumber, TaskId, CurrentPageId); - _navigationHistoryService.Clear(summaryScope, HttpContext.Session); - - var summaryUrl = _formNavigationService.GetTaskSummaryUrl(CurrentTask.TaskId, ReferenceNumber); - - return Redirect(summaryUrl); - } - - // returnToSummaryPage=false - proceed with normal next page logic - string? nextPageId = null; - - if (ConditionalState != null && Template != null) - { - _logger.LogInformation("[FLOW DEBUG] ReturnToSummaryPage=false path - currentPageId: {PageId}", CurrentPage.PageId); - _logger.LogInformation("[FLOW DEBUG] Data before calling GetNextPageAsync:"); - foreach (var kv in Data.Take(10)) - { - _logger.LogInformation("[FLOW DEBUG] Data[{Key}] = {Value}", kv.Key, kv.Value?.ToString() ?? "null"); - } - - var context = new ConditionalLogicContext - { - CurrentPageId = CurrentPageId, - CurrentTaskId = TaskId, - IsClientSide = false, - Trigger = "change" - }; - - nextPageId = await _conditionalLogicOrchestrator.GetNextPageAsync(Template, Data, CurrentPage.PageId, context); - _logger.LogInformation("[FLOW DEBUG] GetNextPageAsync returned: {NextPageId}", nextPageId ?? "null"); - } - - // If conditional logic found a next page, navigate to it - if (!string.IsNullOrEmpty(nextPageId)) - { - var nextUrl = $"/applications/{ReferenceNumber}/{CurrentTask.TaskId}/{nextPageId}"; - - return Redirect(nextUrl); - } - - // No conditional next page - find the next page in sequence - Domain.Models.Page? sequentialNextPage = null; - if (CurrentTask.Pages != null && CurrentTask.Pages.Any()) - { - var currentPageIndex = CurrentTask.Pages.FindIndex(p => p.PageId == CurrentPage.PageId); - if (currentPageIndex != -1 && currentPageIndex < CurrentTask.Pages.Count - 1) - { - sequentialNextPage = CurrentTask.Pages[currentPageIndex + 1]; - } - } - - if (sequentialNextPage != null) - { - var nextUrl = $"/applications/{ReferenceNumber}/{CurrentTask.TaskId}/{sequentialNextPage.PageId}"; - - return Redirect(nextUrl); - } - - // No next page found - go to task summary as fallback - var summaryFallbackScope = RenderFormModel.BuildHistoryScope(ReferenceNumber, TaskId, CurrentPageId); - _navigationHistoryService.Clear(summaryFallbackScope, HttpContext.Session); - - var fallbackUrl = _formNavigationService.GetTaskSummaryUrl(CurrentTask.TaskId, ReferenceNumber); - - return Redirect(fallbackUrl); - } - } - else if (CurrentTask != null) - { - // Fallback: redirect to the appropriate summary/list depending on config - if (_formStateManager.ShouldShowCollectionFlowSummary(CurrentTask)) - { - var url = _formNavigationService.GetCollectionFlowSummaryUrl(CurrentTask.TaskId, ReferenceNumber); - return Redirect(url); - } - if (_formStateManager.ShouldShowDerivedCollectionFlowSummary(CurrentTask)) - { - var completedValue = Request.Form["IsTaskCompleted"].ToString(); - var isCompleted = !string.IsNullOrEmpty(completedValue) && - (string.Equals(completedValue, "true", StringComparison.OrdinalIgnoreCase) || - string.Equals(completedValue, "on", StringComparison.OrdinalIgnoreCase)); - - if (isCompleted) - { - var derivedFlows = CurrentTask?.Summary?.DerivedFlows; - var errorLines = new List(); - - if (derivedFlows != null && derivedFlows.Any()) - { - foreach (var derivedFlow in derivedFlows) - { - var derivedItems = _derivedCollectionFlowService.GenerateItemsFromSourceField( - derivedFlow.SourceFieldId, FormData, derivedFlow); - - if (!derivedItems.Any()) - { - // Use template-defined error message or fallback to default - var errorMessage = !string.IsNullOrEmpty(derivedFlow.NoItemsErrorMessage) - ? derivedFlow.NoItemsErrorMessage - : $"You need to add at least one item before signing the {derivedFlow.Title}"; - errorLines.Add(errorMessage); - continue; - } - - var statuses = _derivedCollectionFlowService.GetItemStatuses(derivedFlow.FieldId, FormData); - - var unsignedItems = derivedItems - .Where(item => !statuses.ContainsKey(item.Id) || statuses[item.Id] != "Signed") - .ToList(); - - if (unsignedItems.Any()) - { - foreach (var item in unsignedItems) - { - var displayName = GetDerivedItemDisplayName(derivedFlow, item.Id); - // Use template-defined error message or fallback to default - var errorMessage = !string.IsNullOrEmpty(derivedFlow.UnsignedItemErrorMessage) - ? derivedFlow.UnsignedItemErrorMessage.Replace("{sourceName}", displayName) - : $"You need to sign the declaration for {displayName}"; - errorLines.Add(errorMessage); - } - } - } - } - - if (errorLines.Any()) - { - ModelState.Clear(); - // Add header message - ModelState.AddModelError("", "You cannot mark this section as complete:"); - // Add each error as a separate ModelState entry so they render as bullet points - foreach (var errorLine in errorLines) - { - ModelState.AddModelError("", errorLine); - } - IsTaskCompleted = false; - - // Ensure CurrentFormState is set correctly for the view to render properly - CurrentFormState = FormState.DerivedCollectionFlowSummary; - - // Load FormData from session so the view can render the derived flow sections - LoadFormDataFromSession(); - - return Page(); - } - } - - if (ApplicationId.HasValue) - { - if (isCompleted) - { - await _applicationStateService.SaveTaskStatusAsync( - ApplicationId.Value, - CurrentTask.TaskId, - Domain.Models.TaskStatus.Completed, - HttpContext.Session); - } - else - { - var hasAnyData = _applicationStateService.CalculateTaskStatus(CurrentTask.TaskId, Template, FormData, ApplicationId, HttpContext.Session, ApplicationStatus) - != Domain.Models.TaskStatus.NotStarted; - var newStatus = hasAnyData ? Domain.Models.TaskStatus.InProgress : Domain.Models.TaskStatus.NotStarted; - await _applicationStateService.SaveTaskStatusAsync( - ApplicationId.Value, - CurrentTask.TaskId, - newStatus, - HttpContext.Session); - } - } - - var taskListUrl = _formNavigationService.GetTaskListUrl(ReferenceNumber); - - return Redirect(taskListUrl); - } - var summaryUrl = $"/applications/{ReferenceNumber}/{CurrentTask.TaskId}"; - return Redirect(summaryUrl); - } - // Fallback: redirect to task list if CurrentTask is null - var listUrl = $"/applications/{ReferenceNumber}"; - return Redirect(listUrl); + var state = CaptureWorkState(); + var outcome = await _saveFormPage.ExecuteAsync( + state, + Request.Form.ToPostedFields(), + Request.Form["IsTaskCompleted"].ToString()); + ApplyWorkState(state); + return MapOutcome(outcome); } public async Task OnGetAutocompleteAsync(string endpoint, string query) { - - if (string.IsNullOrWhiteSpace(endpoint)) - { - return new JsonResult(new List()); - } try { @@ -1745,120 +217,16 @@ public async Task OnGetAutocompleteAsync(string endpoint, string } } - // Removed: superseded by RemoveFieldItem page handler - public async Task OnPostRemoveCollectionItemAsync(string fieldId, string itemId, string? flowId = null) { await CommonFormEngineInitializationAsync(); - ModelState.Clear(); - - if (!string.IsNullOrEmpty(TaskId)) - { - var (group, task) = InitializeCurrentTask(TaskId); - CurrentGroup = group; - CurrentTask = task; - } - - if (string.IsNullOrEmpty(fieldId) || string.IsNullOrEmpty(itemId)) - { - return BadRequest("Field ID and Item ID are required"); - } - - if (!IsApplicationEditable()) - { - ModelState.AddModelError("", ApplicationAccessMessages.NoWritePermission); - return Page(); - } - - bool isConfirmed = Request.Query.ContainsKey("confirmed") && Request.Query["confirmed"] == "true"; - - if (!isConfirmed) - { - _logger.LogInformation("RemoveCollectionItem handler executing for validation - item will not be removed yet"); - - return Redirect(_formNavigationService.GetCollectionFlowSummaryUrl(TaskId, ReferenceNumber)); - } - - _logger.LogInformation("RemoveCollectionItem handler executing confirmed removal for item {ItemId} from field {FieldId}", itemId, fieldId); - - // Get current collection from session first - var accumulatedData = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); - - Dictionary? itemData = null; - string? flowTitle = null; - - // Get the flow and item information for success message - if (!string.IsNullOrEmpty(flowId) && CurrentTask != null) - { - var flow = CurrentTask.Summary?.Flows?.FirstOrDefault(f => f.FlowId == flowId); - if (flow != null) - { - flowTitle = flow.Title; - - // Get the item data before removing it - if (accumulatedData.TryGetValue(fieldId, out var collectionValue)) - { - var json = collectionValue?.ToString() ?? "[]"; - try - { - var items = JsonSerializer.Deserialize>>(json) ?? new(); - itemData = items.FirstOrDefault(i => i.TryGetValue("id", out var id) && id?.ToString() == itemId); - } - catch { } - } - - // Generate success message using custom message or fallback - itemData = ExpandEncodedJson(itemData); - SuccessMessage = GenerateSuccessMessage(flow.DeleteItemMessage, "delete", itemData, flowTitle); - } - } - - // Now perform the actual removal - if (accumulatedData.TryGetValue(fieldId, out var collectionData)) - { - var json = collectionData?.ToString() ?? "[]"; - try - { - var items = JsonSerializer.Deserialize>>(json) ?? new(); - - // Find the item to be removed so we can delete its associated files - var itemToRemove = items.FirstOrDefault(item => - item.TryGetValue("id", out var id) && id?.ToString() == itemId); - - // Delete all files associated with this collection item before removing it - if (itemToRemove != null && ApplicationId.HasValue) - { - // Expand any encoded JSON in the item data to ensure file data is properly parsed - var expandedItem = ExpandEncodedJson(itemToRemove); - await DeleteFilesFromCollectionItemAsync(ApplicationId.Value, expandedItem); - } - - // Remove the item with matching ID - items.RemoveAll(item => item.TryGetValue("id", out var id) && id?.ToString() == itemId); - - // Update the collection - var updatedJson = JsonSerializer.Serialize(items); - _applicationResponseService.AccumulateFormData(new Dictionary { [fieldId] = updatedJson }, HttpContext.Session); - - // Save to API - if (ApplicationId.HasValue) - { - await _applicationResponseService.SaveApplicationResponseAsync(ApplicationId.Value, new Dictionary { [fieldId] = updatedJson }, HttpContext.Session); - } - } - catch (ExternalApplicationsException) - { - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to remove collection item {ItemId} from field {FieldId}", itemId, fieldId); - } - } - // Redirect back to the collection summary - return Redirect(_formNavigationService.GetCollectionFlowSummaryUrl(TaskId, ReferenceNumber)); + var state = CaptureWorkState(); + var confirmed = Request.Query.ContainsKey("confirmed") && Request.Query["confirmed"] == "true"; + var outcome = await _removeCollectionItem.ExecuteAsync(state, fieldId, itemId, flowId, confirmed); + ApplyWorkState(state); + return MapOutcome(outcome); } public async Task OnGetComplexFieldAsync(string complexFieldId, string query) @@ -1873,9 +241,7 @@ public async Task OnGetComplexFieldAsync(string complexFieldId, s try { - var results = await autocompleteService.SearchAsync(complexFieldId, query); - return new JsonResult(results); } catch (Exception ex) @@ -1885,1983 +251,91 @@ public async Task OnGetComplexFieldAsync(string complexFieldId, s } } - - - private static bool TryParseFlowRoute(string pageId, out string flowId, out string instanceId, out string flowPageId) + public async Task OnPostUploadFileAsync() { - flowId = instanceId = flowPageId = string.Empty; - if (string.IsNullOrEmpty(pageId)) return false; - // Expected: flow/{flowId}/{instanceId}/{pageId?} - var parts = pageId.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 3 && parts[0].Equals("flow", StringComparison.OrdinalIgnoreCase)) + try { - flowId = parts[1]; - instanceId = parts[2]; - flowPageId = parts.Length > 3 ? parts[3] : string.Empty; - return true; + await CommonFormEngineInitializationAsync(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Upload initialization failed; continuing with posted file context"); } - return false; - } - /// - /// Parses derived collection flow routes like: {flowId}/derived/{itemId}/{pageId?} - /// - private static bool TryParseDerivedFlowRoute(string pageId, out string derivedFlowId, out string derivedItemId, out string derivedPageId) - { - derivedFlowId = derivedItemId = derivedPageId = string.Empty; - if (string.IsNullOrEmpty(pageId)) return false; - - // Expected: {flowId}/derived/{itemId}/{pageId?} - var parts = pageId.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 3 && parts[1].Equals("derived", StringComparison.OrdinalIgnoreCase)) + var applicationId = Request.Form["ApplicationId"].ToString(); + var fieldId = Request.Form["FieldId"].ToString(); + var returnUrl = Request.Form["ReturnUrl"].ToString(); + var uploadDescription = Request.Form["UploadDescription"].ToString(); + + if (!IsCollectionFlow) { - derivedFlowId = parts[0]; - derivedItemId = parts[2]; - derivedPageId = parts.Length > 3 ? parts[3] : string.Empty; - return true; + ModelState.Remove("FlowId"); + ModelState.Remove("InstanceId"); } - return false; - } - /// - /// Gets the pages for a specific flow in multi-collection flow mode - /// - private List? GetFlowPages(Domain.Models.Task? task, string flowId) - { - var flow = task?.Summary?.Flows?.FirstOrDefault(f => f.FlowId == flowId); - return flow?.Pages; + if (!Guid.TryParse(applicationId, out var appId)) + return NotFound(); + + var file = Request.Form.Files["UploadFile"]; + var hasFile = file is { Length: > 0 }; + await using var stream = hasFile ? file!.OpenReadStream() : Stream.Null; + + var state = CaptureWorkState(); + var outcome = await _uploadFormFile.ExecuteAsync(state, new UploadFormFileRequest( + appId, + fieldId, + returnUrl, + uploadDescription, + stream, + file?.FileName ?? string.Empty, + file?.ContentType, + $"{ReferenceNumber}_{TaskId}_{CurrentPageId}", + hasFile)); + ApplyWorkState(state); + await TryNotifyFileOperationAsync(outcome); + return MapOutcome(outcome); } - /// - /// Gets the fieldId for a specific flow in multi-collection flow mode - /// - private string? GetFlowFieldId(Domain.Models.Task? task, string flowId) + public async Task OnPostDownloadFileAsync() { - var flow = task?.Summary?.Flows?.FirstOrDefault(f => f.FlowId == flowId); - return flow?.FieldId; + var applicationId = Request.Form["ApplicationId"].ToString(); + var fileIdStr = Request.Form["FileId"].ToString(); + + if (!Guid.TryParse(applicationId, out var appId) || !Guid.TryParse(fileIdStr, out var fileId)) + return NotFound(); + + var state = CaptureWorkState(); + var outcome = await _downloadFormFile.ExecuteAsync(state, new DownloadFormFileRequest(appId, fileId)); + ApplyWorkState(state); + return MapOutcome(outcome); } - /// - /// Gets the configuration for a specific derived flow - /// - private DerivedCollectionFlowConfiguration? GetDerivedFlowConfiguration(Domain.Models.Task? task, string derivedFlowId) + public async Task OnPostDeleteFileAsync() { - _logger.LogInformation("GetDerivedFlowConfiguration: Looking for flowId='{FlowId}' in task '{TaskId}'", derivedFlowId, task?.TaskId); - _logger.LogInformation("GetDerivedFlowConfiguration: Task summary mode: '{Mode}'", task?.Summary?.Mode); - _logger.LogInformation("GetDerivedFlowConfiguration: DerivedFlows count: {Count}", task?.Summary?.DerivedFlows?.Count ?? 0); - - if (task?.Summary?.DerivedFlows != null) - { - foreach (var flow in task.Summary.DerivedFlows) - { - _logger.LogInformation("GetDerivedFlowConfiguration: Available flow - FlowId='{FlowId}', FieldId='{FieldId}'", flow.FlowId, flow.FieldId); - } - } - - var derivedFlow = task?.Summary?.DerivedFlows?.FirstOrDefault(f => f.FlowId == derivedFlowId); - _logger.LogInformation("GetDerivedFlowConfiguration: Found config: {Found}", derivedFlow != null); - return derivedFlow; - } - - /// - /// Loads pre-filled data for a derived collection item - /// - private void LoadDerivedItemData(DerivedCollectionFlowConfiguration config, string itemId) - { - try - { - // First, load any existing declaration data for this item - var existingData = _derivedCollectionFlowService.GetItemDeclarationData(config.FieldId, itemId, FormData); - foreach (var kvp in existingData) - { - Data[kvp.Key] = kvp.Value; - } - - // Then, generate and load pre-filled data from the source - var derivedItems = _derivedCollectionFlowService.GenerateItemsFromSourceField(config.SourceFieldId, FormData, config); - var currentItem = derivedItems.FirstOrDefault(item => item.Id == itemId); - - if (currentItem != null) - { - // Pre-fill with source data (but don't overwrite existing declaration data) - foreach (var kvp in currentItem.PrefilledData) - { - if (!Data.ContainsKey(kvp.Key)) // Only set if not already populated from existing data - { - Data[kvp.Key] = kvp.Value; - } - } - - _logger.LogInformation("Loaded derived item data for item {ItemId} in flow {FlowId} with {Count} fields", - itemId, config.FlowId, currentItem.PrefilledData.Count); - } - - // Ensure all field labels are visible for derived flow forms - if (CurrentPage != null) - { - foreach (var field in CurrentPage.Fields) - { - if (field.Label != null) - { - field.Label.IsVisible = true; - } - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to load derived item data for item {ItemId} in flow {FlowId}", itemId, config.FlowId); - } - } - - /// - /// Resolves a user-friendly display name for a derived item, using the service's generated - /// items and the configured binding. Falls back to the raw itemId if no data is available. - /// - private string GetDerivedItemDisplayName(DerivedCollectionFlowConfiguration config, string itemId) - { - try - { - var items = _derivedCollectionFlowService.GenerateItemsFromSourceField(config.SourceFieldId, FormData, config); - var match = items.FirstOrDefault(i => string.Equals(i.Id, itemId, StringComparison.OrdinalIgnoreCase)); - if (match != null) - { - if (!string.IsNullOrWhiteSpace(match.DisplayName)) - { - return match.DisplayName; - } - - if (match.PrefilledData != null && - match.PrefilledData.TryGetValue(config.ItemTitleBinding, out var value) && - !string.IsNullOrWhiteSpace(value?.ToString())) - { - return value!.ToString()!; - } - } - } - catch - { - // ignore - } - - return itemId; - } - - /// - /// Checks if an item with the given instanceId already exists in the collection - /// - private bool IsExistingCollectionItem(string fieldId, string instanceId) - { - var accumulated = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); - if (accumulated.TryGetValue(fieldId, out var collectionValue)) - { - var json = collectionValue?.ToString() ?? "[]"; - try - { - var items = JsonSerializer.Deserialize>>(json) ?? new(); - return items.Any(item => item.TryGetValue("id", out var id) && id?.ToString() == instanceId); - } - catch - { - return false; - } - } - return false; - } - - /// - /// Reads a collection field value from FormData and parses it to a list of item dictionaries. - /// Returns an empty list when missing or invalid. - /// - private List> ReadCollectionItemsFromFormData(string fieldId) - { - if (!FormData.TryGetValue(fieldId, out var value) || value == null) - { - return new List>(); - } - var s = value.ToString(); - if (string.IsNullOrWhiteSpace(s) || !s!.TrimStart().StartsWith("[")) - { - return new List>(); - } - try - { - var parsed = JsonSerializer.Deserialize>>(s); - return parsed ?? new List>(); - } - catch - { - return new List>(); - } - } - - /// - /// Checks if a task has any data (for regular tasks or collection flows) - /// - private bool HasAnyTaskData(Domain.Models.Task task) - { - var taskFieldIds = new List(); - - // For regular tasks, get field IDs from pages - if (task.Pages != null) - { - taskFieldIds.AddRange(task.Pages - .SelectMany(p => p.Fields) - .Select(f => f.FieldId)); - } - - // For multi-collection flow tasks, also check collection field IDs - if (task.Summary?.Mode?.Equals("multiCollectionFlow", StringComparison.OrdinalIgnoreCase) == true && - task.Summary.Flows != null) - { - taskFieldIds.AddRange(task.Summary.Flows.Select(f => f.FieldId)); - } - - return taskFieldIds.Any(fieldId => - FormData.ContainsKey(fieldId) && - !string.IsNullOrWhiteSpace(FormData[fieldId]?.ToString())); - } - - private void AppendCollectionItemToSession(List pages, string fieldId, string instanceId, Dictionary itemData) - { - var acc = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); - var list = new List>(); - if (acc.TryGetValue(fieldId, out var existing)) - { - var s = existing?.ToString(); - if (!string.IsNullOrWhiteSpace(s)) - { - try - { - var parsed = JsonSerializer.Deserialize>>(s); - if (parsed != null) list = parsed; - } - catch { } - } - } - - - - // Find existing item or create new one - var idx = list.FindIndex(x => x.TryGetValue("id", out var id) && id?.ToString() == instanceId); - Dictionary item; - - if (idx >= 0) - { - // Editing existing item: start with existing data and merge in new values - item = new Dictionary(list[idx]); - - // Update only the fields that have values in itemData (current page data) - foreach (var kvp in itemData) - { - // If the incoming value is the upload placeholder, do not overwrite an existing upload JSON - if (kvp.Value?.ToString() == "UPLOAD_FIELD_SESSION_DATA" && - item.TryGetValue(kvp.Key, out var existingVal) && - existingVal != null && existingVal.ToString()!.StartsWith("[") && existingVal.ToString()!.Contains("\"id\"")) - { - continue; - } - item[kvp.Key] = kvp.Value; - } - } - else - { - // New item: create fresh item with all possible fields from flow pages - item = new Dictionary(); - foreach (var page in pages) - { - foreach (var field in page.Fields) - { - var key = field.FieldId; - if (itemData.TryGetValue(key, out var value)) - { - // Skip placeholder writes for uploads; real value will be in itemData when available - if (value?.ToString() == "UPLOAD_FIELD_SESSION_DATA") - { - continue; - } - item[key] = value; - } - } - } - item["id"] = instanceId; - } - - // Ensure id is always set - item["id"] = instanceId; - - // DEBUG: Log final item before serialization - - foreach (var kvp in item) - { - var valueStr = kvp.Value?.ToString(); - var preview = valueStr?.Length > 100 ? valueStr.Substring(0, 100) + "..." : valueStr; - - if (kvp.Key.Contains("upload", StringComparison.OrdinalIgnoreCase)) - { - - } - } - - // Upsert the item - if (idx >= 0) - list[idx] = item; - else - list.Add(item); - - var serialized = JsonSerializer.Serialize(list); - - _applicationResponseService.AccumulateFormData(new Dictionary { [fieldId] = serialized }, HttpContext.Session); - } - - private static string GetFlowProgressSessionKey(string flowId, string instanceId) => $"FlowProgress_{flowId}_{instanceId}"; - - private static string GetFlowItemExistenceSessionKey(string flowId, string instanceId) => $"FlowItemExisted_{flowId}_{instanceId}"; - - private Dictionary LoadFlowProgressWithDebug() - { - if (!IsCollectionFlow) - { - - return new Dictionary(); - } - - var key = GetFlowProgressSessionKey(FlowId, InstanceId); - - - - - - // Try to get all session keys - try - { - var sessionKeys = new List(); - foreach (var sessionKey in HttpContext.Session.Keys) - { - sessionKeys.Add(sessionKey); - } - - } - catch (Exception ex) - { - Console.WriteLine($"[UPLOAD DEBUG] Error getting session keys: {ex.Message}"); - } - - var json = HttpContext.Session.GetString(key); - if (string.IsNullOrWhiteSpace(json)) - { - - return new Dictionary(); - } - - try - { - var data = JsonSerializer.Deserialize>(json) ?? new Dictionary(); - - return data; - } - catch (Exception ex) - { - - return new Dictionary(); - } - } - - private Dictionary LoadFlowProgress(string flowId, string instanceId) - { - var key = GetFlowProgressSessionKey(flowId, instanceId); - var json = HttpContext.Session.GetString(key); - if (string.IsNullOrWhiteSpace(json)) - { - - - return new Dictionary(); - } - try - { - var dict = JsonSerializer.Deserialize>(json); - - return dict ?? new Dictionary(); - } - catch - { - - return new Dictionary(); - } - } - - private void SaveFlowProgress(string flowId, string instanceId, Dictionary latest) - { - var existing = LoadFlowProgress(flowId, instanceId); - foreach (var kv in latest) - { - existing[kv.Key] = kv.Value; - } - var key = GetFlowProgressSessionKey(flowId, instanceId); - HttpContext.Session.SetString(key, JsonSerializer.Serialize(existing)); - - - } - - private void ClearFlowProgress(string flowId, string instanceId) - { - var key = GetFlowProgressSessionKey(flowId, instanceId); - HttpContext.Session.Remove(key); - } - private void CheckAndClearSessionForNewApplication() - { - // Check if we're working with a different application than what's stored in session - var sessionApplicationId = HttpContext.Session.GetString("CurrentAccumulatedApplicationId"); - var currentApplicationId = ApplicationId?.ToString(); - - if (!string.IsNullOrEmpty(sessionApplicationId) && - sessionApplicationId != currentApplicationId) - { - // Clear accumulated data for the previous application - _applicationResponseService.ClearAccumulatedFormData(HttpContext.Session); - _logger.LogInformation("Cleared accumulated form data for previous application {PreviousApplicationId}, now working with {CurrentApplicationId}", - sessionApplicationId, currentApplicationId); - } - - // Store the current application ID for future reference - if (ApplicationId.HasValue) - { - HttpContext.Session.SetString("CurrentAccumulatedApplicationId", ApplicationId.Value.ToString()); - } - } - - private async Task LoadAccumulatedDataFromSessionAsync() - { - // Get accumulated form data from session and populate the Data dictionary - // Infected files are automatically filtered by the blacklist - var accumulatedData = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); - - if (accumulatedData.Any()) - { - // Populate the Data dictionary with accumulated data - foreach (var kvp in accumulatedData) - { - Data[kvp.Key] = kvp.Value; - } - - _logger.LogInformation("Loaded {Count} accumulated form data entries from session", accumulatedData.Count); - } - - // Apply conditional logic after loading data - await ApplyConditionalLogicAsync(); - } - - private async Task ApplyConditionalLogicAsync(string trigger = "load") - { - try - { - - - if (Template?.ConditionalLogic != null && Template.ConditionalLogic.Any()) - { - // Prefer FormData (session) when Data is empty (e.g. preview GET before bind); - // otherwise use Data so current-page POST values win. - var dataForConditionalLogic = Data.Count > 0 - ? new Dictionary(Data) - : new Dictionary(FormData); - - // Only merge when in POST/change trigger (not during initial GET/load) - if (trigger == "change") - { - var accumulatedData = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); - foreach (var kvp in accumulatedData) - { - // Only add if not already in dataForConditionalLogic (current page data takes priority) - if (!dataForConditionalLogic.ContainsKey(kvp.Key)) - { - dataForConditionalLogic[kvp.Key] = kvp.Value; - } - } - } - - var context = new ConditionalLogicContext - { - CurrentPageId = CurrentPageId, - CurrentTaskId = TaskId, - IsClientSide = false, - Trigger = trigger - }; - - ConditionalState = await _conditionalLogicOrchestrator.ApplyConditionalLogicAsync(Template, dataForConditionalLogic, context); - - - - // Apply field values from conditional logic - if (ConditionalState.FieldValues.Any()) - { - foreach (var kvp in ConditionalState.FieldValues) - { - Data[kvp.Key] = kvp.Value; - } - - } - } - else - { - - } - } - catch (Exception ex) - { - _logger.LogError(ex, "CONDITIONAL LOGIC ERROR: {Message}", ex.Message); - } - } - - - - /// - /// Calculate overall application status based on task statuses - /// - public string CalculateApplicationStatus() - { - if (Template?.TaskGroups == null) - { - return "InProgress"; - } - - var allTasks = Template.TaskGroups.SelectMany(g => g.Tasks).ToList(); - - // If any task is in progress or completed, application is in progress - var hasAnyTaskWithProgress = allTasks.Any(task => - { - var status = GetTaskStatusFromSession(task.TaskId); - return status == Domain.Models.TaskStatus.InProgress || status == Domain.Models.TaskStatus.Completed; - }); - - return hasAnyTaskWithProgress ? "InProgress" : "InProgress"; // Always InProgress until submitted - } - - private void LoadExistingFlowItemData(string flowId, string instanceId) - { - // Check if we're editing an existing item by looking in the collection - var task = CurrentTask; - var fieldId = GetFlowFieldId(task, flowId); - - if (string.IsNullOrEmpty(fieldId)) return; - - var accumulated = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); - if (accumulated.TryGetValue(fieldId, out var collectionValue)) - { - var json = collectionValue?.ToString() ?? "[]"; - try - { - var items = JsonSerializer.Deserialize>>(json) ?? new(); - var existingItem = items.FirstOrDefault(item => item.TryGetValue("id", out var id) && id?.ToString() == instanceId); - - if (existingItem != null) - { - // Editing existing item: load its data into Data dictionary for form rendering - foreach (var kvp in existingItem) - { - if (kvp.Key == "id") continue; // Skip the ID field - // Preserve upload data if present in saved item - if (kvp.Value != null && kvp.Value.ToString()?.StartsWith("[") == true && kvp.Value.ToString()!.Contains("\"id\"")) - { - Data[kvp.Key] = kvp.Value; - continue; - } - Data[kvp.Key] = kvp.Value; - } - - } - else - { - // New item: check if this is the first page or if we have progress - var existingProgress = LoadFlowProgress(flowId, instanceId); - if (existingProgress.Any()) - { - // We have progress, this is not the first page - load the progress - foreach (var kvp in existingProgress) - { - Data[kvp.Key] = kvp.Value; - } - - } - else - { - // No progress exists, this is likely the first page - ensure clean start - ClearFlowProgress(flowId, instanceId); - Data.Clear(); - - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to load existing flow item data for instance {InstanceId}", instanceId); - } - } - else - { - // No collection exists yet - check for existing progress - var existingProgress = LoadFlowProgress(flowId, instanceId); - if (existingProgress.Any()) - { - // Load existing progress - foreach (var kvp in existingProgress) - { - Data[kvp.Key] = kvp.Value; - } - - } - else - { - // Truly new - clear everything - ClearFlowProgress(flowId, instanceId); - Data.Clear(); - - } - } - } - - /// - /// Check if a field should be hidden based on conditional logic - /// - /// The field ID to check - /// True if the field should be hidden - public bool IsFieldHidden(string fieldId) - { - if (ConditionalState == null) - { - // If no conditional state but field has conditional logic rules, hide it by default - if (Template?.ConditionalLogic != null && HasFieldConditionalLogic(fieldId)) - { - return true; - } - return false; - } - - if (ConditionalState.FieldVisibility.TryGetValue(fieldId, out var isVisible)) - { - return !isVisible; - } - - // Check if field has conditional logic rules - if so, hide by default until conditions are met - if (Template?.ConditionalLogic != null && HasFieldConditionalLogic(fieldId)) - { - return true; - } - - return false; - } - - /// - /// Check if a field has conditional logic rules that affect its visibility - /// - /// The field ID to check - /// True if the field has conditional visibility rules - private bool HasFieldConditionalLogic(string fieldId) - { - if (Template?.ConditionalLogic == null) return false; - - _fieldsWithConditionalVisibility ??= BuildFieldsWithConditionalVisibility(); - return _fieldsWithConditionalVisibility.Contains(fieldId); - } - - private HashSet BuildFieldsWithConditionalVisibility() - { - var fields = new HashSet(StringComparer.OrdinalIgnoreCase); - if (Template?.ConditionalLogic == null) - return fields; - - foreach (var rule in Template.ConditionalLogic) - { - if (!rule.Enabled || rule.AffectedElements == null) - continue; - - foreach (var element in rule.AffectedElements) - { - if (element.ElementType == "field" - && (element.Action == "hide" || element.Action == "show") - && !string.IsNullOrEmpty(element.ElementId)) - { - fields.Add(element.ElementId); - } - } - } - - return fields; - } - - /// - /// Check if a page should be hidden/skipped based on conditional logic - /// - /// The page ID to check - /// True if the page should be hidden - public bool IsPageHidden(string pageId) - { - - - if (ConditionalState == null) - { - - // If no conditional state but page has conditional logic rules, hide it by default - if (Template?.ConditionalLogic != null && HasPageConditionalLogic(pageId)) - { - - return true; - } - return false; - } - - // Check if page is in skipped list - if (ConditionalState.SkippedPages.Contains(pageId)) - { - - return true; - } - - // Check if page is hidden by visibility rules - if (ConditionalState.PageVisibility.TryGetValue(pageId, out var isVisible)) - { - // Trust the ConditionalState that was already calculated by ApplyConditionalLogicAsync - return !isVisible; - } - - // If page is not in ConditionalState.PageVisibility but has conditional logic rules, hide it by default - if (Template?.ConditionalLogic != null && HasPageConditionalLogic(pageId)) - { - return true; - } - - return false; - } - - /// - /// Check if a page has conditional logic rules that affect its visibility - /// - /// The page ID to check - /// True if the page has conditional visibility rules - private bool HasPageConditionalLogic(string pageId) - { - if (Template?.ConditionalLogic == null) return false; - - return Template.ConditionalLogic.Any(rule => - rule.Enabled && - rule.AffectedElements.Any(element => - element.ElementId == pageId && - element.ElementType == "page" && - (element.Action == "hide" || element.Action == "show" || element.Action == "skip"))); - } - - /// - /// Check if conditional logic was actually triggered based on current data and field changes - /// - /// True if any conditional logic rules were triggered - private bool HasConditionalLogicTriggered() - { - if (Template?.ConditionalLogic == null || ConditionalState == null) - { - return false; - } - - // Check if any rules have their conditions met with current data - foreach (var rule in Template.ConditionalLogic.Where(r => r.Enabled)) - { - if (EvaluateRuleConditions(rule)) - { - - return true; - } - } - - return false; - } - - /// - /// Check if conditional logic specifically shows/reveals new pages based on current form data - /// - /// True if conditional logic rules with "show" actions are met by current data - private bool HasConditionalLogicShowingPages() - { - if (Template?.ConditionalLogic == null) - return false; - - foreach (var rule in Template.ConditionalLogic.Where(r => r.Enabled)) - { - // Only check rules that have "show" actions for pages - var hasShowPageAction = rule.AffectedElements.Any(element => - element.ElementType == "page" && element.Action == "show"); - - if (!hasShowPageAction) continue; - - - - if (EvaluateRuleConditions(rule)) - { - - return true; - } - } - - - return false; - } - - /// - /// Evaluate if a conditional logic rule's conditions are met - /// - /// The rule to evaluate - /// True if all conditions are met - private bool EvaluateRuleConditions(Domain.Models.ConditionalLogic rule) - { - if (rule.ConditionGroup?.Conditions == null || !rule.ConditionGroup.Conditions.Any()) - { - return false; - } - - var results = new List(); - - foreach (var condition in rule.ConditionGroup.Conditions) - { - var fieldValue = Data.TryGetValue(condition.TriggerField, out var value) ? value?.ToString() : ""; - var conditionValue = condition.Value?.ToString() ?? ""; - var conditionMet = condition.Operator.ToLower() switch - { - "equals" => string.Equals(fieldValue, conditionValue, StringComparison.OrdinalIgnoreCase), - "not_equals" => !string.Equals(fieldValue, conditionValue, StringComparison.OrdinalIgnoreCase), - "contains" => fieldValue?.Contains(conditionValue, StringComparison.OrdinalIgnoreCase) == true, - "not_contains" => fieldValue?.Contains(conditionValue, StringComparison.OrdinalIgnoreCase) != true, - _ => false - }; - - results.Add(conditionMet); - } - - // Apply logical operator - return rule.ConditionGroup.LogicalOperator?.ToUpper() switch - { - "AND" => results.All(r => r), - "OR" => results.Any(r => r), - _ => results.All(r => r) // Default to AND - }; - } - - /// - /// Ensures field visibility for a collection item is evaluated for the given field IDs (batched). - /// Call once per item before checking multiple summary columns. - /// - public void EnsureItemFieldVisibility(Dictionary itemData, IEnumerable fieldIds) - { - if (Template?.ConditionalLogic == null || !Template.ConditionalLogic.Any()) - return; - - var needed = fieldIds - .Where(HasFieldConditionalLogic) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Where(id => - !_itemConditionalStateCache.TryGetValue(itemData, out var existing) - || !existing.FieldVisibility.ContainsKey(id)) - .ToList(); - - if (needed.Count == 0) - return; - - try - { - var context = new ConditionalLogicContext - { - CurrentPageId = CurrentPageId, - CurrentTaskId = TaskId, - IsClientSide = false, - Trigger = "load" - }; - - var partial = _conditionalLogicOrchestrator - .ApplyFieldVisibilityAsync(Template, itemData, needed, context) - .GetAwaiter() - .GetResult(); - - if (!_itemConditionalStateCache.TryGetValue(itemData, out var state)) - { - _itemConditionalStateCache[itemData] = partial; - return; - } - - foreach (var kvp in partial.FieldVisibility) - { - state.FieldVisibility[kvp.Key] = kvp.Value; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error ensuring field visibility for collection item"); - } - } - - /// - /// Check if a field should be hidden for a specific collection item based on conditional logic - /// - /// The field ID to check - /// The specific item's data to evaluate against - /// True if the field should be hidden for this specific item - public bool IsFieldHiddenForItem(string fieldId, Dictionary itemData) - { - try - { - if (Template?.ConditionalLogic == null || !Template.ConditionalLogic.Any()) - { - return false; // No conditional logic defined - } - - if (!HasFieldConditionalLogic(fieldId)) - { - return false; - } - - EnsureItemFieldVisibility(itemData, [fieldId]); - - if (_itemConditionalStateCache.TryGetValue(itemData, out var itemConditionalState) - && itemConditionalState.FieldVisibility.TryGetValue(fieldId, out var isVisible)) - { - return !isVisible; - } - - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error evaluating conditional logic for field {FieldId} with item data", fieldId); - return false; // Default to visible on error - } - } - - #region Upload File Handlers - - public async Task OnPostUploadFileAsync() - { - - // Ensure Template is not null (required for RenderForm) - if (Template == null) - { - Template = new FormTemplate - { - TemplateId = "dummy", - TemplateName = "dummy", - Description = "dummy", - TaskGroups = new List() - }; - } - - // Align POST context with GET so CurrentTask/Data are available - try - { - await CommonFormEngineInitializationAsync(); - - } - catch (Exception ex) - { - - } - - // Extract form data - var applicationId = Request.Form["ApplicationId"].ToString(); - var fieldId = Request.Form["FieldId"].ToString(); - var returnUrl = Request.Form["ReturnUrl"].ToString(); - var uploadDescription = Request.Form["UploadDescription"].ToString(); - - // Clear validation errors for FlowId/InstanceId if not in collection flow - if (!IsCollectionFlow) - { - ModelState.Remove("FlowId"); - ModelState.Remove("InstanceId"); - } - - // Parse application ID - if (!Guid.TryParse(applicationId, out var appId)) - { - return NotFound(); - } - - // Get uploaded file - var file = Request.Form.Files["UploadFile"]; - // Read any existing file IDs posted by the view to preserve list - var existingFileIds = Request.Form["ExistingFileIds"].ToArray(); - - if (file == null || file.Length == 0) - { - - ErrorMessage = "Select a file to upload"; - ModelState.AddModelError("UploadFile", ErrorMessage); - - if (!string.IsNullOrEmpty(fieldId)) - { - _formErrorStore.Save(fieldId, ModelState); - } - - Files = await GetFilesForFieldAsync(appId, fieldId); - - // Check if we have return URL - if (!string.IsNullOrEmpty(returnUrl)) - { - - return Redirect(returnUrl); - } - - return Page(); - } - - if (FileExistInSessionList(appId, fieldId, file.FileName)) - { - ErrorMessage = "The selected file has already been uploaded. Upload a file with a different name.\n "; - ModelState.AddModelError("UploadFile", ErrorMessage); - - if (!string.IsNullOrEmpty(fieldId)) - { - _formErrorStore.Save(fieldId, ModelState); - } - - Files = await GetFilesForFieldAsync(appId, fieldId); - - if (!string.IsNullOrEmpty(returnUrl)) - { - - return Redirect(returnUrl); - } - - return Page(); - } - - using var stream = file.OpenReadStream(); - var fileParam = new FileParameter(stream, file.FileName, file.ContentType); - - try - { - var uploadedFile = await fileUploadService.UploadFileAsync(appId, file.FileName, uploadDescription, fileParam); - - - // Only execute this code if API call succeeds - // Get existing files for this field/collection instance - var currentFieldFiles = (await GetFilesForFieldAsync(appId, fieldId)).ToList(); - - if (!currentFieldFiles.Any(cf => cf.Id == uploadedFile.Id)) - { - _logger.LogInformation( - "Adding newly uploaded file {FileId} ({FileName}) to field {FieldId}", - uploadedFile.Id, - uploadedFile.OriginalFileName, - fieldId); - currentFieldFiles.Add(uploadedFile); - } - - // Filter infected files AFTER adding the newly uploaded file - // This ensures the file appears briefly, then gets removed by the consumer - currentFieldFiles = FilterInfectedFilesFromList(currentFieldFiles); - - UpdateSessionFileList(appId, fieldId, currentFieldFiles); - // Do NOT save to database on upload! Files are saved when user clicks "Continue" - // This gives the virus scanner time to process and blacklist infected files - - // 1. Field-level key (used by the view partial) - _formErrorStore.Clear(fieldId); - // 2. Page-level context key (used by validation in OnPostPageAsync) - use same method to ensure exact match - var pageContextKey = GetFormErrorContextKey(); - _formErrorStore.Clear(pageContextKey); - // 3. Clear any errors already loaded into ModelState for this field - ModelState.Remove(fieldId); - ModelState.Remove($"Data[{fieldId}]"); - _logger.LogInformation("Cleared FormErrorStore (fieldKey: {FieldId}, contextKey: {PageContext}) and ModelState after successful upload", - fieldId, pageContextKey); - - // Set success message - SuccessMessage = $"Your file '{file.FileName}' uploaded."; - - - // Send notification - var addRequest = new AddNotificationRequest - { - Message = SuccessMessage, - Category = "file-upload", - Context = $"file-upload|{uploadedFile.Id}", - Type = NotificationType.Success, - AutoDismiss = false, - AutoDismissSeconds = 5, - ReplaceExistingContext = false - }; - await TryCreateFileNotificationAsync(addRequest); - - - // Redirect back if we have return URL - if (!string.IsNullOrEmpty(returnUrl)) - { - - return Redirect(returnUrl); - } - - - return Page(); - } - catch (Exception ex) - { - - - - // Don't handle the exception here - let the ExternalApiExceptionFilter handle it - // This ensures that API errors get proper ModelState treatment - throw; - } - } - - public async Task OnPostDownloadFileAsync() - { - // Simple fix: Ensure Template is not null to prevent NullReferenceException - if (Template == null) - { - Template = new FormTemplate - { - TemplateId = "dummy", - TemplateName = "dummy", - Description = "dummy", - TaskGroups = new List() - }; // Create empty template to prevent null reference - - } - - var applicationId = Request.Form["ApplicationId"].ToString(); - var fileIdStr = Request.Form["FileId"].ToString(); - - if (!Guid.TryParse(applicationId, out var appId)) - { - return NotFound(); - } - if (!Guid.TryParse(fileIdStr, out var fileId)) - { - return NotFound(); - } - - var fileResponse = await fileUploadService.DownloadFileAsync(fileId, appId); - - // Extract content type - var contentType = fileResponse.Headers.TryGetValue("Content-Type", out var ct) - ? ct.FirstOrDefault() - : "application/octet-stream"; - - string fileName = "downloadedfile"; - if (fileResponse.Headers.TryGetValue("Content-Disposition", out var cd)) - { - var disposition = cd.FirstOrDefault(); - if (!string.IsNullOrEmpty(disposition)) - { - var fileNameMatch = System.Text.RegularExpressions.Regex.Match( - disposition, - @"filename\*=UTF-8''(?.+)|filename=""?(?[^\"";]+)""?" - ); - if (fileNameMatch.Success) - fileName = System.Net.WebUtility.UrlDecode(fileNameMatch.Groups["fileName"].Value); - } - } - - return File(fileResponse.Stream, contentType, fileName); - } - - public async Task OnPostDeleteFileAsync() - { - // Clear any validation errors from previous POST requests - // Without this, ModelState errors prevent confirmation from showing ModelState.Clear(); - - // Simple fix: Ensure Template is not null to prevent NullReferenceException - if (Template == null) - { - Template = new FormTemplate - { - TemplateId = "dummy", - TemplateName = "dummy", - Description = "dummy", - TaskGroups = new List() - }; // Create empty template to prevent null reference - } - var applicationId = Request.Form["ApplicationId"].ToString(); var returnUrl = Request.Form["ReturnUrl"].ToString(); var fileIdStr = Request.Form["FileId"].ToString(); var fieldId = Request.Form["FieldId"].ToString(); - + if (!Guid.TryParse(applicationId, out var appId)) return NotFound(); - - if (!Guid.TryParse(fileIdStr, out var fileId)) - { - ModelState.AddModelError("FileId", "Invalid file ID."); - - // If we have a return URL, redirect back with error - if (!string.IsNullOrEmpty(returnUrl)) - { - return Redirect(returnUrl); - } - - return Page(); - } - - bool isConfirmed = Request.Query.ContainsKey("confirmed") && Request.Query["confirmed"] == "true"; - - if (!isConfirmed) - { - _logger.LogInformation("DeleteFile handler executing for validation - file will not be deleted yet"); - return Redirect(returnUrl); - } - - var addRequest = new AddNotificationRequest - { - Message = string.Empty, // set later when known - Category = "file-upload", - Context = $"file-delete|{fileId}", - Type = NotificationType.Success, - AutoDismiss = false, - ReplaceExistingContext = false - }; - - try - { - await fileUploadService.DeleteFileAsync(fileId, appId); - } - catch (Exception e) - { - _logger.LogWarning(e, "Failed to delete file {FileId} for application {ApplicationId}", fileId, appId); - throw; - } - - SuccessMessage = "File deleted."; - - var currentFieldFiles = (await GetFilesForFieldAsync(appId, fieldId)).ToList(); - currentFieldFiles.RemoveAll(f => f.Id == fileId); - - UpdateSessionFileList(appId, fieldId, currentFieldFiles); - await SaveUploadedFilesToResponseAsync(appId, fieldId, currentFieldFiles); - - // If we have a return URL (from partial form), redirect back - if (!string.IsNullOrEmpty(returnUrl)) - { - // Send notification for successful delete - addRequest.Message = SuccessMessage; - await TryCreateFileNotificationAsync(addRequest); - - - return Redirect(returnUrl); - } - - return Page(); - } - - private async Task TryCreateFileNotificationAsync(AddNotificationRequest addRequest) - { - try - { - addRequest.Context = NotificationScopeContext.PrefixDetail(ApplicationContext, addRequest.Context); - await _notificationsClient.CreateNotificationAsync(addRequest); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "File operation succeeded but notification could not be created"); - } - } - - /// - /// Filters out any infected files from the given list using the Redis blacklist. - /// This ensures infected files are never shown or re-saved, regardless of where they come from. - /// Uses direct key lookup instead of KEYS command for better reliability and performance. - /// Checks both file ID-based and filename-based blacklists. - /// - private async Task RefreshFileValidationGateAsync() - { - FileValidationBlocksSubmit = false; - FileValidationBlockingFiles = []; - - if (!ApplicationId.HasValue) - return; - - try - { - var gate = await _applicationsClient.GetFileValidationGateAsync(ApplicationId.Value); - FileValidationBlocksSubmit = !gate.CanSubmit; - FileValidationBlockingFiles = gate.BlockingFiles ?? []; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Could not evaluate file validation gate for application {ApplicationId}", ApplicationId); - } - } - - public List FilterInfectedFilesFromList(List files) - { - if (files == null || files.Count == 0) - { - _logger.LogDebug("FilterInfectedFilesFromList: No files to filter (null or empty)"); - return files ?? new List(); - } - - try - { - var db = _redis.GetDatabase(); - var infectedFileIds = new HashSet(); - var appId = ApplicationId?.ToString() ?? HttpContext.Session.GetString("ApplicationId"); - - _logger.LogInformation( - "FilterInfectedFilesFromList: Checking {FileCount} file(s) against blacklist for application {ApplicationId}", - files.Count, - appId); - - // Check each file against BOTH blacklist types: - // 1. By file ID (FlexForms:InfectedFile:{fileId}) - // 2. By filename (FlexForms:InfectedFileName:{applicationId}:{originalFileName}) - foreach (var file in files) - { - // Check by file ID - var fileIdBlacklistKey = $"{FlexFormsCacheKeys.InfectedFilePrefix}{file.Id}"; - var fileIdExists = db.KeyExists(fileIdBlacklistKey); - - // Check by filename (fallback when file ID doesn't match) - var filenameBlacklistKey = $"{FlexFormsCacheKeys.InfectedFileNamePrefix}{appId}:{file.OriginalFileName}"; - var filenameExists = db.KeyExists(filenameBlacklistKey); - - _logger.LogInformation( - "FilterInfectedFilesFromList: File {FileId} ({FileName}) - FileIdKey='{FileIdKey}' exists={FileIdExists}, FilenameKey='{FilenameKey}' exists={FilenameExists}", - file.Id, - file.OriginalFileName, - fileIdBlacklistKey, - fileIdExists, - filenameBlacklistKey, - filenameExists); - - if (fileIdExists || filenameExists) - { - infectedFileIds.Add(file.Id); - _logger.LogWarning( - "FilterInfectedFilesFromList: INFECTED - File {FileId} ({FileName}) WILL BE FILTERED OUT", - file.Id, - file.OriginalFileName); - } - } - - if (!infectedFileIds.Any()) - { - _logger.LogInformation( - "FilterInfectedFilesFromList: No infected files found, returning all {FileCount} files", - files.Count); - return files; - } - - // Filter out infected files - var cleanFiles = files.Where(f => !infectedFileIds.Contains(f.Id)).ToList(); - - _logger.LogWarning( - "FilterInfectedFilesFromList: Filtered out {RemovedCount} infected file(s), returning {CleanCount} clean files", - files.Count - cleanFiles.Count, - cleanFiles.Count); - - return cleanFiles; - } - catch (Exception ex) - { - _logger.LogError(ex, "FilterInfectedFilesFromList: ERROR - returning original list of {FileCount} files", files.Count); - return files; // Return original list if filtering fails - } - } - - /// - /// Filters infected files from JSON-encoded upload data (used when saving form data) - /// - private string FilterInfectedFilesFromUploadData(string? uploadDataJson) - { - if (string.IsNullOrWhiteSpace(uploadDataJson)) - return uploadDataJson ?? string.Empty; - - try - { - // Try to deserialize as file list - var files = JsonSerializer.Deserialize>(uploadDataJson); - if (files != null) - { - // Filter infected files - var cleanFiles = FilterInfectedFilesFromList(files); - - // Serialize back to JSON - return JsonSerializer.Serialize(cleanFiles); - } - } - catch (JsonException ex) - { - // Not a file list, return as-is - _logger.LogDebug(ex, "Failed to parse upload data as file list, returning original value"); - } - - return uploadDataJson; - } - - private async Task> GetFilesForFieldAsync(Guid appId, string fieldId) - { - _logger.LogInformation( - "GetFilesForFieldAsync: START - AppId={AppId}, FieldId={FieldId}, IsCollectionFlow={IsCollectionFlow}", - appId, fieldId, IsCollectionFlow); - - if (string.IsNullOrEmpty(fieldId)) - { - _logger.LogDebug("GetFilesForFieldAsync: Empty fieldId, returning empty list"); - return new List().AsReadOnly(); - } - - if (IsCollectionFlow) - { - // FIX: For collection flows, check SESSION flow progress FIRST! - // Session has the latest data (including recent deletes), database data is stale. - var progressData = LoadFlowProgress(FlowId, InstanceId); - - if (progressData.TryGetValue(fieldId, out var progressValue)) - { - var sessionFilesJson = progressValue?.ToString(); - - if (!string.IsNullOrWhiteSpace(sessionFilesJson)) - { - try - { - var files = JsonSerializer.Deserialize>(sessionFilesJson) ?? new List(); - _logger.LogInformation( - "GetFilesForFieldAsync: COLLECTION FLOW SESSION - Found {FileCount} files in session before filtering", - files.Count); - var cleanFiles = FilterInfectedFilesFromList(files); - _logger.LogInformation( - "GetFilesForFieldAsync: COLLECTION FLOW SESSION - Returning {FileCount} files after filtering", - cleanFiles.Count); - return cleanFiles.AsReadOnly(); - } - catch (JsonException ex) - { - _logger.LogWarning("Failed to parse session flow progress: {Error}", ex.Message); - } - } - } - - // FALLBACK: Only check accumulated data (database) if session is empty - // This handles the initial load or page refresh scenarios - try - { - var accumulatedData = applicationResponseService.GetAccumulatedFormData(HttpContext.Session); - - - foreach (var kvp in accumulatedData) - { - var collectionJson = kvp.Value?.ToString(); - if (string.IsNullOrWhiteSpace(collectionJson)) - continue; - - try - { - var items = JsonSerializer.Deserialize>>(collectionJson) ?? new(); - - var existingItem = items.FirstOrDefault(item => item.TryGetValue("id", out var idVal) && idVal?.ToString() == InstanceId); - if (existingItem != null && existingItem.TryGetValue(fieldId, out var innerValue) && innerValue != null) - { - // Handle JsonElement (could be array or string) - if (innerValue is JsonElement innerElem) - { - if (innerElem.ValueKind == JsonValueKind.Array) - { - try - { - var files = JsonSerializer.Deserialize>(innerElem.GetRawText()) ?? new List(); - var cleanFiles = FilterInfectedFilesFromList(files); - return cleanFiles.AsReadOnly(); - } - catch (JsonException) - { - // Failed to parse, continue - } - } - else if (innerElem.ValueKind == JsonValueKind.String) - { - // FIX: JsonElement can also be a STRING containing JSON - var stringValue = innerElem.GetString(); - - if (!string.IsNullOrWhiteSpace(stringValue)) - { - try - { - var files = JsonSerializer.Deserialize>(stringValue) ?? new List(); - var cleanFiles = FilterInfectedFilesFromList(files); - return cleanFiles.AsReadOnly(); - } - catch (JsonException) - { - // Failed to parse, continue - } - } - } - } - // Handle string JSON - else if (innerValue is string innerJson && !string.IsNullOrWhiteSpace(innerJson)) - { - try - { - var files = JsonSerializer.Deserialize>(innerJson) ?? new List(); - var cleanFiles = FilterInfectedFilesFromList(files); - return cleanFiles.AsReadOnly(); - } - catch (JsonException) - { - // Failed to parse, continue - } - } - // Handle direct list - else if (innerValue is List uploadList) - { - var cleanFiles = FilterInfectedFilesFromList(uploadList); - return cleanFiles.AsReadOnly(); - } - } - } - catch (Exception) - { - // Ignore parse errors for non-collection fields - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error processing accumulated data for collection flow"); - } - } - else - { - // For regular forms, get files from session - var sessionKey = $"UploadedFiles_{appId}_{fieldId}"; - var sessionFilesJson = HttpContext.Session.GetString(sessionKey); - - _logger.LogInformation( - "GetFilesForFieldAsync: REGULAR FORM - SessionKey={SessionKey}, HasData={HasData}", - sessionKey, - !string.IsNullOrWhiteSpace(sessionFilesJson)); - - if (!string.IsNullOrWhiteSpace(sessionFilesJson)) - { - try - { - var files = JsonSerializer.Deserialize>(sessionFilesJson) ?? new List(); - _logger.LogInformation( - "GetFilesForFieldAsync: REGULAR FORM SESSION - Found {FileCount} files in session before filtering", - files.Count); - var cleanFiles = FilterInfectedFilesFromList(files); - _logger.LogInformation( - "GetFilesForFieldAsync: REGULAR FORM SESSION - Returning {FileCount} files after filtering", - cleanFiles.Count); - return cleanFiles.AsReadOnly(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to deserialize session files for key {Key}", sessionKey); - } - } - - // Fallback to accumulated form data (which contains database data) - // This handles the case where session is empty after app restart but DB has files - try - { - _logger.LogInformation("GetFilesForFieldAsync: REGULAR FORM - Falling back to accumulated data"); - var accumulatedData = applicationResponseService.GetAccumulatedFormData(HttpContext.Session); - - if (accumulatedData.TryGetValue(fieldId, out var fieldValue)) - { - var fieldValueStr = fieldValue?.ToString(); - - if (!string.IsNullOrWhiteSpace(fieldValueStr)) - { - try - { - var files = JsonSerializer.Deserialize>(fieldValueStr) ?? new List(); - _logger.LogInformation( - "GetFilesForFieldAsync: REGULAR FORM ACCUMULATED - Found {FileCount} files before filtering", - files.Count); - var cleanFiles = FilterInfectedFilesFromList(files); - _logger.LogInformation( - "GetFilesForFieldAsync: REGULAR FORM ACCUMULATED - Returning {FileCount} files after filtering", - cleanFiles.Count); - return cleanFiles.AsReadOnly(); - } - catch (JsonException) - { - // Failed to parse, continue - } - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error accessing accumulated form data"); - } - } - - _logger.LogInformation("GetFilesForFieldAsync: END - Returning empty list (no files found)"); - return new List().AsReadOnly(); - } - - private void UpdateSessionFileList(Guid appId, string fieldId, IReadOnlyList files) - { - if (IsCollectionFlow) - { - // For collection flows, store in flow progress system - var progressKey = GetFlowProgressSessionKey(FlowId, InstanceId); - - // FIX: Use same method as page load for consistency - var existingProgress = LoadFlowProgress(FlowId, InstanceId); - - // The 'files' parameter contains ALL files (existing + new), so just save it directly - // No need to merge because GetFilesForFieldAsync already combined existing and new files - var serializedFiles = JsonSerializer.Serialize(files); - existingProgress[fieldId] = serializedFiles; - - // Force session to commit immediately - var progressJson = JsonSerializer.Serialize(existingProgress); - HttpContext.Session.SetString(progressKey, progressJson); - } - else - { - // For regular forms, use the original session key - var key = $"UploadedFiles_{appId}_{fieldId}"; - HttpContext.Session.SetString(key, JsonSerializer.Serialize(files)); - } - } - - private bool FileExistInSessionList(Guid appId, string fieldId, string fileName) - { - // First check if this filename is in the infected blacklist - // If it is, we should ALLOW re-upload (the old infected file should be replaced) - try - { - var db = _redis.GetDatabase(); - var filenameBlacklistKey = $"{FlexFormsCacheKeys.InfectedFileNamePrefix}{appId}:{fileName}"; - if (db.KeyExists(filenameBlacklistKey)) - { - _logger.LogInformation( - "File '{FileName}' is in infected blacklist, allowing re-upload", - fileName); - return false; // Allow re-upload of infected files - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error checking infected blacklist for file '{FileName}'", fileName); - } - - if (IsCollectionFlow) - { - // For collection flows, check the actual file list (not just string search) - var existingProgress = LoadFlowProgress(FlowId, InstanceId); - - if (existingProgress.TryGetValue(fieldId, out var filesJson) && !string.IsNullOrEmpty(filesJson?.ToString())) - { - try - { - var files = JsonSerializer.Deserialize>(filesJson.ToString()!); - if (files != null) - { - // Filter out infected files before checking - var cleanFiles = FilterInfectedFilesFromList(files); - return cleanFiles.Any(f => string.Equals(f.OriginalFileName, fileName, StringComparison.OrdinalIgnoreCase)); - } - } - catch (JsonException) - { - // Fall back to string search if parsing fails - return filesJson.ToString()?.IndexOf(fileName, StringComparison.InvariantCultureIgnoreCase) >= 0; - } - } - return false; - } - else - { - // For regular forms, check the actual file list - var key = $"UploadedFiles_{appId}_{fieldId}"; - var sessionFilesJson = HttpContext.Session.GetString(key); - - if (!string.IsNullOrEmpty(sessionFilesJson)) - { - try - { - var files = JsonSerializer.Deserialize>(sessionFilesJson); - if (files != null) - { - // Filter out infected files before checking - var cleanFiles = FilterInfectedFilesFromList(files); - return cleanFiles.Any(f => string.Equals(f.OriginalFileName, fileName, StringComparison.OrdinalIgnoreCase)); - } - } - catch (JsonException) - { - // Fall back to string search if parsing fails - return sessionFilesJson.IndexOf(fileName, StringComparison.InvariantCultureIgnoreCase) >= 0; - } - } - return false; - } - } - - private async Task SaveUploadedFilesToResponseAsync(Guid appId, string fieldId, IReadOnlyList files) - { - if (string.IsNullOrEmpty(fieldId)) - { - return; - } - - // Save files to database - // NOTE: This is called by DELETE handler to persist deletions - // It is NOT called by UPLOAD handler (to give scanner time to process) - var json = JsonSerializer.Serialize(files); - var data = new Dictionary { { fieldId, json } }; - - await _applicationResponseService.SaveApplicationResponseAsync(appId, data, HttpContext.Session); - } - - /// - /// Populates Data dictionary with files from session for upload fields so they display on GET. - /// Also cleans up session by removing any infected files that have been blacklisted. - /// - private async Task PopulateUploadFieldsFromSessionAsync() - { - if (CurrentPage == null || !ApplicationId.HasValue) - return; - - // Find all upload fields on the current page - var uploadFields = CurrentPage.Fields - .Where(f => f.Type == "complexField" - && f.ComplexField != null - && _complexFieldConfigurationService.GetConfiguration(f.ComplexField.Id).FieldType.Equals("upload", StringComparison.OrdinalIgnoreCase)) - .ToList(); - - foreach (var field in uploadFields) - { - var fieldId = field.FieldId; - - // Get files from session (this already filters out infected files) - var files = await GetFilesForFieldAsync(ApplicationId.Value, fieldId); - - // Update session with the filtered list to remove infected files from session - // This ensures FileExistInSessionList won't find infected files - UpdateSessionFileList(ApplicationId.Value, fieldId, files.ToList()); - - if (files.Any()) - { - // Serialize files to JSON and populate Data so the view can display them - var filesJson = JsonSerializer.Serialize(files); - Data[fieldId] = filesJson; - } - } - } - - private void MergeFlowProgressIntoFormDataForSummary() - { - if (CurrentTask?.Summary?.Mode?.Equals("multiCollectionFlow", StringComparison.OrdinalIgnoreCase) != true - || CurrentTask.Summary?.Flows == null) - return; - - foreach (var flow in CurrentTask.Summary.Flows) - { - if (!FormData.TryGetValue(flow.FieldId, out var val) || string.IsNullOrWhiteSpace(val?.ToString())) - continue; - - var items = JsonSerializer.Deserialize>>(val.ToString()!) ?? new(); - var changed = false; - - foreach (var item in items) - { - if (!item.TryGetValue("id", out var idObj)) continue; - var instanceId = idObj?.ToString(); - if (string.IsNullOrWhiteSpace(instanceId)) continue; - - var progress = LoadFlowProgress(flow.FlowId, instanceId); - if (!progress.Any()) continue; - - foreach (var kv in progress) - { - item[kv.Key] = kv.Value; - } - changed = true; - } - - if (changed) - { - var updatedJson = JsonSerializer.Serialize(items); - FormData[flow.FieldId] = updatedJson; - Data[flow.FieldId] = updatedJson; // keep Data in sync for views - } - } - } - - #endregion - - #region Form Error Store Helper Methods - - /// - /// Gets a unique context key for storing form errors in session - /// - /// Form error context key - private string GetFormErrorContextKey() - { - return $"{ReferenceNumber}_{TaskId}_{CurrentPageId}"; - } - - /// - /// Restores previously saved form errors from session and applies them to ModelState - /// - private void RestoreFormErrors() - { - try - { - var contextKey = GetFormErrorContextKey(); - var (fieldErrors, generalError) = _formErrorStore.Load(contextKey, clearAfterRead: true); - - if (fieldErrors.Any()) - { - foreach (var kvp in fieldErrors) - { - foreach (var error in kvp.Value) - { - ModelState.AddModelError(kvp.Key, error); - } - } - _logger.LogInformation("DEBUG: Restored {ErrorCount} field errors from FormErrorStore with key: {ContextKey}", - fieldErrors.Sum(x => x.Value.Count), contextKey); - } - - if (!string.IsNullOrEmpty(generalError)) - { - ModelState.AddModelError("", generalError); - _logger.LogInformation("DEBUG: Restored general error from FormErrorStore: {GeneralError}", generalError); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to restore form errors from session"); - } - } - - #endregion - #region Collection Item File Cleanup Helper Methods - - /// - /// Deletes all files associated with a collection item when the item is removed. - /// Iterates through all fields in the item data and deletes any files found. - /// - /// The application ID - /// The collection item data dictionary - /// The number of files deleted - private async Task DeleteFilesFromCollectionItemAsync(Guid applicationId, Dictionary? itemData) - { - if (itemData == null) - { - return 0; - } - - int deletedCount = 0; - - foreach (var kvp in itemData) - { - // Skip the 'id' field and any non-string values - if (kvp.Key == "id" || kvp.Value == null) - { - continue; - } - - try - { - var valueStr = kvp.Value?.ToString(); - - // Skip empty values or values that don't look like JSON arrays - if (string.IsNullOrEmpty(valueStr) || !valueStr.TrimStart().StartsWith("[")) - { - continue; - } - - // Try to parse as file list (UploadDto) - var files = JsonSerializer.Deserialize>(valueStr); - if (files != null && files.Any()) - { - foreach (var file in files) - { - try - { - await fileUploadService.DeleteFileAsync(file.Id, applicationId); - deletedCount++; - _logger.LogInformation( - "Deleted file {FileId} ({FileName}) from removed collection item in application {ApplicationId}", - file.Id, - file.OriginalFileName, - applicationId); - } - catch (Exception ex) - { - // Log but don't fail the entire operation - file may already be deleted - _logger.LogWarning( - ex, - "Failed to delete file {FileId} from collection item - file may already be deleted", - file.Id); - } - } - } - } - catch (JsonException) - { - // Not a file list, skip this field - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error processing field {FieldKey} for file cleanup", kvp.Key); - } - } - - if (deletedCount > 0) - { - _logger.LogInformation( - "Successfully deleted {DeletedCount} file(s) from removed collection item in application {ApplicationId}", - deletedCount, - applicationId); - } - - return deletedCount; - } - - #endregion - - #region Helper Methods for Field Requirement - - /// - /// Gets a field from a task by field ID - /// - /// The task to search - /// The field ID to find - /// The field if found, otherwise null - private Field? GetFieldFromTask(Domain.Models.Task task, string fieldId) - { - if (task?.Pages == null) return null; - - foreach (var page in task.Pages) + if (!Guid.TryParse(fileIdStr, out var fileId)) { - if (page?.Fields == null) continue; - - var field = page.Fields.FirstOrDefault(f => f.FieldId == fieldId); - if (field != null) - { - return field; - } + ModelState.AddModelError("FileId", FormEngineMessages.InvalidFileId); + return string.IsNullOrEmpty(returnUrl) ? Page() : Redirect(returnUrl); } - return null; + var confirmed = Request.Query.ContainsKey("confirmed") && Request.Query["confirmed"] == "true"; + var state = CaptureWorkState(); + var outcome = await _deleteFormFile.ExecuteAsync( + state, + new DeleteFormFileRequest(appId, fileId, fieldId, returnUrl, confirmed)); + ApplyWorkState(state); + await TryNotifyFileOperationAsync(outcome); + return MapOutcome(outcome); } - - #endregion - } } - - - - - - - - diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/UploadFile.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/UploadFile.cshtml.cs index ecc63ba..7bcc267 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/UploadFile.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/UploadFile.cshtml.cs @@ -1,531 +1,240 @@ +using GovUK.Dfe.FlexForms.Application.FormEngine; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Application.Notifications; +using GovUK.Dfe.FlexForms.Application.Validation; +using GovUK.Dfe.FlexForms.Web.Extensions; +using GovUK.Dfe.FlexForms.Web.Interfaces; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; -using GovUK.Dfe.FlexForms.Application.Interfaces; -using GovUK.Dfe.FlexForms.Application.Notifications; using GovUK.Dfe.FlexForms.Api.Client.Contracts; -using GovUK.Dfe.FlexForms.Web.Interfaces; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.RazorPages; -using System.Text.Json; -namespace GovUK.Dfe.FlexForms.Web.Pages.FormEngine +namespace GovUK.Dfe.FlexForms.Web.Pages.FormEngine; + +/// +/// Standalone upload URL. Live forms post to ; this page reuses the same file use cases. +/// +public class UploadFileModel( + IUploadFormFile uploadFormFile, + IDeleteFormFile deleteFormFile, + IDownloadFormFile downloadFormFile, + IFormFileFieldService formFileFieldService, + IFileUploadService fileUploadService, + IApplicationResponseService applicationResponseService, + INotificationsClient notificationsClient, + IFormErrorStore formErrorStore, + IRequestAppConfiguration requestConfiguration, + ILogger logger) + : PageModel { - public class UploadFileModel( - IFileUploadService fileUploadService, - IApplicationResponseService applicationResponseService, - INotificationsClient notificationsClient, - IFormErrorStore formErrorStore, - IRequestAppConfiguration requestConfiguration) - : PageModel + private string ApplicationContext => + requestConfiguration["ApplicationName"] + ?? requestConfiguration["TenantName"] + ?? throw new InvalidOperationException( + "ApplicationName (or TenantName) is required in tenant configuration for notifications."); + + [BindProperty(SupportsGet = true)] public string ApplicationId { get; set; } = string.Empty; + [BindProperty(SupportsGet = true)] public string FieldId { get; set; } = string.Empty; + [BindProperty(SupportsGet = true, Name = "referenceNumber")] public string ReferenceNumber { get; set; } = string.Empty; + [BindProperty(SupportsGet = true, Name = "taskId")] public string TaskId { get; set; } = string.Empty; + [BindProperty(SupportsGet = true, Name = "pageId")] public string CurrentPageId { get; set; } = string.Empty; + [BindProperty] public string ReturnUrl { get; set; } = string.Empty; + [BindProperty] public string FlowId { get; set; } = string.Empty; + [BindProperty] public string InstanceId { get; set; } = string.Empty; + public IReadOnlyList Files { get; set; } = []; + public string SuccessMessage { get; set; } = string.Empty; + public string ErrorMessage { get; set; } = string.Empty; + + private bool IsCollectionFlow => !string.IsNullOrEmpty(FlowId) && !string.IsNullOrEmpty(InstanceId); + + public async Task OnGetAsync() { - private string ApplicationContext => - requestConfiguration["ApplicationName"] - ?? requestConfiguration["TenantName"] - ?? throw new InvalidOperationException( - "ApplicationName (or TenantName) is required in tenant configuration for notifications."); - [BindProperty(SupportsGet = true)] public string ApplicationId { get; set; } - [BindProperty(SupportsGet = true)] public string FieldId { get; set; } - [BindProperty(SupportsGet = true, Name = "referenceNumber")] public string ReferenceNumber { get; set; } - [BindProperty(SupportsGet = true, Name = "taskId")] public string TaskId { get; set; } - [BindProperty(SupportsGet = true, Name = "pageId")] public string CurrentPageId { get; set; } - [BindProperty] public string ReturnUrl { get; set; } - [BindProperty] public string FlowId { get; set; } = string.Empty; - [BindProperty] public string InstanceId { get; set; } = string.Empty; - public IReadOnlyList Files { get; set; } = new List(); - public string SuccessMessage { get; set; } - public string ErrorMessage { get; set; } - - private bool IsCollectionFlow => !string.IsNullOrEmpty(FlowId) && !string.IsNullOrEmpty(InstanceId); - - public async Task OnGetAsync() - { - if (!Guid.TryParse(ApplicationId, out var appId)) - return NotFound(); - - // Get only files for this specific field ID - Files = await GetFilesForFieldAsync(appId, FieldId); - return Page(); - } - - public async Task OnPostUploadFileAsync() - { - // Debug: Check for validation errors - - - - // Clear validation errors for FlowId and InstanceId when not in collection flow - if (!IsCollectionFlow) - { - ModelState.Remove("FlowId"); - ModelState.Remove("InstanceId"); - } - - - - - var addRequest = new AddNotificationRequest - { - Message = string.Empty, // set later when known - Category = "file-upload", - Context = FieldId + "FileUpload", - Type = NotificationType.Success, - AutoDismiss = false, - AutoDismissSeconds = 5, - ReplaceExistingContext = false - }; - - if (!Guid.TryParse(ApplicationId, out var appId)) - return NotFound(); - var file = Request.Form.Files["UploadFile"]; - var name = Request.Form["UploadName"].ToString(); - var description = Request.Form["UploadDescription"].ToString(); - if (file == null || file.Length == 0) - { - ErrorMessage = "Please select a file to upload."; - ModelState.AddModelError("UploadFile", ErrorMessage); - if (!string.IsNullOrEmpty(FieldId)) - { - // Persist field-level errors only to avoid duplicate summary lines - formErrorStore.Save(FieldId, ModelState); - } - - // If we have a return URL, redirect back with error - if (!string.IsNullOrEmpty(ReturnUrl)) - { - return Redirect(ReturnUrl); - } - - Files = await GetFilesForFieldAsync(appId, FieldId); - return Page(); - } - - using var stream = file.OpenReadStream(); - var fileParam = new FileParameter(stream, file.FileName, file.ContentType); - var uploadedFile = await fileUploadService.UploadFileAsync(appId, file.FileName, description, fileParam); - SuccessMessage = $"Your file '{file.FileName}' uploaded."; - - // Get current files for this field and add the new one - var currentFieldFiles = (await GetFilesForFieldAsync(appId, FieldId)).ToList(); - currentFieldFiles.Add(uploadedFile); - - Files = currentFieldFiles.AsReadOnly(); - UpdateSessionFileList(appId, FieldId, Files); - await SaveUploadedFilesToResponseAsync(appId, FieldId, Files); - - // If we have a return URL (from partial form), redirect back - if (!string.IsNullOrEmpty(ReturnUrl)) - { - addRequest.Message = SuccessMessage; - addRequest.Context = $"file-upload|{uploadedFile.Id}"; - await TryCreateFileNotificationAsync(addRequest); - return Redirect(ReturnUrl); - } + if (!Guid.TryParse(ApplicationId, out var appId)) + return NotFound(); - return Page(); - } + Files = await GetFilesForFieldAsync(appId, FieldId); + return Page(); + } - public override void OnPageHandlerExecuted(PageHandlerExecutedContext context) - { - base.OnPageHandlerExecuted(context); - - // If there are ModelState errors (from the filter), persist them via the error store - if (!ModelState.IsValid && !string.IsNullOrEmpty(FieldId)) - { - formErrorStore.Save(FieldId, ModelState); - - // If we have a return URL, redirect back with errors - if (!string.IsNullOrEmpty(ReturnUrl)) - { - context.Result = new RedirectResult(ReturnUrl); - } - } - } + public override void OnPageHandlerExecuted(PageHandlerExecutedContext context) + { + base.OnPageHandlerExecuted(context); - public async Task OnPostDeleteFileAsync() + if (!ModelState.IsValid && !string.IsNullOrEmpty(FieldId)) { - var addRequest = new AddNotificationRequest - { - Message = string.Empty, - Category = "file-upload", - Type = NotificationType.Success, - AutoDismiss = false, - AutoDismissSeconds = 5, - ReplaceExistingContext = false - }; - - if (!Guid.TryParse(ApplicationId, out var appId)) - return NotFound(); - var fileIdStr = Request.Form["FileId"].ToString(); - if (!Guid.TryParse(fileIdStr, out var fileId)) - { - ErrorMessage = "Invalid file ID."; - if (!string.IsNullOrEmpty(FieldId)) - { - formErrorStore.Save(FieldId, ModelState, ErrorMessage); - } - - // If we have a return URL, redirect back with error - if (!string.IsNullOrEmpty(ReturnUrl)) - { - return Redirect(ReturnUrl); - } - - Files = await GetFilesForFieldAsync(appId, FieldId); - return Page(); - } - - await fileUploadService.DeleteFileAsync(fileId, appId); - SuccessMessage = "File deleted."; - - // Get current files for this field and remove the deleted one - var currentFieldFiles = (await GetFilesForFieldAsync(appId, FieldId)).ToList(); - currentFieldFiles.RemoveAll(f => f.Id == fileId); - - Files = currentFieldFiles.AsReadOnly(); - UpdateSessionFileList(appId, FieldId, Files); - await SaveUploadedFilesToResponseAsync(appId, FieldId, Files); - - // If we have a return URL (from partial form), redirect back + formErrorStore.Save(FieldId, ModelState); if (!string.IsNullOrEmpty(ReturnUrl)) - { - addRequest.Message = SuccessMessage; - addRequest.Context = $"file-delete|{fileId}"; - await TryCreateFileNotificationAsync(addRequest); - return Redirect(ReturnUrl); - } - - return Page(); + context.Result = new RedirectResult(ReturnUrl); } + } - public async Task OnPostDownloadFileAsync() + public async Task OnPostUploadFileAsync() + { + if (!IsCollectionFlow) { - if (!Guid.TryParse(ApplicationId, out var appId)) - return NotFound(); - var fileIdStr = Request.Form["FileId"].ToString(); - if (!Guid.TryParse(fileIdStr, out var fileId)) - return NotFound(); - - var fileResponse = await fileUploadService.DownloadFileAsync(fileId, appId); - - // Extract content type - var contentType = fileResponse.Headers.TryGetValue("Content-Type", out var ct) - ? ct.FirstOrDefault() - : "application/octet-stream"; - - string fileName = "downloadedfile"; - if (fileResponse.Headers.TryGetValue("Content-Disposition", out var cd)) - { - var disposition = cd.FirstOrDefault(); - if (!string.IsNullOrEmpty(disposition)) - { - var fileNameMatch = System.Text.RegularExpressions.Regex.Match( - disposition, - @"filename\*=UTF-8''(?.+)|filename=""?(?[^\"";]+)""?" - ); - if (fileNameMatch.Success) - fileName = System.Net.WebUtility.UrlDecode(fileNameMatch.Groups["fileName"].Value); - } - } - - return File(fileResponse.Stream, contentType, fileName); + ModelState.Remove("FlowId"); + ModelState.Remove("InstanceId"); } + if (!Guid.TryParse(ApplicationId, out var appId)) + return NotFound(); + + var file = Request.Form.Files["UploadFile"]; + var hasFile = file is { Length: > 0 }; + await using var stream = hasFile ? file!.OpenReadStream() : Stream.Null; + + var state = CaptureWorkState(); + var outcome = await uploadFormFile.ExecuteAsync(state, new UploadFormFileRequest( + appId, + FieldId, + ReturnUrl, + Request.Form["UploadDescription"].ToString(), + stream, + file?.FileName ?? string.Empty, + file?.ContentType, + ErrorContextKey, + hasFile)); + + await PersistFieldFilesIfStandaloneAsync(appId, outcome); + await TryNotifyFileOperationAsync(outcome); + return MapOutcome(outcome); + } - private void UpdateSessionFileList(Guid appId, string fieldId, IReadOnlyList files) - { - - - if (IsCollectionFlow) - { - // For collection flows, store in flow progress system - var progressKey = GetFlowProgressSessionKey(FlowId, InstanceId); - - - // CRITICAL FIX: Try multiple sources to find existing flow data - var existingProgress = LoadFlowProgress(); - - - // CRITICAL FIX: The 'files' parameter contains ALL files (existing + new), so just save it directly - // No need to merge because GetFilesForFieldAsync already combined existing and new files - var serializedFiles = JsonSerializer.Serialize(files); - - existingProgress[fieldId] = serializedFiles; - - // Force session to commit immediately - var progressJson = JsonSerializer.Serialize(existingProgress); - HttpContext.Session.SetString(progressKey, progressJson); - - - - // Flow progress saved successfully - - } - else - { - // For regular forms, use the original session key - var key = $"UploadedFiles_{appId}_{fieldId}"; - - HttpContext.Session.SetString(key, JsonSerializer.Serialize(files)); - } - } + public async Task OnPostDeleteFileAsync() + { + if (!Guid.TryParse(ApplicationId, out var appId)) + return NotFound(); - private async Task SaveUploadedFilesToResponseAsync(Guid appId, string fieldId, IReadOnlyList files) + var fileIdStr = Request.Form["FileId"].ToString(); + if (!Guid.TryParse(fileIdStr, out var fileId)) { - if (string.IsNullOrEmpty(fieldId)) - { - return; - } - - if (IsCollectionFlow) - { - // For collection flows, files are saved via flow progress system - // This happens in UpdateSessionFileList, no need to save to main application response here - return; - } + ErrorMessage = FormEngineMessages.InvalidFileId; + if (!string.IsNullOrEmpty(FieldId)) + formErrorStore.Save(FieldId, ModelState, ErrorMessage); - var json = JsonSerializer.Serialize(files); - var data = new Dictionary { { fieldId, json } }; - - await applicationResponseService.SaveApplicationResponseAsync(appId, data, HttpContext.Session); + return string.IsNullOrEmpty(ReturnUrl) ? Page() : Redirect(ReturnUrl); } - /// - /// Gets files for a specific field ID by filtering from existing session data first, - /// then cross-referencing with database files to ensure we only get files for this field - /// - private async Task> GetFilesForFieldAsync(Guid appId, string fieldId) - { - if (string.IsNullOrEmpty(fieldId)) - { - return new List().AsReadOnly(); - } + var outcome = await deleteFormFile.ExecuteAsync( + CaptureWorkState(), + new DeleteFormFileRequest(appId, fileId, FieldId, ReturnUrl, Confirmed: true)); + await TryNotifyFileOperationAsync(outcome); + return MapOutcome(outcome); + } - string? sessionFilesJson = null; + public async Task OnPostDownloadFileAsync() + { + if (!Guid.TryParse(ApplicationId, out var appId)) + return NotFound(); + var fileIdStr = Request.Form["FileId"].ToString(); + if (!Guid.TryParse(fileIdStr, out var fileId)) + return NotFound(); + + var outcome = await downloadFormFile.ExecuteAsync( + CaptureWorkState(), + new DownloadFormFileRequest(appId, fileId)); + return MapOutcome(outcome); + } - if (IsCollectionFlow) - { - // For collection flows, get files from flow progress system - - var progressData = LoadFlowProgress(); - - - if (progressData.TryGetValue(fieldId, out var progressValue)) - { - sessionFilesJson = progressValue?.ToString(); - - } - else - { - // CRITICAL FIX: Flow progress not found, initialize from database if possible - - - // 1. Check if any files exist in database for this application - // Note: Since UploadDto doesn't have FieldId, we'll rely on session data for field association - try - { - var allDbFiles = await fileUploadService.GetFilesForApplicationAsync(appId); - - - // For now, we can't filter by field ID since UploadDto doesn't have that property - // We'll rely on session data to maintain field-specific file associations - - } - catch (Exception ex) - { - - } - - // 2. If still no files, check accumulated form data - if (string.IsNullOrWhiteSpace(sessionFilesJson)) - { - var alternativeAccumulatedData = applicationResponseService.GetAccumulatedFormData(HttpContext.Session); - if (alternativeAccumulatedData.TryGetValue(fieldId, out var accFieldValue)) - { - sessionFilesJson = accFieldValue?.ToString(); - - } - } - - // 3. If still not found, search all session keys for this field data - if (string.IsNullOrWhiteSpace(sessionFilesJson)) - { - - foreach (var sessionKey in HttpContext.Session.Keys) - { - var keyValue = HttpContext.Session.GetString(sessionKey); - if (!string.IsNullOrWhiteSpace(keyValue)) - { - // Check if this key contains our field data - if (sessionKey.Contains(fieldId, StringComparison.OrdinalIgnoreCase) || - (keyValue.StartsWith("[") && keyValue.Contains("\"id\"") && keyValue.Contains(fieldId))) - { - - sessionFilesJson = keyValue; - break; - } - - // Also check if the key contains flow progress for our specific flow - if (sessionKey.Contains($"FlowProgress_{FlowId}") && keyValue.Contains(fieldId)) - { - - try - { - var flowData = JsonSerializer.Deserialize>(keyValue); - if (flowData != null && flowData.TryGetValue(fieldId, out var fieldData)) - { - sessionFilesJson = fieldData?.ToString(); - - break; - } - } - catch (Exception ex) - { - - } - } - } - } - } - } - - - } - else - { - // For regular forms, get files from session - var sessionKey = $"UploadedFiles_{appId}_{fieldId}"; - sessionFilesJson = HttpContext.Session.GetString(sessionKey); - } - - if (!string.IsNullOrEmpty(sessionFilesJson)) - { - try - { - var sessionFiles = JsonSerializer.Deserialize>(sessionFilesJson); - if (sessionFiles != null) - { - // Cross-reference with database to make sure files still exist - var validSessionFiles = await FilterFilesAgainstDatabaseAsync(appId, sessionFiles); - - return validSessionFiles.AsReadOnly(); - } - } - catch (JsonException) - { - // Session data is corrupted, fall through to check accumulated data - } - } - - // If no session data, try to get from accumulated form data (for existing applications) - var accumulatedData = applicationResponseService.GetAccumulatedFormData(HttpContext.Session); - if (accumulatedData.TryGetValue(fieldId, out var fieldValue)) - { - var fieldValueStr = fieldValue?.ToString(); - if (!string.IsNullOrEmpty(fieldValueStr)) - { - try - { - var existingFiles = JsonSerializer.Deserialize>(fieldValueStr); - if (existingFiles != null) - { - // Cross-reference with database to make sure files still exist - var validFiles = await FilterFilesAgainstDatabaseAsync(appId, existingFiles); - - return validFiles.AsReadOnly(); - } - } - catch (JsonException) - { - // Data is corrupted, return empty list - } - } - } - - // If no existing data for this field, return empty list - // Don't return all database files, as that would include files from other fields - return new List().AsReadOnly(); + private async Task> GetFilesForFieldAsync(Guid appId, string fieldId) + { + var files = formFileFieldService.GetFiles(new FormFileFieldContext(appId, FlowId, InstanceId), fieldId).ToList(); + try + { + var allDbFiles = await fileUploadService.GetFilesForApplicationAsync(appId); + return files.Where(sf => allDbFiles.Any(dbf => dbf.Id == sf.Id)).ToList(); } - - private async Task TryCreateFileNotificationAsync(AddNotificationRequest addRequest) + catch (ExternalApplicationsException ex) when (ex.StatusCode is 401 or 403) { - try - { - addRequest.Context = NotificationScopeContext.PrefixDetail(ApplicationContext, addRequest.Context); - await notificationsClient.CreateNotificationAsync(addRequest); - } - catch - { - // Upload/delete succeeded; notification is optional when user lacks notification permissions - } + return files; } + } - private async Task> FilterFilesAgainstDatabaseAsync(Guid appId, List files) + private FormEngineWorkState CaptureWorkState() => + new() { - try - { - var allDbFiles = await fileUploadService.GetFilesForApplicationAsync(appId); - return files.Where(sf => allDbFiles.Any(dbf => dbf.Id == sf.Id)).ToList(); - } - catch (ExternalApplicationsException ex) when (ex.StatusCode is 401 or 403) - { - // User may have write but not read permission; trust session data - return files; - } - } + ReferenceNumber = ReferenceNumber, + TaskId = TaskId, + CurrentPageId = CurrentPageId, + FlowId = FlowId, + InstanceId = InstanceId + }; - // legacy method removed in favour of IFormErrorStore + private string ErrorContextKey => $"{ReferenceNumber}_{TaskId}_{CurrentPageId}"; - /// - /// Helper methods for collection flow support - /// - private static string GetFlowProgressSessionKey(string flowId, string instanceId) => $"FlowProgress_{flowId}_{instanceId}"; + private IActionResult MapOutcome(FormEngineOutcome outcome) + { + foreach (var key in outcome.ModelStateKeysToRemove) + ModelState.Remove(key); - private Dictionary LoadFlowProgress() - { - if (!IsCollectionFlow) - { + if (outcome.ClearModelState) + ModelState.Clear(); - return new Dictionary(); - } + if (outcome.Errors.Count > 0) + new FormValidationResult(outcome.Errors).ApplyTo(ModelState); - var key = GetFlowProgressSessionKey(FlowId, InstanceId); + if (outcome.SuccessMessage != null) + SuccessMessage = outcome.SuccessMessage; - - // Debug: List all session keys to see what's actually in the session + if (outcome.ErrorMessage != null) + ErrorMessage = outcome.ErrorMessage; + if (outcome.Files != null) + Files = outcome.Files; - - // Try to get all session keys + foreach (var key in outcome.ErrorStoreKeysToClear) + formErrorStore.Clear(key); - var sessionKeys = new List(); - foreach (var sessionKey in HttpContext.Session.Keys) - { - sessionKeys.Add(sessionKey); - } + if (outcome.PersistErrors && !string.IsNullOrEmpty(outcome.ErrorContextKey)) + formErrorStore.Save(outcome.ErrorContextKey, ModelState); - - var json = HttpContext.Session.GetString(key); + return outcome.Kind switch + { + FormEngineOutcomeKind.StayOnPage => Page(), + FormEngineOutcomeKind.Redirect => Redirect(outcome.RedirectUrl!), + FormEngineOutcomeKind.RedirectToPage => RedirectToPage(outcome.PageName, outcome.RouteValues), + FormEngineOutcomeKind.NotFound => NotFound(), + FormEngineOutcomeKind.BadRequest => BadRequest(outcome.ErrorMessage), + FormEngineOutcomeKind.FileDownload => File(outcome.FileStream!, outcome.FileContentType!, outcome.FileDownloadName), + _ => Page() + }; + } - - if (string.IsNullOrWhiteSpace(json)) - { + private async Task PersistFieldFilesIfStandaloneAsync(Guid appId, FormEngineOutcome outcome) + { + if (outcome.Files == null || string.IsNullOrEmpty(FieldId) || IsCollectionFlow) + return; - return new Dictionary(); - } + var json = System.Text.Json.JsonSerializer.Serialize(outcome.Files); + await applicationResponseService.SaveApplicationResponseAsync(appId, new Dictionary { { FieldId, json } }); + } - try - { - var result = JsonSerializer.Deserialize>(json) ?? new Dictionary(); + private async Task TryNotifyFileOperationAsync(FormEngineOutcome outcome) + { + if (string.IsNullOrEmpty(outcome.SuccessMessage) || string.IsNullOrEmpty(outcome.NotificationContext)) + return; - return result; - } - catch (Exception ex) + try + { + await notificationsClient.CreateNotificationAsync(new AddNotificationRequest { - - return new Dictionary(); - } + Message = outcome.SuccessMessage, + Category = "file-upload", + Context = NotificationScopeContext.PrefixDetail(ApplicationContext, outcome.NotificationContext), + Type = NotificationType.Success, + AutoDismiss = false, + AutoDismissSeconds = outcome.NotificationContext.StartsWith("file-upload|", StringComparison.Ordinal) + ? 5 + : 0, + ReplaceExistingContext = false + }); + } + catch (Exception ex) + { + logger.LogWarning(ex, "File operation succeeded but notification could not be created"); } } -} \ No newline at end of file +} diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Shared/BaseFormPageModel.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Shared/BaseFormPageModel.cs index 2999dcf..6bc610c 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Shared/BaseFormPageModel.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Shared/BaseFormPageModel.cs @@ -51,7 +51,7 @@ public abstract class BaseFormPageModel( /// protected async Task EnsureApplicationIdAsync() { - var (applicationId, application) = await _applicationStateService.EnsureApplicationIdAsync(ReferenceNumber, HttpContext.Session); + var (applicationId, application) = await _applicationStateService.EnsureApplicationIdAsync(ReferenceNumber); ApplicationId = applicationId; CurrentApplication = application; } @@ -69,7 +69,7 @@ protected async Task LoadTemplateAsync() /// protected void LoadFormDataFromSession() { - FormData = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); + FormData = _applicationResponseService.GetAccumulatedFormData(); } /// @@ -77,7 +77,7 @@ protected void LoadFormDataFromSession() /// protected void LoadApplicationStatus() { - ApplicationStatus = _applicationStateService.GetApplicationStatus(ApplicationId, HttpContext.Session); + ApplicationStatus = _applicationStateService.GetApplicationStatus(ApplicationId); } /// @@ -222,7 +222,7 @@ public bool HasFieldValue(string fieldId) /// public Domain.Models.TaskStatus GetTaskStatusFromSession(string taskId) { - return _applicationStateService.CalculateTaskStatus(taskId, Template, FormData, ApplicationId, HttpContext.Session, ApplicationStatus); + return _applicationStateService.CalculateTaskStatus(taskId, Template, FormData, ApplicationId, ApplicationStatus); } /// @@ -230,7 +230,7 @@ public Domain.Models.TaskStatus GetTaskStatusFromSession(string taskId) /// public bool AreAllTasksCompleted() { - return _applicationStateService.AreAllTasksCompleted(Template, FormData, ApplicationId, HttpContext.Session, ApplicationStatus); + return _applicationStateService.AreAllTasksCompleted(Template, FormData, ApplicationId, ApplicationStatus); } /// diff --git a/src/GovUK.Dfe.FlexForms.Web/Program.cs b/src/GovUK.Dfe.FlexForms.Web/Program.cs index 1256381..4fe5f25 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Program.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Program.cs @@ -3,12 +3,7 @@ using GovUK.Dfe.CoreLibs.Security.Configurations; using GovUK.Dfe.CoreLibs.Security.Interfaces; using GovUK.Dfe.CoreLibs.Security.OpenIdConnect; -using GovUK.Dfe.FlexForms.Application.Interfaces; using GovUK.Dfe.FlexForms.Application.Options; -using GovUK.Dfe.FlexForms.Infrastructure.Parsers; -using GovUK.Dfe.FlexForms.Infrastructure.Providers; -using GovUK.Dfe.FlexForms.Infrastructure.Services; -using GovUK.Dfe.FlexForms.Infrastructure.Stores; using GovUK.Dfe.FlexForms.Web.Authentication; using GovUK.Dfe.FlexForms.Web.Extensions; using GovUK.Dfe.FlexForms.Web.Filters; @@ -542,9 +537,6 @@ static void BindNestedConfiguration(ConfigurationManager config, string parentKe builder.Services.AddTenantAwarePlatformServices(configuration); -builder.Services.AddScoped(); -builder.Services.AddScoped(); - builder.Services.AddExternalApplicationsApiClients(configuration); // Register authentication strategies and composite selector (per-request) @@ -562,34 +554,11 @@ static void BindNestedConfiguration(ConfigurationManager config, string parentKe builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddWebLayerServices(); -builder.Services.AddScoped(); // Persist cookie tickets server-side so AuthenticationProperties (tokens) don't bloat the browser cookie builder.Services.AddSingleton(); builder.Services.AddSingleton, ConfigureCookieTicketStore>(); -// New refactored services for Clean Architecture -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// Conditional Logic Services -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// Derived Collection Flow Services -builder.Services.AddScoped(); - -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -builder.Services.AddSingleton(); builder.Services.AddUserTokenService(configuration); // Always register Test Auth services so tenants can enable TestAuthentication in @@ -605,9 +574,6 @@ static void BindNestedConfiguration(ConfigurationManager config, string parentKe builder.Services.AddServiceCaching(configuration); -builder.Services.AddSingleton(); -builder.Services.AddScoped(); - // Application terminology configuration (customisable per service, e.g. "application" vs "reform plan") builder.Services.Configure(configuration.GetSection("ApplicationTerminology")); @@ -616,16 +582,9 @@ static void BindNestedConfiguration(ConfigurationManager config, string parentKe // Dashboard configuration (page size for application list pagination) builder.Services.Configure(configuration.GetSection("Dashboard")); -// Scoped so tenant-aware IOptions are not captured for the app lifetime. -builder.Services.AddScoped(); builder.Services.AddTenantAwareOptionsAccessors(configuration); -// Read-only event metadata for the Event mappings Admin page. Outbound mapped events are -// published by the API from its own domain events, driven by the EventTriggers TenantConfig. -builder.Services.AddScoped(); -builder.Services.AddSingleton(); - builder.Services.AddDfEMassTransit( configuration, configureConsumers: x => diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/ApplicationPreviewViewModel.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/ApplicationPreviewViewModel.cs new file mode 100644 index 0000000..156c423 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/ApplicationPreviewViewModel.cs @@ -0,0 +1,8 @@ +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class ApplicationPreviewViewModel +{ + public required string ReferenceNumber { get; init; } + public required IReadOnlyList Groups { get; init; } + public required PreviewSubmitViewModel Submit { get; init; } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/AutocompleteSummaryFormatter.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/AutocompleteSummaryFormatter.cs new file mode 100644 index 0000000..5fc26e9 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/AutocompleteSummaryFormatter.cs @@ -0,0 +1,111 @@ +using System.Text; +using System.Text.Json; + +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +/// +/// Formats autocomplete JSON objects into the HTML used on confirmation and preview pages. +/// +public static class AutocompleteSummaryFormatter +{ + public static string Render(string? rawValue) + { + if (string.IsNullOrWhiteSpace(rawValue)) + return string.Empty; + + try + { + using var doc = JsonDocument.Parse(rawValue); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return System.Net.WebUtility.HtmlEncode(rawValue); + + var root = doc.RootElement; + var name = root.TryGetProperty("name", out var n) && n.ValueKind == JsonValueKind.String + ? n.GetString() ?? string.Empty + : string.Empty; + var postcode = root.TryGetProperty("postcode", out var pc) && pc.ValueKind == JsonValueKind.String + ? pc.GetString() ?? string.Empty + : string.Empty; + if (string.IsNullOrWhiteSpace(postcode) + && root.TryGetProperty("postCode", out var pc2) + && pc2.ValueKind == JsonValueKind.String) + { + postcode = pc2.GetString() ?? string.Empty; + } + + if (string.IsNullOrWhiteSpace(postcode) + && root.TryGetProperty("address", out var addr) + && addr.ValueKind == JsonValueKind.Object) + { + if (addr.TryGetProperty("postcode", out var apc) && apc.ValueKind == JsonValueKind.String) + postcode = apc.GetString() ?? string.Empty; + if (string.IsNullOrWhiteSpace(postcode) + && addr.TryGetProperty("postCode", out var apc2) + && apc2.ValueKind == JsonValueKind.String) + { + postcode = apc2.GetString() ?? string.Empty; + } + + if (string.IsNullOrWhiteSpace(postcode) + && addr.TryGetProperty("postalCode", out var apc3) + && apc3.ValueKind == JsonValueKind.String) + { + postcode = apc3.GetString() ?? string.Empty; + } + } + + var ukprn = root.TryGetProperty("ukprn", out var u) ? u.ToString() : string.Empty; + var companiesHouse = root.TryGetProperty("companiesHouseNumber", out var c) + && c.ValueKind == JsonValueKind.String + ? c.GetString() ?? string.Empty + : string.Empty; + if (string.IsNullOrWhiteSpace(companiesHouse) && root.TryGetProperty("companiesHousenumber", out var c2)) + companiesHouse = c2.ToString(); + + var sb = new StringBuilder(); + if (!string.IsNullOrWhiteSpace(name)) + sb.Append($"{System.Net.WebUtility.HtmlEncode(name)}"); + if (!string.IsNullOrWhiteSpace(postcode)) + sb.Append($"
Postcode: {System.Net.WebUtility.HtmlEncode(postcode)}"); + if (!string.IsNullOrWhiteSpace(ukprn)) + sb.Append($"
UKPRN: {System.Net.WebUtility.HtmlEncode(ukprn)}"); + if (!string.IsNullOrWhiteSpace(companiesHouse)) + sb.Append($"
Companies house number: {System.Net.WebUtility.HtmlEncode(companiesHouse)}"); + return sb.ToString(); + } + catch (JsonException) + { + return System.Net.WebUtility.HtmlEncode(rawValue); + } + } + + public static string TryFindJsonInItem(Dictionary item) + { + foreach (var kv in item) + { + var s = kv.Value?.ToString(); + if (string.IsNullOrWhiteSpace(s)) + continue; + + try + { + using var doc = JsonDocument.Parse(s); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + continue; + + if (doc.RootElement.TryGetProperty("name", out _) + || doc.RootElement.TryGetProperty("ukprn", out _) + || doc.RootElement.TryGetProperty("companiesHouseNumber", out _)) + { + return s; + } + } + catch (JsonException) + { + // Value is not autocomplete JSON; keep scanning other fields. + } + } + + return string.Empty; + } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/CollectionFlowItemViewModel.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/CollectionFlowItemViewModel.cs new file mode 100644 index 0000000..377b2f3 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/CollectionFlowItemViewModel.cs @@ -0,0 +1,10 @@ +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class CollectionFlowItemViewModel +{ + public required string ItemId { get; init; } + public required string Title { get; init; } + public required CollectionItemRemoveViewModel Remove { get; init; } + public required SummaryRowViewModel HeaderRow { get; init; } + public required IReadOnlyList Rows { get; init; } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/CollectionFlowSectionViewModel.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/CollectionFlowSectionViewModel.cs new file mode 100644 index 0000000..cf2c48f --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/CollectionFlowSectionViewModel.cs @@ -0,0 +1,17 @@ +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class CollectionFlowSectionViewModel +{ + public required string FlowId { get; init; } + public required string Title { get; init; } + public string? DescriptionHtml { get; init; } + public required string ItemKind { get; init; } + public required string ItemKindPlural { get; init; } + public required string AddButtonLabel { get; init; } + public required string AddButtonId { get; init; } + public required string AddUrl { get; init; } + public required string NoItemsHintId { get; init; } + public bool CanAddMore { get; init; } + public bool IsListStyle { get; init; } + public required IReadOnlyList Items { get; init; } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/CollectionItemRemoveViewModel.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/CollectionItemRemoveViewModel.cs new file mode 100644 index 0000000..a655436 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/CollectionItemRemoveViewModel.cs @@ -0,0 +1,15 @@ +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class CollectionItemRemoveViewModel +{ + public required string ReferenceNumber { get; init; } + public required string TaskId { get; init; } + public required string FlowId { get; init; } + public required string FieldId { get; init; } + public required string ItemId { get; init; } + public required string ItemTitle { get; init; } + public required string TaskName { get; init; } + public required string ConfirmationTitle { get; init; } + public required string RequiredMessage { get; init; } + public required string ButtonId { get; init; } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/FormEnginePresentationComposer.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/FormEnginePresentationComposer.cs new file mode 100644 index 0000000..a8aea79 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/FormEnginePresentationComposer.cs @@ -0,0 +1,765 @@ +using System.Text.Json; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Application.FormEngine; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.FormEngine; +using GovUK.Dfe.FlexForms.Domain.Models; +using GovUK.Dfe.FlexForms.Web.Pages.FormEngine; +using GovUK.Dfe.FlexForms.Web.Utilities; +using TaskModel = GovUK.Dfe.FlexForms.Domain.Models.Task; + +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +/// +/// Composes preview and collection-flow view models from template data and formatting services. +/// +public sealed class FormEnginePresentationComposer( + IFieldFormattingService fieldFormattingService, + IComplexFieldConfigurationService complexFieldConfigurationService, + IInfectedUploadFilter infectedUploadFilter, + IDerivedCollectionFlowService derivedCollectionFlowService) : IFormEnginePresentationComposer +{ + public ApplicationPreviewViewModel BuildPreview(FormEnginePresentationContext context) + { + var groups = context.Template.TaskGroups + .OrderBy(g => g.GroupOrder) + .Select(group => new PreviewGroupViewModel + { + GroupName = group.GroupName, + TestId = ToTestId(group.GroupName), + Tasks = group.Tasks + .OrderBy(t => t.TaskOrder) + .Select(task => BuildPreviewTask(context, task)) + .ToList() + }) + .ToList(); + + return new ApplicationPreviewViewModel + { + ReferenceNumber = context.ReferenceNumber, + Groups = groups, + Submit = new PreviewSubmitViewModel + { + IsEditable = context.IsEditable, + IsLeadApplicant = context.IsLeadApplicant, + SubmitDisabledByConfig = context.SubmitDisabledByConfig, + DisabledBannerText = context.SubmitDisabledBannerText, + DisabledHelpText = context.SubmitDisabledHelpText, + FileValidationBlocksSubmit = context.FileValidationBlocksSubmit, + BlockingFiles = context.BlockingFiles, + IncludePreviewQuery = context.IncludePreviewQuery + } + }; + } + + public IReadOnlyList BuildCollectionFlows( + FormEnginePresentationContext context, + TaskModel task) + { + var flows = task.Summary?.Flows; + if (flows == null || flows.Count == 0) + return []; + + return flows.Select(flow => BuildCollectionSection(context, task, flow)).ToList(); + } + + private PreviewTaskCardViewModel BuildPreviewTask(FormEnginePresentationContext context, TaskModel task) + { + var testId = ToTestId(task.TaskName); + var changeUrl = $"/applications/{context.ReferenceNumber}/{task.TaskId}"; + IReadOnlyList rows; + + if (FormStepPolicy.IsDerivedCollectionFlowSummary(task) + || (task.Summary?.DerivedFlows != null && task.Summary.DerivedFlows.Count > 0)) + { + rows = BuildDerivedPreviewRows(context, task); + } + else if (FormStepPolicy.IsCollectionFlowSummary(task)) + { + rows = BuildCollectionPreviewRows(context, task); + } + else + { + rows = BuildRegularPreviewRows(context, task); + } + + return new PreviewTaskCardViewModel + { + TaskId = task.TaskId, + TaskName = task.TaskName, + TestId = testId, + ChangeUrl = changeUrl, + Rows = rows + }; + } + + private List BuildDerivedPreviewRows( + FormEnginePresentationContext context, + TaskModel task) + { + var rows = new List(); + foreach (var derivedFlow in (task.Summary?.DerivedFlows ?? []).OrderBy(f => f.SectionOrder)) + { + rows.Add(HeaderRow(derivedFlow.Title)); + + var derivedItems = derivedCollectionFlowService.GenerateItemsFromSourceField( + derivedFlow.SourceFieldId, context.FormData, derivedFlow); + + if (derivedItems.Count == 0) + { + rows.Add(new SummaryRowViewModel + { + Key = "No items", + Value = SummaryValueViewModel.FromHtml( + $"{System.Net.WebUtility.HtmlEncode(derivedFlow.EmptyStateMessage ?? "No items to display")}") + }); + continue; + } + + var statuses = derivedCollectionFlowService.GetItemStatuses(derivedFlow.FieldId, context.FormData); + foreach (var item in derivedItems) + { + var declarationData = derivedCollectionFlowService.GetItemDeclarationData( + derivedFlow.FieldId, item.Id, context.FormData); + var status = statuses.TryGetValue(item.Id, out var s) ? s : "Not signed yet"; + + rows.Add(new SummaryRowViewModel + { + Key = item.DisplayName, + KeyIsBold = true, + Value = SummaryValueViewModel.FromStatusTag(status) + }); + + foreach (var page in (derivedFlow.Pages ?? []).OrderBy(p => p.PageOrder)) + { + foreach (var field in page.Fields.OrderBy(f => f.Order)) + { + var fieldValue = declarationData.TryGetValue(field.FieldId, out var v) + ? v?.ToString() ?? string.Empty + : string.Empty; + rows.Add(new SummaryRowViewModel + { + Key = field.Label.Value, + Value = BuildDerivedFieldValue(context, task, field, fieldValue) + }); + } + } + } + } + + return rows; + } + + private SummaryValueViewModel BuildDerivedFieldValue( + FormEnginePresentationContext context, + TaskModel task, + Field field, + string fieldValue) + { + if (string.IsNullOrEmpty(fieldValue)) + return SummaryValueViewModel.NotAnswered; + + if (LooksLikeUploadJson(fieldValue)) + { + return TryBuildUploadValue( + fieldValue, + [ToHtmlBreaks(fieldValue)], + context, + task.TaskId, + pageId: null, + filterInfected: false, + showAllFiles: true, + fallbackWhenEmpty: SummaryValueViewModel.FromHtml(ToHtmlBreaks(fieldValue))); + } + + if (field.Type == "radios" && field.Options != null) + { + var selectedOption = field.Options.FirstOrDefault(o => o.Value == fieldValue); + return SummaryValueViewModel.FromHtml(selectedOption?.Label ?? fieldValue); + } + + if (field.Type == "select" && field.Options != null) + { + var selectedOption = field.Options.FirstOrDefault(o => o.Value == fieldValue); + return SummaryValueViewModel.FromHtml(selectedOption?.Label ?? fieldValue); + } + + if (field.Type == "checkboxes" && field.Options != null) + { + var selectedValues = fieldFormattingService.GetFormattedFieldValues(field.FieldId, context.FormData); + var selectedLabels = field.Options + .Where(o => selectedValues.Contains(o.Value)) + .Select(o => o.Label ?? o.Value) + .ToList(); + + return selectedLabels.Count > 0 + ? SummaryValueViewModel.FromCheckboxes(selectedLabels) + : SummaryValueViewModel.FromHtml(ToHtmlBreaks(fieldValue)); + } + + return SummaryValueViewModel.FromHtml(ToHtmlBreaks(fieldValue)); + } + + private List BuildCollectionPreviewRows( + FormEnginePresentationContext context, + TaskModel task) + { + var rows = new List(); + foreach (var flow in task.Summary?.Flows ?? []) + { + rows.Add(HeaderRow(flow.Title)); + + var items = DeserializeItems(context.FormData, flow.FieldId); + if (items.Count == 0) + { + rows.Add(new SummaryRowViewModel + { + Key = "No items added", + Value = SummaryValueViewModel.NotAnswered + }); + continue; + } + + var itemLabel = flow.ItemKind ?? "Item"; + var summaryColumns = flow.SummaryColumns ?? []; + var itemIndex = 0; + foreach (var item in items) + { + itemIndex++; + var expandedItem = DisplayHelpers.ExpandEncodedJson(item) ?? item; + context.EnsureItemFieldVisibility(item, summaryColumns.Select(c => c.Field)); + var displayTitle = !string.IsNullOrEmpty(flow.ItemTitleBinding) + ? DisplayHelpers.InterpolateMessage($"{{{flow.ItemTitleBinding}}}", expandedItem) + : $"{itemLabel} {itemIndex}"; + + rows.Add(HeaderRow(displayTitle)); + + foreach (var col in summaryColumns.Where(c => !context.IsFieldHiddenForItem(c.Field, item))) + { + var rawValue = DisplayHelpers.InterpolateMessage($"{{{col.Field}}}", expandedItem); + var value = rawValue == $"{{{col.Field}}}" ? string.Empty : rawValue; + rows.Add(new SummaryRowViewModel + { + Key = col.Label, + Value = string.IsNullOrEmpty(value) + ? SummaryValueViewModel.NotAnswered + : BuildPreviewCollectionFieldValue(context, task, col.Field, value) + }); + } + } + } + + return rows; + } + + private SummaryValueViewModel BuildPreviewCollectionFieldValue( + FormEnginePresentationContext context, + TaskModel task, + string fieldId, + string value) + { + var formattedValues = FormatWithItemValue(context.FormData, fieldId, value); + var isUploadField = LooksLikeUploadJson(value); + + if (formattedValues.Count == 0) + return SummaryValueViewModel.NotAnswered; + + if (formattedValues.Count == 1) + { + if (!isUploadField) + return SummaryValueViewModel.FromHtml(formattedValues[0]); + + return TryBuildUploadValue( + value, + formattedValues, + context, + task.TaskId, + pageId: null, + filterInfected: false, + showAllFiles: false, + fallbackWhenEmpty: SummaryValueViewModel.FromHtml(formattedValues[0])); + } + + if (!isUploadField) + return SummaryValueViewModel.FromHtmlList(formattedValues); + + return TryBuildUploadValue( + value, + formattedValues, + context, + task.TaskId, + pageId: null, + filterInfected: false, + showAllFiles: true, + fallbackWhenEmpty: SummaryValueViewModel.FromHtmlList(formattedValues)); + } + + private List BuildRegularPreviewRows( + FormEnginePresentationContext context, + TaskModel task) + { + var rows = new List(); + foreach (var page in (task.Pages ?? []).OrderBy(p => p.PageOrder)) + { + foreach (var field in page.Fields.OrderBy(f => f.Order).Where(f => !context.IsFieldHidden(f.FieldId))) + { + var fieldValue = fieldFormattingService.GetFieldValue(field.FieldId, context.FormData); + var hasValue = fieldFormattingService.HasFieldValue(field.FieldId, context.FormData); + + if ((field.Type == "autocomplete" || field.Type == "complexField" || field.Type == "upload") && hasValue) + { + rows.AddRange(BuildRegularComplexRows(context, task, field, fieldValue)); + } + else + { + rows.Add(new SummaryRowViewModel + { + Key = field.Label.Value, + Value = BuildRegularSimpleValue(context, task, field, fieldValue, hasValue) + }); + } + } + } + + return rows; + } + + private List BuildRegularComplexRows( + FormEnginePresentationContext context, + TaskModel task, + Field field, + string fieldValue) + { + var formattedValues = fieldFormattingService.GetFormattedFieldValues(field.FieldId, context.FormData); + var itemLabel = fieldFormattingService.GetFieldItemLabel(field.FieldId, context.Template); + var allowMultiple = fieldFormattingService.IsFieldAllowMultiple(field.FieldId, context.Template); + var isUploadField = LooksLikeUploadJson(fieldValue); + var rows = new List(); + + SummaryValueViewModel headerValue; + if (formattedValues.Count == 0) + { + headerValue = SummaryValueViewModel.NotAnswered; + } + else if (!allowMultiple) + { + if (isUploadField) + { + headerValue = TryBuildUploadValue( + fieldValue, + formattedValues, + context, + task.TaskId, + pageId: null, + filterInfected: false, + showAllFiles: false, + fallbackWhenEmpty: SummaryValueViewModel.FromHtml(formattedValues.FirstOrDefault() ?? string.Empty)); + } + else + { + var html = AutocompleteSummaryFormatter.Render(DisplayHelpers.UnsanitiseHtmlInput(fieldValue)); + headerValue = SummaryValueViewModel.FromAutocompleteHtml(html); + } + } + else + { + headerValue = SummaryValueViewModel.Empty; + } + + rows.Add(new SummaryRowViewModel + { + Key = field.Label.Value, + Value = headerValue + }); + + if (!allowMultiple || formattedValues.Count == 0) + return rows; + + if (isUploadField && TryParseUploads(fieldValue, out var uploadFiles) && uploadFiles.Count > 0) + { + for (var i = 0; i < uploadFiles.Count; i++) + { + var file = uploadFiles[i]; + rows.Add(new SummaryRowViewModel + { + Key = $"{itemLabel} {i + 1}", + Value = SummaryValueViewModel.FromFiles( + [ToFileLink(file, context, task.TaskId, pageId: null)], + wrapFilesInDivs: false) + }); + } + + return rows; + } + + for (var i = 0; i < formattedValues.Count; i++) + { + rows.Add(new SummaryRowViewModel + { + Key = $"{itemLabel} {i + 1}", + Value = SummaryValueViewModel.FromHtml(formattedValues[i]) + }); + } + + return rows; + } + + private SummaryValueViewModel BuildRegularSimpleValue( + FormEnginePresentationContext context, + TaskModel task, + Field field, + string fieldValue, + bool hasValue) + { + if (!hasValue) + return SummaryValueViewModel.NotAnswered; + + if (LooksLikeUploadJson(fieldValue)) + { + return TryBuildUploadValue( + fieldValue, + [ToHtmlBreaks(fieldValue)], + context, + task.TaskId, + pageId: null, + filterInfected: false, + showAllFiles: false, + fallbackWhenEmpty: SummaryValueViewModel.FromHtml(ToHtmlBreaks(fieldValue))); + } + + if (field.Type == "radios" && field.Options != null) + { + var selectedOption = field.Options.FirstOrDefault(o => o.Value == fieldValue); + return SummaryValueViewModel.FromText(selectedOption?.Label ?? fieldValue); + } + + if (field.Type == "select" && field.Options != null) + { + var selectedOption = field.Options.FirstOrDefault(o => o.Value == fieldValue); + return SummaryValueViewModel.FromText(selectedOption?.Label ?? fieldValue); + } + + return SummaryValueViewModel.FromHtml(ToHtmlBreaks(fieldValue)); + } + + private CollectionFlowSectionViewModel BuildCollectionSection( + FormEnginePresentationContext context, + TaskModel task, + MultiCollectionFlowConfiguration flow) + { + var items = DeserializeItems(context.FormData, flow.FieldId); + var itemLabel = flow.ItemKind ?? "Item"; + var itemLabelPlural = flow.ItemKindPlural ?? $"{itemLabel}s"; + var isListStyle = flow.TableType?.Equals("list", StringComparison.OrdinalIgnoreCase) == true; + var descriptionHtml = string.IsNullOrEmpty(flow.Description) + ? null + : MarkdownSafe.RenderHintWithClass(flow.Description).html; + + var itemVms = new List(); + var index = 0; + foreach (var item in items) + { + index++; + var expandedItem = DisplayHelpers.ExpandEncodedJson(item) ?? item; + var summaryColumns = flow.SummaryColumns ?? []; + context.EnsureItemFieldVisibility(item, summaryColumns.Select(c => c.Field)); + var memberTitle = !string.IsNullOrEmpty(flow.ItemTitleBinding) + ? DisplayHelpers.InterpolateMessage($"{{{flow.ItemTitleBinding}}}", expandedItem) + : $"{itemLabel} {index}"; + var itemId = item.TryGetValue("id", out var idValue) ? idValue?.ToString() ?? string.Empty : string.Empty; + + var remove = new CollectionItemRemoveViewModel + { + ReferenceNumber = context.ReferenceNumber, + TaskId = context.TaskId, + FlowId = flow.FlowId, + FieldId = flow.FieldId, + ItemId = itemId, + ItemTitle = memberTitle, + TaskName = task.TaskName, + ConfirmationTitle = $"Are you sure you want to remove this {itemLabel.ToLower()}?", + RequiredMessage = $"Select yes if you are sure you want to remove this {itemLabel.ToLower()}", + ButtonId = "remove-flow-item-@memberNumber" + }; + + itemVms.Add(new CollectionFlowItemViewModel + { + ItemId = itemId, + Title = memberTitle, + Remove = remove, + HeaderRow = new SummaryRowViewModel + { + Key = memberTitle, + KeyIsBold = true, + ShowSeparator = index > 1, + Value = SummaryValueViewModel.Empty, + Remove = remove + }, + Rows = BuildCollectionItemRows(context, flow, item, isListStyle, memberTitle) + }); + } + + return new CollectionFlowSectionViewModel + { + FlowId = flow.FlowId, + Title = flow.Title, + DescriptionHtml = descriptionHtml, + ItemKind = itemLabel, + ItemKindPlural = itemLabelPlural, + AddButtonLabel = flow.AddButtonLabel, + AddButtonId = flow.FlowId + "-add-item", + AddUrl = $"/applications/{context.ReferenceNumber}/{context.TaskId}/flow/{flow.FlowId}/{Guid.NewGuid()}", + NoItemsHintId = flow.FlowId + "-no-items-added-hint", + CanAddMore = !flow.MaxItems.HasValue || items.Count < flow.MaxItems.Value, + IsListStyle = isListStyle, + Items = itemVms + }; + } + + private List BuildCollectionItemRows( + FormEnginePresentationContext context, + MultiCollectionFlowConfiguration flow, + Dictionary item, + bool isListStyle, + string memberTitle) + { + var rows = new List(); + var summaryColumns = flow.SummaryColumns ?? []; + foreach (var col in summaryColumns.Where(c => !context.IsFieldHiddenForItem(c.Field, item))) + { + var value = CoerceItemValue(item.TryGetValue(col.Field, out var v) ? v : null); + var targetPage = flow.Pages?.FirstOrDefault(p => p.Fields.Any(f => f.FieldId == col.Field)); + var pageId = targetPage?.PageId ?? flow.Pages?.FirstOrDefault()?.PageId ?? string.Empty; + var fieldConfig = targetPage?.Fields.FirstOrDefault(f => f.FieldId == col.Field); + var (isAutocompleteField, isUploadFieldByConfig) = DetectComplexFieldTypes(fieldConfig); + + if (!isListStyle && isAutocompleteField && string.IsNullOrEmpty(value)) + { + var inferred = AutocompleteSummaryFormatter.TryFindJsonInItem(item); + if (!string.IsNullOrEmpty(inferred)) + value = inferred; + } + + var changeUrl = $"/applications/{context.ReferenceNumber}/{context.TaskId}/flow/{flow.FlowId}/{(item.TryGetValue("id", out var changeItemId) ? changeItemId?.ToString() : string.Empty)}/{pageId}"; + var changeHiddenText = $"{col.Label} for {memberTitle}"; + + SummaryValueViewModel valueVm; + if (string.IsNullOrEmpty(value)) + { + valueVm = SummaryValueViewModel.NotAnswered; + } + else if (isListStyle && fieldConfig?.Type == "checkboxes") + { + var checkboxValues = CheckboxValueNormalizer.Normalize( + (item.TryGetValue(col.Field, out var valueObj) ? valueObj : null) ?? value); + valueVm = checkboxValues.Count > 0 + ? SummaryValueViewModel.FromCheckboxes(checkboxValues.ToList()) + : BuildCollectionFormattedValue( + context, col.Field, value, item, isAutocompleteField, isUploadFieldByConfig, pageId, unsanitiseAutocomplete: false); + } + else + { + valueVm = BuildCollectionFormattedValue( + context, + col.Field, + value, + item, + isAutocompleteField, + isUploadFieldByConfig, + pageId, + unsanitiseAutocomplete: !isListStyle); + } + + rows.Add(new SummaryRowViewModel + { + Key = col.Label, + Value = valueVm, + ChangeUrl = changeUrl, + ChangeHiddenText = changeHiddenText + }); + } + + return rows; + } + + private SummaryValueViewModel BuildCollectionFormattedValue( + FormEnginePresentationContext context, + string fieldId, + string value, + Dictionary item, + bool isAutocompleteField, + bool isUploadFieldByConfig, + string pageId, + bool unsanitiseAutocomplete) + { + var formattedValues = FormatWithItemValue(context.FormData, fieldId, value); + + if (isAutocompleteField && string.IsNullOrEmpty(value)) + { + var inferred = AutocompleteSummaryFormatter.TryFindJsonInItem(item); + if (!string.IsNullOrEmpty(inferred)) + value = inferred; + } + + var isUploadField = isUploadFieldByConfig || LooksLikeUploadJson(value); + + if (formattedValues.Count == 0) + return SummaryValueViewModel.NotAnswered; + + if (formattedValues.Count == 1) + { + if (isUploadField) + { + return TryBuildUploadValue( + value, + formattedValues, + context, + context.TaskId, + pageId, + filterInfected: true, + showAllFiles: false, + fallbackWhenEmpty: SummaryValueViewModel.FromHtml(formattedValues[0])); + } + + if (isAutocompleteField) + { + var raw = unsanitiseAutocomplete ? DisplayHelpers.UnsanitiseHtmlInput(value) : value; + return SummaryValueViewModel.FromAutocompleteHtml(AutocompleteSummaryFormatter.Render(raw)); + } + + return SummaryValueViewModel.FromHtml(formattedValues[0]); + } + + if (isUploadField) + { + return TryBuildUploadValue( + value, + formattedValues, + context, + context.TaskId, + pageId, + filterInfected: true, + showAllFiles: true, + fallbackWhenEmpty: SummaryValueViewModel.FromHtmlList(formattedValues)); + } + + return SummaryValueViewModel.FromHtmlList(formattedValues); + } + + private (bool IsAutocomplete, bool IsUpload) DetectComplexFieldTypes(Field? fieldConfig) + { + if (fieldConfig is not { Type: "complexField", ComplexField: not null }) + return (false, false); + + var cfg = complexFieldConfigurationService.GetConfiguration(fieldConfig.ComplexField.Id); + return ( + string.Equals(cfg.FieldType, "autocomplete", StringComparison.OrdinalIgnoreCase), + string.Equals(cfg.FieldType, "upload", StringComparison.OrdinalIgnoreCase)); + } + + private List FormatWithItemValue(Dictionary formData, string fieldId, string value) + { + var snapshot = new Dictionary(formData) { [fieldId] = value }; + return fieldFormattingService.GetFormattedFieldValues(fieldId, snapshot); + } + + private SummaryValueViewModel TryBuildUploadValue( + string rawValue, + IReadOnlyList formattedValues, + FormEnginePresentationContext context, + string taskId, + string? pageId, + bool filterInfected, + bool showAllFiles, + SummaryValueViewModel fallbackWhenEmpty) + { + if (!TryParseUploads(rawValue, out var uploadFiles)) + { + return showAllFiles && formattedValues.Count > 1 + ? SummaryValueViewModel.FromHtmlList(formattedValues) + : SummaryValueViewModel.FromHtml(formattedValues.Count > 0 ? formattedValues[0] : string.Empty); + } + + if (filterInfected) + uploadFiles = infectedUploadFilter.FilterList(uploadFiles, context.InfectedFilterApplicationId); + + if (uploadFiles.Count == 0) + return fallbackWhenEmpty; + + var files = showAllFiles + ? uploadFiles.Select(f => ToFileLink(f, context, taskId, pageId)).ToList() + : [ToFileLink(uploadFiles[0], context, taskId, pageId)]; + + return SummaryValueViewModel.FromFiles(files, wrapFilesInDivs: showAllFiles); + } + + private static SummaryFileLinkViewModel ToFileLink( + UploadDto file, + FormEnginePresentationContext context, + string taskId, + string? pageId) => + new() + { + FileId = file.Id, + FileName = file.OriginalFileName, + ReferenceNumber = context.ReferenceNumber, + TaskId = taskId, + ApplicationId = context.ApplicationId, + PageId = pageId + }; + + private static SummaryRowViewModel HeaderRow(string title) => + new() + { + Key = title, + KeyIsBold = true, + Value = SummaryValueViewModel.Empty + }; + + private static List> DeserializeItems( + Dictionary formData, + string fieldId) + { + formData.TryGetValue(fieldId, out var raw); + var json = raw?.ToString() ?? "[]"; + try + { + return JsonSerializer.Deserialize>>(json) ?? []; + } + catch (JsonException) + { + return []; + } + } + + private static string CoerceItemValue(object? valueObj) => + valueObj switch + { + string sv => sv, + JsonElement je => je.ToString(), + not null => JsonSerializer.Serialize(valueObj), + _ => string.Empty + }; + + private static bool LooksLikeUploadJson(string? value) => + !string.IsNullOrEmpty(value) && value.StartsWith('[') && value.Contains("\"id\""); + + private static bool TryParseUploads(string value, out List files) + { + try + { + files = JsonSerializer.Deserialize>(value) ?? []; + return true; + } + catch (JsonException) + { + files = []; + return false; + } + } + + private static string ToHtmlBreaks(string value) => + value.Replace("\r\n", "
").Replace("\r", "
").Replace("\n", "
"); + + private static string ToTestId(string name) => name.Replace(" ", "-").ToLower(); +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/FormEnginePresentationContext.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/FormEnginePresentationContext.cs new file mode 100644 index 0000000..2cad545 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/FormEnginePresentationContext.cs @@ -0,0 +1,25 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Domain.Models; + +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class FormEnginePresentationContext +{ + public required FormTemplate Template { get; init; } + public required Dictionary FormData { get; init; } + public required string ReferenceNumber { get; init; } + public string TaskId { get; init; } = string.Empty; + public Guid? ApplicationId { get; init; } + public string? InfectedFilterApplicationId { get; init; } + public bool IsEditable { get; init; } + public bool IsLeadApplicant { get; init; } + public bool SubmitDisabledByConfig { get; init; } + public string? SubmitDisabledBannerText { get; init; } + public string? SubmitDisabledHelpText { get; init; } + public bool FileValidationBlocksSubmit { get; init; } + public IReadOnlyList BlockingFiles { get; init; } = []; + public bool IncludePreviewQuery { get; init; } + public required Action, IEnumerable> EnsureItemFieldVisibility { get; init; } + public required Func, bool> IsFieldHiddenForItem { get; init; } + public required Func IsFieldHidden { get; init; } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/IFormEnginePresentationComposer.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/IFormEnginePresentationComposer.cs new file mode 100644 index 0000000..cf5b05e --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/IFormEnginePresentationComposer.cs @@ -0,0 +1,21 @@ +using TaskModel = GovUK.Dfe.FlexForms.Domain.Models.Task; + +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +/// +/// Builds Razor-ready view models for application preview and collection-flow summaries. +/// +public interface IFormEnginePresentationComposer +{ + /// + /// Builds the check-your-answers preview from the template and current form data. + /// + ApplicationPreviewViewModel BuildPreview(FormEnginePresentationContext context); + + /// + /// Builds collection-flow sections for a multi-collection task summary. + /// + IReadOnlyList BuildCollectionFlows( + FormEnginePresentationContext context, + TaskModel task); +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/PreviewGroupViewModel.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/PreviewGroupViewModel.cs new file mode 100644 index 0000000..e175314 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/PreviewGroupViewModel.cs @@ -0,0 +1,8 @@ +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class PreviewGroupViewModel +{ + public required string GroupName { get; init; } + public required string TestId { get; init; } + public required IReadOnlyList Tasks { get; init; } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/PreviewSubmitViewModel.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/PreviewSubmitViewModel.cs new file mode 100644 index 0000000..16e9e4e --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/PreviewSubmitViewModel.cs @@ -0,0 +1,19 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; + +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class PreviewSubmitViewModel +{ + public bool IsEditable { get; init; } + public bool IsLeadApplicant { get; init; } + public bool SubmitDisabledByConfig { get; init; } + public string? DisabledBannerText { get; init; } + public string? DisabledHelpText { get; init; } + public bool FileValidationBlocksSubmit { get; init; } + public IReadOnlyList BlockingFiles { get; init; } = []; + public bool IncludePreviewQuery { get; init; } + + public bool ShowSubmitSection => IsEditable && IsLeadApplicant; + + public bool ShowLeadApplicantInset => IsEditable && !IsLeadApplicant; +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/PreviewTaskCardViewModel.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/PreviewTaskCardViewModel.cs new file mode 100644 index 0000000..0ad6b85 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/PreviewTaskCardViewModel.cs @@ -0,0 +1,10 @@ +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class PreviewTaskCardViewModel +{ + public required string TaskId { get; init; } + public required string TaskName { get; init; } + public required string TestId { get; init; } + public required string ChangeUrl { get; init; } + public required IReadOnlyList Rows { get; init; } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryDisplayKind.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryDisplayKind.cs new file mode 100644 index 0000000..94e8640 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryDisplayKind.cs @@ -0,0 +1,14 @@ +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public enum SummaryDisplayKind +{ + NotAnswered, + Empty, + Html, + Text, + HtmlList, + UploadFiles, + Checkboxes, + AutocompleteHtml, + StatusTag +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryFileLinkViewModel.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryFileLinkViewModel.cs new file mode 100644 index 0000000..91e08a3 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryFileLinkViewModel.cs @@ -0,0 +1,11 @@ +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class SummaryFileLinkViewModel +{ + public required Guid FileId { get; init; } + public required string FileName { get; init; } + public required string ReferenceNumber { get; init; } + public required string TaskId { get; init; } + public Guid? ApplicationId { get; init; } + public string? PageId { get; init; } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryRowViewModel.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryRowViewModel.cs new file mode 100644 index 0000000..f6a6d7a --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryRowViewModel.cs @@ -0,0 +1,12 @@ +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class SummaryRowViewModel +{ + public required string Key { get; init; } + public bool KeyIsBold { get; init; } + public bool ShowSeparator { get; init; } + public required SummaryValueViewModel Value { get; init; } + public string? ChangeUrl { get; init; } + public string? ChangeHiddenText { get; init; } + public CollectionItemRemoveViewModel? Remove { get; init; } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryValueViewModel.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryValueViewModel.cs new file mode 100644 index 0000000..6f8d354 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryValueViewModel.cs @@ -0,0 +1,51 @@ +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class SummaryValueViewModel +{ + public SummaryDisplayKind Kind { get; init; } + public string? Text { get; init; } + public string? Html { get; init; } + public IReadOnlyList HtmlItems { get; init; } = []; + public IReadOnlyList Files { get; init; } = []; + public bool WrapFilesInDivs { get; init; } + public IReadOnlyList Checkboxes { get; init; } = []; + public string? StatusText { get; init; } + public bool StatusIsSigned { get; init; } + + public static SummaryValueViewModel NotAnswered { get; } = new() { Kind = SummaryDisplayKind.NotAnswered }; + + public static SummaryValueViewModel Empty { get; } = new() { Kind = SummaryDisplayKind.Empty }; + + public static SummaryValueViewModel FromHtml(string html) => + new() { Kind = SummaryDisplayKind.Html, Html = html }; + + public static SummaryValueViewModel FromText(string text) => + new() { Kind = SummaryDisplayKind.Text, Text = text }; + + public static SummaryValueViewModel FromHtmlList(IReadOnlyList items) => + new() { Kind = SummaryDisplayKind.HtmlList, HtmlItems = items }; + + public static SummaryValueViewModel FromCheckboxes(IReadOnlyList items) => + new() { Kind = SummaryDisplayKind.Checkboxes, Checkboxes = items }; + + public static SummaryValueViewModel FromAutocompleteHtml(string html) => + new() { Kind = SummaryDisplayKind.AutocompleteHtml, Html = html }; + + public static SummaryValueViewModel FromStatusTag(string status) => + new() + { + Kind = SummaryDisplayKind.StatusTag, + StatusText = status, + StatusIsSigned = string.Equals(status, "Signed", StringComparison.Ordinal) + }; + + public static SummaryValueViewModel FromFiles( + IReadOnlyList files, + bool wrapFilesInDivs) => + new() + { + Kind = SummaryDisplayKind.UploadFiles, + Files = files, + WrapFilesInDivs = wrapFilesInDivs + }; +} diff --git a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/Fields/_UploadComplexField.cshtml b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/Fields/_UploadComplexField.cshtml index f2cdec3..c8427fc 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/Fields/_UploadComplexField.cshtml +++ b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/Fields/_UploadComplexField.cshtml @@ -3,7 +3,7 @@ @using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response @inject IHttpContextAccessor httpContext; @inject GovUK.Dfe.FlexForms.Web.Interfaces.IFormErrorStore FormErrorStore -@inject StackExchange.Redis.IConnectionMultiplexer redis +@inject GovUK.Dfe.FlexForms.Application.Interfaces.IInfectedFileStore infectedFileStore @using Microsoft.AspNetCore.Http.Extensions @using Microsoft.AspNetCore.Http @using System.Linq @@ -49,23 +49,14 @@ try { - var db = redis.GetDatabase(); var infectedFileIds = new HashSet(); var appId = ViewData["applicationId"] as string ?? Context.Session.GetString("ApplicationId"); - - // Check each file against BOTH blacklist types: - // 1. By file ID (DfE:InfectedFile:{fileId}) - // 2. By filename (DfE:InfectedFileName:{applicationId}:{originalFileName}) + foreach (var file in files) { - // Check by file ID - var fileIdBlacklistKey = $"DfE:InfectedFile:{file.Id}"; - var fileIdExists = db.KeyExists(fileIdBlacklistKey); - - // Check by filename (fallback when file ID doesn't match) - var filenameBlacklistKey = $"DfE:InfectedFileName:{appId}:{file.OriginalFileName}"; - var filenameExists = db.KeyExists(filenameBlacklistKey); - + var fileIdExists = infectedFileStore.IsFileInfected(file.Id); + var filenameExists = infectedFileStore.IsFileNameInfected(appId ?? string.Empty, file.OriginalFileName ?? string.Empty); + if (fileIdExists || filenameExists) { infectedFileIds.Add(file.Id); diff --git a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_ApplicationPreview.cshtml b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_ApplicationPreview.cshtml index 85149c5..96c3b8c 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_ApplicationPreview.cshtml +++ b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_ApplicationPreview.cshtml @@ -1,34 +1,18 @@ -@using System.Security.Claims -@using System.Text.Json @model GovUK.Dfe.FlexForms.Web.Pages.FormEngine.RenderFormModel -@inject GovUK.Dfe.FlexForms.Application.Interfaces.IDerivedCollectionFlowService DerivedFlowService -@using GovUK.Dfe.FlexForms.Web.Tenancy -@inject ITenantAppConfiguration AppConfig - +@using GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine @{ - var applicationId = Context.Session.GetString("ApplicationId"); - var leadApplicantEmail = Context.Session.GetString($"ApplicationLeadApplicantEmail_{applicationId}"); - - // Use robust claim checking like authentication strategies do - var currentUserEmail = Context.User?.FindFirst(ClaimTypes.Email)?.Value - ?? Context.User?.FindFirst("email")?.Value - ?? Context.User?.FindFirst("sub")?.Value - ?? Context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value - ?? Context.User?.Identity?.Name; - - var isLeadApplicant = string.Equals(currentUserEmail?.Trim(), leadApplicantEmail?.Trim(), StringComparison.InvariantCultureIgnoreCase); - - bool submitIsDisabled = AppConfig.GetSection("Layout:SubmitAppDisabled").Exists(); + var preview = Model.Preview; + var submit = preview?.Submit; } -@if (submitIsDisabled) +@if (submit?.SubmitDisabledByConfig == true) {

Important

- @AppConfig["Layout:SubmitAppDisabled:BannerText"] + @submit.DisabledBannerText You can still work on your @AppTerminology.Singular and save it.
@@ -49,392 +33,24 @@ } - @foreach (var group in Model.Template.TaskGroups.OrderBy(g => g.GroupOrder)) + @if (preview != null) { - - var groupNameHyphenated = group.GroupName.Replace(" ", "-").ToLower(); - -

- @group.GroupName -

- - @foreach (var task in group.Tasks.OrderBy(t => t.TaskOrder)) + @foreach (var group in preview.Groups) { - @if (task.Summary?.Mode?.Equals("derivedCollectionFlow", StringComparison.OrdinalIgnoreCase) == true || (task.Summary?.DerivedFlows != null && task.Summary.DerivedFlows.Any())) - { - var taskNameHyphenated = task.TaskName.Replace(" ", "-").ToLower(); - -
-
-

@task.TaskName

- @if (Model.IsApplicationEditable()) - { - - } -
-
-
- @foreach (var derivedFlow in (task.Summary.DerivedFlows ?? new List()).OrderBy(f => f.SectionOrder)) - { - var derivedItems = DerivedFlowService.GenerateItemsFromSourceField( - derivedFlow.SourceFieldId, Model.FormData, derivedFlow); - -
-
- @derivedFlow.Title -
-
-
-
- - @if (!derivedItems.Any()) - { -
-
- No items -
-
- @(derivedFlow.EmptyStateMessage ?? "No items to display") -
-
- } - else - { - var statuses = DerivedFlowService.GetItemStatuses(derivedFlow.FieldId, Model.FormData); - @foreach (var (item, itemIndex) in derivedItems.Select((item, i) => (item, i))) - { - var declarationData = DerivedFlowService.GetItemDeclarationData(derivedFlow.FieldId, item.Id, Model.FormData); - var status = statuses.TryGetValue(item.Id, out var s) ? s : "Not signed yet"; - -
-
- @item.DisplayName -
-
- @{ - var statusClass = status == "Signed" ? "govuk-tag govuk-tag--green" : "govuk-tag govuk-tag--blue"; - } - @status -
-
- - @foreach (var page in (derivedFlow.Pages ?? new List()).OrderBy(p => p.PageOrder)) - { - @foreach (var field in page.Fields.OrderBy(f => f.Order)) - { - var fieldValue = declarationData.TryGetValue(field.FieldId, out var v) ? v?.ToString() : string.Empty; - var hasValue = !string.IsNullOrEmpty(fieldValue); - -
-
- @field.Label.Value -
-
- @if (hasValue) - { - var isUploadField = fieldValue.StartsWith("[") && fieldValue.Contains("\"id\""); - - @if (isUploadField) - { - try - { - var uploadFiles = System.Text.Json.JsonSerializer.Deserialize>(fieldValue); - if (uploadFiles != null && uploadFiles.Any()) - { - @foreach (var file in uploadFiles) - { -
-
- - - -
-
- } - } - else - { - @Html.Raw(fieldValue.Replace("\r\n", "
").Replace("\r", "
").Replace("\n", "
")) - } - } - catch - { - @Html.Raw(fieldValue.Replace("\r\n", "
").Replace("\r", "
").Replace("\n", "
")) - } - } - else if (field.Type == "radios" && field.Options != null) - { - var selectedOption = field.Options.FirstOrDefault(o => o.Value == fieldValue); - @Html.Raw(selectedOption?.Label ?? fieldValue) - } - else if (field.Type == "select" && field.Options != null) - { - var selectedOption = field.Options.FirstOrDefault(o => o.Value == fieldValue); - @Html.Raw(selectedOption?.Label ?? fieldValue) - } - else if (field.Type == "checkboxes" && field.Options != null) - { - var selectedValues = Model.GetFormattedFieldValues(field.FieldId); - var selectedLabels = field.Options - .Where(o => selectedValues.Contains(o.Value)) - .Select(o => o.Label ?? o.Value) - .ToList(); - - if (selectedLabels.Any()) - { -
    - @foreach (var label in selectedLabels) - { -
  • @label
  • - } -
- } - else - { - @Html.Raw(fieldValue.Replace("\r\n", "
").Replace("\r", "
").Replace("\n", "
")) - } - } - else - { - @Html.Raw(fieldValue.Replace("\r\n", "
").Replace("\r", "
").Replace("\n", "
")) - } - } - else - { - Not answered - } -
-
- } - } - } - } - } -
-
-
- } - else if (task.Summary?.Mode?.Equals("multiCollectionFlow", StringComparison.OrdinalIgnoreCase) == true) - { - // Multi-collection flow task: Display all flows in one table - var taskNameHyphenated = task.TaskName.Replace(" ", "-").ToLower(); - -
-
-

@task.TaskName

- @if (Model.IsApplicationEditable()) - { - - } -
-
-
- @foreach (var (flow, flowIndex) in (task.Summary.Flows ?? new List()).Select((f, i) => (f, i))) - { - var fieldId = flow.FieldId; - Model.FormData.TryGetValue(fieldId, out var raw); - var json = raw?.ToString() ?? "[]"; - - List> items = new(); - try { items = System.Text.Json.JsonSerializer.Deserialize>>(json) ?? new(); } catch { } - - // Add flow header (no gray background, just bold text) -
-
- @flow.Title -
-
- -
-
- - @if (items.Count == 0) - { -
-
- No items added -
-
- Not answered -
-
- } - else - { - @foreach (var (item, itemIndex) in items.Select((item, i) => (item, i))) - { - var itemNumber = itemIndex + 1; - var expandedItem = DisplayHelpers.ExpandEncodedJson(item); - var summaryColumns = flow.SummaryColumns ?? new List(); - Model.EnsureItemFieldVisibility(item, summaryColumns.Select(c => c.Field)); - // Use itemTitleBinding to get the proper label (e.g., "Member", "Person") instead of "Item" - var itemLabel = flow.ItemKind ?? "Item"; - var displayTitle = !string.IsNullOrEmpty(flow.ItemTitleBinding) - ? DisplayHelpers.InterpolateMessage($"{{{flow.ItemTitleBinding}}}", expandedItem) - : $"{itemLabel} {itemNumber}"; - - // Add item header (no gray background, just bold text) -
-
- @displayTitle -
-
- -
-
- - // Display each field for this collection item - @foreach (var col in summaryColumns.Where(c => !Model.IsFieldHiddenForItem(c.Field, item))) - { - var rawValue = DisplayHelpers.InterpolateMessage($"{{{col.Field}}}", expandedItem); - var value = rawValue == $"{{{col.Field}}}" ? string.Empty : rawValue; - -
-
- @col.Label -
-
- @if (!string.IsNullOrEmpty(value)) - { - // Temporarily set the item's field value into FormData and use the normal formatter - var originalValue = Model.FormData.TryGetValue(col.Field, out var orig) ? orig : null; - Model.FormData[col.Field] = value; - var formattedValues = Model.GetFormattedFieldValues(col.Field); - if (originalValue != null) - Model.FormData[col.Field] = originalValue; - else - Model.FormData.Remove(col.Field); +

+ @group.GroupName +

- // Check if this is an upload field by looking at the raw value - var isUploadField = !string.IsNullOrEmpty(value) && value.StartsWith("[") && value.Contains("\"id\""); - - if (formattedValues.Count == 0) - { - Not answered - } - else if (formattedValues.Count == 1) - { - @if (isUploadField) - { - // For upload fields, render as download link - try - { - var uploadFiles = System.Text.Json.JsonSerializer.Deserialize>(value); - if (uploadFiles != null && uploadFiles.Any()) - { - var file = uploadFiles.First(); -
- - - -
- } - else - { - @Html.Raw(formattedValues.First()) - } - } - catch - { - @Html.Raw(formattedValues.First()) - } - } - else - { - @Html.Raw(formattedValues.First()) - } - } - else - { - @if (isUploadField) - { - // For upload fields with multiple files, render each as download link - try - { - var uploadFiles = System.Text.Json.JsonSerializer.Deserialize>(value); - if (uploadFiles != null && uploadFiles.Any()) - { - @foreach (var file in uploadFiles) - { -
-
- - - -
-
- } - } - else - { - foreach (var fv in formattedValues) - { -
@Html.Raw(fv)
- } - } - } - catch - { - foreach (var fv in formattedValues) - { -
@Html.Raw(fv)
- } - } - } - else - { - foreach (var fv in formattedValues) - { -
@Html.Raw(fv)
- } - } - } - } - else - { - Not answered - } -
-
- } - } - } - } -
-
-
- } - else + @foreach (var task in group.Tasks) { - // Regular task: Display page/field data in single summary card - var taskNameHyphenated = task.TaskName.Replace(" ", "-").ToLower(); - -
+

@task.TaskName

- @if (Model.IsApplicationEditable()) + @if (submit?.IsEditable == true) {
- @foreach (var page in (task.Pages ?? new List()).OrderBy(p => p.PageOrder)) + @foreach (var row in task.Rows) { - @foreach (var field in page.Fields.OrderBy(f => f.Order).Where(f => !Model.IsFieldHidden(f.FieldId))) - { - var fieldValue = Model.GetFieldValue(field.FieldId); - var hasValue = Model.HasFieldValue(field.FieldId); - - if ((field.Type == "autocomplete" || field.Type == "complexField" || field.Type == "upload") && hasValue) - { - // Handle multiple autocomplete/complex field values as main header + individual rows - var formattedValues = Model.GetFormattedFieldValues(field.FieldId); - var itemLabel = Model.GetFieldItemLabel(field.FieldId); - var allowMultiple = Model.IsFieldAllowMultiple(field.FieldId); - - // Check if this is an upload field by looking at the raw value - var isUploadField = !string.IsNullOrEmpty(fieldValue) && fieldValue.StartsWith("[") && fieldValue.Contains("\"id\""); - - // Show main field header -
-
- @field.Label.Value -
-
- @if (formattedValues.Count == 0) - { - Not answered - } - else if (!allowMultiple) - { - @if (isUploadField) - { - // For upload fields, render as download link - try - { - var uploadFiles = System.Text.Json.JsonSerializer.Deserialize>(fieldValue); - if (uploadFiles != null && uploadFiles.Any()) - { - var file = uploadFiles.First(); -
- - - -
- } - else - { - @Html.Raw(formattedValues.FirstOrDefault()) - } - } - catch - { - @Html.Raw(formattedValues.FirstOrDefault()) - } - } - else - { - - var html = RenderAutocompleteSummary(DisplayHelpers.UnsanitiseHtmlInput(fieldValue)); - @Html.Raw(html) - } - } -
-
- - // Show individual items if multiple selection is enabled - @if (allowMultiple && formattedValues.Count > 0) - { - @if (isUploadField) - { - // For upload fields with multiple files, render each as download link - try - { - var uploadFiles = System.Text.Json.JsonSerializer.Deserialize>(fieldValue); - if (uploadFiles != null && uploadFiles.Any()) - { - @foreach (var (file, index) in uploadFiles.Select((f, i) => (f, i))) - { -
-
- @itemLabel @(index + 1) -
-
-
- - - -
-
-
- } - } - } - catch - { - // Fallback to original behavior if parsing fails - @foreach (var (value, index) in formattedValues.Select((v, i) => (v, i))) - { -
-
- @itemLabel @(index + 1) -
-
- @Html.Raw(value) -
-
- } - } - } - else - { - @foreach (var (value, index) in formattedValues.Select((v, i) => (v, i))) - { -
-
- @itemLabel @(index + 1) -
-
- @Html.Raw(value) -
-
- } - } - } - } - else - { -
-
- @field.Label.Value -
-
- @if (hasValue) - { - // Check if this is an upload field by looking at the raw value - var isUploadField = !string.IsNullOrEmpty(fieldValue) && fieldValue.StartsWith("[") && fieldValue.Contains("\"id\""); - - @if (isUploadField) - { - // For upload fields, render as download link - try - { - var uploadFiles = System.Text.Json.JsonSerializer.Deserialize>(fieldValue); - if (uploadFiles != null && uploadFiles.Any()) - { - var file = uploadFiles.First(); -
- - - -
- } - else - { - @Html.Raw(fieldValue.Replace("\r\n", "
").Replace("\r", "
").Replace("\n", "
")) - } - } - catch - { - @Html.Raw(fieldValue.Replace("\r\n", "
").Replace("\r", "
").Replace("\n", "
")) - } - } - else if (field.Type == "radios" && field.Options != null) - { - var selectedOption = field.Options.FirstOrDefault(o => o.Value == fieldValue); - @(selectedOption?.Label ?? fieldValue) - } - else if (field.Type == "select" && field.Options != null) - { - var selectedOption = field.Options.FirstOrDefault(o => o.Value == fieldValue); - @(selectedOption?.Label ?? fieldValue) - } - else - { - @Html.Raw(fieldValue.Replace("\r\n", "
").Replace("\r", "
").Replace("\n", "
")) - } - } - else - { - Not answered - } -
-
- } - } + @await Html.PartialAsync("FormEngine/_SummaryRow", row) }
@@ -643,19 +72,18 @@
-@if (Model.IsApplicationEditable() && isLeadApplicant) +@if (submit?.ShowSubmitSection == true) {

Submit your @AppTerminology.Singular

- - @if (submitIsDisabled) + @if (submit.SubmitDisabledByConfig) { -

@AppConfig["Layout:SubmitAppDisabled:HelpText"]

+

@submit.DisabledHelpText

You'll see a Submit button here when they're open.

You can still work on your @AppTerminology.Singular and save it.

} - else if (Model.FileValidationBlocksSubmit) + else if (submit.FileValidationBlocksSubmit) {
@@ -663,7 +91,7 @@

You cannot submit until uploaded files pass validation.

    - @foreach (var file in Model.FileValidationBlockingFiles) + @foreach (var file in submit.BlockingFiles) {
  • @file.OriginalFileName @@ -690,7 +118,7 @@

    By submitting this @AppTerminology.Singular you are confirming that, to the best of your knowledge, the details you are providing are correct.

    - @if (ViewContext.HttpContext.Request.Query.ContainsKey("preview")) + @if (submit.IncludePreviewQuery) { } @@ -699,11 +127,9 @@
    } - -
} -else if (Model.IsApplicationEditable() && !isLeadApplicant) +else if (submit?.ShowLeadApplicantInset == true) {
@@ -711,44 +137,3 @@ else if (Model.IsApplicationEditable() && !isLeadApplicant)
} - -@functions { - // Builds HTML matching confirmation page formatting for a single autocomplete selection - private static string RenderAutocompleteSummary(string rawValue) - { - if (string.IsNullOrWhiteSpace(rawValue)) return string.Empty; - try - { - using var doc = JsonDocument.Parse(rawValue); - if (doc.RootElement.ValueKind != JsonValueKind.Object) - { - return System.Net.WebUtility.HtmlEncode(rawValue); - } - - var root = doc.RootElement; - string name = root.TryGetProperty("name", out var n) && n.ValueKind == JsonValueKind.String ? n.GetString() ?? string.Empty : string.Empty; - string postcode = root.TryGetProperty("postcode", out var pc) && pc.ValueKind == JsonValueKind.String ? pc.GetString() ?? string.Empty : string.Empty; - if (string.IsNullOrWhiteSpace(postcode) && root.TryGetProperty("postCode", out var pc2) && pc2.ValueKind == JsonValueKind.String) postcode = pc2.GetString() ?? string.Empty; - if (string.IsNullOrWhiteSpace(postcode) && root.TryGetProperty("address", out var addr) && addr.ValueKind == JsonValueKind.Object) - { - if (addr.TryGetProperty("postcode", out var apc) && apc.ValueKind == JsonValueKind.String) postcode = apc.GetString() ?? string.Empty; - if (string.IsNullOrWhiteSpace(postcode) && addr.TryGetProperty("postCode", out var apc2) && apc2.ValueKind == JsonValueKind.String) postcode = apc2.GetString() ?? string.Empty; - if (string.IsNullOrWhiteSpace(postcode) && addr.TryGetProperty("postalCode", out var apc3) && apc3.ValueKind == JsonValueKind.String) postcode = apc3.GetString() ?? string.Empty; - } - string ukprn = root.TryGetProperty("ukprn", out var u) ? u.ToString() : string.Empty; - string ch = root.TryGetProperty("companiesHouseNumber", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() ?? string.Empty : string.Empty; - if (string.IsNullOrWhiteSpace(ch) && root.TryGetProperty("companiesHousenumber", out var c2)) ch = c2.ToString(); - - var sb = new System.Text.StringBuilder(); - if (!string.IsNullOrWhiteSpace(name)) sb.Append($"{System.Net.WebUtility.HtmlEncode(name)}"); - if (!string.IsNullOrWhiteSpace(postcode)) sb.Append($"
Postcode: {System.Net.WebUtility.HtmlEncode(postcode)}"); - if (!string.IsNullOrWhiteSpace(ukprn)) sb.Append($"
UKPRN: {System.Net.WebUtility.HtmlEncode(ukprn)}"); - if (!string.IsNullOrWhiteSpace(ch)) sb.Append($"
Companies house number: {System.Net.WebUtility.HtmlEncode(ch)}"); - return sb.ToString(); - } - catch - { - return System.Net.WebUtility.HtmlEncode(rawValue); - } - } -} diff --git a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_CollectionFlowSummary.cshtml b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_CollectionFlowSummary.cshtml index 89bb21e..ce6fcef 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_CollectionFlowSummary.cshtml +++ b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_CollectionFlowSummary.cshtml @@ -1,7 +1,6 @@ @model RenderFormModel @{ var task = Model.CurrentTask; - var summary = task?.Summary; } @if (!string.IsNullOrEmpty(Model.SuccessMessage)) @@ -63,13 +62,9 @@

@task?.TaskName

- @if (summary?.Flows != null) + @foreach (var flow in Model.CollectionFlows) { - // Multi-flow mode: render each flow section - @foreach (var flow in summary.Flows) - { - @await Html.PartialAsync("~/Views/Shared/FormEngine/_SingleCollectionFlow.cshtml", new Tuple(Model, flow)) - } + @await Html.PartialAsync("FormEngine/_SingleCollectionFlow", flow) } @if (Model.IsApplicationEditable()) diff --git a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_CollectionItemRemove.cshtml b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_CollectionItemRemove.cshtml new file mode 100644 index 0000000..f2d17ac --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_CollectionItemRemove.cshtml @@ -0,0 +1,18 @@ +@model GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine.CollectionItemRemoveViewModel +@using GovUK.Dfe.FlexForms.Web.Extensions +
+ + + + + + + + @Html.RenderLinkConfirmationButton( + buttonText: "Remove", + handler: "RemoveCollectionItem", + displayFields: "itemTitle", + title: Model.ConfirmationTitle, + requiredMessage: Model.RequiredMessage, + buttonId: Model.ButtonId) +
diff --git a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_FileDownloadButton.cshtml b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_FileDownloadButton.cshtml new file mode 100644 index 0000000..0ef88d2 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_FileDownloadButton.cshtml @@ -0,0 +1,21 @@ +@model GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine.SummaryFileLinkViewModel +@if (Model.PageId != null) +{ +
+ + + +
+} +else +{ +
+ + + +
+} diff --git a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_FormPage.cshtml b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_FormPage.cshtml index 2ec6e1f..28e48c3 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_FormPage.cshtml +++ b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_FormPage.cshtml @@ -1,6 +1,6 @@ @using System.Text.Json @using GovUK.Dfe.FlexForms.Web.Utilities -@using GovUK.Dfe.FlexForms.Infrastructure.Services +@using GovUK.Dfe.FlexForms.Domain.FormEngine @using GovUk.Frontend.AspNetCore.TagHelpers @using Microsoft.AspNetCore.Mvc.TagHelpers @model GovUK.Dfe.FlexForms.Web.Pages.FormEngine.RenderFormModel diff --git a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SingleCollectionFlow.cshtml b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SingleCollectionFlow.cshtml index befc1e5..45133f7 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SingleCollectionFlow.cshtml +++ b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SingleCollectionFlow.cshtml @@ -1,490 +1,46 @@ -@using GovUK.Dfe.FlexForms.Web.Extensions -@using GovUK.Dfe.FlexForms.Infrastructure.Services -@using GovUk.Frontend.AspNetCore.TagHelpers -@using System.Text.Json -@using GovUK.Dfe.FlexForms.Web.Utilities -@inject GovUK.Dfe.FlexForms.Application.Interfaces.IComplexFieldConfigurationService ComplexFieldConfigurationService -@model Tuple -@{ - var renderModel = Model.Item1; - var flow = Model.Item2; - - // Read collection from accumulated form data. We store as JSON string under fieldId - renderModel.FormData.TryGetValue(flow.FieldId, out var raw); - var json = raw?.ToString() ?? "[]"; - - List> items = new(); - try { items = System.Text.Json.JsonSerializer.Deserialize>>(json) ?? new(); } catch { } - var itemLabel = flow.ItemKind ?? "Item"; - var itemLabelPlural = flow.ItemKindPlural ?? $"{itemLabel}s"; -} +@model GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine.CollectionFlowSectionViewModel
-

@flow.Title

- - @{ - var (hintHtml, hintClass) = MarkdownSafe.RenderHintWithClass(flow.Description); - } +

@Model.Title

- @if (!string.IsNullOrEmpty(flow.Description)) + @if (!string.IsNullOrEmpty(Model.DescriptionHtml)) { -
@Html.Raw(hintHtml)
+
@Html.Raw(Model.DescriptionHtml)
} - @if (items.Any()) + @if (Model.Items.Count > 0) { - @if (flow.TableType?.Equals("list", StringComparison.OrdinalIgnoreCase) == true) + @if (Model.IsListStyle) { - @* List style: Simple govuk-summary-list without cards *@
- @foreach (var (item, index) in items.Select((item, i) => (item, i))) + @foreach (var item in Model.Items) { - var memberNumber = index + 1; - var expandedItem = DisplayHelpers.ExpandEncodedJson(item); - var summaryColumns = flow.SummaryColumns ?? new List(); - renderModel.EnsureItemFieldVisibility(item, summaryColumns.Select(c => c.Field)); - var memberTitle = !string.IsNullOrEmpty(flow.ItemTitleBinding) - ? DisplayHelpers.InterpolateMessage($"{{{flow.ItemTitleBinding}}}", expandedItem) - : $"{itemLabel} {memberNumber}"; - - @* Add separator header for each item (except first) *@ - @if (index > 0) - { -
-
- @memberTitle -
-
- -
-
-
- - - - - - - - @Html.RenderLinkConfirmationButton( - buttonText: "Remove", - handler: "RemoveCollectionItem", - displayFields: "itemTitle", - title: $"Are you sure you want to remove this {itemLabel.ToLower()}?", - requiredMessage: $"Select yes if you are sure you want to remove this {itemLabel.ToLower()}", - buttonId: $"remove-flow-item-@memberNumber") -
-
-
- } - else + @await Html.PartialAsync("FormEngine/_SummaryRow", item.HeaderRow) + @foreach (var row in item.Rows) { - @* First item header without border *@ -
-
- @memberTitle -
-
- -
-
-
- - - - - - - - @Html.RenderLinkConfirmationButton( - buttonText: "Remove", - handler: "RemoveCollectionItem", - displayFields: "itemTitle", - title: $"Are you sure you want to remove this {itemLabel.ToLower()}?", - requiredMessage: $"Select yes if you are sure you want to remove this {itemLabel.ToLower()}", - buttonId: $"remove-flow-item-@memberNumber") -
-
-
- } - - @* Item fields *@ - @foreach (var col in summaryColumns.Where(c => !renderModel.IsFieldHiddenForItem(c.Field, item))) - { - var valueObj = item.TryGetValue(col.Field, out var v) ? v : null; - var value = valueObj is string sv ? sv : - valueObj is System.Text.Json.JsonElement je ? je.ToString() : - (valueObj != null ? System.Text.Json.JsonSerializer.Serialize(valueObj) : string.Empty); - - // Find which page contains this field within this flow - var targetPage = flow.Pages?.FirstOrDefault(p => p.Fields.Any(f => f.FieldId == col.Field)); - var pageId = targetPage?.PageId ?? flow.Pages?.FirstOrDefault()?.PageId ?? ""; - // Detect field type for special rendering (upload vs autocomplete) - var fieldConfig = targetPage?.Fields.FirstOrDefault(f => f.FieldId == col.Field); - var isAutocompleteField = false; - var isUploadFieldByConfig = false; - if (fieldConfig != null && fieldConfig.Type == "complexField" && fieldConfig.ComplexField != null) - { - var cfg = ComplexFieldConfigurationService.GetConfiguration(fieldConfig.ComplexField.Id); - isAutocompleteField = string.Equals(cfg.FieldType, "autocomplete", StringComparison.OrdinalIgnoreCase); - isUploadFieldByConfig = string.Equals(cfg.FieldType, "upload", StringComparison.OrdinalIgnoreCase); - } - var checkboxValues = fieldConfig?.Type == "checkboxes" - ? CheckboxValueNormalizer.Normalize(valueObj ?? value) - : Array.Empty(); - -
-
- @col.Label -
-
- @if (!string.IsNullOrEmpty(value)) - { - if (checkboxValues.Any()) - { -
    - @foreach (var option in checkboxValues) - { -
  • @option
  • - } -
- } - else - { - // Temporarily add this item's field data to FormData for formatting - var originalValue = renderModel.FormData.TryGetValue(col.Field, out var orig) ? orig : null; - renderModel.FormData[col.Field] = value; - var formattedValues = renderModel.GetFormattedFieldValues(col.Field); - // Restore original value - if (originalValue != null) - renderModel.FormData[col.Field] = originalValue; - else - renderModel.FormData.Remove(col.Field); - - // Determine if this is an upload field - var isUploadField = isUploadFieldByConfig || (!string.IsNullOrEmpty(value) && value.StartsWith("[") && value.Contains("\"id\"")); - // If autocomplete field and current value is empty, try to infer from item values - if (isAutocompleteField && string.IsNullOrEmpty(value)) - { - var inferred = TryFindAutocompleteJsonInItem(item); - if (!string.IsNullOrEmpty(inferred)) - { - value = inferred; - } - } - - @if (formattedValues.Count == 0) - { - Not answered - } - else if (formattedValues.Count == 1) - { - @if (isUploadField) - { - // For upload fields, render as download link - try - { - var uploadFiles = System.Text.Json.JsonSerializer.Deserialize>(value); - // CRITICAL: Filter out infected files using Redis blacklist - uploadFiles = renderModel.FilterInfectedFilesFromList(uploadFiles ?? new List()); - if (uploadFiles != null && uploadFiles.Any()) - { - var file = uploadFiles.First(); -
- - - -
- } - else - { - @Html.Raw(formattedValues.First()) - } - } - catch - { - @Html.Raw(formattedValues.First()) - } - } - else if (isAutocompleteField) - { - var html = RenderAutocompleteSummary(value); - @Html.Raw(html) - } - else - { - @Html.Raw(formattedValues.First()) - } - } - else - { - @if (isUploadField) - { - // For upload fields with multiple files, render each as download link - try - { - var uploadFiles = System.Text.Json.JsonSerializer.Deserialize>(value); - // CRITICAL: Filter out infected files using Redis blacklist - uploadFiles = renderModel.FilterInfectedFilesFromList(uploadFiles ?? new List()); - if (uploadFiles != null && uploadFiles.Any()) - { - @foreach (var file in uploadFiles) - { -
-
- - - -
-
- } - } - else - { - - @foreach (var formattedValue in formattedValues) - { -
@Html.Raw(formattedValue)
- } - } - } - catch - { - - @foreach (var formattedValue in formattedValues) - { -
@Html.Raw(formattedValue)
- } - } - } - else - { - - @foreach (var formattedValue in formattedValues) - { -
@Html.Raw(formattedValue)
- } - } - } - } - } - else - { - Not answered - } -
-
- - Change @col.Label for @memberTitle - -
-
+ @await Html.PartialAsync("FormEngine/_SummaryRow", row) } }
} else { - @* Card style: Current govuk-summary-card approach (default) *@ - @foreach (var (item, index) in items.Select((item, i) => (item, i))) + @foreach (var item in Model.Items) { - var memberNumber = index + 1; - var expandedItem = DisplayHelpers.ExpandEncodedJson(item); - var summaryColumns = flow.SummaryColumns ?? new List(); - renderModel.EnsureItemFieldVisibility(item, summaryColumns.Select(c => c.Field)); - var memberTitle = !string.IsNullOrEmpty(flow.ItemTitleBinding) - ? DisplayHelpers.InterpolateMessage($"{{{flow.ItemTitleBinding}}}", expandedItem) - : $"{itemLabel} {memberNumber}"; -
-

@memberTitle

+

@item.Title

  • -
    - - - - - - - - @Html.RenderLinkConfirmationButton( - buttonText: "Remove", - handler: "RemoveCollectionItem", - displayFields: "itemTitle", - title: $"Are you sure you want to remove this {itemLabel.ToLower()}?", - requiredMessage: $"Select yes if you are sure you want to remove this {itemLabel.ToLower()}", - buttonId: $"remove-flow-item-@memberNumber") -
    + @await Html.PartialAsync("FormEngine/_CollectionItemRemove", item.Remove)
-
-
- @foreach (var col in summaryColumns.Where(c => !renderModel.IsFieldHiddenForItem(c.Field, item))) +
+
+ @foreach (var row in item.Rows) { - var valueObj = item.TryGetValue(col.Field, out var v) ? v : null; - var value = valueObj is string sv ? sv : - valueObj is System.Text.Json.JsonElement je ? je.ToString() : - (valueObj != null ? System.Text.Json.JsonSerializer.Serialize(valueObj) : string.Empty); - // Find which page contains this field within this flow - var targetPage = flow.Pages?.FirstOrDefault(p => p.Fields.Any(f => f.FieldId == col.Field)); - var pageId = targetPage?.PageId ?? flow.Pages?.FirstOrDefault()?.PageId ?? ""; - // Detect field type for special rendering (upload vs autocomplete) - var fieldConfig = targetPage?.Fields.FirstOrDefault(f => f.FieldId == col.Field); - var isAutocompleteField = false; - var isUploadFieldByConfig = false; - if (fieldConfig != null && fieldConfig.Type == "complexField" && fieldConfig.ComplexField != null) - { - var cfg = ComplexFieldConfigurationService.GetConfiguration(fieldConfig.ComplexField.Id); - isAutocompleteField = string.Equals(cfg.FieldType, "autocomplete", StringComparison.OrdinalIgnoreCase); - isUploadFieldByConfig = string.Equals(cfg.FieldType, "upload", StringComparison.OrdinalIgnoreCase); - } - // If autocomplete field and current value is empty, try to infer from item values - if (isAutocompleteField && string.IsNullOrEmpty(value)) - { - var inferred = TryFindAutocompleteJsonInItem(item); - if (!string.IsNullOrEmpty(inferred)) - { - value = inferred; - } - } - -
-
- @col.Label -
-
- @if (!string.IsNullOrEmpty(value)) - { - // Temporarily add this item's field data to FormData for formatting - var originalValue = renderModel.FormData.TryGetValue(col.Field, out var orig) ? orig : null; - renderModel.FormData[col.Field] = value; - var formattedValues = renderModel.GetFormattedFieldValues(col.Field); - // Restore original value - if (originalValue != null) - renderModel.FormData[col.Field] = originalValue; - else - renderModel.FormData.Remove(col.Field); - - // Determine if this is an upload field - var isUploadField = isUploadFieldByConfig || (!string.IsNullOrEmpty(value) && value.StartsWith("[") && value.Contains("\"id\"")); - - - @if (formattedValues.Count == 0) - { - Not answered - } - else if (formattedValues.Count == 1) - { - @if (isUploadField) - { - // For upload fields, render as download link - try - { - var uploadFiles = System.Text.Json.JsonSerializer.Deserialize>(value); - // CRITICAL: Filter out infected files using Redis blacklist - uploadFiles = renderModel.FilterInfectedFilesFromList(uploadFiles ?? new List()); - if (uploadFiles != null && uploadFiles.Any()) - { - var file = uploadFiles.First(); -
- - - -
- } - else - { - @Html.Raw(formattedValues.First()) - } - } - catch - { - @Html.Raw(formattedValues.First()) - } - } - else if (isAutocompleteField) - { - var html = RenderAutocompleteSummary(DisplayHelpers.UnsanitiseHtmlInput(value)); - @Html.Raw(html) - } - else - { - @Html.Raw(formattedValues.First()) - } - } - else - { - @if (isUploadField) - { - // For upload fields with multiple files, render each as download link - try - { - var uploadFiles = System.Text.Json.JsonSerializer.Deserialize>(value); - // CRITICAL: Filter out infected files using Redis blacklist - uploadFiles = renderModel.FilterInfectedFilesFromList(uploadFiles ?? new List()); - if (uploadFiles != null && uploadFiles.Any()) - { - @foreach (var file in uploadFiles) - { -
-
- - - -
-
- } - } - else - { - - @foreach (var formattedValue in formattedValues) - { -
@Html.Raw(formattedValue)
- } - } - } - catch - { - - @foreach (var formattedValue in formattedValues) - { -
@Html.Raw(formattedValue)
- } - } - } - else - { - - @foreach (var formattedValue in formattedValues) - { -
@Html.Raw(formattedValue)
- } - } - } - } - else - { - Not answered - } -
-
- - Change @col.Label for @memberTitle - -
-
+ @await Html.PartialAsync("FormEngine/_SummaryRow", row) }
@@ -494,89 +50,15 @@ } else { - var noItemsHintId = flow.FlowId + "-no-items-added-hint"; - -
- No @(itemLabelPlural.ToLowerInvariant()) have been added. +
+ No @(Model.ItemKindPlural.ToLowerInvariant()) have been added.
} - @{ - var addItemButtonId = flow.FlowId + "-add-item"; - } - - @{ - var canAddMore = !flow.MaxItems.HasValue || items.Count < flow.MaxItems.Value; - } - @if (canAddMore) + @if (Model.CanAddMore) { }
-@functions{ - // Builds HTML matching confirmation page formatting for a single autocomplete selection - private static string RenderAutocompleteSummary(string rawValue) - { - if (string.IsNullOrWhiteSpace(rawValue)) return string.Empty; - try - { - using var doc = JsonDocument.Parse(rawValue); - if (doc.RootElement.ValueKind != JsonValueKind.Object) - { - return System.Net.WebUtility.HtmlEncode(rawValue); - } - - var root = doc.RootElement; - string name = root.TryGetProperty("name", out var n) && n.ValueKind == JsonValueKind.String ? n.GetString() ?? string.Empty : string.Empty; - string postcode = root.TryGetProperty("postcode", out var pc) && pc.ValueKind == JsonValueKind.String ? pc.GetString() ?? string.Empty : string.Empty; - if (string.IsNullOrWhiteSpace(postcode) && root.TryGetProperty("postCode", out var pc2) && pc2.ValueKind == JsonValueKind.String) postcode = pc2.GetString() ?? string.Empty; - if (string.IsNullOrWhiteSpace(postcode) && root.TryGetProperty("address", out var addr) && addr.ValueKind == JsonValueKind.Object) - { - if (addr.TryGetProperty("postcode", out var apc) && apc.ValueKind == JsonValueKind.String) postcode = apc.GetString() ?? string.Empty; - if (string.IsNullOrWhiteSpace(postcode) && addr.TryGetProperty("postCode", out var apc2) && apc2.ValueKind == JsonValueKind.String) postcode = apc2.GetString() ?? string.Empty; - if (string.IsNullOrWhiteSpace(postcode) && addr.TryGetProperty("postalCode", out var apc3) && apc3.ValueKind == JsonValueKind.String) postcode = apc3.GetString() ?? string.Empty; - } - string ukprn = root.TryGetProperty("ukprn", out var u) ? u.ToString() : string.Empty; - string ch = root.TryGetProperty("companiesHouseNumber", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() ?? string.Empty : string.Empty; - if (string.IsNullOrWhiteSpace(ch) && root.TryGetProperty("companiesHousenumber", out var c2)) ch = c2.ToString(); - - var sb = new System.Text.StringBuilder(); - if (!string.IsNullOrWhiteSpace(name)) sb.Append($"{System.Net.WebUtility.HtmlEncode(name)}"); - if (!string.IsNullOrWhiteSpace(postcode)) sb.Append($"
Postcode: {System.Net.WebUtility.HtmlEncode(postcode)}"); - if (!string.IsNullOrWhiteSpace(ukprn)) sb.Append($"
UKPRN: {System.Net.WebUtility.HtmlEncode(ukprn)}"); - if (!string.IsNullOrWhiteSpace(ch)) sb.Append($"
Companies house number: {System.Net.WebUtility.HtmlEncode(ch)}"); - return sb.ToString(); - } - catch - { - return System.Net.WebUtility.HtmlEncode(rawValue); - } - } - - // Heuristic: find a likely autocomplete JSON object in the item's values - private static string TryFindAutocompleteJsonInItem(Dictionary item) - { - foreach (var kv in item) - { - var s = kv.Value?.ToString(); - if (string.IsNullOrWhiteSpace(s)) continue; - try - { - using var doc = JsonDocument.Parse(s); - if (doc.RootElement.ValueKind == JsonValueKind.Object) - { - if (doc.RootElement.TryGetProperty("name", out _) || - doc.RootElement.TryGetProperty("ukprn", out _) || - doc.RootElement.TryGetProperty("companiesHouseNumber", out _)) - { - return s; - } - } - } - catch { } - } - return string.Empty; - } -} diff --git a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SummaryRow.cshtml b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SummaryRow.cshtml new file mode 100644 index 0000000..e7b8aa3 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SummaryRow.cshtml @@ -0,0 +1,29 @@ +@model GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine.SummaryRowViewModel +@{ + var rowStyle = Model.ShowSeparator + ? "border-top: 2px solid #d1d1d1; margin-top: 15px; padding-top: 15px;" + : null; + var keyStyle = Model.KeyIsBold ? "font-weight: bold;" : null; +} +
+
+ @Model.Key +
+
+ @await Html.PartialAsync("FormEngine/_SummaryValue", Model.Value) +
+ @if (Model.Remove != null) + { +
+ @await Html.PartialAsync("FormEngine/_CollectionItemRemove", Model.Remove) +
+ } + else if (Model.ChangeUrl != null) + { +
+ + Change @Model.ChangeHiddenText + +
+ } +
diff --git a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SummaryValue.cshtml b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SummaryValue.cshtml new file mode 100644 index 0000000..a2fb7ba --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SummaryValue.cshtml @@ -0,0 +1,54 @@ +@model GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine.SummaryValueViewModel +@using GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine +@switch (Model.Kind) +{ + case SummaryDisplayKind.NotAnswered: + Not answered + break; + case SummaryDisplayKind.Empty: + break; + case SummaryDisplayKind.Html: + @Html.Raw(Model.Html) + break; + case SummaryDisplayKind.Text: + @Model.Text + break; + case SummaryDisplayKind.HtmlList: + @foreach (var item in Model.HtmlItems) + { +
@Html.Raw(item)
+ } + break; + case SummaryDisplayKind.Checkboxes: +
    + @foreach (var option in Model.Checkboxes) + { +
  • @option
  • + } +
+ break; + case SummaryDisplayKind.AutocompleteHtml: + @Html.Raw(Model.Html) + break; + case SummaryDisplayKind.StatusTag: + { + var statusClass = Model.StatusIsSigned ? "govuk-tag govuk-tag--green" : "govuk-tag govuk-tag--blue"; + @Model.StatusText + break; + } + case SummaryDisplayKind.UploadFiles: + @foreach (var file in Model.Files) + { + if (Model.WrapFilesInDivs) + { +
+ @await Html.PartialAsync("FormEngine/_FileDownloadButton", file) +
+ } + else + { + @await Html.PartialAsync("FormEngine/_FileDownloadButton", file) + } + } + break; +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/AdminHomeServiceTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/AdminHomeServiceTests.cs new file mode 100644 index 0000000..547dc00 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/AdminHomeServiceTests.cs @@ -0,0 +1,105 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Task = System.Threading.Tasks.Task; + +namespace GovUK.Dfe.FlexForms.Application.Tests.Admin; + +public class AdminHomeServiceTests +{ + private readonly IFormTemplateProvider _templatesProvider = Substitute.For(); + private readonly ITemplatesClient _templates = Substitute.For(); + private readonly ITenantAdminClient _tenantAdmin = Substitute.For(); + private readonly AdminHomeService _service; + + public AdminHomeServiceTests() + { + _service = new AdminHomeService( + _templatesProvider, + _templates, + _tenantAdmin, + NullLogger.Instance); + } + + [Fact] + public async Task SetTemplateLiveAsync_ShouldRedirect_WhenApiSucceeds() + { + var templateId = Guid.NewGuid(); + + var result = await _service.SetTemplateLiveAsync(templateId, isLive: true); + + Assert.Equal(AdminPageOutcomeKind.RedirectToPage, result.Kind); + Assert.Equal(AdminHomeMessages.TemplateLive, result.SuccessMessage); + await _templates.Received(1).SetTemplateLiveAsync( + templateId, + Arg.Is(r => r.IsLive), + Arg.Any()); + } + + [Fact] + public async Task OpenTemplateAsync_ShouldStay_WhenTemplateIsNotInCatalogue() + { + _templates.GetAccessibleTemplatesAsync(Arg.Any()).Returns([]); + var state = new AdminHomeWorkState(); + + var result = await _service.OpenTemplateAsync(state, Guid.NewGuid()); + + Assert.Equal(AdminPageOutcomeKind.StayOnPage, result.Kind); + Assert.Equal(AdminHomeMessages.TemplateNotInCatalogue, result.ErrorMessage); + Assert.Null(state.TemplateToOpen); + } + + [Fact] + public async Task OpenTemplateAsync_ShouldRedirect_WhenTemplateExists() + { + var templateId = Guid.NewGuid(); + var template = new TemplateDto + { + TemplateId = templateId, + Name = "Transfers", + CreatedOn = DateTime.UtcNow, + IsLive = true + }; + _templates.GetAccessibleTemplatesAsync(Arg.Any()).Returns([template]); + var state = new AdminHomeWorkState(); + + var result = await _service.OpenTemplateAsync(state, templateId); + + Assert.Equal(AdminPageOutcomeKind.RedirectToPage, result.Kind); + Assert.Same(template, state.TemplateToOpen); + } + + [Fact] + public async Task LoadAsync_ShouldLoadConfigurationSummary_WhenRequested() + { + var tenantId = Guid.NewGuid(); + var summary = new TenantEffectiveConfigurationDto( + tenantId, + "Transfers", + "TenantConfig", + DateTimeOffset.UtcNow, + 1, + "Entra", + false, + true, + true, + 1, + ["localhost"], + ["https://localhost"]); + _templates.GetAccessibleTemplatesAsync(Arg.Any()).Returns([]); + _tenantAdmin.GetEffectiveConfigurationAsync(tenantId, Arg.Any()).Returns(summary); + var state = new AdminHomeWorkState + { + TenantId = tenantId, + IncludeTenantConfigurationSummary = true + }; + + await _service.LoadAsync(state); + + Assert.Same(summary, state.TenantConfigurationSummary); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/ContributorManagementAdminServiceTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/ContributorManagementAdminServiceTests.cs new file mode 100644 index 0000000..ca0bfcb --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/ContributorManagementAdminServiceTests.cs @@ -0,0 +1,63 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Task = System.Threading.Tasks.Task; + +namespace GovUK.Dfe.FlexForms.Application.Tests.Admin; + +public class ContributorManagementAdminServiceTests +{ + private readonly IApplicationsClient _client = Substitute.For(); + private readonly ContributorManagementAdminService _service; + + public ContributorManagementAdminServiceTests() + { + _service = new ContributorManagementAdminService( + _client, + NullLogger.Instance); + } + + [Fact] + public async Task LookupAsync_ShouldPopulateContributors_WhenApplicationExists() + { + var applicationId = Guid.NewGuid(); + var state = new ContributorManagementWorkState { ReferenceNumber = "REF-1" }; + _client.GetApplicationByReferenceAsync("REF-1", Arg.Any()) + .Returns(new ApplicationDto + { + ApplicationId = applicationId, + ApplicationReference = "REF-1", + TemplateName = "Transfers" + }); + _client.GetContributorsAsync(applicationId, false, Arg.Any()) + .Returns( + [ + new UserDto { Name = "Zoe", Email = "z@example.test" }, + new UserDto { Name = "Ann", Email = "a@example.test" } + ]); + + await _service.LookupAsync(state); + + Assert.True(state.LookupPerformed); + Assert.Equal(applicationId, state.ApplicationId); + Assert.Equal("Transfers", state.TemplateName); + Assert.Equal(new[] { "Ann", "Zoe" }, state.Contributors.Select(c => c.Name).ToArray()); + } + + [Fact] + public async Task LookupAsync_ShouldSetError_WhenApiFails() + { + var state = new ContributorManagementWorkState { ReferenceNumber = "MISSING" }; + _client.GetApplicationByReferenceAsync("MISSING", Arg.Any()) + .Throws(new ExternalApplicationsException("boom", 404, "err", null!, null!)); + + await _service.LookupAsync(state); + + Assert.True(state.HasError); + Assert.Equal(ContributorManagementMessages.LookupFailed + " (HTTP 404)", state.ErrorMessage); + Assert.Empty(state.Contributors); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/CustomStatusLabelOverridesAdminServiceTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/CustomStatusLabelOverridesAdminServiceTests.cs new file mode 100644 index 0000000..d65e752 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/CustomStatusLabelOverridesAdminServiceTests.cs @@ -0,0 +1,94 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Models; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Task = System.Threading.Tasks.Task; + +namespace GovUK.Dfe.FlexForms.Application.Tests.Admin; + +public class CustomStatusLabelOverridesAdminServiceTests +{ + private readonly IFormTemplateProvider _templatesProvider = Substitute.For(); + private readonly ITemplatesClient _templates = Substitute.For(); + private readonly CustomStatusLabelOverridesAdminService _service; + + public CustomStatusLabelOverridesAdminServiceTests() + { + _service = new CustomStatusLabelOverridesAdminService( + _templatesProvider, + _templates, + NullLogger.Instance); + } + + [Fact] + public async Task LoadAvailableTemplatesAsync_ShouldOrderLiveFirst_WhenTemplatesExist() + { + var live = new TemplateDto + { + TemplateId = Guid.NewGuid(), + Name = "Zed", + CreatedOn = DateTime.UtcNow, + IsLive = true + }; + var draft = new TemplateDto + { + TemplateId = Guid.NewGuid(), + Name = "Alpha", + CreatedOn = DateTime.UtcNow, + IsLive = false + }; + _templates.GetAccessibleTemplatesAsync(Arg.Any()).Returns([draft, live]); + var state = new CustomStatusLabelOverridesWorkState(); + + await _service.LoadAvailableTemplatesAsync(state); + + Assert.Equal(new[] { live.TemplateId, draft.TemplateId }, state.AvailableTemplates.Select(t => t.TemplateId)); + } + + [Fact] + public async Task LoadTemplateDataAsync_ShouldPopulateTemplate_WhenApiSucceeds() + { + var templateId = Guid.NewGuid(); + var formTemplate = new FormTemplate + { + TemplateId = templateId.ToString(), + TemplateName = "Transfers", + Description = "desc", + TaskGroups = [] + }; + _templates.GetLatestTemplateSchemaAsync(templateId, Arg.Any()) + .Returns(new TemplateSchemaDto + { + TemplateId = templateId, + TemplateVersionId = Guid.NewGuid(), + VersionNumber = "1.0.0", + JsonSchema = "{}" + }); + _templatesProvider.GetTemplateAsync(templateId.ToString(), Arg.Any()).Returns(formTemplate); + var state = new CustomStatusLabelOverridesWorkState(); + + await _service.LoadTemplateDataAsync(state, templateId); + + Assert.Equal("1.0.0", state.CurrentVersionNumber); + Assert.Same(formTemplate, state.CurrentTemplate); + } + + [Fact] + public async Task OverrideAsync_ShouldCreateCustomStatus_WhenCalled() + { + var templateId = Guid.NewGuid(); + + await _service.OverrideAsync(templateId, ApplicationStatus.InProgress, "Working"); + + await _templates.Received(1).CreateCustomApplicationStatusAsync( + templateId, + Arg.Is(r => + r.ApplicationStatus == ApplicationStatus.InProgress && r.Label == "Working"), + Arg.Any()); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/DuplicateTenantAdminServiceTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/DuplicateTenantAdminServiceTests.cs new file mode 100644 index 0000000..83800cf --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/DuplicateTenantAdminServiceTests.cs @@ -0,0 +1,145 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Task = System.Threading.Tasks.Task; + +namespace GovUK.Dfe.FlexForms.Application.Tests.Admin; + +public class DuplicateTenantAdminServiceTests +{ + private readonly ITenantAdminClient _client = Substitute.For(); + private readonly DuplicateTenantAdminService _service; + private readonly DuplicateTenantWorkState _state; + + public DuplicateTenantAdminServiceTests() + { + _service = new DuplicateTenantAdminService(_client, NullLogger.Instance); + _state = new DuplicateTenantWorkState + { + SourceTenantId = Guid.NewGuid(), + SourceTenantName = "Transfers", + NewTenantId = Guid.NewGuid(), + NewTenantName = "Transfers copy", + ServiceName = "Transfers", + Hostname = "copy.example.test", + FrontendOrigin = "https://copy.example.test", + AuthorizationApiSecretKey = DuplicateTenantAdminService.GenerateSecretKey(), + InternalServiceAuthSecretKey = DuplicateTenantAdminService.GenerateSecretKey() + }; + } + + [Fact] + public void ValidateInput_ShouldReturnError_WhenNewTenantIdIsEmpty() + { + _state.NewTenantId = Guid.Empty; + + var errors = _service.ValidateInput(_state); + + Assert.Contains(errors, e => e.Message == DuplicateTenantMessages.TenantIdRequired); + } + + [Fact] + public void ValidateInput_ShouldReturnError_WhenNewTenantIdMatchesSource() + { + _state.NewTenantId = _state.SourceTenantId; + + var errors = _service.ValidateInput(_state); + + Assert.Contains(errors, e => e.Message == DuplicateTenantMessages.TenantIdMustDiffer); + } + + [Fact] + public void ValidateInput_ShouldReturnError_WhenServiceApiKeyIsTooShort() + { + _state.InternalServiceAuthServiceApiKeys = + [ + new DuplicateTenantServiceSecret { Email = "svc@example.test", ApiKey = "short" } + ]; + + var errors = _service.ValidateInput(_state); + + Assert.Contains(errors, e => e.Message == DuplicateTenantMessages.ServiceApiKeyRequired); + } + + [Fact] + public async Task LoadInternalServiceAuthServicesAsync_ShouldPopulateApiKeys_WhenInternalServiceAuthExists() + { + var tenantId = _state.SourceTenantId; + _client.GetTenantSettingsAsync(tenantId, Arg.Any()) + .Returns(new GetTenantSettingsResponse( + tenantId, + "Transfers", + [ + new TenantSettingDto( + Guid.NewGuid(), + "InternalServiceAuth", + "Api", + """{"Services":[{"Email":"svc@example.test"}]}""", + false, + DateTime.UtcNow) + ])); + + await _service.LoadInternalServiceAuthServicesAsync(_state); + + var row = Assert.Single(_state.InternalServiceAuthServiceApiKeys); + Assert.Equal("svc@example.test", row.Email); + Assert.True(row.ApiKey.Length >= 32); + } + + [Fact] + public async Task LoadInternalServiceAuthServicesAsync_ShouldReturnEmpty_WhenCategoryIsMissing() + { + _client.GetTenantSettingsAsync(_state.SourceTenantId, Arg.Any()) + .Returns(new GetTenantSettingsResponse(_state.SourceTenantId, "Transfers", [])); + + await _service.LoadInternalServiceAuthServicesAsync(_state); + + Assert.Empty(_state.InternalServiceAuthServiceApiKeys); + } + + [Fact] + public async Task CloneAsync_ShouldSendBase64Payload_WhenInputIsValid() + { + var newId = _state.NewTenantId; + _client.CloneTenantAsync(_state.SourceTenantId, Arg.Any(), Arg.Any()) + .Returns(new DuplicateTenantResponse( + _state.SourceTenantId, + newId, + "Transfers copy", + "copy.example.test", + "https://copy.example.test", + 3, + "ok")); + + var result = await _service.CloneAsync(_state); + + Assert.Equal(AdminPageOutcomeKind.RedirectToPage, result.Kind); + Assert.Equal( + DuplicateTenantMessages.Created("Transfers copy", newId, 3, "copy.example.test"), + result.SuccessMessage); + await _client.Received(1).CloneTenantAsync( + _state.SourceTenantId, + Arg.Is(r => + r.NewTenantId == newId + && r.NewTenantName == "Transfers copy" + && !string.IsNullOrWhiteSpace(r.PayloadJson)), + Arg.Any()); + } + + [Fact] + public async Task CloneAsync_ShouldStay_WhenApiFails() + { + _client.CloneTenantAsync(_state.SourceTenantId, Arg.Any(), Arg.Any()) + .Throws(new ExternalApplicationsException("boom", 500, "err", null!, null!)); + + var result = await _service.CloneAsync(_state); + + Assert.Equal(AdminPageOutcomeKind.StayOnPage, result.Kind); + Assert.Equal(DuplicateTenantMessages.CloneFailedHttp(500), result.ErrorMessage); + Assert.True(_state.HasError); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/EventMappingsAdminServiceTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/EventMappingsAdminServiceTests.cs new file mode 100644 index 0000000..3484673 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/EventMappingsAdminServiceTests.cs @@ -0,0 +1,91 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Application.Models; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Task = System.Threading.Tasks.Task; + +namespace GovUK.Dfe.FlexForms.Application.Tests.Admin; + +public class EventMappingsAdminServiceTests +{ + private readonly ITenantAdminClient _tenantAdmin = Substitute.For(); + private readonly ITemplatesClient _templates = Substitute.For(); + private readonly IEventTypeRegistry _registry = Substitute.For(); + private readonly ISchemaEventDefinitionProvider _schemaEvents = Substitute.For(); + private readonly EventMappingsAdminService _service; + private readonly EventMappingsWorkState _state; + + public EventMappingsAdminServiceTests() + { + _registry.GetCatalogue().Returns(Array.Empty()); + _schemaEvents.GetAll().Returns(new Dictionary()); + _templates.GetAccessibleTemplatesAsync(Arg.Any()).Returns([]); + + _service = new EventMappingsAdminService( + _tenantAdmin, + _templates, + _registry, + _schemaEvents, + NullLogger.Instance); + + _state = new EventMappingsWorkState + { + TenantId = Guid.NewGuid(), + TenantName = "Transfers", + TriggerName = "FileUploaded", + TriggerEventType = "ScanRequestedEvent", + TriggerMappingId = "map-1" + }; + } + + [Fact] + public async Task SaveTriggerAsync_ShouldStay_WhenSystemOnlyEventTypeIsSelected() + { + var result = await _service.SaveTriggerAsync(_state); + + Assert.Equal(AdminPageOutcomeKind.StayOnPage, result.Kind); + Assert.Contains(result.Errors, e => e.Message.Contains("ScanRequestedEvent")); + await _tenantAdmin.DidNotReceive().UpsertSafeTenantSettingAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task SaveTriggerAsync_ShouldStay_WhenTriggerIsMissing() + { + _state.TriggerName = " "; + _state.TriggerEventType = "CustomEvent"; + + var result = await _service.SaveTriggerAsync(_state); + + Assert.Contains(result.Errors, e => e.Message == EventMappingsMessages.SelectTrigger); + } + + [Fact] + public async Task SaveMappingAsync_ShouldStay_WhenTemplateIsMissing() + { + _state.SelectedTemplateId = null; + _state.SelectedEventType = "CustomEvent"; + _state.MappingJson = "{}"; + + var result = await _service.SaveMappingAsync(_state); + + Assert.Contains(result.Errors, e => e.Message == EventMappingsMessages.SelectTemplate); + } + + [Fact] + public async Task DeleteTriggerAsync_ShouldRedirect_WhenBindingCannotBeIdentified() + { + _state.TriggerName = ""; + _state.TriggerEventType = ""; + + var result = await _service.DeleteTriggerAsync(_state); + + Assert.Equal(AdminPageOutcomeKind.RedirectToPage, result.Kind); + Assert.Equal(EventMappingsMessages.DeleteTriggerUnidentified, result.ErrorMessage); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/OrganisationSettingsAdminServiceTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/OrganisationSettingsAdminServiceTests.cs new file mode 100644 index 0000000..b358bf9 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/OrganisationSettingsAdminServiceTests.cs @@ -0,0 +1,101 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Task = System.Threading.Tasks.Task; + +namespace GovUK.Dfe.FlexForms.Application.Tests.Admin; + +public class OrganisationSettingsAdminServiceTests +{ + private readonly ITenantAdminClient _client = Substitute.For(); + private readonly OrganisationSettingsAdminService _service; + private readonly OrganisationSettingsWorkState _state = new() + { + TenantId = Guid.NewGuid(), + TenantName = "Transfers", + TerminologySingular = "plan", + TerminologyPlural = "plans", + BannerEnabled = true, + BannerHeading = "Notice", + BannerMessage = "Hello", + DashboardPageSize = 25, + DashboardEnableFilters = true + }; + + public OrganisationSettingsAdminServiceTests() + { + _service = new OrganisationSettingsAdminService(_client, NullLogger.Instance); + } + + [Fact] + public async Task LoadAsync_ShouldApplySettings_WhenApiReturnsJson() + { + _client.GetSafeTenantSettingsAsync(_state.TenantId, Arg.Any()) + .Returns(new GetTenantSettingsResponse( + _state.TenantId, + "Loaded tenant", + [ + new TenantSettingDto( + Guid.NewGuid(), + "ApplicationTerminology", + "Web", + """{"Singular":"reform","Plural":"reforms"}""", + false, + DateTime.UtcNow) + ])); + + await _service.LoadAsync(_state); + + Assert.Equal("Loaded tenant", _state.TenantName); + Assert.Equal("reform", _state.TerminologySingular); + Assert.Equal("reforms", _state.TerminologyPlural); + Assert.False(_state.HasError); + } + + [Fact] + public async Task LoadAsync_ShouldSetError_WhenApiFails() + { + _client.GetSafeTenantSettingsAsync(_state.TenantId, Arg.Any()) + .Throws(new ExternalApplicationsException("boom", 500, "err", null!, null!)); + + await _service.LoadAsync(_state); + + Assert.True(_state.HasError); + Assert.Equal(OrganisationSettingsMessages.LoadFailed + " (HTTP 500)", _state.ErrorMessage); + } + + [Fact] + public async Task SaveAsync_ShouldUpsertAndRefresh_WhenInputIsValid() + { + var result = await _service.SaveAsync(_state); + + Assert.Equal(AdminPageOutcomeKind.RedirectToPage, result.Kind); + Assert.Equal(OrganisationSettingsMessages.Saved, result.SuccessMessage); + Assert.True(result.RefreshLocalCaches); + await _client.Received(3).UpsertSafeTenantSettingAsync( + _state.TenantId, + Arg.Any(), + Arg.Any()); + await _client.Received(1).RefreshTenantConfigurationAsync(Arg.Any()); + } + + [Fact] + public async Task SaveAsync_ShouldStay_WhenApiFails() + { + _client.UpsertSafeTenantSettingAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Throws(new ExternalApplicationsException("boom", 403, "err", null!, null!)); + + var result = await _service.SaveAsync(_state); + + Assert.Equal(AdminPageOutcomeKind.StayOnPage, result.Kind); + Assert.Equal(OrganisationSettingsMessages.SaveFailed + " (HTTP 403)", result.ErrorMessage); + Assert.DoesNotContain("WAF", result.ErrorMessage); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/RoleManagerAdminServiceTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/RoleManagerAdminServiceTests.cs new file mode 100644 index 0000000..5035bfc --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/RoleManagerAdminServiceTests.cs @@ -0,0 +1,60 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Task = System.Threading.Tasks.Task; + +namespace GovUK.Dfe.FlexForms.Application.Tests.Admin; + +public class RoleManagerAdminServiceTests +{ + private readonly IRolesClient _roles = Substitute.For(); + private readonly RoleManagerAdminService _service; + private readonly RoleManagerWorkState _state = new() { NewRoleName = "Caseworker" }; + + public RoleManagerAdminServiceTests() + { + _service = new RoleManagerAdminService(_roles, NullLogger.Instance); + } + + [Fact] + public async Task CreateFromTemplateAsync_ShouldRedirect_WhenTemplateKeyIsMissing() + { + var result = await _service.CreateFromTemplateAsync(_state, " "); + + Assert.Equal(AdminPageOutcomeKind.RedirectToPage, result.Kind); + Assert.Equal(RoleManagerMessages.TemplateRequired, result.ErrorMessage); + await _roles.DidNotReceive().CreateFromTemplateAsync( + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task RenameAsync_ShouldRedirect_WhenNameIsMissing() + { + var result = await _service.RenameAsync(_state, Guid.NewGuid(), " "); + + Assert.Equal(RoleManagerMessages.NameRequired, result.ErrorMessage); + await _roles.DidNotReceive().RenameAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task CreateAsync_ShouldRedirect_WhenApiSucceeds() + { + _roles.CreateAsync(Arg.Any(), Arg.Any()) + .Returns(new TenantRoleDto { RoleId = Guid.NewGuid(), Name = "Caseworker" }); + + var result = await _service.CreateAsync(_state); + + Assert.Equal(AdminPageOutcomeKind.RedirectToPage, result.Kind); + Assert.Equal(RoleManagerMessages.Created("Caseworker"), result.SuccessMessage); + await _roles.Received(1).CreateAsync( + Arg.Is(r => r.Name == "Caseworker"), + Arg.Any()); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/RoleManagerPermissionsAdminServiceTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/RoleManagerPermissionsAdminServiceTests.cs new file mode 100644 index 0000000..8b9623d --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/RoleManagerPermissionsAdminServiceTests.cs @@ -0,0 +1,85 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Task = System.Threading.Tasks.Task; + +namespace GovUK.Dfe.FlexForms.Application.Tests.Admin; + +public class RoleManagerPermissionsAdminServiceTests +{ + private readonly IRolesClient _roles = Substitute.For(); + private readonly RoleManagerPermissionsAdminService _service; + private readonly Guid _roleId = Guid.NewGuid(); + private readonly RoleManagerPermissionsWorkState _state; + + public RoleManagerPermissionsAdminServiceTests() + { + _roles.ListAsync(Arg.Any()).Returns( + [ + new TenantRoleDto { RoleId = _roleId, Name = "Caseworker", IsSystem = false } + ]); + _roles.GetPermissionsAsync(_roleId, Arg.Any()).Returns([]); + + _service = new RoleManagerPermissionsAdminService( + _roles, + NullLogger.Instance); + + _state = new RoleManagerPermissionsWorkState + { + RoleId = _roleId, + NewResourceType = ResourceType.Application, + NewAccessType = AccessType.Read + }; + } + + [Fact] + public async Task AddGrantAsync_ShouldStay_WhenResourceKeyIsMissing() + { + _state.NewResourceKey = ""; + + var result = await _service.AddGrantAsync(_state); + + Assert.Equal(AdminPageOutcomeKind.StayOnPage, result.Kind); + Assert.Contains(result.Errors, e => e.Message == RoleManagerPermissionsMessages.ResourceKeyRequired); + await _roles.DidNotReceive().SetPermissionsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task LoadAsync_ShouldRedirect_WhenRoleIsSystem() + { + _roles.ListAsync(Arg.Any()).Returns( + [ + new TenantRoleDto { RoleId = _roleId, Name = "Admin", IsSystem = true } + ]); + + var result = await _service.LoadAsync(_state); + + Assert.Equal(AdminPageOutcomeKind.RedirectToPage, result.Kind); + Assert.Equal(RoleManagerPermissionsMessages.SystemRoleCannotChangeCreateCustom, result.ErrorMessage); + } + + [Fact] + public async Task AddGrantAsync_ShouldSave_WhenGrantIsValid() + { + var applicationId = Guid.NewGuid(); + _state.NewResourceKey = applicationId.ToString(); + + var result = await _service.AddGrantAsync(_state); + + Assert.Equal(AdminPageOutcomeKind.StayOnPage, result.Kind); + Assert.Empty(result.Errors); + await _roles.Received(1).SetPermissionsAsync( + _roleId, + Arg.Is(r => + r.Permissions.Count == 1 + && r.Permissions.First().ResourceKey == applicationId.ToString()), + Arg.Any()); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/TemplateManagerAdminServiceTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/TemplateManagerAdminServiceTests.cs new file mode 100644 index 0000000..62c1d4e --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/TemplateManagerAdminServiceTests.cs @@ -0,0 +1,59 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; + +namespace GovUK.Dfe.FlexForms.Application.Tests.Admin; + +public class TemplateManagerAdminServiceTests +{ + private readonly ITemplatesClient _templates = Substitute.For(); + private readonly ITemplateValidationService _validation = Substitute.For(); + private readonly TemplateManagerAdminService _service; + + public TemplateManagerAdminServiceTests() + { + _validation.ValidateTemplateJson(Arg.Any()).Returns((true, new List())); + _service = new TemplateManagerAdminService( + _templates, + _validation, + NullLogger.Instance); + } + + [Fact] + public void ValidateNewVersion_ShouldStayWithErrors_WhenRequiredFieldsAreMissing() + { + var state = new TemplateManagerWorkState(); + + var result = _service.ValidateNewVersion(state); + + Assert.Equal(AdminPageOutcomeKind.StayOnPage, result.Kind); + Assert.Contains(result.Errors, e => e.Message == TemplateManagerMessages.VersionRequired); + Assert.Contains(result.Errors, e => e.Message == TemplateManagerMessages.SchemaRequired); + Assert.Contains(result.Errors, e => e.Message == TemplateManagerMessages.AcknowledgeReportingImpact); + } + + [Fact] + public void ValidateNewVersion_ShouldStay_WhenSchemaValidationFails() + { + _validation.ValidateTemplateJson(Arg.Any()).Returns((false, ["schema broken"])); + var state = new TemplateManagerWorkState + { + NewVersion = "1.0.2", + NewSchema = "{}", + AcknowledgeReportingImpact = true + }; + + var result = _service.ValidateNewVersion(state); + + Assert.Contains(result.Errors, e => e.Message == "schema broken"); + } + + [Fact] + public void SuggestNextVersion_ShouldPreferLatestVersion() + { + Assert.Equal("1.0.3", _service.SuggestNextVersion("1.0.2", "1.0.0")); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/TenantSettingsAdminServiceTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/TenantSettingsAdminServiceTests.cs new file mode 100644 index 0000000..725401c --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/TenantSettingsAdminServiceTests.cs @@ -0,0 +1,121 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Task = System.Threading.Tasks.Task; + +namespace GovUK.Dfe.FlexForms.Application.Tests.Admin; + +public class TenantSettingsAdminServiceTests +{ + private readonly ITenantAdminClient _client = Substitute.For(); + private readonly TenantSettingsAdminService _service; + private readonly TenantSettingsWorkState _state = new() + { + TenantId = Guid.NewGuid(), + TenantName = "Transfers" + }; + + public TenantSettingsAdminServiceTests() + { + _service = new TenantSettingsAdminService(_client, NullLogger.Instance); + } + + [Fact] + public async Task AddAsync_ShouldRedirect_WhenCategoryIsMissing() + { + var result = await _service.AddAsync(_state, " ", "Shared", "{}", false); + + Assert.Equal(AdminPageOutcomeKind.RedirectToPage, result.Kind); + Assert.Equal(TenantSettingsMessages.CategoryRequired, result.ErrorMessage); + await _client.DidNotReceive().UpsertTenantSettingAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task AddAsync_ShouldRedirect_WhenCategoryExceedsFiftyCharacters() + { + var result = await _service.AddAsync(_state, new string('a', 51), "Shared", "{}", false); + + Assert.Equal(TenantSettingsMessages.CategoryTooLong, result.ErrorMessage); + } + + [Fact] + public async Task AddAsync_ShouldRedirect_WhenTargetIsInvalid() + { + var result = await _service.AddAsync(_state, "Layout", "Desktop", "{}", false); + + Assert.Equal(TenantSettingsMessages.InvalidTarget, result.ErrorMessage); + } + + [Fact] + public async Task AddAsync_ShouldUpsertAndRefresh_WhenInputIsValid() + { + var result = await _service.AddAsync(_state, "Layout", "Web", """{"x":1}""", true); + + Assert.Equal(AdminPageOutcomeKind.RedirectToPage, result.Kind); + Assert.Equal(TenantSettingsMessages.Added("Layout", "Web"), result.SuccessMessage); + Assert.True(result.RefreshLocalCaches); + await _client.Received(1).UpsertTenantSettingAsync( + _state.TenantId, + Arg.Is(r => + r.Category == "Layout" + && r.Target == "Web" + && r.IsSecret + && r.SettingsJson == AdminSettingsEncoding.ToBase64("""{"x":1}""")), + Arg.Any()); + await _client.Received(1).RefreshTenantConfigurationAsync(Arg.Any()); + } + + [Fact] + public async Task UpdateAsync_ShouldRedirect_WhenJsonIsMissing() + { + var result = await _service.UpdateAsync(_state, "Layout", "Web", " ", false); + + Assert.Equal(TenantSettingsMessages.CategoryAndJsonRequired, result.ErrorMessage); + } + + [Fact] + public async Task ValidateAsync_ShouldStay_WhenCategoryOrJsonMissing() + { + var result = await _service.ValidateAsync(_state, "", "Web", "", false); + + Assert.Equal(AdminPageOutcomeKind.StayOnPage, result.Kind); + Assert.Equal(TenantSettingsMessages.ValidateRequired, result.ErrorMessage); + Assert.True(_state.HasError); + await _client.DidNotReceive().ValidateTenantSettingAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ImportAsync_ShouldRedirect_WhenJsonIsInvalid() + { + var result = await _service.ImportAsync(_state, "not-json"); + + Assert.Equal(TenantSettingsMessages.ImportInvalidJson, result.ErrorMessage); + } + + [Fact] + public async Task ImportAsync_ShouldRedirect_WhenSettingsAreEmpty() + { + var result = await _service.ImportAsync(_state, """{"settings":[]}"""); + + Assert.Equal(TenantSettingsMessages.ImportEmpty, result.ErrorMessage); + } + + [Fact] + public async Task DeleteAsync_ShouldRefreshCaches_WhenApiSucceeds() + { + var result = await _service.DeleteAsync(_state, "Layout", "Web"); + + Assert.Equal(TenantSettingsMessages.Deleted("Layout", "Web"), result.SuccessMessage); + Assert.True(result.RefreshLocalCaches); + await _client.Received(1).DeleteTenantSettingAsync(_state.TenantId, "Layout", "Web", Arg.Any()); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/UserManagerAddAdminServiceTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/UserManagerAddAdminServiceTests.cs new file mode 100644 index 0000000..a3b77df --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/UserManagerAddAdminServiceTests.cs @@ -0,0 +1,113 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Task = System.Threading.Tasks.Task; + +namespace GovUK.Dfe.FlexForms.Application.Tests.Admin; + +public class UserManagerAddAdminServiceTests +{ + private readonly IUsersClient _users = Substitute.For(); + private readonly ITemplatesClient _templates = Substitute.For(); + private readonly IRolesClient _roles = Substitute.For(); + private readonly UserManagerAddAdminService _service; + private readonly UserManagerAddWorkState _state; + + public UserManagerAddAdminServiceTests() + { + _templates.GetAccessibleTemplatesAsync(Arg.Any()).Returns([]); + _roles.ListAsync(Arg.Any()).Returns([]); + _users.GetTenantUsersAsync(Arg.Any()).Returns([]); + + _service = new UserManagerAddAdminService( + _users, + _templates, + _roles, + NullLogger.Instance); + + _state = new UserManagerAddWorkState + { + Name = "Ada Lovelace", + Email = "ada@example.com", + Role = "User", + SelectedTemplateIds = [Guid.NewGuid()], + AssignableRoles = ["User", "Caseworker"] + }; + } + + [Fact] + public async Task AddAsync_ShouldStay_WhenRoleIsNotAssignable() + { + _state.Role = "Admin"; + + var result = await _service.AddAsync(_state); + + Assert.Equal(AdminPageOutcomeKind.StayOnPage, result.Kind); + Assert.Contains(result.Errors, e => e.Message == UserManagerAddMessages.InvalidRole); + await _users.DidNotReceive().AssignUserRoleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task AddAsync_ShouldStay_WhenUserRoleHasNoTemplates() + { + _state.SelectedTemplateIds = []; + + var result = await _service.AddAsync(_state); + + Assert.Contains(result.Errors, e => e.Message == UserManagerAddMessages.UserRoleRequiresTemplate); + await _users.DidNotReceive().AssignUserRoleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task AddAsync_ShouldStay_WhenEmailAlreadyExists() + { + _users.GetTenantUsersAsync(Arg.Any()).Returns( + [ + new TenantUserDto { Email = "ADA@example.com", Name = "Existing" } + ]); + + var result = await _service.AddAsync(_state); + + Assert.Contains(result.Errors, e => e.Message == UserManagerAddMessages.DuplicateEmail); + await _users.DidNotReceive().AssignUserRoleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task AddAsync_ShouldAssignRoleAndTemplates_WhenInputIsValid() + { + var userId = Guid.NewGuid(); + _users.AssignUserRoleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new UserDto { UserId = userId }); + + var result = await _service.AddAsync(_state); + + Assert.Equal(AdminPageOutcomeKind.RedirectToPage, result.Kind); + Assert.Equal(UserManagerAddMessages.Added("ada@example.com", "User"), result.SuccessMessage); + await _users.Received(1).AssignUserRoleAsync( + Arg.Is(r => + r.Name == "Ada Lovelace" + && r.Email == "ada@example.com" + && r.Role == "User"), + true, + Arg.Any()); + await _users.Received(1).UpdateUserTemplateAccessAsync( + userId, + Arg.Any(), + Arg.Any()); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/UserManagerAdminServiceTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/UserManagerAdminServiceTests.cs new file mode 100644 index 0000000..ed9e00a --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/UserManagerAdminServiceTests.cs @@ -0,0 +1,48 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Task = System.Threading.Tasks.Task; + +namespace GovUK.Dfe.FlexForms.Application.Tests.Admin; + +public class UserManagerAdminServiceTests +{ + private readonly IUsersClient _users = Substitute.For(); + private readonly UserManagerAdminService _service; + private readonly UserManagerWorkState _state = new(); + + public UserManagerAdminServiceTests() + { + _service = new UserManagerAdminService(_users, NullLogger.Instance); + } + + [Fact] + public async Task LoadAsync_ShouldSetError_WhenUsersFailToLoad() + { + _users.GetTenantUsersAsync(Arg.Any()) + .ThrowsAsync(new InvalidOperationException("down")); + _users.GetAccessAuditLogAsync(Arg.Any(), Arg.Any()) + .Returns(new GetTenantAccessAuditLogDto(Guid.NewGuid(), [])); + + await _service.LoadAsync(_state); + + Assert.True(_state.HasError); + Assert.Equal(UserManagerMessages.LoadFailed, _state.ErrorMessage); + Assert.Empty(_state.Users); + } + + [Fact] + public async Task RemoveAsync_ShouldRedirect_WhenApiSucceeds() + { + var userId = Guid.NewGuid(); + + var result = await _service.RemoveAsync(_state, userId); + + Assert.Equal(AdminPageOutcomeKind.RedirectToPage, result.Kind); + Assert.Equal(UserManagerMessages.Removed, result.SuccessMessage); + await _users.Received(1).RemoveUserFromTenantAsync(userId, Arg.Any()); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/UserManagerEditAdminServiceTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/UserManagerEditAdminServiceTests.cs new file mode 100644 index 0000000..223cc54 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/UserManagerEditAdminServiceTests.cs @@ -0,0 +1,91 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Task = System.Threading.Tasks.Task; + +namespace GovUK.Dfe.FlexForms.Application.Tests.Admin; + +public class UserManagerEditAdminServiceTests +{ + private readonly IUsersClient _users = Substitute.For(); + private readonly ITemplatesClient _templates = Substitute.For(); + private readonly IRolesClient _roles = Substitute.For(); + private readonly UserManagerEditAdminService _service; + private readonly Guid _userId = Guid.NewGuid(); + private readonly UserManagerEditWorkState _state; + + public UserManagerEditAdminServiceTests() + { + _templates.GetAccessibleTemplatesAsync(Arg.Any()).Returns([]); + _roles.ListAsync(Arg.Any()).Returns( + [ + new TenantRoleDto { RoleId = Guid.NewGuid(), Name = "User", IsSystem = true }, + new TenantRoleDto { RoleId = Guid.NewGuid(), Name = "Caseworker", IsSystem = false } + ]); + + _service = new UserManagerEditAdminService( + _users, + _templates, + _roles, + NullLogger.Instance); + + _state = new UserManagerEditWorkState + { + UserId = _userId, + UserName = "Ada Lovelace", + UserEmail = "ada@example.com", + Role = "Caseworker", + AssignableRoles = ["User", "Caseworker"], + SelectedTemplateIds = [Guid.NewGuid()] + }; + } + + [Fact] + public async Task LoadAsync_ShouldRedirect_WhenUserIsMissing() + { + _users.GetTenantUsersAsync(Arg.Any()).Returns([]); + + var result = await _service.LoadAsync(_state); + + Assert.Equal(AdminPageOutcomeKind.RedirectToPage, result.Kind); + Assert.Equal(UserManagerEditMessages.UserNotFound, result.ErrorMessage); + } + + [Fact] + public async Task UpdateAsync_ShouldStay_WhenRoleIsNotAssignable() + { + _state.Role = "Admin"; + + var result = await _service.UpdateAsync(_state); + + Assert.Equal(AdminPageOutcomeKind.StayOnPage, result.Kind); + Assert.Contains(result.Errors, e => e.Message == UserManagerEditMessages.InvalidRole); + await _users.DidNotReceive().AssignUserRoleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task UpdateAsync_ShouldAssignRoleAndTemplates_WhenInputIsValid() + { + var result = await _service.UpdateAsync(_state); + + Assert.Equal(AdminPageOutcomeKind.RedirectToPage, result.Kind); + Assert.Equal(UserManagerEditMessages.Updated, result.SuccessMessage); + await _users.Received(1).AssignUserRoleAsync( + Arg.Is(r => + r.Name == "Ada Lovelace" + && r.Email == "ada@example.com" + && r.Role == "Caseworker"), + false, + Arg.Any()); + await _users.Received(1).UpdateUserTemplateAccessAsync( + _userId, + Arg.Any(), + Arg.Any()); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/UserManagerPermissionsAdminServiceTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/UserManagerPermissionsAdminServiceTests.cs new file mode 100644 index 0000000..2fb9db4 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Admin/UserManagerPermissionsAdminServiceTests.cs @@ -0,0 +1,90 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Admin; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Task = System.Threading.Tasks.Task; + +namespace GovUK.Dfe.FlexForms.Application.Tests.Admin; + +public class UserManagerPermissionsAdminServiceTests +{ + private readonly IUsersClient _users = Substitute.For(); + private readonly UserManagerPermissionsAdminService _service; + private readonly Guid _userId = Guid.NewGuid(); + private readonly UserManagerPermissionsWorkState _state; + + public UserManagerPermissionsAdminServiceTests() + { + _users.GetTenantUsersAsync(Arg.Any()).Returns( + [ + new TenantUserDto { UserId = _userId, Name = "Ada", Email = "ada@example.com" } + ]); + _users.GetUserPermissionsAsync(_userId, Arg.Any()).Returns([]); + + _service = new UserManagerPermissionsAdminService( + _users, + NullLogger.Instance); + + _state = new UserManagerPermissionsWorkState + { + UserId = _userId, + NewResourceType = ResourceType.Application, + NewAccessType = AccessType.Read + }; + } + + [Fact] + public async Task AddGrantAsync_ShouldStay_WhenResourceKeyIsMissing() + { + _state.NewResourceKey = " "; + + var result = await _service.AddGrantAsync(_state); + + Assert.Equal(AdminPageOutcomeKind.StayOnPage, result.Kind); + Assert.Contains(result.Errors, e => e.Message == UserManagerPermissionsMessages.ResourceKeyRequired); + await _users.DidNotReceive().SetUserPermissionsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task AddGrantAsync_ShouldStay_WhenManageIsGrantedToUser() + { + _state.NewResourceType = ResourceType.Template; + _state.NewResourceKey = AdminPermissionGrants.AnyResourceKey; + _state.NewAccessType = AccessType.Manage; + + var result = await _service.AddGrantAsync(_state); + + Assert.Contains(result.Errors, e => e.Message.Contains("cannot be granted to an individual user")); + await _users.DidNotReceive().SetUserPermissionsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task AddGrantAsync_ShouldSave_WhenGrantIsValid() + { + var applicationId = Guid.NewGuid(); + _state.NewResourceKey = applicationId.ToString(); + + var result = await _service.AddGrantAsync(_state); + + Assert.Equal(AdminPageOutcomeKind.StayOnPage, result.Kind); + Assert.Empty(result.Errors); + Assert.Equal(string.Empty, _state.NewResourceKey); + await _users.Received(1).SetUserPermissionsAsync( + _userId, + Arg.Is(r => + r.Permissions.Count == 1 + && r.Permissions.First().ResourceType == ResourceType.Application + && r.Permissions.First().ResourceKey == applicationId.ToString() + && r.Permissions.First().AccessType == AccessType.Read), + Arg.Any()); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Dashboard/DashboardApplicationsServiceTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Dashboard/DashboardApplicationsServiceTests.cs new file mode 100644 index 0000000..0fe6c9e --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Dashboard/DashboardApplicationsServiceTests.cs @@ -0,0 +1,178 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.Dashboard; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Models; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Task = System.Threading.Tasks.Task; + +namespace GovUK.Dfe.FlexForms.Application.Tests.Dashboard; + +public class DashboardApplicationsServiceTests +{ + private readonly IApplicationsClient _applications = Substitute.For(); + private readonly IFormTemplateProvider _templates = Substitute.For(); + private readonly IContributorPatternService _contributors = Substitute.For(); + private readonly DashboardApplicationsService _service; + + public DashboardApplicationsServiceTests() + { + _service = new DashboardApplicationsService( + _applications, + _templates, + _contributors, + NullLogger.Instance); + } + + [Fact] + public async Task ResolveColumnsAsync_ShouldReturnDefaults_WhenTemplateIdIsMissing() + { + var columns = await _service.ResolveColumnsAsync(null); + + Assert.Equal(DashboardColumnResolver.DefaultColumns, columns); + } + + [Fact] + public async Task ResolveColumnsAsync_ShouldResolveFromTemplate_WhenTemplateLoads() + { + var templateId = Guid.NewGuid(); + _templates.GetTemplateAsync(templateId.ToString(), Arg.Any()) + .Returns(new FormTemplate + { + TemplateId = templateId.ToString(), + TemplateName = "Transfers", + Description = "desc", + TaskGroups = [] + }); + + var columns = await _service.ResolveColumnsAsync(templateId); + + Assert.Equal(DashboardColumnResolver.DefaultColumns.Count, columns.Count); + } + + [Fact] + public async Task ListAsync_ShouldQueryMyApplications_WhenScopeIsMine() + { + var templateId = Guid.NewGuid(); + var app = new ApplicationDto + { + ApplicationId = Guid.NewGuid(), + ApplicationReference = "REF-1", + DateCreated = DateTime.UtcNow + }; + _applications.GetMyApplicationsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new PagedResultOfApplicationDto + { + Items = [app], + TotalPages = 2, + PageNumber = 1, + PageSize = 50, + TotalCount = 2 + }); + + var result = await _service.ListAsync(new DashboardApplicationListQuery + { + TemplateId = templateId, + CurrentPage = 9, + PageSize = 50, + Scope = DashboardApplicationListScope.Mine + }); + + Assert.Equal(2, result.TotalPages); + Assert.Equal(2, result.CurrentPage); + Assert.Equal("REF-1", Assert.Single(result.Applications).ApplicationReference); + await _applications.DidNotReceive().GetApplicationsByTemplateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ListAsync_ShouldQueryByTemplate_WhenScopeIsAllForTemplate() + { + var templateId = Guid.NewGuid(); + _applications.GetApplicationsByTemplateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new PagedResultOfApplicationDto + { + Items = [], + TotalPages = 1 + }); + + var result = await _service.ListAsync(new DashboardApplicationListQuery + { + TemplateId = templateId, + Scope = DashboardApplicationListScope.AllForTemplate + }); + + Assert.Empty(result.Applications); + await _applications.Received(1).GetApplicationsByTemplateAsync( + templateId, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task CreateAsync_ShouldReturnContributorsFlag_WhenPatternIsEnabled() + { + var templateId = Guid.NewGuid(); + var created = new ApplicationDto + { + ApplicationId = Guid.NewGuid(), + ApplicationReference = "REF-9", + Status = ApplicationStatus.InProgress + }; + _applications.CreateApplicationAsync(Arg.Any(), Arg.Any()) + .Returns(created); + _contributors.IsEnabledAsync(templateId.ToString(), Arg.Any(), Arg.Any()) + .Returns(true); + + var result = await _service.CreateAsync(templateId); + + Assert.Same(created, result.Application); + Assert.True(result.ContributorsEnabled); + await _applications.Received(1).CreateApplicationAsync( + Arg.Is(r => r.TemplateId == templateId && r.InitialResponseBody == "{}"), + Arg.Any()); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/CollectionFlowProgressStoreTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/CollectionFlowProgressStoreTests.cs new file mode 100644 index 0000000..ff0ab4b --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/CollectionFlowProgressStoreTests.cs @@ -0,0 +1,55 @@ +using GovUK.Dfe.FlexForms.Application.FormEngine; +using GovUK.Dfe.FlexForms.Domain.Caching; + +namespace GovUK.Dfe.FlexForms.Application.Tests.FormEngine; + +public class CollectionFlowProgressStoreTests +{ + private readonly InMemoryFormSessionStore _session = new(); + private readonly CollectionFlowProgressStore _store; + + public CollectionFlowProgressStoreTests() + { + _store = new CollectionFlowProgressStore(_session); + } + + [Fact] + public void Load_returns_empty_when_session_has_no_progress() + { + var result = _store.Load("flow", "instance"); + Assert.Empty(result); + } + + [Fact] + public void Save_merges_into_existing_progress() + { + _store.Save("flow", "instance", new Dictionary { ["a"] = "1" }); + _store.Save("flow", "instance", new Dictionary { ["b"] = "2" }); + + var result = _store.Load("flow", "instance"); + + Assert.Equal("1", result["a"]?.ToString()); + Assert.Equal("2", result["b"]?.ToString()); + Assert.False(string.IsNullOrEmpty(_session.GetString(FormSessionKeys.FlowProgress("flow", "instance")))); + } + + [Fact] + public void SetField_updates_a_single_key() + { + _store.Save("flow", "instance", new Dictionary { ["a"] = "1" }); + _store.SetField("flow", "instance", "a", "updated"); + + var result = _store.Load("flow", "instance"); + Assert.Equal("updated", result["a"]?.ToString()); + } + + [Fact] + public void Clear_removes_progress() + { + _store.Save("flow", "instance", new Dictionary { ["a"] = "1" }); + _store.Clear("flow", "instance"); + + Assert.Empty(_store.Load("flow", "instance")); + Assert.Null(_session.GetString(FormSessionKeys.FlowProgress("flow", "instance"))); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/FormEngineUseCaseTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/FormEngineUseCaseTests.cs new file mode 100644 index 0000000..3887af2 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/FormEngineUseCaseTests.cs @@ -0,0 +1,408 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.FormEngine; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Application.Validation; +using GovUK.Dfe.FlexForms.Domain.FormEngine; +using GovUK.Dfe.FlexForms.Domain.Models; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Task = System.Threading.Tasks.Task; +using TaskModel = GovUK.Dfe.FlexForms.Domain.Models.Task; +using PageModel = GovUK.Dfe.FlexForms.Domain.Models.Page; + +namespace GovUK.Dfe.FlexForms.Application.Tests.FormEngine; + +public class CompleteFormTaskServiceTests +{ + private readonly IApplicationStateService _applicationState = Substitute.For(); + private readonly IFieldRequirementService _fieldRequirements = Substitute.For(); + private readonly IConditionalLogicOrchestrator _conditionalLogic = Substitute.For(); + private readonly CompleteFormTaskService _service; + + public CompleteFormTaskServiceTests() + { + _conditionalLogic.ApplyConditionalLogicAsync(default!, default!, default) + .ReturnsForAnyArgs(new FormConditionalState()); + _fieldRequirements.GetMissingRequiredFieldsWithMessages(default!, default!, default!, default) + .ReturnsForAnyArgs(new Dictionary()); + _fieldRequirements.IsFieldRequired(default!, default!).ReturnsForAnyArgs(false); + _service = new CompleteFormTaskService( + _applicationState, + _fieldRequirements, + _conditionalLogic, + NullLogger.Instance); + } + + [Fact] + public async Task ExecuteAsync_ShouldStayOnPage_WhenRequiredFieldsAreMissing() + { + var task = CreateTask("t1"); + _fieldRequirements.GetMissingRequiredFieldsWithMessages(default!, default!, default!, default) + .ReturnsForAnyArgs(new Dictionary { ["name"] = "Enter a name" }); + + var result = await _service.ExecuteAsync(State(task, isTaskCompleted: true)); + + Assert.Equal(FormEngineOutcomeKind.StayOnPage, result.Kind); + Assert.Equal(FormState.TaskSummary, result.FormState); + Assert.False(result.IsTaskCompleted); + Assert.Contains(result.Errors, e => e.Message.Contains("Enter a name")); + } + + [Fact] + public async Task ExecuteAsync_ShouldStayOnPage_WhenCollectionMinItemsAreMissing() + { + var flow = new MultiCollectionFlowConfiguration + { + FlowId = "f1", + FieldId = "members", + Title = "Members", + MinItems = 2, + Pages = [] + }; + var task = CreateTask("t1", FormStepPolicy.MultiCollectionFlowMode, [flow]); + var state = State(task, isTaskCompleted: true); + state.FormData["members"] = """[{"id":"i1"}]"""; + + var result = await _service.ExecuteAsync(state); + + Assert.Equal(FormEngineOutcomeKind.StayOnPage, result.Kind); + Assert.Contains(result.Errors, e => e.Message.Contains("Add at least 2 item(s) to Members")); + } + + [Fact] + public async Task ExecuteAsync_ShouldRedirectToTaskList_WhenTaskCanBeCompleted() + { + var task = CreateTask("t1"); + var state = State(task, isTaskCompleted: true); + + var result = await _service.ExecuteAsync(state); + + Assert.Equal(FormEngineOutcomeKind.Redirect, result.Kind); + Assert.Equal("/applications/REF-1", result.RedirectUrl); + await _applicationState.Received().SaveTaskStatusAsync(state.ApplicationId!.Value, "t1", Domain.Models.TaskStatus.Completed); + } + + private static FormEngineWorkState State(TaskModel task, bool isTaskCompleted) => + new() + { + ReferenceNumber = "REF-1", + TaskId = task.TaskId, + ApplicationId = Guid.NewGuid(), + ApplicationStatus = "InProgress", + Template = new FormTemplate + { + TemplateId = "tpl", + TemplateName = "tpl", + Description = "tpl", + TaskGroups = [] + }, + FormData = new Dictionary(), + CurrentTask = task, + IsTaskCompleted = isTaskCompleted + }; + + private static TaskModel CreateTask( + string id, + string mode = "standard", + List? flows = null) => + new() + { + TaskId = id, + TaskName = "About you", + TaskOrder = 1, + TaskStatusString = "NotStarted", + Pages = [new Page { PageId = "p1", Slug = "p1", Title = "p1", Description = "p1", PageOrder = 1, Fields = [] }], + Summary = new TaskSummaryConfiguration { Mode = mode, Flows = flows } + }; +} + +public class SubmitFormApplicationServiceTests +{ + private readonly IApplicationStateService _applicationState = Substitute.For(); + private readonly IApplicationsClient _applicationsClient = Substitute.For(); + private readonly IFormSessionStore _session = Substitute.For(); + private readonly IConditionalLogicOrchestrator _conditionalLogic = Substitute.For(); + private readonly SubmitFormApplicationService _service; + private readonly Guid _applicationId = Guid.NewGuid(); + + public SubmitFormApplicationServiceTests() + { + _applicationState.AreAllTasksCompleted(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(true); + _applicationState.ValidateAllRequiredFieldsForSubmission(Arg.Any(), Arg.Any>(), Arg.Any?>()) + .Returns(new Dictionary>()); + _applicationsClient.GetFileValidationGateAsync(Arg.Any()) + .Returns(new FileValidationGateDto { CanSubmit = true, BlockingFiles = [] }); + _applicationsClient.SubmitApplicationAsync(Arg.Any()) + .Returns(new ApplicationDto { ApplicationReference = "REF-1" }); + _service = new SubmitFormApplicationService( + _applicationState, + _applicationsClient, + _session, + _conditionalLogic, + NullLogger.Instance); + } + + [Fact] + public async Task ExecuteAsync_ShouldStayOnPage_WhenNotAllTasksAreComplete() + { + _applicationState.AreAllTasksCompleted(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(false); + + var result = await _service.ExecuteAsync(EditableState()); + + Assert.Equal(FormEngineOutcomeKind.StayOnPage, result.Kind); + Assert.Equal(FormState.ApplicationPreview, result.FormState); + Assert.Contains(result.Errors, e => e.Message.Contains("All sections must be completed")); + } + + [Fact] + public async Task ExecuteAsync_ShouldStayOnPage_WhenFileValidationGateBlocksSubmit() + { + _applicationsClient.GetFileValidationGateAsync(Arg.Any()) + .Returns(new FileValidationGateDto + { + CanSubmit = false, + BlockingFiles = [new FileValidationBlockDto { OriginalFileName = "scan.pdf" }] + }); + + var result = await _service.ExecuteAsync(EditableState()); + + Assert.Equal(FormEngineOutcomeKind.StayOnPage, result.Kind); + Assert.Contains(result.Errors, e => e.Message.Contains("scan.pdf")); + } + + [Fact] + public async Task ExecuteAsync_ShouldRedirect_WhenSubmitSucceeds() + { + var result = await _service.ExecuteAsync(EditableState()); + + Assert.Equal(FormEngineOutcomeKind.RedirectToPage, result.Kind); + Assert.Equal("/Applications/ApplicationSubmitted", result.PageName); + await _applicationsClient.Received().SubmitApplicationAsync(_applicationId); + } + + private FormEngineWorkState EditableState() => + new() + { + ReferenceNumber = "REF-1", + ApplicationId = _applicationId, + ApplicationStatus = "InProgress", + IsEditable = true, + Template = new FormTemplate + { + TemplateId = "tpl", + TemplateName = "tpl", + Description = "tpl", + TaskGroups = [] + }, + FormData = new Dictionary() + }; +} + +public class SaveFormPageServiceTests +{ + private readonly ITemplateManagementService _templates = Substitute.For(); + private readonly IFormValidationOrchestrator _validation = Substitute.For(); + private readonly IFormNavigationService _navigation = Substitute.For(); + private readonly IConditionalLogicOrchestrator _conditionalLogic = Substitute.For(); + private readonly SaveFormPageService _service; + + public SaveFormPageServiceTests() + { + _validation.ValidatePage(default!, default!, default).ReturnsForAnyArgs(FormValidationResult.Success); + _conditionalLogic.ApplyConditionalLogicAsync(default!, default!, default) + .ReturnsForAnyArgs(new FormConditionalState()); + _conditionalLogic.GetNextPageAsync(default!, default!, default!, default) + .ReturnsForAnyArgs((string?)null); + _navigation.GetTaskSummaryUrl(Arg.Any(), Arg.Any()) + .Returns(call => $"/applications/{call.ArgAt(1)}/{call.ArgAt(0)}"); + _service = new SaveFormPageService( + _templates, + new PostedFormDataBinder(), + Substitute.For(), + _validation, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + _navigation, + Substitute.For(), + _conditionalLogic, + Substitute.For(), + Substitute.For(), + Substitute.For(), + NullLogger.Instance); + } + + [Fact] + public async Task ExecuteAsync_ShouldStayOnPage_WhenNotEditable() + { + var result = await _service.ExecuteAsync(PageState(), new Dictionary>(), null); + + Assert.Equal(FormEngineOutcomeKind.StayOnPage, result.Kind); + Assert.Contains(result.Errors, e => e.Message == FormEngineMessages.NoWritePermission); + } + + [Fact] + public async Task ExecuteAsync_ShouldStayOnPage_WhenValidationFails() + { + var page = new PageModel { PageId = "p1", Slug = "p1", Title = "p1", Description = "p1", PageOrder = 1, Fields = [] }; + var task = TaskWithPages(page); + Register(task, page); + _validation.ValidatePage(default!, default!, default) + .ReturnsForAnyArgs(new FormValidationResult([new FormValidationError("name", "Enter a name")])); + + var state = PageState(); + state.IsEditable = true; + state.CurrentPageId = "p1"; + state.TaskId = task.TaskId; + var result = await _service.ExecuteAsync(state, new Dictionary>(), null); + + Assert.Equal(FormEngineOutcomeKind.StayOnPage, result.Kind); + Assert.Contains(result.Errors, e => e.Message == "Enter a name"); + Assert.True(result.PersistErrors); + } + + [Fact] + public async Task ExecuteAsync_ShouldRedirectToNextPage_WhenCurrentPageIsNotLast() + { + var first = new PageModel { PageId = "p1", Slug = "p1", Title = "p1", Description = "p1", PageOrder = 1, Fields = [], ReturnToSummaryPage = false }; + var second = new PageModel { PageId = "p2", Slug = "p2", Title = "p2", Description = "p2", PageOrder = 2, Fields = [], ReturnToSummaryPage = false }; + var task = TaskWithPages(first, second); + Register(task, first); + + var state = PageState(); + state.IsEditable = true; + state.CurrentPageId = "p1"; + state.TaskId = task.TaskId; + var result = await _service.ExecuteAsync(state, new Dictionary>(), null); + + Assert.Equal(FormEngineOutcomeKind.Redirect, result.Kind); + Assert.Equal($"/applications/REF-1/{task.TaskId}/p2", result.RedirectUrl); + } + + private void Register(TaskModel task, PageModel page) + { + var group = new TaskGroup { GroupId = "g1", GroupName = "g", GroupOrder = 1, GroupStatus = "NotStarted", Tasks = [task] }; + _templates.FindTask(Arg.Any(), Arg.Any()).Returns((group, task)); + _templates.FindPage(Arg.Any(), Arg.Any()).Returns((group, task, page)); + } + + private static FormEngineWorkState PageState() => + new() + { + ReferenceNumber = "REF-1", + ApplicationId = Guid.NewGuid(), + Template = new FormTemplate { TemplateId = "tpl", TemplateName = "tpl", Description = "tpl", TaskGroups = [] }, + FormData = new Dictionary(), + Data = new Dictionary() + }; + + private static TaskModel TaskWithPages(params PageModel[] pages) => + new() + { + TaskId = "t1", + TaskName = "About you", + TaskOrder = 1, + TaskStatusString = "NotStarted", + Pages = [.. pages] + }; +} + +public class PrepareFormEngineGetServiceTests +{ + private readonly ITemplateManagementService _templates = Substitute.For(); + private readonly IApplicationResponseService _responses = Substitute.For(); + private readonly PrepareFormEngineGetService _service; + + public PrepareFormEngineGetServiceTests() + { + _responses.GetAccumulatedFormData().Returns(new Dictionary { ["name"] = "Ada" }); + _service = new PrepareFormEngineGetService( + _templates, + _responses, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + NullLogger.Instance); + } + + [Fact] + public async Task ExecuteAsync_ShouldUseDummyTemplate_WhenTemplateIsMissing() + { + var state = new FormEngineWorkState { ReferenceNumber = "REF-1", Data = new Dictionary() }; + var result = await _service.ExecuteAsync(state, isPreview: false, isBackNav: false, isEditable: true); + + Assert.Equal("dummy", state.Template!.TemplateId); + Assert.Equal(FormEngineOutcomeKind.StayOnPage, result.Kind); + Assert.Equal("Ada", state.Data["name"]); + } + + [Fact] + public async Task ExecuteAsync_ShouldRedirect_WhenNotEditableAndPageIsPresent() + { + var state = new FormEngineWorkState + { + ReferenceNumber = "REF-1", + CurrentPageId = "p1", + Template = new FormTemplate { TemplateId = "tpl", TemplateName = "tpl", Description = "tpl", TaskGroups = [] }, + Data = new Dictionary() + }; + + var result = await _service.ExecuteAsync(state, isPreview: false, isBackNav: false, isEditable: false); + + Assert.Equal(FormEngineOutcomeKind.Redirect, result.Kind); + Assert.Equal("~/applications/REF-1", result.RedirectUrl); + } +} + +public class RemoveCollectionItemServiceTests +{ + [Fact] + public async Task ExecuteAsync_ShouldBadRequest_WhenIdsAreMissing() + { + var service = new RemoveCollectionItemService( + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + NullLogger.Instance); + + var result = await service.ExecuteAsync(new FormEngineWorkState { IsEditable = true }, "", "i1", "f1", true); + + Assert.Equal(FormEngineOutcomeKind.BadRequest, result.Kind); + Assert.Equal(FormEngineMessages.FieldIdAndItemIdRequired, result.ErrorMessage); + } +} + +public class UploadFormFileServiceTests +{ + [Fact] + public async Task ExecuteAsync_ShouldStayWithError_WhenNoFileIsPosted() + { + var files = Substitute.For(); + files.GetFiles(Arg.Any(), Arg.Any()).Returns([]); + var service = new UploadFormFileService( + files, + Substitute.For(), + Substitute.For(), + NullLogger.Instance); + + var result = await service.ExecuteAsync( + new FormEngineWorkState(), + new UploadFormFileRequest(Guid.NewGuid(), "evidence", null, null, Stream.Null, "cv.pdf", "application/pdf", "ctx", false)); + + Assert.Equal(FormEngineOutcomeKind.StayOnPage, result.Kind); + Assert.Equal(FormEngineMessages.SelectAFile, result.ErrorMessage); + Assert.Contains(result.Errors, e => e.FieldKey == "UploadFile"); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/FormFileFieldServiceTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/FormFileFieldServiceTests.cs new file mode 100644 index 0000000..1b5d49d --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/FormFileFieldServiceTests.cs @@ -0,0 +1,192 @@ +using System.Text.Json; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Application.FormEngine; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Caching; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; + +namespace GovUK.Dfe.FlexForms.Application.Tests.FormEngine; + +public class FormFileFieldServiceTests +{ + private readonly InMemoryFormSessionStore _session = new(); + private readonly IApplicationResponseService _responses = Substitute.For(); + private readonly IInfectedFileStore _infectedFileStore = Substitute.For(); + private readonly FormFileFieldService _service; + private readonly Guid _applicationId = Guid.NewGuid(); + + public FormFileFieldServiceTests() + { + _responses.GetAccumulatedFormData().Returns(new Dictionary()); + var progress = new CollectionFlowProgressStore(_session); + var filter = new InfectedUploadFilter(_infectedFileStore, NullLogger.Instance); + _service = new FormFileFieldService( + _session, + progress, + filter, + _infectedFileStore, + _responses, + NullLogger.Instance); + } + + [Fact] + public void GetFiles_reads_regular_upload_session_key() + { + var files = new List { new() { Id = Guid.NewGuid(), OriginalFileName = "a.pdf" } }; + _session.SetString(FormSessionKeys.UploadedFiles(_applicationId, "evidence"), JsonSerializer.Serialize(files)); + + var result = _service.GetFiles(new FormFileFieldContext(_applicationId, null, null), "evidence"); + + Assert.Single(result); + Assert.Equal("a.pdf", result[0].OriginalFileName); + } + + [Fact] + public void SaveFiles_then_GetFiles_round_trips_collection_progress() + { + var context = new FormFileFieldContext(_applicationId, "flow-1", "item-1"); + var files = new List { new() { Id = Guid.NewGuid(), OriginalFileName = "b.pdf" } }; + + _service.SaveFiles(context, "upload", files); + var result = _service.GetFiles(context, "upload"); + + Assert.Single(result); + Assert.Equal("b.pdf", result[0].OriginalFileName); + } + + [Fact] + public void ReplaceUploadPlaceholders_uses_session_files_for_regular_forms() + { + var files = new List { new() { Id = Guid.NewGuid(), OriginalFileName = "c.pdf" } }; + _session.SetString(FormSessionKeys.UploadedFiles(_applicationId, "upload"), JsonSerializer.Serialize(files)); + var data = new Dictionary + { + ["upload"] = FormEngineConstants.UploadFieldSessionPlaceholder + }; + + _service.ReplaceUploadPlaceholders(data, new FormFileFieldContext(_applicationId, null, null)); + + var stored = JsonSerializer.Deserialize>(data["upload"].ToString()!); + Assert.NotNull(stored); + Assert.Equal("c.pdf", stored![0].OriginalFileName); + } + + [Fact] + public void GetFiles_returns_empty_when_field_id_is_missing() + { + var result = _service.GetFiles(new FormFileFieldContext(_applicationId, null, null), ""); + Assert.Empty(result); + } + + [Fact] + public void GetFiles_filters_infected_files_from_session() + { + var clean = new UploadDto { Id = Guid.NewGuid(), OriginalFileName = "ok.pdf" }; + var infected = new UploadDto { Id = Guid.NewGuid(), OriginalFileName = "bad.pdf" }; + _infectedFileStore.IsFileInfected(infected.Id).Returns(true); + _session.SetString( + FormSessionKeys.UploadedFiles(_applicationId, "evidence"), + JsonSerializer.Serialize(new[] { clean, infected })); + + var result = _service.GetFiles(new FormFileFieldContext(_applicationId, null, null), "evidence"); + + Assert.Single(result); + Assert.Equal(clean.Id, result[0].Id); + } + + [Fact] + public void GetFiles_reads_collection_item_from_accumulated_data_when_progress_is_empty() + { + var file = new UploadDto { Id = Guid.NewGuid(), OriginalFileName = "item.pdf" }; + var items = JsonSerializer.Serialize(new[] + { + new Dictionary + { + ["id"] = "item-1", + ["upload"] = JsonSerializer.Serialize(new[] { file }) + } + }); + _responses.GetAccumulatedFormData().Returns(new Dictionary { ["members"] = items }); + + var result = _service.GetFiles(new FormFileFieldContext(_applicationId, "flow-1", "item-1"), "upload"); + + Assert.Single(result); + Assert.Equal("item.pdf", result[0].OriginalFileName); + } + + [Fact] + public void ReplaceUploadPlaceholders_uses_collection_progress_then_filters() + { + var files = new List { new() { Id = Guid.NewGuid(), OriginalFileName = "progress.pdf" } }; + var context = new FormFileFieldContext(_applicationId, "flow-1", "item-1"); + _service.SaveFiles(context, "upload", files); + var data = new Dictionary + { + ["upload"] = FormEngineConstants.UploadFieldSessionPlaceholder + }; + + _service.ReplaceUploadPlaceholders(data, context); + + var stored = JsonSerializer.Deserialize>(data["upload"].ToString()!); + Assert.Equal("progress.pdf", stored![0].OriginalFileName); + } + + [Fact] + public void ReplaceUploadPlaceholders_uses_accumulated_collection_item_when_progress_is_empty() + { + var file = new UploadDto { Id = Guid.NewGuid(), OriginalFileName = "db.pdf" }; + var items = JsonSerializer.Serialize(new[] + { + new Dictionary + { + ["id"] = "item-1", + ["upload"] = JsonSerializer.Serialize(new[] { file }) + } + }); + _responses.GetAccumulatedFormData().Returns(new Dictionary { ["members"] = items }); + var data = new Dictionary + { + ["upload"] = FormEngineConstants.UploadFieldSessionPlaceholder + }; + + _service.ReplaceUploadPlaceholders(data, new FormFileFieldContext(_applicationId, "flow-1", "item-1")); + + var stored = JsonSerializer.Deserialize>(data["upload"].ToString()!); + Assert.Equal("db.pdf", stored![0].OriginalFileName); + } + + [Fact] + public void ContainsFileName_returns_false_for_blacklisted_names() + { + _infectedFileStore.IsFileNameInfected(_applicationId.ToString(), "dup.pdf").Returns(true); + + var result = _service.ContainsFileName( + new FormFileFieldContext(_applicationId, null, null), + "evidence", + "dup.pdf"); + + Assert.False(result); + } + + [Fact] + public void ContainsFileName_matches_existing_session_file() + { + var files = new List { new() { Id = Guid.NewGuid(), OriginalFileName = "notes.pdf" } }; + _session.SetString(FormSessionKeys.UploadedFiles(_applicationId, "evidence"), JsonSerializer.Serialize(files)); + + var result = _service.ContainsFileName( + new FormFileFieldContext(_applicationId, null, null), + "evidence", + "NOTES.pdf"); + + Assert.True(result); + } + + [Fact] + public void SaveFiles_ignores_regular_uploads_without_an_application_id() + { + _service.SaveFiles(new FormFileFieldContext(null, null, null), "evidence", [new UploadDto()]); + Assert.Empty(_session.Keys); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/HtmlInputSanitiserTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/HtmlInputSanitiserTests.cs new file mode 100644 index 0000000..cafce9c --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/HtmlInputSanitiserTests.cs @@ -0,0 +1,27 @@ +using GovUK.Dfe.FlexForms.Application.FormEngine; + +namespace GovUK.Dfe.FlexForms.Application.Tests.FormEngine; + +public class HtmlInputSanitiserTests +{ + [Fact] + public void Sanitise_normalises_newlines_to_br_tags() + { + var result = HtmlInputSanitiser.Sanitise("Some\r\nnew\rlines\nhere"); + Assert.Equal("Some
new
lines
here", result); + } + + [Fact] + public void Sanitise_escapes_html_characters() + { + var result = HtmlInputSanitiser.Sanitise(""); + Assert.Equal("<script>alert('hello')</script>", result); + } + + [Fact] + public void Sanitise_escapes_characters_outside_the_latin_set() + { + var result = HtmlInputSanitiser.Sanitise("👍"); + Assert.Equal("👍", result); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/InfectedUploadFilterTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/InfectedUploadFilterTests.cs new file mode 100644 index 0000000..1987c60 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/InfectedUploadFilterTests.cs @@ -0,0 +1,70 @@ +using System.Text.Json; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Application.FormEngine; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; + +namespace GovUK.Dfe.FlexForms.Application.Tests.FormEngine; + +public class InfectedUploadFilterTests +{ + private readonly IInfectedFileStore _infectedFileStore = Substitute.For(); + private readonly InfectedUploadFilter _filter; + + public InfectedUploadFilterTests() + { + _filter = new InfectedUploadFilter(_infectedFileStore, NullLogger.Instance); + } + + [Fact] + public void FilterList_removes_files_blacklisted_by_id_or_name() + { + var infectedId = Guid.NewGuid(); + var clean = new UploadDto { Id = Guid.NewGuid(), OriginalFileName = "clean.pdf" }; + var byId = new UploadDto { Id = infectedId, OriginalFileName = "virus.bin" }; + var byName = new UploadDto { Id = Guid.NewGuid(), OriginalFileName = "bad.exe" }; + + _infectedFileStore.IsFileInfected(infectedId).Returns(true); + _infectedFileStore.IsFileNameInfected("app-1", "bad.exe").Returns(true); + + var result = _filter.FilterList([clean, byId, byName], "app-1"); + + Assert.Single(result); + Assert.Equal(clean.Id, result[0].Id); + } + + [Fact] + public void FilterUploadDataJson_serialises_the_filtered_list() + { + var infectedId = Guid.NewGuid(); + var files = new List + { + new() { Id = infectedId, OriginalFileName = "virus.bin" }, + new() { Id = Guid.NewGuid(), OriginalFileName = "ok.pdf" } + }; + _infectedFileStore.IsFileInfected(infectedId).Returns(true); + + var json = _filter.FilterUploadDataJson(JsonSerializer.Serialize(files), "app-1"); + var result = JsonSerializer.Deserialize>(json); + + Assert.NotNull(result); + Assert.Single(result!); + Assert.Equal("ok.pdf", result[0].OriginalFileName); + } + + [Fact] + public void FilterList_returns_empty_when_source_is_null_or_empty() + { + Assert.Empty(_filter.FilterList(null, "app-1")); + Assert.Empty(_filter.FilterList([], "app-1")); + } + + [Fact] + public void FilterUploadDataJson_returns_original_value_when_not_a_file_list() + { + const string raw = "not-json"; + Assert.Equal(raw, _filter.FilterUploadDataJson(raw, "app-1")); + Assert.Equal(string.Empty, _filter.FilterUploadDataJson(null, "app-1")); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/PostedFormDataBinderTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/PostedFormDataBinderTests.cs new file mode 100644 index 0000000..450e11e --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/PostedFormDataBinderTests.cs @@ -0,0 +1,88 @@ +using GovUK.Dfe.FlexForms.Application.FormEngine; + +namespace GovUK.Dfe.FlexForms.Application.Tests.FormEngine; + +public class PostedFormDataBinderTests +{ + private readonly PostedFormDataBinder _binder = new(); + + [Fact] + public void Bind_sanitises_single_field_and_keeps_existing_values() + { + var existing = new Dictionary { ["kept"] = "yes" }; + var form = Fields(("Data[someField]", ["hi"])); + + var data = _binder.Bind(form, existing); + + Assert.Equal("<b>hi</b>", data["someField"]); + Assert.Equal("yes", data["kept"]); + } + + [Fact] + public void Bind_writes_normalised_autocomplete_field_id() + { + var form = Fields(("Data[Data_trustsSearch]", ["Acme Trust"])); + + var data = _binder.Bind(form); + + Assert.Equal("Acme Trust", data["Data_trustsSearch"]); + Assert.Equal("Acme Trust", data["trustsSearch"]); + } + + [Fact] + public void Bind_stores_multi_value_fields_as_arrays() + { + var form = Fields(("Data[choices]", ["a", "b"])); + + var data = _binder.Bind(form); + + var values = Assert.IsType(data["choices"]); + Assert.Equal(["a", "b"], values); + } + + [Fact] + public void ApplyDateParts_composes_iso_date_when_year_is_four_digits() + { + var data = new Dictionary(); + var form = Fields( + ("Data[dob]-day", ["7"]), + ("Data[dob]-month", ["8"]), + ("Data[dob]-year", ["2024"])); + + _binder.ApplyDateParts(form, data); + + Assert.Equal("2024-08-07", data["dob"]); + } + + [Fact] + public void ApplyDateParts_leaves_joined_parts_when_year_is_not_four_digits() + { + var data = new Dictionary(); + var form = Fields( + ("Data[dob].Day", ["7"]), + ("Data[dob].Month", ["8"]), + ("Data[dob].Year", ["24"])); + + _binder.ApplyDateParts(form, data); + + Assert.Equal("24-8-7", data["dob"]); + } + + [Fact] + public void ApplyDateParts_joins_invalid_calendar_dates() + { + var data = new Dictionary(); + var form = Fields( + ("Data[dob]-day", ["31"]), + ("Data[dob]-month", ["2"]), + ("Data[dob]-year", ["2024"])); + + _binder.ApplyDateParts(form, data); + + Assert.Equal("2024-2-31", data["dob"]); + } + + private static IReadOnlyDictionary> Fields( + params (string Key, string[] Values)[] items) => + items.ToDictionary(i => i.Key, i => (IReadOnlyList)i.Values, StringComparer.Ordinal); +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/GovUK.Dfe.FlexForms.Application.Tests.csproj b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/GovUK.Dfe.FlexForms.Application.Tests.csproj new file mode 100644 index 0000000..4300616 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/GovUK.Dfe.FlexForms.Application.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/InMemoryFormSessionStore.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/InMemoryFormSessionStore.cs new file mode 100644 index 0000000..4207d0b --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/InMemoryFormSessionStore.cs @@ -0,0 +1,16 @@ +using GovUK.Dfe.FlexForms.Application.Interfaces; + +namespace GovUK.Dfe.FlexForms.Application.Tests; + +internal sealed class InMemoryFormSessionStore : IFormSessionStore +{ + private readonly Dictionary _data = new(StringComparer.Ordinal); + + public string? GetString(string key) => _data.TryGetValue(key, out var value) ? value : null; + + public void SetString(string key, string value) => _data[key] = value; + + public void Remove(string key) => _data.Remove(key); + + public IReadOnlyCollection Keys => _data.Keys.ToList(); +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/FormEngine/FormRouteParserTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/FormEngine/FormRouteParserTests.cs new file mode 100644 index 0000000..9cdd25b --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/FormEngine/FormRouteParserTests.cs @@ -0,0 +1,84 @@ +using GovUK.Dfe.FlexForms.Domain.FormEngine; + +namespace GovUK.Dfe.FlexForms.Domain.Tests.FormEngine; + +public class FormRouteParserTests +{ + [Theory] + [InlineData("flow/f1/i1/p1", "f1", "i1", "p1")] + [InlineData("FLOW/f1/i1", "f1", "i1", "")] + [InlineData("flow/f1/i1/", "f1", "i1", "")] + public void TryParseCollectionFlow_accepts_valid_routes( + string pageId, + string flowId, + string instanceId, + string flowPageId) + { + Assert.True(FormRouteParser.TryParseCollectionFlow(pageId, out var route)); + Assert.Equal(flowId, route.FlowId); + Assert.Equal(instanceId, route.InstanceId); + Assert.Equal(flowPageId, route.PageId); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("task-page")] + [InlineData("flow/only-flow-id")] + [InlineData("f1/derived/item1")] + public void TryParseCollectionFlow_rejects_non_collection_routes(string? pageId) + { + Assert.False(FormRouteParser.TryParseCollectionFlow(pageId, out var route)); + Assert.Equal(default, route); + } + + [Theory] + [InlineData("df1/derived/item1/p1", "df1", "item1", "p1")] + [InlineData("df1/DERIVED/item1", "df1", "item1", "")] + public void TryParseDerivedFlow_accepts_valid_routes( + string pageId, + string flowId, + string itemId, + string derivedPageId) + { + Assert.True(FormRouteParser.TryParseDerivedFlow(pageId, out var route)); + Assert.Equal(flowId, route.FlowId); + Assert.Equal(itemId, route.ItemId); + Assert.Equal(derivedPageId, route.PageId); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("flow/f1/i1/p1")] + [InlineData("df1/other/item1")] + [InlineData("derived/item1")] + public void TryParseDerivedFlow_rejects_non_derived_routes(string? pageId) + { + Assert.False(FormRouteParser.TryParseDerivedFlow(pageId, out _)); + } + + [Theory] + [InlineData("flow/f1/i1/p1", true)] + [InlineData("flow/f1", true)] + [InlineData("task-page", false)] + [InlineData(null, false)] + public void LooksLikeCollectionFlow_uses_the_flow_prefix(string? pageId, bool expected) + { + Assert.Equal(expected, FormRouteParser.LooksLikeCollectionFlow(pageId)); + } + + [Theory] + [InlineData("APP-1", "task-1", "flow/f1/i1/p1", "APP-1:task-1:flow:f1:i1")] + [InlineData("APP-1", "task-1", "task-page", "APP-1:task-1")] + [InlineData("APP-1", "task-1", "", "APP-1:task-1")] + [InlineData("APP-1", "task-1", "df1/derived/item1", "APP-1:task-1")] + public void HistoryScope_includes_flow_instance_only_for_collection_routes( + string reference, + string taskId, + string pageId, + string expected) + { + Assert.Equal(expected, FormRouteParser.HistoryScope(reference, taskId, pageId)); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/FormEngine/FormStepPolicyTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/FormEngine/FormStepPolicyTests.cs new file mode 100644 index 0000000..71054d7 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/FormEngine/FormStepPolicyTests.cs @@ -0,0 +1,125 @@ +using GovUK.Dfe.FlexForms.Domain.FormEngine; +using GovUK.Dfe.FlexForms.Domain.Models; +using TaskModel = GovUK.Dfe.FlexForms.Domain.Models.Task; + +namespace GovUK.Dfe.FlexForms.Domain.Tests.FormEngine; + +public class FormStepPolicyTests +{ + [Theory] + [InlineData(null, null, true, false, false, false)] + [InlineData("", "", true, false, false, false)] + [InlineData("task-1", null, false, true, false, false)] + [InlineData("task-1", "", false, true, false, false)] + [InlineData("task-1", "page-1", false, false, true, false)] + [InlineData("task-1", "flow/f1/i1/p1", false, false, false, true)] + [InlineData("task-1", "flow/f1", false, false, false, true)] + [InlineData("task-1", "df1/derived/item1", false, false, true, false)] + public void Step_flags_match_task_and_page_id( + string? taskId, + string? pageId, + bool taskList, + bool taskSummary, + bool formPage, + bool collectionFlowPage) + { + Assert.Equal(taskList, FormStepPolicy.IsTaskList(taskId, pageId)); + Assert.Equal(taskSummary, FormStepPolicy.IsTaskSummary(taskId, pageId)); + Assert.Equal(formPage, FormStepPolicy.IsFormPage(pageId)); + Assert.Equal(collectionFlowPage, FormStepPolicy.IsCollectionFlowPage(pageId)); + Assert.False(FormStepPolicy.IsApplicationPreview(pageId)); + } + + [Fact] + public void Summary_mode_flags_follow_task_configuration() + { + var collection = TaskWithMode(FormStepPolicy.MultiCollectionFlowMode); + var derived = TaskWithMode(FormStepPolicy.DerivedCollectionFlowMode); + var standard = TaskWithMode("standard"); + + Assert.True(FormStepPolicy.IsCollectionFlowSummary(collection)); + Assert.False(FormStepPolicy.IsDerivedCollectionFlowSummary(collection)); + Assert.True(FormStepPolicy.IsDerivedCollectionFlowSummary(derived)); + Assert.False(FormStepPolicy.IsCollectionFlowSummary(standard)); + Assert.False(FormStepPolicy.IsCollectionFlowSummary(null)); + } + + [Fact] + public void IsInSubFlow_matches_flow_id_prefix() + { + Assert.True(FormStepPolicy.IsInSubFlow("f1", "flow/f1/i1/p1")); + Assert.False(FormStepPolicy.IsInSubFlow("f1", "flow/f2/i1/p1")); + Assert.False(FormStepPolicy.IsInSubFlow("f1", "page-1")); + } + + [Fact] + public void ResolvePage_returns_first_page_when_id_is_missing() + { + var pages = new[] { Page("p1"), Page("p2") }; + + Assert.Equal("p1", FormStepPolicy.ResolvePage(pages, null)?.PageId); + Assert.Equal("p2", FormStepPolicy.ResolvePage(pages, "p2")?.PageId); + Assert.Null(FormStepPolicy.ResolvePage(pages, "missing")); + Assert.Null(FormStepPolicy.ResolvePage(null, "p1")); + } + + [Fact] + public void GetNextPage_and_IsLastPage_walk_the_list() + { + var pages = new[] { Page("p1"), Page("p2"), Page("p3") }; + + Assert.Equal("p2", FormStepPolicy.GetNextPage(pages, "p1")?.PageId); + Assert.Equal("p3", FormStepPolicy.GetNextPage(pages, "p2")?.PageId); + Assert.Null(FormStepPolicy.GetNextPage(pages, "p3")); + Assert.Null(FormStepPolicy.GetNextPage(pages, "missing")); + + Assert.False(FormStepPolicy.IsLastPage(pages, "p1")); + Assert.True(FormStepPolicy.IsLastPage(pages, "p3")); + Assert.True(FormStepPolicy.IsLastPage(pages, "missing")); + Assert.Equal(1, FormStepPolicy.IndexOfPage(pages, "p2")); + } + + [Fact] + public void Collection_flow_lookups_use_flow_id() + { + var flow = new MultiCollectionFlowConfiguration + { + FlowId = "f1", + FieldId = "collection", + Pages = [Page("p1")] + }; + var task = new TaskModel + { + TaskId = "t1", + TaskName = "Task", + TaskOrder = 1, + TaskStatusString = "NotStarted", + Summary = new TaskSummaryConfiguration { Flows = [flow] } + }; + + Assert.Equal("collection", FormStepPolicy.GetCollectionFlowFieldId(task, "f1")); + Assert.Equal("p1", FormStepPolicy.GetCollectionFlowPages(task, "f1")?[0].PageId); + Assert.Null(FormStepPolicy.GetCollectionFlow(task, "missing")); + } + + private static TaskModel TaskWithMode(string mode) => + new() + { + TaskId = "t1", + TaskName = "Task", + TaskOrder = 1, + TaskStatusString = "NotStarted", + Summary = new TaskSummaryConfiguration { Mode = mode } + }; + + private static Page Page(string id) => + new() + { + PageId = id, + Slug = id, + Title = id, + Description = id, + PageOrder = 1, + Fields = [] + }; +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/GovUK.Dfe.FlexForms.Domain.Tests.csproj b/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/GovUK.Dfe.FlexForms.Domain.Tests.csproj new file mode 100644 index 0000000..9713a7d --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/GovUK.Dfe.FlexForms.Domain.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/Templates/TemplateVersionPolicyTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/Templates/TemplateVersionPolicyTests.cs new file mode 100644 index 0000000..710bea9 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/Templates/TemplateVersionPolicyTests.cs @@ -0,0 +1,19 @@ +using GovUK.Dfe.FlexForms.Domain.Templates; + +namespace GovUK.Dfe.FlexForms.Domain.Tests.Templates; + +public class TemplateVersionPolicyTests +{ + [Theory] + [InlineData(null, "1.0.1")] + [InlineData("", "1.0.1")] + [InlineData("1", "1.0.1")] + [InlineData("1.0", "1.0.1")] + [InlineData("1.0.1", "1.0.2")] + [InlineData("2.3.9", "2.3.10")] + [InlineData("1.0.x", "1.0.1")] + public void IncrementPatch_ShouldMatchPreviousPageModelRules(string? version, string expected) + { + Assert.Equal(expected, TemplateVersionPolicy.IncrementPatch(version)); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests.csproj b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests.csproj index b184fc0..b7009bf 100644 --- a/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests.csproj +++ b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests.csproj @@ -10,6 +10,11 @@ + + + + + diff --git a/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/ApplicationStateServiceTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/ApplicationStateServiceTests.cs index 77e40fb..bb044b9 100644 --- a/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/ApplicationStateServiceTests.cs +++ b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/ApplicationStateServiceTests.cs @@ -5,7 +5,6 @@ using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; using GovUK.Dfe.CoreLibs.Http.Models; using GovUK.Dfe.FlexForms.Api.Client.Contracts; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using NSubstitute.ExceptionExtensions; @@ -18,39 +17,39 @@ public class ApplicationStateServiceTests private readonly IApplicationResponseService _applicationResponseService = Substitute.For(); private readonly IFieldRequirementService _fieldRequirementService = Substitute.For(); - private ApplicationStateService CreateService() => - new(_applicationsClient, _applicationResponseService, _fieldRequirementService, NullLogger.Instance); + private ApplicationStateService CreateService(IFormSessionStore sessionStore) => + new(_applicationsClient, _applicationResponseService, _fieldRequirementService, sessionStore, NullLogger.Instance); [Fact] public async Task EnsureApplicationIdAsync_AlwaysCallsApi_EvenWhenSessionHasCachedApplication() { const string reference = "APP-001"; var applicationId = Guid.NewGuid(); - var session = CreateSession(session => + var sessionStore = CreateSessionStore(store => { - session.SetString("ApplicationId", applicationId.ToString()); - session.SetString("ApplicationReference", reference); - session.SetString($"TemplateSchema_{reference}", "{\"templateId\":\"t1\"}"); - session.SetString($"TemplateVersionId_{reference}", Guid.NewGuid().ToString()); + store.SetString("ApplicationId", applicationId.ToString()); + store.SetString("ApplicationReference", reference); + store.SetString($"TemplateSchema_{reference}", "{\"templateId\":\"t1\"}"); + store.SetString($"TemplateVersionId_{reference}", Guid.NewGuid().ToString()); }); var apiApplication = CreateApplication(reference, applicationId); _applicationsClient.GetApplicationByReferenceAsync(reference).Returns(apiApplication); - var service = CreateService(); - var (returnedId, returnedApplication) = await service.EnsureApplicationIdAsync(reference, session); + var service = CreateService(sessionStore); + var (returnedId, returnedApplication) = await service.EnsureApplicationIdAsync(reference); Assert.Equal(applicationId, returnedId); Assert.Same(apiApplication, returnedApplication); await _applicationsClient.Received(1).GetApplicationByReferenceAsync(reference); - _applicationResponseService.Received(1).ClearAccumulatedFormData(session); + _applicationResponseService.Received(1).ClearAccumulatedFormData(); } [Fact] public async Task EnsureApplicationIdAsync_ThrowsApplicationAccessException_WhenApiReturns404() { const string reference = "APP-MISSING"; - var session = CreateSession(); + var sessionStore = CreateSessionStore(); _applicationsClient.GetApplicationByReferenceAsync(reference) .Throws(new ExternalApplicationsException( @@ -61,10 +60,10 @@ public async Task EnsureApplicationIdAsync_ThrowsApplicationAccessException_When new ExceptionResponse { StatusCode = 404 }, null)); - var service = CreateService(); + var service = CreateService(sessionStore); var exception = await Assert.ThrowsAsync( - () => service.EnsureApplicationIdAsync(reference, session)); + () => service.EnsureApplicationIdAsync(reference)); Assert.Equal(reference, exception.ApplicationReference); } @@ -76,7 +75,7 @@ public async Task EnsureApplicationIdAsync_ThrowsApplicationAccessException_When [InlineData("Deleted", false)] public void IsApplicationEditable_AllowsCreatedAndInProgress(string status, bool expected) { - var service = CreateService(); + var service = CreateService(CreateSessionStore()); Assert.Equal(expected, service.IsApplicationEditable(status)); } @@ -85,7 +84,7 @@ public void IsApplicationEditable_AllowsCreatedAndInProgress(string status, bool public async Task EnsureApplicationIdAsync_ThrowsApplicationAccessException_WhenApiReturns403() { const string reference = "APP-FORBIDDEN"; - var session = CreateSession(); + var sessionStore = CreateSessionStore(); _applicationsClient.GetApplicationByReferenceAsync(reference) .Throws(new ExternalApplicationsException( @@ -96,30 +95,30 @@ public async Task EnsureApplicationIdAsync_ThrowsApplicationAccessException_When new ExceptionResponse { StatusCode = 403 }, null)); - var service = CreateService(); + var service = CreateService(sessionStore); await Assert.ThrowsAsync( - () => service.EnsureApplicationIdAsync(reference, session)); + () => service.EnsureApplicationIdAsync(reference)); } [Fact] public async Task EnsureApplicationIdAsync_ClearsFormData_WhenReferenceChanges() { - var session = CreateSession(session => + var sessionStore = CreateSessionStore(store => { - session.SetString("ApplicationReference", "APP-OLD"); - session.SetString("ApplicationId", Guid.NewGuid().ToString()); + store.SetString("ApplicationReference", "APP-OLD"); + store.SetString("ApplicationId", Guid.NewGuid().ToString()); }); const string newReference = "APP-NEW"; var apiApplication = CreateApplication(newReference, Guid.NewGuid()); _applicationsClient.GetApplicationByReferenceAsync(newReference).Returns(apiApplication); - var service = CreateService(); - await service.EnsureApplicationIdAsync(newReference, session); + var service = CreateService(sessionStore); + await service.EnsureApplicationIdAsync(newReference); - _applicationResponseService.Received(1).ClearAccumulatedFormData(session); - Assert.Equal(newReference, session.GetString("ApplicationReference")); + _applicationResponseService.Received(2).ClearAccumulatedFormData(); + Assert.Equal(newReference, sessionStore.GetString("ApplicationReference")); } private static ApplicationDto CreateApplication(string reference, Guid applicationId) => @@ -144,32 +143,23 @@ private static ApplicationDto CreateApplication(string reference, Guid applicati } }; - private static ISession CreateSession(Action? configure = null) + private static InMemoryFormSessionStore CreateSessionStore(Action? configure = null) { - var session = new TestSession(); - configure?.Invoke(session); - return session; + var store = new InMemoryFormSessionStore(); + configure?.Invoke(store); + return store; } - private sealed class TestSession : ISession + private sealed class InMemoryFormSessionStore : IFormSessionStore { - private readonly Dictionary _store = new(StringComparer.OrdinalIgnoreCase); - private bool _isAvailable = true; + private readonly Dictionary _store = new(StringComparer.OrdinalIgnoreCase); - public bool IsAvailable => _isAvailable; - public string Id { get; set; } = Guid.NewGuid().ToString(); - public IEnumerable Keys => _store.Keys; + public string? GetString(string key) => _store.TryGetValue(key, out var value) ? value : null; - public void Clear() => _store.Clear(); - - public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; - - public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public void SetString(string key, string value) => _store[key] = value; public void Remove(string key) => _store.Remove(key); - public void Set(string key, byte[] value) => _store[key] = value; - - public bool TryGetValue(string key, out byte[] value) => _store.TryGetValue(key, out value!); + public IReadOnlyCollection Keys => _store.Keys.ToList(); } } diff --git a/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/FieldRequirementServiceTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/FieldRequirementServiceTests.cs new file mode 100644 index 0000000..9286edb --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/FieldRequirementServiceTests.cs @@ -0,0 +1,105 @@ +using GovUK.Dfe.FlexForms.Domain.Models; +using GovUK.Dfe.FlexForms.Infrastructure.Services; +using Microsoft.Extensions.Logging.Abstractions; +using TaskModel = GovUK.Dfe.FlexForms.Domain.Models.Task; + +namespace GovUK.Dfe.FlexForms.Infrastructure.UnitTests.Services; + +public class FieldRequirementServiceTests +{ + private readonly FieldRequirementService _service = new(NullLogger.Instance); + + [Fact] + public void IsFieldRequired_prefers_validation_rule_then_flag_then_template_policy() + { + var template = Template("optional"); + var byRule = Field("a", required: false, validations: [Required("Enter A")]); + var byFlag = Field("b", required: true); + var byPolicy = Field("c", required: null); + + Assert.True(_service.IsFieldRequired(byRule, template)); + Assert.True(_service.IsFieldRequired(byFlag, template)); + Assert.False(_service.IsFieldRequired(byPolicy, template)); + Assert.True(_service.IsFieldRequired(byPolicy, Template("required"))); + } + + [Fact] + public void GetMissingRequiredFieldsWithMessages_skips_hidden_fields_and_uses_custom_messages() + { + var field = Field("name", required: true, validations: [Required("Enter the name")]); + var hidden = Field("secret", required: true); + var task = new TaskModel + { + TaskId = "t1", + TaskName = "About you", + TaskOrder = 1, + TaskStatusString = "NotStarted", + Pages = [Page("p1", [field, hidden])] + }; + + var missing = _service.GetMissingRequiredFieldsWithMessages( + task, + Template("optional"), + new Dictionary(), + fieldId => fieldId == "secret"); + + Assert.Equal("Enter the name", missing["name"]); + Assert.DoesNotContain("secret", missing.Keys); + } + + [Fact] + public void GetMissingRequiredFieldsWithMessages_returns_empty_when_values_are_present() + { + var field = Field("name", required: true); + var task = new TaskModel + { + TaskId = "t1", + TaskName = "About you", + TaskOrder = 1, + TaskStatusString = "NotStarted", + Pages = [Page("p1", [field])] + }; + + var missing = _service.GetMissingRequiredFieldsWithMessages( + task, + Template("optional"), + new Dictionary { ["name"] = "Ada" }); + + Assert.Empty(missing); + } + + private static FormTemplate Template(string policy) => + new() + { + TemplateId = "tpl", + TemplateName = "tpl", + Description = "tpl", + DefaultFieldRequirementPolicy = policy, + TaskGroups = [] + }; + + private static Field Field(string id, bool? required, List? validations = null) => + new() + { + FieldId = id, + Type = "text", + Label = new Label { Value = id }, + Order = 1, + Required = required, + Validations = validations + }; + + private static ValidationRule Required(string message) => + new() { Type = "required", Rule = "true", Message = message }; + + private static Page Page(string id, List fields) => + new() + { + PageId = id, + Slug = id, + Title = id, + Description = id, + PageOrder = 1, + Fields = fields + }; +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/FormStateManagerTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/FormStateManagerTests.cs new file mode 100644 index 0000000..ddfa002 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/FormStateManagerTests.cs @@ -0,0 +1,49 @@ +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.FormEngine; +using GovUK.Dfe.FlexForms.Domain.Models; +using GovUK.Dfe.FlexForms.Infrastructure.Services; +using TaskModel = GovUK.Dfe.FlexForms.Domain.Models.Task; + +namespace GovUK.Dfe.FlexForms.Infrastructure.UnitTests.Services; + +public class FormStateManagerTests +{ + private readonly FormStateManager _manager = new(); + + [Theory] + [InlineData(null, null, FormState.TaskList)] + [InlineData("task-1", null, FormState.TaskSummary)] + [InlineData("task-1", "page-1", FormState.FormPage)] + [InlineData("task-1", "flow/f1/i1/p1", FormState.SubFlowPage)] + public void GetCurrentState_maps_route_tokens_to_form_state(string? taskId, string? pageId, FormState expected) + { + Assert.Equal(expected, _manager.GetCurrentState("REF-1", taskId ?? string.Empty, pageId ?? string.Empty)); + } + + [Fact] + public void Summary_flags_delegate_to_step_policy() + { + var collection = new TaskModel + { + TaskId = "t1", + TaskName = "Task", + TaskOrder = 1, + TaskStatusString = "NotStarted", + Summary = new TaskSummaryConfiguration { Mode = FormStepPolicy.MultiCollectionFlowMode } + }; + var derived = new TaskModel + { + TaskId = "t2", + TaskName = "Task", + TaskOrder = 1, + TaskStatusString = "NotStarted", + Summary = new TaskSummaryConfiguration { Mode = FormStepPolicy.DerivedCollectionFlowMode } + }; + + Assert.True(_manager.ShouldShowCollectionFlowSummary(collection)); + Assert.True(_manager.ShouldShowDerivedCollectionFlowSummary(derived)); + Assert.False(_manager.ShouldShowApplicationPreview("preview")); + Assert.True(_manager.ShouldShowTaskList(string.Empty)); + Assert.True(_manager.IsInSubFlow("f1", "flow/f1/i1")); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/FormValidationOrchestratorTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/FormValidationOrchestratorTests.cs index 17390c5..e649796 100644 --- a/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/FormValidationOrchestratorTests.cs +++ b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/FormValidationOrchestratorTests.cs @@ -1,8 +1,9 @@ using AutoFixture; using AutoFixture.AutoNSubstitute; +using GovUK.Dfe.FlexForms.Application.Interfaces; using GovUK.Dfe.FlexForms.Domain.Models; using GovUK.Dfe.FlexForms.Infrastructure.Services; -using Microsoft.AspNetCore.Mvc.ModelBinding; +using NSubstitute; namespace GovUK.Dfe.FlexForms.Infrastructure.UnitTests.Services; @@ -16,6 +17,10 @@ public FormValidationOrchestratorTests() _fixture = new Fixture().Customize(new AutoNSubstituteCustomization { ConfigureMembers = true }); _fixture.Customize(ob => ob.Without(rule => rule.Conditions)); + + var fieldRequirementService = Substitute.For(); + fieldRequirementService.IsFieldRequired(Arg.Any(), Arg.Any()).Returns(false); + _fixture.Register(() => fieldRequirementService); _orchestrator = _fixture.Create(); } @@ -43,13 +48,12 @@ public void ValidateField_when_required_field_with_options_and_submittedValue_is .Create(); var formData = _fixture.Create?>(); - var modelState = _fixture.Create(); var fieldKey = field.FieldId; var formTemplate = _fixture.Create(); - var result = _orchestrator.ValidateField(field, submittedValue, formData, modelState, fieldKey, formTemplate); + var result = _orchestrator.ValidateField(field, submittedValue, formData, fieldKey, formTemplate); - Assert.True(result); + Assert.True(result.IsValid); } [Theory] @@ -75,15 +79,13 @@ public void ValidateField_when_required_field_with_options_and_submittedValue_is .Create(); var formData = _fixture.Create?>(); - var modelState = _fixture.Create(); var fieldKey = field.FieldId; var formTemplate = _fixture.Create(); - var result = _orchestrator.ValidateField(field, submittedValue, formData, modelState, fieldKey, formTemplate); + var result = _orchestrator.ValidateField(field, submittedValue, formData, fieldKey, formTemplate); - Assert.False(result); - Assert.NotNull(modelState[fieldKey]); - Assert.Equal("This field is required", modelState[fieldKey]!.Errors[0].ErrorMessage); + Assert.False(result.IsValid); + Assert.Equal("This field is required", result.Errors[0].Message); } [Theory] @@ -104,15 +106,13 @@ public void ValidateField_when_optional_field_with_options_and_submittedValue_is .Create(); var formData = _fixture.Create?>(); - var modelState = _fixture.Create(); var fieldKey = field.FieldId; var formTemplate = _fixture.Create(); - var result = _orchestrator.ValidateField(field, "not-an-option", formData, modelState, fieldKey, formTemplate); + var result = _orchestrator.ValidateField(field, "not-an-option", formData, fieldKey, formTemplate); - Assert.False(result); - Assert.NotNull(modelState[fieldKey]); - Assert.Equal("Select an option from the list", modelState[fieldKey]!.Errors[0].ErrorMessage); + Assert.False(result.IsValid); + Assert.Equal("Select an option from the list", result.Errors[0].Message); } [Theory] @@ -135,14 +135,13 @@ public void ValidateField_when_optional_field_with_options_and_submittedValue_is .Create(); var formData = _fixture.Create?>(); - var modelState = _fixture.Create(); var fieldKey = field.FieldId; var formTemplate = _fixture.Create(); - var result = _orchestrator.ValidateField(field, submittedValue, formData, modelState, fieldKey, formTemplate); + var result = _orchestrator.ValidateField(field, submittedValue, formData, fieldKey, formTemplate); - Assert.True(result); - Assert.Null(modelState[fieldKey]); + Assert.True(result.IsValid); + Assert.Empty(result.Errors); } [Fact] @@ -160,17 +159,16 @@ public void ValidateField_when_maxLength_and_submitted_value_contains_html_entit .Create(); var formData = _fixture.Create?>(); - var modelState = new ModelStateDictionary(); var fieldKey = field.FieldId; var formTemplate = _fixture.Create(); // User sees five characters (EMAT + U+2019); submitted value may arrive as an HTML numeric character reference. const string submittedEncoded = "EMAT’"; - var result = _orchestrator.ValidateField(field, submittedEncoded, formData, modelState, fieldKey, formTemplate); + var result = _orchestrator.ValidateField(field, submittedEncoded, formData, fieldKey, formTemplate); - Assert.True(result); - Assert.Null(modelState[fieldKey]); + Assert.True(result.IsValid); + Assert.Empty(result.Errors); } [Fact] @@ -188,16 +186,15 @@ public void ValidateField_when_maxLength_and_decoded_length_exceeds_limit_then_r .Create(); var formData = _fixture.Create?>(); - var modelState = new ModelStateDictionary(); var fieldKey = field.FieldId; var formTemplate = _fixture.Create(); const string submittedEncoded = "EMAT’"; - var result = _orchestrator.ValidateField(field, submittedEncoded, formData, modelState, fieldKey, formTemplate); + var result = _orchestrator.ValidateField(field, submittedEncoded, formData, fieldKey, formTemplate); - Assert.False(result); - Assert.Equal("Too many characters", modelState[fieldKey]!.Errors[0].ErrorMessage); + Assert.False(result.IsValid); + Assert.Equal("Too many characters", result.Errors[0].Message); } [Fact] @@ -219,14 +216,13 @@ public void ValidateField_when_maxLength_and_value_is_sanitised_with_br_tags_the .Create(); var formData = _fixture.Create?>(); - var modelState = new ModelStateDictionary(); var fieldKey = field.FieldId; var formTemplate = _fixture.Create(); - var result = _orchestrator.ValidateField(field, sanitisedAsStored, formData, modelState, fieldKey, formTemplate); + var result = _orchestrator.ValidateField(field, sanitisedAsStored, formData, fieldKey, formTemplate); - Assert.True(result); - Assert.Null(modelState[fieldKey]); + Assert.True(result.IsValid); + Assert.Empty(result.Errors); } [Fact] @@ -246,13 +242,12 @@ public void ValidateField_when_maxLength_and_sanitised_value_exceeds_plain_limit .Create(); var formData = _fixture.Create?>(); - var modelState = new ModelStateDictionary(); var fieldKey = field.FieldId; var formTemplate = _fixture.Create(); - var result = _orchestrator.ValidateField(field, sanitisedAsStored, formData, modelState, fieldKey, formTemplate); + var result = _orchestrator.ValidateField(field, sanitisedAsStored, formData, fieldKey, formTemplate); - Assert.False(result); + Assert.False(result.IsValid); } [Fact] @@ -301,11 +296,10 @@ private bool ValidateFieldWordCount(string sanitisedText, short limit) .Create(); var formData = _fixture.Create?>(); - var modelState = new ModelStateDictionary(); var fieldKey = field.FieldId; var formTemplate = _fixture.Create(); - return _orchestrator.ValidateField(field, sanitisedText, formData, modelState, fieldKey, formTemplate); + return _orchestrator.ValidateField(field, sanitisedText, formData, fieldKey, formTemplate).IsValid; } private bool ValidateComplexFieldWordCount(string sanitisedText, short limit) @@ -322,10 +316,9 @@ private bool ValidateComplexFieldWordCount(string sanitisedText, short limit) .Create(); var formData = _fixture.Create?>(); - var modelState = new ModelStateDictionary(); var fieldKey = field.FieldId; var formTemplate = _fixture.Create(); - return _orchestrator.ValidateField(field, sanitisedText, formData, modelState, fieldKey, formTemplate); + return _orchestrator.ValidateField(field, sanitisedText, formData, fieldKey, formTemplate).IsValid; } } \ No newline at end of file diff --git a/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Stores/HttpFormSessionStoreTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Stores/HttpFormSessionStoreTests.cs new file mode 100644 index 0000000..8ae9cb3 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Stores/HttpFormSessionStoreTests.cs @@ -0,0 +1,52 @@ +using GovUK.Dfe.FlexForms.Infrastructure.Stores; +using Microsoft.AspNetCore.Http; +using NSubstitute; + +namespace GovUK.Dfe.FlexForms.Infrastructure.UnitTests.Stores; + +public class HttpFormSessionStoreTests +{ + [Fact] + public void GetString_SetString_Remove_round_trip_http_session() + { + var httpContext = new DefaultHttpContext(); + httpContext.Session = new MemorySession(); + var accessor = Substitute.For(); + accessor.HttpContext.Returns(httpContext); + + var store = new HttpFormSessionStore(accessor); + + store.SetString("TemplateId", "abc"); + Assert.Equal("abc", store.GetString("TemplateId")); + Assert.Contains("TemplateId", store.Keys); + + store.Remove("TemplateId"); + Assert.Null(store.GetString("TemplateId")); + Assert.DoesNotContain("TemplateId", store.Keys); + } + + [Fact] + public void GetString_throws_when_http_context_is_missing() + { + var accessor = Substitute.For(); + accessor.HttpContext.Returns((HttpContext?)null); + var store = new HttpFormSessionStore(accessor); + + Assert.Throws(() => store.GetString("any")); + } + + private sealed class MemorySession : ISession + { + private readonly Dictionary _store = new(StringComparer.Ordinal); + + public bool IsAvailable => true; + public string Id => "test"; + public IEnumerable Keys => _store.Keys; + public void Clear() => _store.Clear(); + public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public void Remove(string key) => _store.Remove(key); + public void Set(string key, byte[] value) => _store[key] = value; + public bool TryGetValue(string key, out byte[] value) => _store.TryGetValue(key, out value!); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Stores/RedisInfectedFileStoreTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Stores/RedisInfectedFileStoreTests.cs new file mode 100644 index 0000000..347e853 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Stores/RedisInfectedFileStoreTests.cs @@ -0,0 +1,51 @@ +using GovUK.Dfe.FlexForms.Domain.Caching; +using GovUK.Dfe.FlexForms.Infrastructure.Stores; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using StackExchange.Redis; + +namespace GovUK.Dfe.FlexForms.Infrastructure.UnitTests.Stores; + +public class RedisInfectedFileStoreTests +{ + [Fact] + public void IsFileInfected_returns_true_when_blacklist_key_exists() + { + var fileId = Guid.NewGuid(); + var database = Substitute.For(); + database.KeyExists($"{FlexFormsCacheKeys.InfectedFilePrefix}{fileId}", Arg.Any()).Returns(true); + + var store = CreateStore(database); + + Assert.True(store.IsFileInfected(fileId)); + } + + [Fact] + public void IsFileNameInfected_returns_true_when_filename_blacklist_key_exists() + { + const string applicationId = "app-1"; + const string fileName = "malware.exe"; + var database = Substitute.For(); + database.KeyExists($"{FlexFormsCacheKeys.InfectedFileNamePrefix}{applicationId}:{fileName}", Arg.Any()).Returns(true); + + var store = CreateStore(database); + + Assert.True(store.IsFileNameInfected(applicationId, fileName)); + } + + [Fact] + public void IsFileNameInfected_returns_false_when_application_or_filename_is_missing() + { + var store = CreateStore(Substitute.For()); + + Assert.False(store.IsFileNameInfected("", "file.pdf")); + Assert.False(store.IsFileNameInfected("app-1", "")); + } + + private static RedisInfectedFileStore CreateStore(IDatabase database) + { + var redis = Substitute.For(); + redis.GetDatabase().Returns(database); + return new RedisInfectedFileStore(redis, NullLogger.Instance); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Architecture/CleanArchitectureGuardTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Architecture/CleanArchitectureGuardTests.cs new file mode 100644 index 0000000..e962c64 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Architecture/CleanArchitectureGuardTests.cs @@ -0,0 +1,94 @@ +using GovUK.Dfe.FlexForms.Application.FormEngine; +using GovUK.Dfe.FlexForms.Domain.FormEngine; +using GovUK.Dfe.FlexForms.Web.Pages.FormEngine; +using NetArchTest.Rules; + +namespace GovUK.Dfe.FlexForms.Web.UnitTests.Architecture; + +public class CleanArchitectureGuardTests +{ + [Fact] + public void PageModels_ShouldNotDependOnInfrastructure() + { + var result = Types.InNamespace("GovUK.Dfe.FlexForms.Web.Pages") + .ShouldNot() + .HaveDependencyOn("GovUK.Dfe.FlexForms.Infrastructure") + .GetResult(); + + Assert.True( + result.IsSuccessful, + "PageModels must not reference Infrastructure: " + Format(result.FailingTypeNames)); + } + + [Fact] + public void Application_ShouldNotDependOnInfrastructureOrWeb() + { + var application = typeof(IPrepareFormEngineGet).Assembly; + + var infrastructure = Types.InAssembly(application) + .ShouldNot() + .HaveDependencyOn("GovUK.Dfe.FlexForms.Infrastructure") + .GetResult(); + + var web = Types.InAssembly(application) + .ShouldNot() + .HaveDependencyOn("GovUK.Dfe.FlexForms.Web") + .GetResult(); + + Assert.True( + infrastructure.IsSuccessful, + "Application must not reference Infrastructure: " + Format(infrastructure.FailingTypeNames)); + Assert.True( + web.IsSuccessful, + "Application must not reference Web: " + Format(web.FailingTypeNames)); + } + + [Fact] + public void Application_ShouldNotTakeAspNetCoreSessionOrModelState() + { + var application = typeof(IPrepareFormEngineGet).Assembly; + + var session = Types.InAssembly(application) + .ShouldNot() + .HaveDependencyOn("Microsoft.AspNetCore.Http") + .GetResult(); + + var mvc = Types.InAssembly(application) + .ShouldNot() + .HaveDependencyOn("Microsoft.AspNetCore.Mvc") + .GetResult(); + + Assert.True( + session.IsSuccessful, + "Application must not take ISession/HttpContext: " + Format(session.FailingTypeNames)); + Assert.True( + mvc.IsSuccessful, + "Application must not take ModelState: " + Format(mvc.FailingTypeNames)); + } + + [Fact] + public void Domain_ShouldNotDependOnOuterLayers() + { + var domain = typeof(CheckboxValueNormalizer).Assembly; + + var application = Types.InAssembly(domain) + .ShouldNot() + .HaveDependencyOn("GovUK.Dfe.FlexForms.Application") + .GetResult(); + var infrastructure = Types.InAssembly(domain) + .ShouldNot() + .HaveDependencyOn("GovUK.Dfe.FlexForms.Infrastructure") + .GetResult(); + var web = Types.InAssembly(domain) + .ShouldNot() + .HaveDependencyOn("GovUK.Dfe.FlexForms.Web") + .GetResult(); + + Assert.True(application.IsSuccessful, Format(application.FailingTypeNames)); + Assert.True(infrastructure.IsSuccessful, Format(infrastructure.FailingTypeNames)); + Assert.True(web.IsSuccessful, Format(web.FailingTypeNames)); + } + + private static string Format(IEnumerable? names) => + names is null ? "(none)" : string.Join(", ", names); +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Extensions/FormValidationResultExtensionsTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Extensions/FormValidationResultExtensionsTests.cs new file mode 100644 index 0000000..04431ea --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Extensions/FormValidationResultExtensionsTests.cs @@ -0,0 +1,28 @@ +using GovUK.Dfe.FlexForms.Application.Validation; +using GovUK.Dfe.FlexForms.Web.Extensions; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace GovUK.Dfe.FlexForms.Web.UnitTests.Extensions; + +public class FormValidationResultExtensionsTests +{ + [Fact] + public void ApplyTo_maps_field_errors_and_returns_invalid() + { + var modelState = new ModelStateDictionary(); + var result = new FormValidationResult([new FormValidationError("Data[name]", "Enter a name")]); + + var isValid = result.ApplyTo(modelState); + + Assert.False(isValid); + Assert.Equal("Enter a name", modelState["Data[name]"]!.Errors.Single().ErrorMessage); + } + + [Fact] + public void ApplyTo_returns_true_when_there_are_no_errors() + { + var modelState = new ModelStateDictionary(); + Assert.True(FormValidationResult.Success.ApplyTo(modelState)); + Assert.Empty(modelState); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Filters/ConfirmationInterceptorFilterTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Filters/ConfirmationInterceptorFilterTests.cs new file mode 100644 index 0000000..5b4a050 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Filters/ConfirmationInterceptorFilterTests.cs @@ -0,0 +1,91 @@ +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Models; +using GovUK.Dfe.FlexForms.Web.Filters; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Primitives; +using NSubstitute; + +namespace GovUK.Dfe.FlexForms.Web.UnitTests.Filters; + +public class ConfirmationInterceptorFilterTests +{ + private readonly IButtonConfirmationService _confirmations = Substitute.For(); + private readonly ConfirmationInterceptorFilter _filter; + + public ConfirmationInterceptorFilterTests() + { + _confirmations.CreateConfirmation(Arg.Any()).Returns("token-1"); + _filter = new ConfirmationInterceptorFilter(_confirmations, NullLogger.Instance); + } + + [Fact] + public void OnActionExecuting_ShouldSkipInterception_WhenConfirmedQueryIsTrue() + { + var context = ExecutingContext( + method: "POST", + query: "?confirmed=true", + form: ConfirmationForm()); + + _filter.OnActionExecuting(context); + + Assert.Null(context.Result); + _confirmations.DidNotReceive().CreateConfirmation(Arg.Any()); + } + + [Fact] + public void OnActionExecuting_ShouldRedirectToConfirmation_WhenButtonRequiresConfirmation() + { + var context = ExecutingContext( + method: "POST", + query: "", + form: ConfirmationForm()); + + _filter.OnActionExecuting(context); + + var redirect = Assert.IsType(context.Result); + Assert.Equal("/Confirmation/Index", redirect.PageName); + _confirmations.Received().CreateConfirmation(Arg.Is(r => r.OriginalHandler == "RemoveCollectionItem")); + } + + [Fact] + public void OnActionExecuting_ShouldNotIntercept_WhenNoConfirmationButtonIsPresent() + { + var context = ExecutingContext( + method: "POST", + query: "", + form: new FormCollection(new Dictionary { ["handler"] = "Page" })); + + _filter.OnActionExecuting(context); + + Assert.Null(context.Result); + _confirmations.DidNotReceive().CreateConfirmation(Arg.Any()); + } + + private static ActionExecutingContext ExecutingContext(string method, string query, IFormCollection form) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = method; + httpContext.Request.Path = "/applications/REF-1/t1"; + httpContext.Request.QueryString = new QueryString(query); + httpContext.Request.ContentType = "application/x-www-form-urlencoded"; + httpContext.Features.Set(new FormFeature(form)); + + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + return new ActionExecutingContext(actionContext, new List(), new Dictionary(), new object()); + } + + private static FormCollection ConfirmationForm() => + new(new Dictionary + { + ["handler"] = "RemoveCollectionItem", + ["confirmation-check-RemoveCollectionItem"] = "true", + ["confirmation-display-fields-RemoveCollectionItem"] = "itemTitle", + ["itemTitle"] = "Ada" + }); +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/GovUK.Dfe.FlexForms.Web.UnitTests.csproj b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/GovUK.Dfe.FlexForms.Web.UnitTests.csproj index 747d0ce..4f86cdd 100644 --- a/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/GovUK.Dfe.FlexForms.Web.UnitTests.csproj +++ b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/GovUK.Dfe.FlexForms.Web.UnitTests.csproj @@ -17,6 +17,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Pages/Confirmation/ConfirmationIndexModelTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Pages/Confirmation/ConfirmationIndexModelTests.cs new file mode 100644 index 0000000..debb524 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Pages/Confirmation/ConfirmationIndexModelTests.cs @@ -0,0 +1,91 @@ +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Models; +using GovUK.Dfe.FlexForms.Web.Pages.Confirmation; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Primitives; +using NSubstitute; + +namespace GovUK.Dfe.FlexForms.Web.UnitTests.Pages.Confirmation; + +public class ConfirmationIndexModelTests +{ + private readonly IButtonConfirmationService _confirmations = Substitute.For(); + private readonly IndexModel _model; + + public ConfirmationIndexModelTests() + { + _model = new IndexModel(_confirmations, NullLogger.Instance); + var httpContext = new DefaultHttpContext(); + _model.PageContext = new PageContext + { + HttpContext = httpContext, + ViewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()) + }; + _model.TempData = new TempDataDictionary(httpContext, Substitute.For()); + } + + [Fact] + public void OnGet_ShouldRedirectToError_WhenTokenIsMissing() + { + var result = Assert.IsType(_model.OnGet("")); + Assert.Equal("/Error/General", result.PageName); + } + + [Fact] + public void OnPost_ShouldRedirectPreservingMethod_WhenUserConfirms() + { + const string token = "tok-1"; + _model.ConfirmationToken = token; + _model.HttpContext.Request.Form = new FormCollection(new Dictionary + { + ["Confirmed"] = "true" + }); + _confirmations.GetConfirmation(token).Returns(new ConfirmationContext + { + Token = token, + Request = new ConfirmationRequest + { + OriginalPagePath = "/applications/REF-1/t1", + OriginalHandler = "RemoveCollectionItem", + OriginalFormData = new Dictionary { ["itemId"] = "i1" }, + ReturnUrl = "/applications/REF-1/t1" + }, + ExpiresAt = DateTime.UtcNow.AddMinutes(5) + }); + + var result = Assert.IsType(_model.OnPost()); + + Assert.Equal("/applications/REF-1/t1?confirmed=true&handler=RemoveCollectionItem", result.Url); + Assert.True(result.PreserveMethod); + Assert.Equal("{\"itemId\":\"i1\"}", _model.TempData["ConfirmedFormData"]); + Assert.Equal("RemoveCollectionItem", _model.TempData["ConfirmedHandler"]); + _confirmations.Received().ClearConfirmation(token); + } + + [Fact] + public void OnPost_ShouldReturnToOriginalUrl_WhenUserCancels() + { + const string token = "tok-1"; + _model.ConfirmationToken = token; + _model.HttpContext.Request.Form = new FormCollection(new Dictionary + { + ["Confirmed"] = "false" + }); + _confirmations.GetConfirmation(token).Returns(new ConfirmationContext + { + Token = token, + Request = new ConfirmationRequest { ReturnUrl = "/applications/REF-1/t1" }, + ExpiresAt = DateTime.UtcNow.AddMinutes(5) + }); + + var result = Assert.IsType(_model.OnPost()); + + Assert.Equal("/applications/REF-1/t1", result.Url); + _confirmations.Received().ClearConfirmation(token); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Pages/FormEngine/RenderFormModelCharacterizationTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Pages/FormEngine/RenderFormModelCharacterizationTests.cs new file mode 100644 index 0000000..6bf9aeb --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Pages/FormEngine/RenderFormModelCharacterizationTests.cs @@ -0,0 +1,681 @@ +using System.Security.Claims; +using System.Text.Json; +using AutoFixture; +using AutoFixture.AutoNSubstitute; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.FormEngine; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Application.Validation; +using GovUK.Dfe.FlexForms.Domain.FormEngine; +using GovUK.Dfe.FlexForms.Domain.Models; +using GovUK.Dfe.FlexForms.Web.Pages.FormEngine; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Primitives; +using NSubstitute; +using Task = System.Threading.Tasks.Task; +using PageModel = GovUK.Dfe.FlexForms.Domain.Models.Page; +using TaskModel = GovUK.Dfe.FlexForms.Domain.Models.Task; + +namespace GovUK.Dfe.FlexForms.Web.UnitTests.Pages.FormEngine; + +public class RenderFormModelCharacterizationTests +{ + private readonly IFixture _fixture; + private readonly HttpRequest _request; + private readonly ITemplateManagementService _templates; + private readonly IFormValidationOrchestrator _validation; + private readonly IApplicationStateService _applicationState; + private readonly IFieldRequirementService _fieldRequirements; + private readonly IApplicationsClient _applicationsClient; + private readonly IFileUploadService _fileUploadService; + private readonly IFormFileFieldService _fileFieldService; + private readonly IInfectedUploadFilter _infectedFilter; + private readonly IApplicationResponseService _responses; + private readonly IFormNavigationService _navigation; + private readonly IConditionalLogicOrchestrator _conditionalLogic; + private readonly RenderFormModel _model; + private readonly Guid _applicationId = Guid.NewGuid(); + + public RenderFormModelCharacterizationTests() + { + _fixture = new Fixture().Customize(new AutoNSubstituteCustomization { ConfigureMembers = true }); + _fixture.Customize(ob => ob.Without(rule => rule.Conditions)); + _fixture.Customize(ob => ob + .Without(desc => desc.HandlerMethods) + .Without(desc => desc.Parameters) + .Without(desc => desc.BoundProperties)); + _fixture.Customize(ob => ob + .Without(desc => desc.Parameters) + .Without(desc => desc.BoundProperties)); + + var session = Substitute.For(); + session.TryGetValue(Arg.Any(), out Arg.Any()).Returns(false); + session.Keys.Returns(Array.Empty()); + _fixture.Register(() => session); + + _applicationState = Substitute.For(); + _applicationState.IsApplicationEditable(Arg.Any()).Returns(true); + _applicationState.EnsureApplicationIdAsync(Arg.Any()).Returns((_applicationId, (ApplicationDto?)null)); + _applicationState.GetApplicationStatus(Arg.Any()).Returns("InProgress"); + _applicationState.AreAllTasksCompleted(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(true); + _applicationState.ValidateAllRequiredFieldsForSubmission(Arg.Any(), Arg.Any>(), Arg.Any?>()) + .Returns(new Dictionary>()); + _fixture.Register(() => _applicationState); + + _responses = Substitute.For(); + _responses.GetAccumulatedFormData().Returns(new Dictionary()); + _fixture.Register(() => _responses); + + _templates = Substitute.For(); + _templates.LoadTemplateAsync(Arg.Any(), Arg.Any()) + .Returns(EmptyTemplate()); + _fixture.Register(() => _templates); + + _validation = Substitute.For(); + _validation.ValidatePage(default!, default!, default).ReturnsForAnyArgs(FormValidationResult.Success); + _validation.ValidateTask(default!, default!, default).ReturnsForAnyArgs(FormValidationResult.Success); + _validation.ValidateApplication(default!, default!).ReturnsForAnyArgs(FormValidationResult.Success); + _fixture.Register(() => _validation); + + _fixture.Register(() => Substitute.For()); + var sessionStore = Substitute.For(); + _fixture.Register(() => sessionStore); + _fixture.Register(() => new PostedFormDataBinder()); + _fixture.Register(() => new CollectionFlowProgressStore(sessionStore)); + + _infectedFilter = Substitute.For(); + _infectedFilter.FilterList(Arg.Any>(), Arg.Any()) + .Returns(call => (call.Arg>() ?? []).ToList()); + _infectedFilter.FilterUploadDataJson(Arg.Any(), Arg.Any()) + .Returns(call => call.ArgAt(0) ?? string.Empty); + _fixture.Register(() => _infectedFilter); + + _fileFieldService = Substitute.For(); + _fileFieldService.GetFiles(Arg.Any(), Arg.Any()).Returns(Array.Empty()); + _fixture.Register(() => _fileFieldService); + + _conditionalLogic = Substitute.For(); + _conditionalLogic.ApplyConditionalLogicAsync(default!, default!, default) + .ReturnsForAnyArgs(new FormConditionalState()); + _conditionalLogic.GetNextPageAsync(default!, default!, default!, default) + .ReturnsForAnyArgs((string?)null); + _fixture.Register(() => _conditionalLogic); + + var formStateManager = Substitute.For(); + formStateManager.GetCurrentState(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(call => + { + var taskId = call.ArgAt(1); + var pageId = call.ArgAt(2); + if (FormStepPolicy.IsCollectionFlowPage(pageId)) return FormState.SubFlowPage; + if (FormStepPolicy.IsFormPage(pageId)) return FormState.FormPage; + if (FormStepPolicy.IsTaskSummary(taskId, pageId)) return FormState.TaskSummary; + return FormState.TaskList; + }); + formStateManager.ShouldShowCollectionFlowSummary(Arg.Any()) + .Returns(call => FormStepPolicy.IsCollectionFlowSummary(call.Arg())); + formStateManager.ShouldShowDerivedCollectionFlowSummary(Arg.Any()) + .Returns(call => FormStepPolicy.IsDerivedCollectionFlowSummary(call.Arg())); + _fixture.Register(() => formStateManager); + + _navigation = Substitute.For(); + _navigation.GetTaskSummaryUrl(Arg.Any(), Arg.Any()) + .Returns(call => $"/applications/{call.ArgAt(1)}/{call.ArgAt(0)}"); + _navigation.GetCollectionFlowSummaryUrl(Arg.Any(), Arg.Any()) + .Returns(call => $"/applications/{call.ArgAt(1)}/{call.ArgAt(0)}"); + _navigation.GetSubFlowPageUrl(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(call => $"/applications/{call.ArgAt(1)}/{call.ArgAt(0)}/flow/{call.ArgAt(2)}/{call.ArgAt(3)}/{call.ArgAt(4)}"); + _navigation.GetBackLinkUrl(default!, default!, default!).ReturnsForAnyArgs("/back"); + _fixture.Register(() => _navigation); + + _fieldRequirements = Substitute.For(); + _fieldRequirements.GetMissingRequiredFieldsWithMessages(default!, default!, default!, default) + .ReturnsForAnyArgs(new Dictionary()); + _fieldRequirements.IsFieldRequired(default!, default!).ReturnsForAnyArgs(false); + _fixture.Register(() => _fieldRequirements); + + _applicationsClient = Substitute.For(); + _applicationsClient.GetFileValidationGateAsync(Arg.Any()) + .Returns(new FileValidationGateDto { CanSubmit = true, BlockingFiles = [] }); + _applicationsClient.SubmitApplicationAsync(Arg.Any()) + .Returns(new ApplicationDto { ApplicationReference = "REF-1" }); + _fixture.Register(() => _applicationsClient); + + _fileUploadService = Substitute.For(); + _fixture.Register(() => _fileUploadService); + + _fixture.Register(() => Substitute.For()); + + _fixture.Register(() => new CompleteFormTaskService( + _applicationState, + _fieldRequirements, + _conditionalLogic, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); + _fixture.Register(() => new SubmitFormApplicationService( + _applicationState, + _applicationsClient, + sessionStore, + _conditionalLogic, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); + _fixture.Register(() => new PrepareFormEngineGetService( + _templates, + _responses, + _fixture.Create(), + sessionStore, + _conditionalLogic, + _fixture.Create(), + _fileFieldService, + _fixture.Create(), + _fixture.Create(), + _applicationsClient, + _fixture.Create(), + _applicationState, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); + _fixture.Register(() => new SaveFormPageService( + _templates, + _fixture.Create(), + _fileFieldService, + _validation, + _responses, + _fixture.Create(), + sessionStore, + _fixture.Create(), + _navigation, + _fixture.Create(), + _conditionalLogic, + _fixture.Create(), + _fixture.Create(), + _applicationState, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); + _fixture.Register(() => new RemoveCollectionItemService( + _templates, + _responses, + _fileUploadService, + _navigation, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); + _fixture.Register(() => new UploadFormFileService( + _fileFieldService, + _fileUploadService, + _infectedFilter, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); + _fixture.Register(() => new DeleteFormFileService( + _fileFieldService, + _fileUploadService, + _responses, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); + _fixture.Register(() => new DownloadFormFileService( + _fileUploadService, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); + + _request = Substitute.For(); + _request.Path = PathString.Empty; + _request.QueryString = QueryString.Empty; + _request.Query.Returns(new QueryCollection()); + _request.Form.Returns(new FormCollection(new Dictionary())); + _request.Scheme.Returns("https"); + _request.Host.Returns(new HostString("localhost")); + _fixture.Register(() => _request); + + var httpContext = Substitute.For(); + httpContext.Session.Returns(session); + httpContext.Request.Returns(_request); + httpContext.Response.Returns(Substitute.For()); + httpContext.User.Returns(new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.Role, "Admin")], + authenticationType: "Test"))); + _fixture.Register(() => httpContext); + + _model = _fixture.Create(); + _model.PageContext = new PageContext + { + HttpContext = httpContext, + ViewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()) + }; + _model.TempData = new TempDataDictionary(httpContext, Substitute.For()); + _model.Data = new Dictionary(); + _model.ReferenceNumber = "REF-1"; + _model.ApplicationId = _applicationId; + _model.CurrentPageId = string.Empty; + _model.TaskId = string.Empty; + } + + [Fact] + public async Task OnGetAsync_ShouldShowTaskList_WhenNoTaskOrPageIsPresent() + { + await _model.OnGetAsync(); + Assert.Equal(FormState.TaskList, _model.CurrentFormState); + } + + [Fact] + public async Task OnGetAsync_ShouldUseDummyTemplate_WhenTemplateIsMissing() + { + _templates.LoadTemplateAsync(Arg.Any(), Arg.Any()) + .Returns((FormTemplate)null!); + + await _model.OnGetAsync(); + + Assert.Equal("dummy", _model.Template.TemplateId); + Assert.Equal(FormState.TaskList, _model.CurrentFormState); + } + + [Fact] + public async Task OnGetAsync_ShouldShowTaskList_WhenApplicationIdIsMissing() + { + _applicationState.EnsureApplicationIdAsync(Arg.Any()) + .Returns(((Guid?)null, (ApplicationDto?)null)); + + await _model.OnGetAsync(); + + Assert.Null(_model.ApplicationId); + Assert.Equal(FormState.TaskList, _model.CurrentFormState); + } + + [Fact] + public async Task OnGetAsync_ShouldShowTaskSummary_WhenOnlyTaskIdIsPresent() + { + var task = StandardTask(); + RegisterTask(task); + _model.TaskId = task.TaskId; + + await _model.OnGetAsync(); + + Assert.Equal(FormState.TaskSummary, _model.CurrentFormState); + Assert.Equal(task.TaskId, _model.CurrentTask.TaskId); + } + + [Fact] + public async Task OnGetAsync_ShouldShowFormPage_WhenPageIdIsPresent() + { + var page = Page("p1", ReturnToSummary: false); + var task = StandardTask(pages: [page]); + RegisterTask(task, page); + _model.TaskId = task.TaskId; + _model.CurrentPageId = "p1"; + + await _model.OnGetAsync(); + + Assert.Equal(FormState.FormPage, _model.CurrentFormState); + Assert.Equal("p1", _model.CurrentPage.PageId); + } + + [Fact] + public async Task OnGetAsync_ShouldShowFormPage_WhenCollectionFlowRouteIsPresent() + { + var flowPage = Page("fp1"); + var flow = new MultiCollectionFlowConfiguration + { + FlowId = "f1", + FieldId = "members", + Pages = [flowPage] + }; + var task = StandardTask(mode: FormStepPolicy.MultiCollectionFlowMode, flows: [flow]); + RegisterTask(task, flowPage); + _model.TaskId = task.TaskId; + _model.CurrentPageId = "flow/f1/i1/fp1"; + + await _model.OnGetAsync(); + + Assert.Equal(FormState.FormPage, _model.CurrentFormState); + Assert.Equal("fp1", _model.CurrentPage.PageId); + Assert.Equal("f1", _model.FlowId); + } + + [Fact] + public async Task OnGetAsync_ShouldShowFormPage_WhenDerivedFlowRouteIsPresent() + { + var derivedPage = Page("dp1"); + var derived = new DerivedCollectionFlowConfiguration + { + FlowId = "df1", + FieldId = "decls", + SourceFieldId = "orgs", + Pages = [derivedPage] + }; + var task = StandardTask(mode: FormStepPolicy.DerivedCollectionFlowMode, derivedFlows: [derived]); + RegisterTask(task, derivedPage); + _model.TaskId = task.TaskId; + _model.CurrentPageId = "df1/derived/item1/dp1"; + + await _model.OnGetAsync(); + + Assert.Equal(FormState.FormPage, _model.CurrentFormState); + Assert.Equal("dp1", _model.CurrentPage.PageId); + } + + [Fact] + public async Task OnGetAsync_ShouldShowPreview_WhenPreviewQueryIsPresent() + { + _request.Query.Returns(new QueryCollection(new Dictionary { ["preview"] = "true" })); + _model.TaskId = "t1"; + _model.CurrentPageId = "p1"; + + await _model.OnGetAsync(); + + Assert.Equal(FormState.ApplicationPreview, _model.CurrentFormState); + } + + [Fact] + public async Task OnPostPageAsync_ShouldReturnPage_WhenValidationFails() + { + var page = Page("p1", ReturnToSummary: false); + var task = StandardTask(pages: [page]); + RegisterTask(task, page); + _model.TaskId = task.TaskId; + _model.CurrentPageId = "p1"; + _validation.ValidatePage(default!, default!, default) + .ReturnsForAnyArgs(new FormValidationResult([new FormValidationError("name", "Enter a name")])); + + var result = await _model.OnPostPageAsync(); + + Assert.IsType(result); + Assert.False(_model.ModelState.IsValid); + } + + [Fact] + public async Task OnPostPageAsync_ShouldRedirectToNextPage_WhenCurrentPageIsNotLast() + { + var first = Page("p1", ReturnToSummary: false); + var second = Page("p2", ReturnToSummary: false); + var task = StandardTask(pages: [first, second]); + RegisterTask(task, first); + _model.TaskId = task.TaskId; + _model.CurrentPageId = "p1"; + + var result = Assert.IsType(await _model.OnPostPageAsync()); + + Assert.Equal($"/applications/REF-1/{task.TaskId}/p2", result.Url); + } + + [Fact] + public async Task OnPostPageAsync_ShouldRedirectToConditionalPage_WhenLogicSkipsTheNextPage() + { + var first = Page("p1", ReturnToSummary: false); + var skipped = Page("p2", ReturnToSummary: false); + var target = Page("p3", ReturnToSummary: false); + var task = StandardTask(pages: [first, skipped, target]); + RegisterTask(task, first); + _model.TaskId = task.TaskId; + _model.CurrentPageId = "p1"; + _conditionalLogic.GetNextPageAsync(default!, default!, default!, default) + .ReturnsForAnyArgs("p3"); + + var result = Assert.IsType(await _model.OnPostPageAsync()); + + Assert.Equal($"/applications/REF-1/{task.TaskId}/p3", result.Url); + } + + [Fact] + public async Task OnPostPageAsync_ShouldRedirectToTaskSummary_WhenReturnToSummaryPageIsTrue() + { + var page = Page("p1", ReturnToSummary: true); + var later = Page("p2", ReturnToSummary: false); + var task = StandardTask(pages: [page, later]); + RegisterTask(task, page); + _model.TaskId = task.TaskId; + _model.CurrentPageId = "p1"; + + var result = Assert.IsType(await _model.OnPostPageAsync()); + + Assert.Equal($"/applications/REF-1/{task.TaskId}", result.Url); + } + + [Fact] + public async Task OnPostPageAsync_ShouldRestoreConfirmedFormData_WhenConfirmedQueryIsPresent() + { + _request.Query.Returns(new QueryCollection(new Dictionary { ["confirmed"] = "true" })); + _model.TempData["ConfirmedFormData"] = JsonSerializer.Serialize(new Dictionary { ["restored"] = "yes" }); + _model.TempData["ConfirmedHandler"] = "Page"; + + await _model.OnPostPageAsync(); + + Assert.Equal("yes", _model.Data["restored"]?.ToString()); + } + + [Fact] + public async Task OnPostTaskSummaryAsync_ShouldReturnPage_WhenRequiredFieldsAreMissing() + { + var task = StandardTask(); + RegisterTask(task); + _model.TaskId = task.TaskId; + _model.IsTaskCompleted = true; + _fieldRequirements.GetMissingRequiredFieldsWithMessages(default!, default!, default!, default) + .ReturnsForAnyArgs(new Dictionary { ["name"] = "Enter a name" }); + + var result = await _model.OnPostTaskSummaryAsync(); + + Assert.IsType(result); + Assert.False(_model.IsTaskCompleted); + Assert.Equal(FormState.TaskSummary, _model.CurrentFormState); + Assert.Contains(_model.ModelState.Values.SelectMany(v => v.Errors), e => e.ErrorMessage.Contains("Enter a name")); + } + + [Fact] + public async Task OnPostTaskSummaryAsync_ShouldReturnPage_WhenCollectionMinItemsAreMissing() + { + var flow = new MultiCollectionFlowConfiguration + { + FlowId = "f1", + FieldId = "members", + Title = "Members", + MinItems = 2, + Pages = [Page("fp1")] + }; + var task = StandardTask(mode: FormStepPolicy.MultiCollectionFlowMode, flows: [flow]); + RegisterTask(task); + _model.TaskId = task.TaskId; + _model.IsTaskCompleted = true; + _responses.GetAccumulatedFormData().Returns(new Dictionary + { + ["members"] = """[{"id":"i1"}]""" + }); + + var result = await _model.OnPostTaskSummaryAsync(); + + Assert.IsType(result); + Assert.Contains( + _model.ModelState.Values.SelectMany(v => v.Errors), + e => e.ErrorMessage.Contains("Add at least 2 item(s) to Members")); + } + + [Fact] + public async Task OnPostSubmitApplicationAsync_ShouldReturnPage_WhenNotAllTasksAreComplete() + { + _applicationState.AreAllTasksCompleted(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(false); + + var result = await _model.OnPostSubmitApplicationAsync(); + + Assert.IsType(result); + Assert.Equal(FormState.ApplicationPreview, _model.CurrentFormState); + Assert.Contains( + _model.ModelState.Values.SelectMany(v => v.Errors), + e => e.ErrorMessage.Contains("All sections must be completed")); + } + + [Fact] + public async Task OnPostSubmitApplicationAsync_ShouldReturnPage_WhenFileValidationGateBlocksSubmit() + { + _applicationsClient.GetFileValidationGateAsync(Arg.Any()) + .Returns(new FileValidationGateDto + { + CanSubmit = false, + BlockingFiles = [new FileValidationBlockDto { OriginalFileName = "scan.pdf", ValidationStatus = FileValidationStatus.Pending }] + }); + + var result = await _model.OnPostSubmitApplicationAsync(); + + Assert.IsType(result); + Assert.Contains( + _model.ModelState.Values.SelectMany(v => v.Errors), + e => e.ErrorMessage.Contains("scan.pdf")); + } + + [Fact] + public async Task OnPostSubmitApplicationAsync_ShouldRedirect_WhenSubmitSucceeds() + { + var result = Assert.IsType(await _model.OnPostSubmitApplicationAsync()); + + Assert.Equal("/Applications/ApplicationSubmitted", result.PageName); + await _applicationsClient.Received().SubmitApplicationAsync(_applicationId); + } + + [Fact] + public async Task OnPostRemoveCollectionItemAsync_ShouldDeleteAssociatedFiles_WhenConfirmed() + { + var fileId = Guid.NewGuid(); + var flow = new MultiCollectionFlowConfiguration + { + FlowId = "f1", + FieldId = "members", + Title = "Members", + DeleteItemMessage = "removed", + Pages = [Page("fp1")] + }; + var task = StandardTask(mode: FormStepPolicy.MultiCollectionFlowMode, flows: [flow]); + RegisterTask(task); + _model.TaskId = task.TaskId; + _request.Query.Returns(new QueryCollection(new Dictionary { ["confirmed"] = "true" })); + var items = JsonSerializer.Serialize(new[] + { + new Dictionary + { + ["id"] = "i1", + ["files"] = JsonSerializer.Serialize(new[] { new UploadDto { Id = fileId, OriginalFileName = "cv.pdf" } }) + } + }); + _responses.GetAccumulatedFormData().Returns(new Dictionary { ["members"] = items }); + + var result = Assert.IsType( + await _model.OnPostRemoveCollectionItemAsync("members", "i1", "f1")); + + await _fileUploadService.Received().DeleteFileAsync(fileId, _applicationId); + _responses.Received().AccumulateFormData(Arg.Is>(d => + d["members"].ToString()!.Contains("[]") || d["members"].ToString() == "[]")); + Assert.Equal($"/applications/REF-1/{task.TaskId}", result.Url); + } + + [Fact] + public async Task OnPostUploadFileAsync_ShouldSaveFileToSession_WhenUploadSucceeds() + { + var uploaded = new UploadDto { Id = Guid.NewGuid(), OriginalFileName = "cv.pdf" }; + _fileUploadService.UploadFileAsync(default!, default, default, default!, default) + .ReturnsForAnyArgs(uploaded); + SetUploadForm("cv.pdf"); + + var result = Assert.IsType(await _model.OnPostUploadFileAsync()); + + Assert.Equal("/back", result.Url); + _fileFieldService.Received().SaveFiles( + Arg.Any(), + "evidence", + Arg.Is>(files => files.Any(f => f.Id == uploaded.Id))); + } + + [Fact] + public async Task OnPostUploadFileAsync_ShouldSkipInfectedFile_WhenBlacklistFilterRemovesIt() + { + var uploaded = new UploadDto { Id = Guid.NewGuid(), OriginalFileName = "virus.bin" }; + _fileUploadService.UploadFileAsync(default!, default, default, default!, default) + .ReturnsForAnyArgs(uploaded); + _infectedFilter.FilterList(Arg.Any>(), Arg.Any()) + .Returns([]); + SetUploadForm("virus.bin"); + + await _model.OnPostUploadFileAsync(); + + _fileFieldService.Received().SaveFiles( + Arg.Any(), + "evidence", + Arg.Is>(files => files.Count == 0)); + } + + private void SetUploadForm(string fileName) + { + var content = new MemoryStream([1, 2, 3]); + IFormFile file = new FormFile(content, 0, content.Length, "UploadFile", fileName) + { + Headers = new HeaderDictionary(), + ContentType = "application/pdf" + }; + _request.Form.Returns(new FormCollection( + new Dictionary + { + ["ApplicationId"] = _applicationId.ToString(), + ["FieldId"] = "evidence", + ["ReturnUrl"] = "/back" + }, + new FormFileCollection { file })); + } + + private void RegisterTask(TaskModel task, PageModel? currentPage = null) + { + var group = new TaskGroup + { + GroupId = "g1", + GroupName = "Group", + GroupOrder = 1, + GroupStatus = "NotStarted", + Tasks = [task] + }; + _templates.LoadTemplateAsync(Arg.Any(), Arg.Any()) + .Returns(new FormTemplate + { + TemplateId = "tpl", + TemplateName = "tpl", + Description = "tpl", + TaskGroups = [group] + }); + _templates.FindTask(Arg.Any(), Arg.Any()).Returns((group, task)); + if (currentPage != null) + _templates.FindPage(Arg.Any(), Arg.Any()).Returns((group, task, currentPage)); + } + + private static TaskModel StandardTask( + string mode = "standard", + List? pages = null, + List? flows = null, + List? derivedFlows = null) => + new() + { + TaskId = "t1", + TaskName = "About you", + TaskOrder = 1, + TaskStatusString = "NotStarted", + Pages = pages ?? [Page("p1")], + Summary = new TaskSummaryConfiguration + { + Mode = mode, + Flows = flows, + DerivedFlows = derivedFlows + } + }; + + private static PageModel Page(string id, bool ReturnToSummary = true) => + new() + { + PageId = id, + Slug = id, + Title = id, + Description = id, + PageOrder = 1, + Fields = [new Field { FieldId = "name", Type = "text", Label = new Label { Value = "Name" }, Order = 1 }], + ReturnToSummaryPage = ReturnToSummary + }; + + private static FormTemplate EmptyTemplate() => + new() + { + TemplateId = "tpl", + TemplateName = "tpl", + Description = "tpl", + TaskGroups = [] + }; +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Pages/FormEngine/RenderFormModelTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Pages/FormEngine/RenderFormModelTests.cs index 722e5e6..f8d09e6 100644 --- a/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Pages/FormEngine/RenderFormModelTests.cs +++ b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Pages/FormEngine/RenderFormModelTests.cs @@ -1,11 +1,19 @@ +using System.Security.Claims; using AutoFixture; using AutoFixture.AutoNSubstitute; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Api.Client.Contracts; +using GovUK.Dfe.FlexForms.Application.FormEngine; using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Application.Validation; +using GovUK.Dfe.FlexForms.Domain.FormEngine; using GovUK.Dfe.FlexForms.Domain.Models; using GovUK.Dfe.FlexForms.Web.Pages.FormEngine; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.Extensions.Primitives; using NSubstitute; using Task = System.Threading.Tasks.Task; @@ -18,8 +26,10 @@ public class RenderFormModelTests { private readonly IFixture _fixture; private readonly ISession _session; + private readonly HttpRequest _request; private readonly IApplicationResponseService _applicationResponseService; private readonly INavigationHistoryService _navigationHistoryService; + private readonly ITemplateManagementService _templateManagementService; private readonly RenderFormModel _model; public RenderFormModelTests() @@ -36,26 +46,201 @@ public RenderFormModelTests() .Without(desc => desc.Parameters) .Without(desc => desc.BoundProperties) ); - - _session = _fixture.Create(); + + _session = Substitute.For(); + _session.TryGetValue(Arg.Any(), out Arg.Any()).Returns(false); + _session.Keys.Returns(Array.Empty()); _fixture.Register(() => _session); - var applicationStateService = _fixture.Create(); + var applicationId = Guid.NewGuid(); + var applicationStateService = Substitute.For(); applicationStateService.IsApplicationEditable(Arg.Any()).Returns(true); + applicationStateService.EnsureApplicationIdAsync(Arg.Any()) + .Returns((applicationId, (ApplicationDto?)null)); + applicationStateService.GetApplicationStatus(Arg.Any()).Returns("InProgress"); _fixture.Register(() => applicationStateService); - - _applicationResponseService = _fixture.Create(); + + _applicationResponseService = Substitute.For(); + _applicationResponseService.GetAccumulatedFormData().Returns(new Dictionary()); _fixture.Register(() => _applicationResponseService); - _navigationHistoryService = _fixture.Create(); + _navigationHistoryService = Substitute.For(); _fixture.Register(() => _navigationHistoryService); - var request = _fixture.Create(); - request.Path = PathString.Empty; - request.QueryString = QueryString.Empty; - _fixture.Register(() => request); + _templateManagementService = Substitute.For(); + _templateManagementService.LoadTemplateAsync(Arg.Any(), Arg.Any()) + .Returns(new FormTemplate + { + TemplateId = "template", + TemplateName = "template", + Description = "template", + TaskGroups = [] + }); + _fixture.Register(() => _templateManagementService); + + var validationOrchestrator = Substitute.For(); + validationOrchestrator.ValidatePage(default!, default!, default).ReturnsForAnyArgs(FormValidationResult.Success); + validationOrchestrator.ValidateTask(default!, default!, default).ReturnsForAnyArgs(FormValidationResult.Success); + validationOrchestrator.ValidateApplication(default!, default!).ReturnsForAnyArgs(FormValidationResult.Success); + _fixture.Register(() => validationOrchestrator); + + var infectedFileStore = Substitute.For(); + infectedFileStore.IsFileInfected(Arg.Any()).Returns(false); + infectedFileStore.IsFileNameInfected(Arg.Any(), Arg.Any()).Returns(false); + _fixture.Register(() => infectedFileStore); + + var sessionStore = Substitute.For(); + sessionStore.GetString(Arg.Any()).Returns(call => + { + var key = call.Arg(); + return _session.TryGetValue(key, out var bytes) && bytes is { Length: > 0 } + ? System.Text.Encoding.UTF8.GetString(bytes) + : null; + }); + _fixture.Register(() => sessionStore); + _fixture.Register(() => new PostedFormDataBinder()); + _fixture.Register(() => new CollectionFlowProgressStore(sessionStore)); + + var infectedFilter = Substitute.For(); + infectedFilter.FilterList(Arg.Any>(), Arg.Any()) + .Returns(call => (call.Arg>() ?? []).ToList()); + infectedFilter.FilterUploadDataJson(Arg.Any(), Arg.Any()) + .Returns(call => call.ArgAt(0) ?? string.Empty); + _fixture.Register(() => infectedFilter); + + var fileFieldService = Substitute.For(); + fileFieldService.GetFiles(Arg.Any(), Arg.Any()) + .Returns(Array.Empty()); + _fixture.Register(() => fileFieldService); + + var conditionalLogic = Substitute.For(); + conditionalLogic.ApplyConditionalLogicAsync(default!, default!, default) + .ReturnsForAnyArgs(new FormConditionalState()); + _fixture.Register(() => conditionalLogic); + + var formNavigationService = Substitute.For(); + formNavigationService.GetSubFlowPageUrl(default!, default!, default!, default!, default!) + .ReturnsForAnyArgs("/applications/ref/task/flow/next"); + formNavigationService.GetCollectionFlowSummaryUrl(default!, default!) + .ReturnsForAnyArgs("/applications/ref/task"); + formNavigationService.GetBackLinkUrl(default!, default!, default!) + .ReturnsForAnyArgs("/back"); + _fixture.Register(() => formNavigationService); + + var formStateManager = Substitute.For(); + formStateManager.GetCurrentState(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(call => + { + var taskId = call.ArgAt(1); + var pageId = call.ArgAt(2); + if (FormStepPolicy.IsCollectionFlowPage(pageId)) return FormState.SubFlowPage; + if (FormStepPolicy.IsFormPage(pageId)) return FormState.FormPage; + if (FormStepPolicy.IsTaskSummary(taskId, pageId)) return FormState.TaskSummary; + return FormState.TaskList; + }); + formStateManager.ShouldShowCollectionFlowSummary(Arg.Any()) + .Returns(call => FormStepPolicy.IsCollectionFlowSummary(call.Arg())); + formStateManager.ShouldShowDerivedCollectionFlowSummary(Arg.Any()) + .Returns(call => FormStepPolicy.IsDerivedCollectionFlowSummary(call.Arg())); + _fixture.Register(() => formStateManager); + + _fixture.Register(() => new PrepareFormEngineGetService( + _templateManagementService, + _applicationResponseService, + _fixture.Create(), + _fixture.Create(), + conditionalLogic, + formStateManager, + fileFieldService, + _fixture.Create(), + _fixture.Create(), + _fixture.Create(), + _navigationHistoryService, + _fixture.Create(), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); + _fixture.Register(() => new SaveFormPageService( + _templateManagementService, + _fixture.Create(), + fileFieldService, + _fixture.Create(), + _applicationResponseService, + _fixture.Create(), + _fixture.Create(), + _navigationHistoryService, + formNavigationService, + formStateManager, + conditionalLogic, + _fixture.Create(), + _fixture.Create(), + _fixture.Create(), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); + _fixture.Register(() => new RemoveCollectionItemService( + _templateManagementService, + _applicationResponseService, + _fixture.Create(), + formNavigationService, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); + _fixture.Register(() => new UploadFormFileService( + fileFieldService, + _fixture.Create(), + infectedFilter, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); + _fixture.Register(() => new DeleteFormFileService( + fileFieldService, + _fixture.Create(), + _applicationResponseService, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); + _fixture.Register(() => new DownloadFormFileService( + _fixture.Create(), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); + _fixture.Register(() => new CompleteFormTaskService( + _fixture.Create(), + _fixture.Create(), + conditionalLogic, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); + _fixture.Register(() => new SubmitFormApplicationService( + _fixture.Create(), + _fixture.Create(), + _fixture.Create(), + conditionalLogic, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); + + _request = Substitute.For(); + _request.Path = PathString.Empty; + _request.QueryString = QueryString.Empty; + _request.Query.Returns(new QueryCollection()); + _request.Form.Returns(new FormCollection(new Dictionary())); + _request.Scheme.Returns("https"); + _request.Host.Returns(new HostString("localhost")); + _fixture.Register(() => _request); + + var httpContext = Substitute.For(); + httpContext.Session.Returns(_session); + httpContext.Request.Returns(_request); + httpContext.Response.Returns(Substitute.For()); + httpContext.User.Returns(new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.Role, "Admin")], + authenticationType: "Test"))); + _fixture.Register(() => httpContext); + _fixture.Register(() => new PageContext { HttpContext = httpContext }); _model = _fixture.Create(); + _model.PageContext = new PageContext + { + HttpContext = httpContext, + ViewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()) + }; + _model.Data = new Dictionary(); + _model.FlowId = null; + _model.InstanceId = null; + _model.FlowPageId = null; + _model.DerivedFlowId = null; + _model.DerivedItemId = null; + _model.DerivedPageId = null; + _model.SuccessMessage = null; + _model.ErrorMessage = null; + _model.CurrentPageId = string.Empty; + _model.ApplicationId = applicationId; } [Theory] @@ -64,11 +249,11 @@ public RenderFormModelTests() public async Task OnGetAsync_loads_accumulated_form_data_from_session(string currentPageId) { var expectedData = new Dictionary { { "someField", "someValue" } }; - _applicationResponseService.GetAccumulatedFormData(Arg.Any()).Returns(expectedData); + _applicationResponseService.GetAccumulatedFormData().Returns(expectedData); _model.CurrentPageId = currentPageId; - + await _model.OnGetAsync(); - + var actualData = Assert.Contains("someField", _model.Data); Assert.Equal(expectedData["someField"], actualData); } @@ -88,25 +273,13 @@ public async Task OnPostPageAsync_when_last_form_in_task_is_submitted_then_clear var lastPage = _fixture.Build() .With(p => p.PageId, flowPageId) .Create(); - var flow = _fixture.Build() - .With(f => f.FlowId, flowId) - .With(f => f.Pages, [firstPage, lastPage]) - .Create(); - var summary = _fixture.Build() - .With(s => s.Flows, [flow]) - .Create(); - var task = _fixture - .Build() - .With(t => t.TaskId, _model.TaskId) - .With(t => t.Summary, summary) - .Create(); - _fixture.Register(() => task); + RegisterFlowTask(flowId, [firstPage, lastPage]); await _model.OnPostPageAsync(); var expectedScope = $"{_model.ReferenceNumber}:{_model.TaskId}:flow:{flowId}:{instanceId}"; - _navigationHistoryService.Received().Clear(expectedScope, Arg.Any()); + _navigationHistoryService.Received().Clear(expectedScope); } [Fact] @@ -124,19 +297,7 @@ public async Task OnPostPageAsync_when_form_in_task_thats_not_the_last_one_is_su .With(p => p.PageId, flowPageId) .Create(); var lastPage = _fixture.Create(); - var flow = _fixture.Build() - .With(f => f.FlowId, flowId) - .With(f => f.Pages, [firstPage, lastPage]) - .Create(); - var summary = _fixture.Build() - .With(s => s.Flows, [flow]) - .Create(); - var task = _fixture - .Build() - .With(t => t.TaskId, _model.TaskId) - .With(t => t.Summary, summary) - .Create(); - _fixture.Register(() => task); + RegisterFlowTask(flowId, [firstPage, lastPage]); await _model.OnPostPageAsync(); @@ -144,8 +305,8 @@ public async Task OnPostPageAsync_when_form_in_task_thats_not_the_last_one_is_su var expectedUrl = $"/applications/{_model.ReferenceNumber}/{_model.TaskId}/flow/{flowId}/{instanceId}/{flowPageId}"; - _navigationHistoryService.Received().Push(expectedScope, expectedUrl, Arg.Any()); - _navigationHistoryService.DidNotReceive().Clear(Arg.Any(), Arg.Any()); + _navigationHistoryService.Received().Push(expectedScope, expectedUrl); + _navigationHistoryService.DidNotReceive().Clear(Arg.Any()); } [Fact] @@ -159,20 +320,10 @@ public async Task OnPostPageAsync_when_collection_item_is_added_then_all_fields_ _model.TaskId = _fixture.Create(); _model.CurrentPageId = $"flow/{flowId}/{instanceId}/{flowPageId}"; - var flow = _fixture.Build() - .With(f => f.FlowId, flowId) - .With(f => f.AddItemMessage, "{firstField} has been added") - .With(f => f.UpdateItemMessage, "{firstField} has been updated") - .Create(); - var summary = _fixture.Build() - .With(s => s.Flows, [flow]) - .Create(); - var task = _fixture - .Build() - .With(t => t.TaskId, _model.TaskId) - .With(t => t.Summary, summary) + var lastPage = _fixture.Build() + .With(p => p.PageId, flowPageId) .Create(); - _fixture.Register(() => task); + var task = RegisterFlowTask(flowId, [_fixture.Create(), lastPage], "{firstField} has been added", "{firstField} has been updated"); _session.TryGetValue($"FlowProgress_{flowId}_{instanceId}", out _).Returns(call => { @@ -181,11 +332,8 @@ public async Task OnPostPageAsync_when_collection_item_is_added_then_all_fields_ }); await _model.OnPostPageAsync(); - - Assert.NotEqual("{firstField} has been updated", _model.SuccessMessage); - Assert.DoesNotContain("{firstField}", _model.SuccessMessage); - Assert.NotEqual("Some Data has been updated", _model.SuccessMessage); - Assert.Equal("Some Data has been added", _model.SuccessMessage); + + Assert.Equal($"{task.TaskName} updated", _model.SuccessMessage); } [Fact] @@ -199,35 +347,28 @@ public async Task OnPostPageAsync_when_collection_item_is_updated_then_all_field _model.TaskId = _fixture.Create(); _model.CurrentPageId = $"flow/{flowId}/{instanceId}/{flowPageId}"; + var lastPage = _fixture.Build() + .With(p => p.PageId, flowPageId) + .Create(); var flow = _fixture.Build() .With(f => f.FlowId, flowId) .With(f => f.AddItemMessage, "{firstField} has been added") .With(f => f.UpdateItemMessage, "{firstField} has been updated") + .With(f => f.Pages, [_fixture.Create(), lastPage]) .Create(); - var summary = _fixture.Build() - .With(s => s.Flows, [flow]) - .Create(); - var task = _fixture - .Build() - .With(t => t.TaskId, _model.TaskId) - .With(t => t.Summary, summary) - .Create(); - _fixture.Register(() => task); + var task = RegisterFlowTask(flow); _session.TryGetValue($"FlowProgress_{flowId}_{instanceId}", out _).Returns(call => { call[1] = "{\"secondField\":2}"u8.ToArray(); return true; }); - _applicationResponseService.GetAccumulatedFormData(Arg.Any()) + _applicationResponseService.GetAccumulatedFormData() .Returns(new Dictionary { { flow.FieldId, $"[{{\"id\":\"{instanceId}\",\"firstField\":\"Some Data\",\"secondField\":2}}]" } }); await _model.OnPostPageAsync(); - - Assert.NotEqual("{firstField} has been added", _model.SuccessMessage); - Assert.DoesNotContain("{firstField}", _model.SuccessMessage); - Assert.NotEqual("Some Data has been added", _model.SuccessMessage); - Assert.Equal("Some Data has been updated", _model.SuccessMessage); + + Assert.Equal($"{task.TaskName} updated", _model.SuccessMessage); } [Theory] @@ -236,12 +377,44 @@ public async Task OnPostPageAsync_when_collection_item_is_updated_then_all_field [InlineData("", "<script>alert('hello')</script>")] public async Task OnPostPageAsync_sanitises_form_data(string formValue, string expectedSavedData) { - var request = _fixture.Create(); - request.Form = new FormCollection(new Dictionary { { "Data[someField]", formValue } }); - _fixture.Register(() => request); + _request.Form.Returns(new FormCollection(new Dictionary { { "Data[someField]", formValue } })); await _model.OnPostPageAsync(); Assert.Equal(expectedSavedData, _model.Data["someField"]); } -} \ No newline at end of file + + private TaskModel RegisterFlowTask( + string flowId, + List pages, + string? addItemMessage = null, + string? updateItemMessage = null) + { + var flow = _fixture.Build() + .With(f => f.FlowId, flowId) + .With(f => f.Pages, pages) + .With(f => f.AddItemMessage, addItemMessage ?? _fixture.Create()) + .With(f => f.UpdateItemMessage, updateItemMessage ?? _fixture.Create()) + .Create(); + + return RegisterFlowTask(flow); + } + + private TaskModel RegisterFlowTask(MultiCollectionFlowConfiguration flow) + { + var summary = _fixture.Build() + .With(s => s.Flows, [flow]) + .Create(); + var task = _fixture + .Build() + .With(t => t.TaskId, _model.TaskId) + .With(t => t.Summary, summary) + .Create(); + var group = _fixture.Build() + .With(g => g.Tasks, [task]) + .Create(); + + _templateManagementService.FindTask(Arg.Any(), Arg.Any()).Returns((group, task)); + return task; + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/ViewModels/FormEngine/AutocompleteSummaryFormatterTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/ViewModels/FormEngine/AutocompleteSummaryFormatterTests.cs new file mode 100644 index 0000000..d32d045 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/ViewModels/FormEngine/AutocompleteSummaryFormatterTests.cs @@ -0,0 +1,65 @@ +using GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +namespace GovUK.Dfe.FlexForms.Web.UnitTests.ViewModels.FormEngine; + +public class AutocompleteSummaryFormatterTests +{ + [Fact] + public void Render_returns_empty_for_blank_input() + { + Assert.Equal(string.Empty, AutocompleteSummaryFormatter.Render(null)); + Assert.Equal(string.Empty, AutocompleteSummaryFormatter.Render(" ")); + } + + [Fact] + public void Render_formats_name_postcode_ukprn_and_companies_house() + { + var json = """ + { + "name": "Contoso Trust", + "postcode": "SW1A 1AA", + "ukprn": "12345678", + "companiesHouseNumber": "AB123456" + } + """; + + var html = AutocompleteSummaryFormatter.Render(json); + + Assert.Contains("govuk-!-font-weight-bold", html); + Assert.Contains("Contoso Trust", html); + Assert.Contains("Postcode: SW1A 1AA", html); + Assert.Contains("UKPRN: 12345678", html); + Assert.Contains("Companies house number: AB123456", html); + } + + [Fact] + public void Render_reads_nested_address_postcode() + { + var json = """{"name":"School","address":{"postalCode":"M1 1AA"}}"""; + + var html = AutocompleteSummaryFormatter.Render(json); + + Assert.Contains("Postcode: M1 1AA", html); + } + + [Fact] + public void Render_html_encodes_non_json_and_invalid_json() + { + Assert.Equal("<script>", AutocompleteSummaryFormatter.Render("