diff --git a/.gitignore b/.gitignore
index 70fa819d..94c46e49 100644
--- a/.gitignore
+++ b/.gitignore
@@ -182,3 +182,6 @@ backend.vars
/Benchmarks/Dfe.PersonsApi.Benchmarks/BenchmarkDotNet.Artifacts/results
/uploads/
uploads/
+
+Directory.Build.targets.user
+/src/LocalPackages
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3570e54a..11343c26 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ All notable changes to this service will be documented in this file.
### Notes
- FlexForms (Forms Engine SaaS)
+## [2.1.0] - Application delete functionality
+### Notes
+- Added functionality to soft delete an application, which will be marked with the deleted status in the DB
+
---------------------------------------------------------------------------
@@ -140,3 +144,4 @@ All notable changes to this service will be documented in this file.
## [1.5.2] - Filter on status fix
### Notes
- Fix for the filtering not working when a null value for status is encountered in the db
+
diff --git a/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Client.g.cs b/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Client.g.cs
index 3cbff283..c74deb2c 100644
--- a/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Client.g.cs
+++ b/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Client.g.cs
@@ -951,6 +951,144 @@ public string BaseUrl
}
}
+ /// A cancellation token that can be used by other objects or threads to receive notice of cancellation.
+ ///
+ /// Soft deletes an application, changing its status to Deleted.
+ ///
+ /// Application deleted successfully.
+ /// A server side error occurred.
+ public virtual async System.Threading.Tasks.Task DeleteApplicationAsync(System.Guid applicationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken))
+ {
+ if (applicationId == null)
+ throw new System.ArgumentNullException("applicationId");
+
+ var client_ = _httpClient;
+ var disposeClient_ = false;
+ try
+ {
+ using (var request_ = new System.Net.Http.HttpRequestMessage())
+ {
+ request_.Method = new System.Net.Http.HttpMethod("DELETE");
+ request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json"));
+
+ var urlBuilder_ = new System.Text.StringBuilder();
+ if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl);
+ // Operation Path: "v1/Applications/{applicationId}"
+ urlBuilder_.Append("v1/Applications/");
+ urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(applicationId, System.Globalization.CultureInfo.InvariantCulture)));
+
+ PrepareRequest(client_, request_, urlBuilder_);
+
+ var url_ = urlBuilder_.ToString();
+ request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute);
+
+ PrepareRequest(client_, request_, url_);
+
+ var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+ var disposeResponse_ = true;
+ try
+ {
+ var headers_ = new System.Collections.Generic.Dictionary>();
+ foreach (var item_ in response_.Headers)
+ headers_[item_.Key] = item_.Value;
+ if (response_.Content != null && response_.Content.Headers != null)
+ {
+ foreach (var item_ in response_.Content.Headers)
+ headers_[item_.Key] = item_.Value;
+ }
+
+ ProcessResponse(client_, response_);
+
+ var status_ = (int)response_.StatusCode;
+ if (status_ == 200)
+ {
+ var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false);
+ if (objectResponse_.Object == null)
+ {
+ throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
+ }
+ return objectResponse_.Object;
+ }
+ else
+ if (status_ == 400)
+ {
+ var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false);
+ if (objectResponse_.Object == null)
+ {
+ throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
+ }
+ throw new ExternalApplicationsException("Invalid request data or application not found.", status_, objectResponse_.Text, headers_, objectResponse_.Object, null);
+ }
+ else
+ if (status_ == 401)
+ {
+ var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false);
+ if (objectResponse_.Object == null)
+ {
+ throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
+ }
+ throw new ExternalApplicationsException("Unauthorized - no valid user token", status_, objectResponse_.Text, headers_, objectResponse_.Object, null);
+ }
+ else
+ if (status_ == 403)
+ {
+ var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false);
+ if (objectResponse_.Object == null)
+ {
+ throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
+ }
+ throw new ExternalApplicationsException("User does not have permission to delete this application", status_, objectResponse_.Text, headers_, objectResponse_.Object, null);
+ }
+ else
+ if (status_ == 404)
+ {
+ var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false);
+ if (objectResponse_.Object == null)
+ {
+ throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
+ }
+ throw new ExternalApplicationsException("Application not found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null);
+ }
+ else
+ if (status_ == 429)
+ {
+ var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false);
+ if (objectResponse_.Object == null)
+ {
+ throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
+ }
+ throw new ExternalApplicationsException("Too Many Requests.", status_, objectResponse_.Text, headers_, objectResponse_.Object, null);
+ }
+ else
+ if (status_ == 500)
+ {
+ var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false);
+ if (objectResponse_.Object == null)
+ {
+ throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
+ }
+ throw new ExternalApplicationsException("Internal server error.", status_, objectResponse_.Text, headers_, objectResponse_.Object, null);
+ }
+ else
+ {
+ var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false);
+ throw new ExternalApplicationsException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null);
+ }
+ }
+ finally
+ {
+ if (disposeResponse_)
+ response_.Dispose();
+ }
+ }
+ }
+ finally
+ {
+ if (disposeClient_)
+ client_.Dispose();
+ }
+ }
+
/// A cancellation token that can be used by other objects or threads to receive notice of cancellation.
///
/// Submits an application, changing its status to Submitted.
diff --git a/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Contracts.g.cs b/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Contracts.g.cs
index 1e155090..8cd5ddb8 100644
--- a/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Contracts.g.cs
+++ b/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Contracts.g.cs
@@ -100,6 +100,14 @@ public partial interface IApplicationsClient
/// A server side error occurred.
System.Threading.Tasks.Task GetApplicationByReferenceAsync(string applicationReference, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));
+ /// A cancellation token that can be used by other objects or threads to receive notice of cancellation.
+ ///
+ /// Soft deletes an application, changing its status to Deleted.
+ ///
+ /// Application deleted successfully.
+ /// A server side error occurred.
+ System.Threading.Tasks.Task DeleteApplicationAsync(System.Guid applicationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));
+
/// A cancellation token that can be used by other objects or threads to receive notice of cancellation.
///
/// Submits an application, changing its status to Submitted.
diff --git a/src/GovUK.Dfe.FlexForms.Api.Client/Generated/swagger.json b/src/GovUK.Dfe.FlexForms.Api.Client/Generated/swagger.json
index c99fad1c..387a3708 100644
--- a/src/GovUK.Dfe.FlexForms.Api.Client/Generated/swagger.json
+++ b/src/GovUK.Dfe.FlexForms.Api.Client/Generated/swagger.json
@@ -714,6 +714,99 @@
}
}
},
+ "/v1/Applications/{applicationId}": {
+ "delete": {
+ "tags": [
+ "Applications"
+ ],
+ "summary": "Soft deletes an application, changing its status to Deleted.",
+ "operationId": "Applications_DeleteApplication",
+ "parameters": [
+ {
+ "name": "applicationId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "guid"
+ },
+ "x-position": 1
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Application deleted successfully.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApplicationDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid request data or application not found.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ExceptionResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - no valid user token",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ExceptionResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "User does not have permission to delete this application",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ExceptionResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Application not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ExceptionResponse"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ExceptionResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Too Many Requests.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ExceptionResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/v1/Applications/{applicationId}/submit": {
"post": {
"tags": [
diff --git a/src/GovUK.Dfe.FlexForms.Api/Controllers/ApplicationsController.cs b/src/GovUK.Dfe.FlexForms.Api/Controllers/ApplicationsController.cs
index df3204c5..6a55f0b8 100644
--- a/src/GovUK.Dfe.FlexForms.Api/Controllers/ApplicationsController.cs
+++ b/src/GovUK.Dfe.FlexForms.Api/Controllers/ApplicationsController.cs
@@ -1,18 +1,19 @@
using Asp.Versioning;
+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.CoreLibs.Http.Models;
+using GovUK.Dfe.FlexForms.Api.Filters;
using GovUK.Dfe.FlexForms.Api.Models.Applications;
using GovUK.Dfe.FlexForms.Application.Applications.Commands;
using GovUK.Dfe.FlexForms.Application.Applications.Queries;
using GovUK.Dfe.FlexForms.Application.Common.Exceptions;
-using GovUK.Dfe.FlexForms.Api.Filters;
+using GovUK.Dfe.FlexForms.Infrastructure.Security;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
using ApplicationId = GovUK.Dfe.FlexForms.Domain.ValueObjects.ApplicationId;
-using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums;
namespace GovUK.Dfe.FlexForms.Api.Controllers;
@@ -167,6 +168,31 @@ public async Task GetApplicationByReferenceAsync(
};
}
+ ///
+ /// Soft deletes an application, changing its status to Deleted.
+ ///
+ [HttpDelete("{applicationId}")]
+ [SwaggerResponse(200, "Application deleted successfully.", typeof(ApplicationDto))]
+ [SwaggerResponse(400, "Invalid request data or application not found.", typeof(ExceptionResponse))]
+ [SwaggerResponse(401, "Unauthorized - no valid user token", typeof(ExceptionResponse))]
+ [SwaggerResponse(403, "User does not have permission to delete this application", typeof(ExceptionResponse))]
+ [SwaggerResponse(404, "Application not found", typeof(ExceptionResponse))]
+ [SwaggerResponse(500, "Internal server error.", typeof(ExceptionResponse))]
+ [SwaggerResponse(429, "Too Many Requests.", typeof(ExceptionResponse))]
+ [Authorize(Policy = AuthConstants.TenantAdminUserPolicy)]
+ public async Task DeleteApplicationAsync(
+ [FromRoute] Guid applicationId,
+ CancellationToken cancellationToken)
+ {
+ var command = new DeleteApplicationCommand(applicationId);
+ var result = await sender.Send(command, cancellationToken);
+
+ return new ObjectResult(result)
+ {
+ StatusCode = StatusCodes.Status200OK
+ };
+ }
+
///
/// Submits an application, changing its status to Submitted.
///
diff --git a/src/GovUK.Dfe.FlexForms.Api/GovUK.Dfe.FlexForms.Api.csproj b/src/GovUK.Dfe.FlexForms.Api/GovUK.Dfe.FlexForms.Api.csproj
index 2c0bbd2f..b30895cf 100644
--- a/src/GovUK.Dfe.FlexForms.Api/GovUK.Dfe.FlexForms.Api.csproj
+++ b/src/GovUK.Dfe.FlexForms.Api/GovUK.Dfe.FlexForms.Api.csproj
@@ -74,5 +74,8 @@
-
+
+
+
+
diff --git a/src/GovUK.Dfe.FlexForms.Application/Applications/Commands/DeleteApplicationCommandHandler.cs b/src/GovUK.Dfe.FlexForms.Application/Applications/Commands/DeleteApplicationCommandHandler.cs
new file mode 100644
index 00000000..725fa520
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Applications/Commands/DeleteApplicationCommandHandler.cs
@@ -0,0 +1,89 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums;
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response;
+using GovUK.Dfe.FlexForms.Application.Applications.QueryObjects;
+using GovUK.Dfe.FlexForms.Application.Common.Attributes;
+using GovUK.Dfe.FlexForms.Application.Common.Behaviours;
+using GovUK.Dfe.FlexForms.Application.Services;
+using GovUK.Dfe.FlexForms.Domain.Interfaces;
+using GovUK.Dfe.FlexForms.Domain.Interfaces.Repositories;
+using GovUK.Dfe.FlexForms.Domain.Services;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+using ApplicationId = GovUK.Dfe.FlexForms.Domain.ValueObjects.ApplicationId;
+
+namespace GovUK.Dfe.FlexForms.Application.Applications.Commands;
+
+[RateLimit(1, 30)]
+public sealed record DeleteApplicationCommand(Guid ApplicationId) : IRequest>, IRateLimitedRequest;
+
+public sealed class DeleteApplicationCommandHandler(
+ IEaRepository applicationRepo,
+ IAuthenticatedUserService authenticatedUserService,
+ IPermissionCheckerService permissionCheckerService,
+ IUserCacheInvalidator userCacheInvalidator,
+ IUnitOfWork unitOfWork) : IRequestHandler>
+{
+ public async Task> Handle(
+ DeleteApplicationCommand request,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var currentUserResult = await authenticatedUserService.GetCurrentUserAsync(cancellationToken);
+ if (!currentUserResult.IsSuccess)
+ {
+ return currentUserResult.ErrorCode switch
+ {
+ DomainErrorCode.NotFound => Result.NotFound(currentUserResult.Error!),
+ DomainErrorCode.Forbidden => Result.Forbid(currentUserResult.Error!),
+ _ => Result.Failure(currentUserResult.Error!)
+ };
+ }
+
+ var dbUser = currentUserResult.Value!;
+ var canAccess = permissionCheckerService.HasPermission(
+ ResourceType.Application,
+ request.ApplicationId.ToString(),
+ AccessType.Write);
+
+ if (!canAccess)
+ return Result.Forbid("User does not have permission to delete this application");
+
+ var applicationId = new ApplicationId(request.ApplicationId);
+ var application = await (new GetApplicationByIdQueryObject(applicationId))
+ .Apply(applicationRepo.Query())
+ .FirstOrDefaultAsync(cancellationToken);
+
+ if (application is null)
+ return Result.NotFound("Application not found");
+
+ var now = DateTime.UtcNow;
+ application.Delete(now, dbUser.Id!, dbUser.Email, dbUser.Name);
+
+ await unitOfWork.CommitAsync(cancellationToken);
+
+ await userCacheInvalidator.InvalidateForUserAsync(
+ dbUser.Email,
+ dbUser.ExternalProviderId,
+ dbUser.Id!,
+ cancellationToken);
+
+ return Result.Success(new ApplicationDto
+ {
+ ApplicationId = application.Id!.Value,
+ ApplicationReference = application.ApplicationReference,
+ TemplateVersionId = application.TemplateVersionId.Value,
+ TemplateName = application.TemplateVersion?.Template?.Name ?? string.Empty,
+ Status = application.Status,
+ DateCreated = application.CreatedOn,
+ DateDeleted = application.LastModifiedOn,
+ LatestResponse = null,
+ TemplateSchema = null
+ });
+ }
+ catch (Exception e)
+ {
+ return Result.Failure(e.Message);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/GovUK.Dfe.FlexForms.Application/Applications/Commands/DeleteApplicationCommandValidator.cs b/src/GovUK.Dfe.FlexForms.Application/Applications/Commands/DeleteApplicationCommandValidator.cs
new file mode 100644
index 00000000..69706be1
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Applications/Commands/DeleteApplicationCommandValidator.cs
@@ -0,0 +1,15 @@
+using FluentValidation;
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("GovUK.Dfe.FlexForms.Application.Tests")]
+namespace GovUK.Dfe.FlexForms.Application.Applications.Commands;
+
+internal class DeleteApplicationCommandValidator : AbstractValidator
+{
+ public DeleteApplicationCommandValidator()
+ {
+ RuleFor(x => x.ApplicationId)
+ .NotEmpty()
+ .WithMessage("Application ID is required");
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationByReferenceQueryHandler.cs b/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationByReferenceQueryHandler.cs
index 08c17c3a..f66b6913 100644
--- a/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationByReferenceQueryHandler.cs
+++ b/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationByReferenceQueryHandler.cs
@@ -47,6 +47,11 @@ public async Task> Handle(
return Result.Forbid("Application does not belong to the current tenant");
}
+ if (dto.Status == ApplicationStatus.Deleted && !permissionCheckerService.IsAdmin())
+ {
+ return Result.NotFound("Application not found");
+ }
+
var canAccess = permissionCheckerService.HasPermission(
ResourceType.Application,
dto.ApplicationId.ToString(),
diff --git a/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationsByTemplateQueryHandler.cs b/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationsByTemplateQueryHandler.cs
index 5b8344bc..0772e33c 100644
--- a/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationsByTemplateQueryHandler.cs
+++ b/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationsByTemplateQueryHandler.cs
@@ -35,11 +35,11 @@ public sealed class GetApplicationsByTemplateQueryHandler(
IHttpContextAccessor httpContextAccessor,
IEaRepository userRepo,
IEaRepository appRepo,
+ IPermissionCheckerService permissionCheckerService,
IApplicationRepository applicationRepository,
ICacheService cacheService,
ITenantContextAccessor tenantContextAccessor,
- ITenantTemplateResolver tenantTemplateResolver,
- IPermissionCheckerService permissionCheckerService)
+ ITenantTemplateResolver tenantTemplateResolver)
: IRequestHandler>>
{
public async Task>> Handle(
@@ -100,7 +100,7 @@ public async Task>> Handle(
query = ApplicationListingQueryBuilder.ApplySearchFilters(
query,
request.Search,
- excludeStatus: request.Search?.Status is not null);
+ excludeStatus: (request.Search?.Status is not null) || (request.Search?.Status == ApplicationStatus.Deleted && !permissionCheckerService.IsAdmin()));
var pagedResult = await ApplicationListingQueryBuilder.MapPagedResultAsync(
query,
diff --git a/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationsForUserQueryHandler.cs b/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationsForUserQueryHandler.cs
index 15c51625..0e8358aa 100644
--- a/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationsForUserQueryHandler.cs
+++ b/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationsForUserQueryHandler.cs
@@ -7,6 +7,7 @@
using GovUK.Dfe.FlexForms.Application.Users.QueryObjects;
using GovUK.Dfe.FlexForms.Domain.Entities;
using GovUK.Dfe.FlexForms.Domain.Interfaces.Repositories;
+using GovUK.Dfe.FlexForms.Domain.Services;
using GovUK.Dfe.FlexForms.Domain.Tenancy;
using MediatR;
using Microsoft.EntityFrameworkCore;
@@ -26,6 +27,7 @@ public sealed record GetApplicationsForUserQuery(
public sealed class GetApplicationsForUserQueryHandler(
IEaRepository userRepo,
IEaRepository appRepo,
+ IPermissionCheckerService permissionCheckerService,
IApplicationRepository applicationRepository,
ICacheService cacheService,
ITenantContextAccessor tenantContextAccessor,
@@ -99,7 +101,7 @@ public async Task>> Handle(
userWithAuthorization,
templateIdsFilter);
- query = ApplicationListingQueryBuilder.ApplySearchFilters(query, request.Search);
+ query = ApplicationListingQueryBuilder.ApplySearchFilters(query, request.Search, request.Search?.Status == ApplicationStatus.Deleted && !permissionCheckerService.IsAdmin());
var pagedResult = await ApplicationListingQueryBuilder.MapPagedResultAsync(
query,
diff --git a/src/GovUK.Dfe.FlexForms.Domain/Entities/Application.cs b/src/GovUK.Dfe.FlexForms.Domain/Entities/Application.cs
index 70acd616..44fb2035 100644
--- a/src/GovUK.Dfe.FlexForms.Domain/Entities/Application.cs
+++ b/src/GovUK.Dfe.FlexForms.Domain/Entities/Application.cs
@@ -20,6 +20,9 @@ public sealed class Application : BaseAggregateRoot, IEntity
public User? CreatedByUser { get; private set; }
public ApplicationStatus? Status { get; private set; }
public DateTime? LastModifiedOn { get; private set; }
+ public DateTime? DeletedOn { get; private set; } = null;
+ public UserId? DeletedBy { get; private set; }
+ public User? DeletedByUser { get; private set; }
public UserId? LastModifiedBy { get; private set; }
public User? LastModifiedByUser { get; private set; }
public IReadOnlyCollection Responses => _responses.AsReadOnly();
@@ -39,7 +42,9 @@ public Application(
UserId createdBy,
ApplicationStatus? status = null,
DateTime? lastModifiedOn = null,
- UserId? lastModifiedBy = null)
+ UserId? lastModifiedBy = null,
+ DateTime? deletedOn = null,
+ UserId? deletedBy = null)
{
Id = id ?? throw new ArgumentNullException(nameof(id));
ApplicationReference = applicationReference?.Trim()
@@ -50,7 +55,10 @@ public Application(
Status = status;
LastModifiedOn = lastModifiedOn;
LastModifiedBy = lastModifiedBy;
- if(status is null)
+ DeletedOn = deletedOn;
+ DeletedBy = deletedBy;
+
+ if (status is null)
{
Status = ApplicationStatus.Created;
}
@@ -118,4 +126,38 @@ public void Submit(DateTime submittedOn, UserId submittedBy, string userEmail, s
userFullName,
submittedOn));
}
+
+ ///
+ /// Deletes the application, setting its status to Deleted and updating last modified tracking.
+ ///
+ public void Delete(DateTime deletedOn, UserId deletedBy, string userEmail, string userFullName)
+ {
+ if (deletedBy == null)
+ throw new ArgumentNullException(nameof(deletedBy));
+
+ if (string.IsNullOrWhiteSpace(userEmail))
+ throw new ArgumentException("User email cannot be null or empty", nameof(userEmail));
+
+ if (string.IsNullOrWhiteSpace(userFullName))
+ throw new ArgumentException("User full name cannot be null or empty", nameof(userFullName));
+
+ if (Status == ApplicationStatus.Deleted)
+ throw new InvalidOperationException("Application has already been deleted");
+
+ Status = ApplicationStatus.Deleted;
+ DeletedOn = deletedOn;
+ DeletedBy = deletedBy;
+ LastModifiedOn = deletedOn;
+ LastModifiedBy = deletedBy;
+
+ // Raise domain event
+ AddDomainEvent(new ApplicationDeletedEvent(
+ Id!,
+ ApplicationReference,
+ TemplateVersion!.TemplateId,
+ deletedBy,
+ userEmail,
+ userFullName,
+ deletedOn));
+ }
}
diff --git a/src/GovUK.Dfe.FlexForms.Domain/Events/ApplicationDeletedEvent.cs b/src/GovUK.Dfe.FlexForms.Domain/Events/ApplicationDeletedEvent.cs
new file mode 100644
index 00000000..11cb13de
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Domain/Events/ApplicationDeletedEvent.cs
@@ -0,0 +1,17 @@
+using GovUK.Dfe.FlexForms.Domain.Common;
+using GovUK.Dfe.FlexForms.Domain.ValueObjects;
+using ApplicationId = GovUK.Dfe.FlexForms.Domain.ValueObjects.ApplicationId;
+
+namespace GovUK.Dfe.FlexForms.Domain.Events;
+
+public sealed record ApplicationDeletedEvent(
+ ApplicationId ApplicationId,
+ string ApplicationReference,
+ TemplateId TemplateId,
+ UserId DeletedBy,
+ string UserEmail,
+ string UserFullName,
+ DateTime DeletedOn) : IDomainEvent
+{
+ public DateTime OccurredOn => DeletedOn;
+}
diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Database/ExternalApplicationsContext.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Database/ExternalApplicationsContext.cs
index bcc3b776..d9ea9e5d 100644
--- a/src/GovUK.Dfe.FlexForms.Infrastructure/Database/ExternalApplicationsContext.cs
+++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Database/ExternalApplicationsContext.cs
@@ -587,6 +587,13 @@ private static void ConfigureApplication(EntityTypeBuilder v!.Value, v => new UserId(v))
.IsRequired(false);
+ b.Property(e => e.DeletedOn)
+ .HasColumnName("DeletedOn")
+ .IsRequired(false);
+ b.Property(e => e.DeletedBy)
+ .HasColumnName("DeletedBy")
+ .HasConversion(v => v!.Value, v => new UserId(v))
+ .IsRequired(false);
b.HasOne(e => e.TemplateVersion)
.WithMany()
@@ -599,6 +606,10 @@ private static void ConfigureApplication(EntityTypeBuilder e.LastModifiedBy)
.OnDelete(DeleteBehavior.Restrict);
+ b.HasOne(e => e.DeletedByUser)
+ .WithMany()
+ .HasForeignKey(e => e.DeletedBy)
+ .OnDelete(DeleteBehavior.Restrict);
// Index for efficient lookup by ApplicationReference (used by GET /Applications/reference/{applicationReference})
b.HasIndex(e => e.ApplicationReference)
diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/20260814170240_AddApplicationDeletionColumns.Designer.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/20260814170240_AddApplicationDeletionColumns.Designer.cs
new file mode 100644
index 00000000..1874f65a
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/20260814170240_AddApplicationDeletionColumns.Designer.cs
@@ -0,0 +1,1174 @@
+//
+using System;
+using GovUK.Dfe.FlexForms.Infrastructure.Database;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace GovUK.Dfe.FlexForms.Infrastructure.Migrations
+{
+ [DbContext(typeof(ExternalApplicationsContext))]
+ [Migration("20260814170240_AddApplicationDeletionColumns")]
+ partial class AddApplicationDeletionColumns
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Application", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("ApplicationId");
+
+ b.Property("ApplicationReference")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("nvarchar(20)")
+ .HasColumnName("ApplicationReference");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("CreatedBy");
+
+ b.Property("CreatedOn")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasColumnName("CreatedOn")
+ .HasDefaultValueSql("GETDATE()");
+
+ b.Property("DeletedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("DeletedBy");
+
+ b.Property("DeletedOn")
+ .HasColumnType("datetime2")
+ .HasColumnName("DeletedOn");
+
+ b.Property("LastModifiedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("LastModifiedBy");
+
+ b.Property("LastModifiedOn")
+ .HasColumnType("datetime2")
+ .HasColumnName("LastModifiedOn");
+
+ b.Property("PeriodEnd")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("datetime2")
+ .HasColumnName("PeriodEnd");
+
+ b.Property("PeriodStart")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("datetime2")
+ .HasColumnName("PeriodStart");
+
+ b.Property("Status")
+ .HasColumnType("int")
+ .HasColumnName("Status");
+
+ b.Property("TemplateVersionId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("TemplateVersionId");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApplicationReference")
+ .IsUnique()
+ .HasDatabaseName("IX_Applications_ApplicationReference");
+
+ b.HasIndex("CreatedBy");
+
+ b.HasIndex("CreatedOn")
+ .HasDatabaseName("IX_Applications_CreatedOn");
+
+ b.HasIndex("DeletedBy");
+
+ b.HasIndex("LastModifiedBy");
+
+ b.HasIndex("TemplateVersionId")
+ .HasDatabaseName("IX_Applications_TemplateVersionId");
+
+ b.HasIndex("Status", "LastModifiedOn")
+ .HasDatabaseName("IX_Applications_Status_LastModifiedOn");
+
+ b.ToTable("Applications", "ea");
+
+ b.ToTable(tb => tb.IsTemporal(ttb =>
+ {
+ ttb.UseHistoryTable("History_Applications", "ea");
+ ttb
+ .HasPeriodStart("PeriodStart")
+ .HasColumnName("PeriodStart");
+ ttb
+ .HasPeriodEnd("PeriodEnd")
+ .HasColumnName("PeriodEnd");
+ }));
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.ApplicationResponse", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("ResponseId");
+
+ b.Property("ApplicationId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("ApplicationId");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("CreatedBy");
+
+ b.Property("CreatedOn")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasColumnName("CreatedOn")
+ .HasDefaultValueSql("GETDATE()");
+
+ b.Property("LastModifiedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("LastModifiedBy");
+
+ b.Property("LastModifiedOn")
+ .HasColumnType("datetime2")
+ .HasColumnName("LastModifiedOn");
+
+ b.Property("ResponseBody")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)")
+ .HasColumnName("ResponseBody");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedBy");
+
+ b.HasIndex("LastModifiedBy");
+
+ b.HasIndex("ApplicationId", "CreatedOn")
+ .IsDescending(false, true)
+ .HasDatabaseName("IX_ApplicationResponses_ApplicationId_CreatedOn");
+
+ b.ToTable("ApplicationResponses", "ea");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.CustomApplicationStatus", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("CustomApplicationStatusId");
+
+ b.Property("ApplicationStatus")
+ .HasColumnType("int")
+ .HasColumnName("ApplicationStatus");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("CreatedBy");
+
+ b.Property("CreatedOn")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasColumnName("CreatedOn")
+ .HasDefaultValueSql("GETDATE()");
+
+ b.Property("Label")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)")
+ .HasColumnName("Label");
+
+ b.Property("PeriodEnd")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("datetime2")
+ .HasColumnName("PeriodEnd");
+
+ b.Property("PeriodStart")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("datetime2")
+ .HasColumnName("PeriodStart");
+
+ b.Property("TemplateId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("TemplateId");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedBy");
+
+ b.HasIndex("TemplateId", "ApplicationStatus")
+ .IsUnique()
+ .HasDatabaseName("IX_CustomApplicationStatuses_TemplateId_ApplicationStatus");
+
+ b.ToTable("CustomApplicationStatuses", "ea");
+
+ b.ToTable(tb => tb.IsTemporal(ttb =>
+ {
+ ttb.UseHistoryTable("History_CustomApplicationStatuses", "ea");
+ ttb
+ .HasPeriodStart("PeriodStart")
+ .HasColumnName("PeriodStart");
+ ttb
+ .HasPeriodEnd("PeriodEnd")
+ .HasColumnName("PeriodEnd");
+ }));
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.File", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("FileId");
+
+ b.Property("ApplicationId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("ApplicationId");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)")
+ .HasColumnName("Description");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)")
+ .HasColumnName("FileName");
+
+ b.Property("FileSize")
+ .HasColumnType("bigint")
+ .HasColumnName("FileSize");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)")
+ .HasColumnName("Name");
+
+ b.Property("OriginalFileName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)")
+ .HasColumnName("OriginalFileName");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)")
+ .HasColumnName("Path");
+
+ b.Property("UploadedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("UploadedBy");
+
+ b.Property("UploadedOn")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasColumnName("UploadedOn")
+ .HasDefaultValueSql("GETDATE()");
+
+ b.Property("ValidatedOn")
+ .HasColumnType("datetime2")
+ .HasColumnName("ValidatedOn");
+
+ b.Property("ValidationMessage")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)")
+ .HasColumnName("ValidationMessage");
+
+ b.Property("ValidationSource")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)")
+ .HasColumnName("ValidationSource");
+
+ b.Property("ValidationStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("nvarchar(20)")
+ .HasColumnName("ValidationStatus");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApplicationId")
+ .HasDatabaseName("IX_Files_ApplicationId");
+
+ b.HasIndex("UploadedBy");
+
+ b.HasIndex("ApplicationId", "FileName")
+ .HasDatabaseName("IX_Files_ApplicationId_FileName");
+
+ b.HasIndex("Path", "FileName")
+ .HasDatabaseName("IX_Files_Path_FileName");
+
+ b.ToTable("Files", "ea");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Permission", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("PermissionId");
+
+ b.Property("AccessType")
+ .HasColumnType("tinyint")
+ .HasColumnName("AccessType");
+
+ b.Property("ApplicationId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("ApplicationId");
+
+ b.Property("GrantedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("GrantedBy");
+
+ b.Property("GrantedOn")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasColumnName("GrantedOn")
+ .HasDefaultValueSql("GETDATE()");
+
+ b.Property("ResourceKey")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)")
+ .HasColumnName("ResourceKey");
+
+ b.Property("ResourceType")
+ .HasColumnType("tinyint")
+ .HasColumnName("ResourceType");
+
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("UserId");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GrantedBy");
+
+ b.HasIndex("ApplicationId", "ResourceType")
+ .HasDatabaseName("IX_Permissions_ApplicationId_ResourceType");
+
+ b.HasIndex("UserId", "ResourceType", "ApplicationId")
+ .HasDatabaseName("IX_Permissions_UserId_ResourceType_ApplicationId");
+
+ b.ToTable("Permissions", "ea");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Role", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("RoleId");
+
+ b.Property("IsSystem")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(false)
+ .HasColumnName("IsSystem");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)")
+ .HasColumnName("Name");
+
+ b.Property("PeriodEnd")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("datetime2")
+ .HasColumnName("PeriodEnd");
+
+ b.Property("PeriodStart")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("datetime2")
+ .HasColumnName("PeriodStart");
+
+ b.Property("TenantId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("TenantId");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("IX_Roles_TenantId");
+
+ b.HasIndex("TenantId", "Name")
+ .IsUnique()
+ .HasDatabaseName("IX_Roles_TenantId_Name")
+ .HasFilter("[TenantId] IS NOT NULL");
+
+ b.ToTable("Roles", "ea");
+
+ b.ToTable(tb => tb.IsTemporal(ttb =>
+ {
+ ttb.UseHistoryTable("History_Roles", "ea");
+ ttb
+ .HasPeriodStart("PeriodStart")
+ .HasColumnName("PeriodStart");
+ ttb
+ .HasPeriodEnd("PeriodEnd")
+ .HasColumnName("PeriodEnd");
+ }));
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.RolePermission", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("RolePermissionId");
+
+ b.Property("AccessType")
+ .HasColumnType("int")
+ .HasColumnName("AccessType");
+
+ b.Property("CreatedOn")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasColumnName("CreatedOn")
+ .HasDefaultValueSql("GETDATE()");
+
+ b.Property("ResourceKey")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)")
+ .HasColumnName("ResourceKey");
+
+ b.Property("ResourceType")
+ .HasColumnType("int")
+ .HasColumnName("ResourceType");
+
+ b.Property("RoleId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("RoleId");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId", "ResourceType", "ResourceKey", "AccessType")
+ .IsUnique()
+ .HasDatabaseName("IX_RolePermissions_Role_Resource_Access");
+
+ b.ToTable("RolePermissions", "ea");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TaskAssignmentLabel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("TaskAssignmentLabelsId");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("CreatedBy");
+
+ b.Property("CreatedOn")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasColumnName("CreatedOn")
+ .HasDefaultValueSql("GETDATE()");
+
+ b.Property("TaskId")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("nvarchar(10)")
+ .HasColumnName("TaskId");
+
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("UserId");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)")
+ .HasColumnName("Value");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedBy");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("TaskAssignmentLabels", "ea");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Template", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("TemplateId");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("CreatedBy");
+
+ b.Property("CreatedOn")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasColumnName("CreatedOn")
+ .HasDefaultValueSql("GETDATE()");
+
+ b.Property("IsLive")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(false)
+ .HasColumnName("IsLive");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)")
+ .HasColumnName("Name");
+
+ b.Property("PeriodEnd")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("datetime2")
+ .HasColumnName("PeriodEnd");
+
+ b.Property("PeriodStart")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("datetime2")
+ .HasColumnName("PeriodStart");
+
+ b.Property("TenantId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("TenantId");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedBy");
+
+ b.HasIndex("TenantId");
+
+ b.ToTable("Templates", "ea");
+
+ b.ToTable(tb => tb.IsTemporal(ttb =>
+ {
+ ttb.UseHistoryTable("History_Templates", "ea");
+ ttb
+ .HasPeriodStart("PeriodStart")
+ .HasColumnName("PeriodStart");
+ ttb
+ .HasPeriodEnd("PeriodEnd")
+ .HasColumnName("PeriodEnd");
+ }));
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TemplatePermission", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("TemplatePermissionId");
+
+ b.Property("AccessType")
+ .HasColumnType("tinyint")
+ .HasColumnName("AccessType");
+
+ b.Property("GrantedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("GrantedBy");
+
+ b.Property("GrantedOn")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasColumnName("GrantedOn")
+ .HasDefaultValueSql("GETDATE()");
+
+ b.Property("PeriodEnd")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("datetime2")
+ .HasColumnName("PeriodEnd");
+
+ b.Property("PeriodStart")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("datetime2")
+ .HasColumnName("PeriodStart");
+
+ b.Property("TemplateId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("TemplateId");
+
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("UserId");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GrantedBy");
+
+ b.HasIndex("TemplateId");
+
+ b.HasIndex("UserId", "TemplateId")
+ .HasDatabaseName("IX_TemplatePermissions_UserId_TemplateId");
+
+ b.ToTable("TemplatePermissions", "ea");
+
+ b.ToTable(tb => tb.IsTemporal(ttb =>
+ {
+ ttb.UseHistoryTable("History_TemplatePermissions", "ea");
+ ttb
+ .HasPeriodStart("PeriodStart")
+ .HasColumnName("PeriodStart");
+ ttb
+ .HasPeriodEnd("PeriodEnd")
+ .HasColumnName("PeriodEnd");
+ }));
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TemplateVersion", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("TemplateVersionId");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("CreatedBy");
+
+ b.Property("CreatedOn")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasColumnName("CreatedOn")
+ .HasDefaultValueSql("GETDATE()");
+
+ b.Property("JsonSchema")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)")
+ .HasColumnName("JsonSchema");
+
+ b.Property("LastModifiedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("LastModifiedBy");
+
+ b.Property("LastModifiedOn")
+ .HasColumnType("datetime2")
+ .HasColumnName("LastModifiedOn");
+
+ b.Property("TemplateId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("TemplateId");
+
+ b.Property("VersionNumber")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)")
+ .HasColumnName("VersionNumber");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedBy");
+
+ b.HasIndex("LastModifiedBy");
+
+ b.HasIndex("TemplateId", "CreatedOn")
+ .IsDescending(false, true)
+ .HasDatabaseName("IX_TemplateVersions_TemplateId_CreatedOn");
+
+ b.ToTable("TemplateVersions", "ea");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TenantAccessAudit", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("ActorEmail")
+ .IsRequired()
+ .HasMaxLength(320)
+ .HasColumnType("nvarchar(320)");
+
+ b.Property("ActorUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Details")
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.Property("OccurredAtUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("RoleName")
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("SubjectEmail")
+ .IsRequired()
+ .HasMaxLength(320)
+ .HasColumnType("nvarchar(320)");
+
+ b.Property("SubjectUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("TenantId")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "OccurredAtUtc")
+ .HasDatabaseName("IX_TenantAccessAudits_TenantId_OccurredAtUtc");
+
+ b.HasIndex("TenantId", "SubjectEmail")
+ .HasDatabaseName("IX_TenantAccessAudits_TenantId_SubjectEmail");
+
+ b.ToTable("TenantAccessAudits", "ea");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TenantMembership", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("TenantMembershipId");
+
+ b.Property("CreatedOn")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasColumnName("CreatedOn")
+ .HasDefaultValueSql("GETDATE()");
+
+ b.Property("IsActive")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(true)
+ .HasColumnName("IsActive");
+
+ b.Property("LastModifiedOn")
+ .HasColumnType("datetime2")
+ .HasColumnName("LastModifiedOn");
+
+ b.Property("PeriodEnd")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("datetime2")
+ .HasColumnName("PeriodEnd");
+
+ b.Property("PeriodStart")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("datetime2")
+ .HasColumnName("PeriodStart");
+
+ b.Property("RoleId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("RoleId");
+
+ b.Property("TenantId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("TenantId");
+
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("UserId");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("IX_TenantMemberships_UserId");
+
+ b.HasIndex("TenantId", "UserId")
+ .IsUnique()
+ .HasDatabaseName("IX_TenantMemberships_TenantId_UserId");
+
+ b.ToTable("TenantMemberships", "ea");
+
+ b.ToTable(tb => tb.IsTemporal(ttb =>
+ {
+ ttb.UseHistoryTable("History_TenantMemberships", "ea");
+ ttb
+ .HasPeriodStart("PeriodStart")
+ .HasColumnName("PeriodStart");
+ ttb
+ .HasPeriodEnd("PeriodEnd")
+ .HasColumnName("PeriodEnd");
+ }));
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("UserId");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("CreatedBy");
+
+ b.Property("CreatedOn")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasColumnName("CreatedOn")
+ .HasDefaultValueSql("GETDATE()");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)")
+ .HasColumnName("Email");
+
+ b.Property("ExternalProviderId")
+ .HasMaxLength(100)
+ .IsUnicode(false)
+ .HasColumnType("varchar(100)");
+
+ b.Property("LastModifiedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("LastModifiedBy");
+
+ b.Property("LastModifiedOn")
+ .HasColumnType("datetime2")
+ .HasColumnName("LastModifiedOn");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)")
+ .HasColumnName("Name");
+
+ b.Property("PeriodEnd")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("datetime2")
+ .HasColumnName("PeriodEnd");
+
+ b.Property("PeriodStart")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("datetime2")
+ .HasColumnName("PeriodStart");
+
+ b.Property("RoleId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("RoleId");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedBy");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.HasIndex("ExternalProviderId")
+ .IsUnique()
+ .HasFilter("[ExternalProviderId] IS NOT NULL");
+
+ b.HasIndex("LastModifiedBy");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("Users", "ea");
+
+ b.ToTable(tb => tb.IsTemporal(ttb =>
+ {
+ ttb.UseHistoryTable("History_Users", "ea");
+ ttb
+ .HasPeriodStart("PeriodStart")
+ .HasColumnName("PeriodStart");
+ ttb
+ .HasPeriodEnd("PeriodEnd")
+ .HasColumnName("PeriodEnd");
+ }));
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Application", b =>
+ {
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "CreatedByUser")
+ .WithMany()
+ .HasForeignKey("CreatedBy")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "DeletedByUser")
+ .WithMany()
+ .HasForeignKey("DeletedBy")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "LastModifiedByUser")
+ .WithMany()
+ .HasForeignKey("LastModifiedBy")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.TemplateVersion", "TemplateVersion")
+ .WithMany()
+ .HasForeignKey("TemplateVersionId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("CreatedByUser");
+
+ b.Navigation("DeletedByUser");
+
+ b.Navigation("LastModifiedByUser");
+
+ b.Navigation("TemplateVersion");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.ApplicationResponse", b =>
+ {
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Application", "Application")
+ .WithMany("Responses")
+ .HasForeignKey("ApplicationId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "CreatedByUser")
+ .WithMany()
+ .HasForeignKey("CreatedBy")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "LastModifiedByUser")
+ .WithMany()
+ .HasForeignKey("LastModifiedBy")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.Navigation("Application");
+
+ b.Navigation("CreatedByUser");
+
+ b.Navigation("LastModifiedByUser");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.CustomApplicationStatus", b =>
+ {
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "CreatedByUser")
+ .WithMany()
+ .HasForeignKey("CreatedBy")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Template", "Template")
+ .WithMany()
+ .HasForeignKey("TemplateId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("CreatedByUser");
+
+ b.Navigation("Template");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.File", b =>
+ {
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Application", "Application")
+ .WithMany("Files")
+ .HasForeignKey("ApplicationId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "UploadedByUser")
+ .WithMany("Files")
+ .HasForeignKey("UploadedBy")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Application");
+
+ b.Navigation("UploadedByUser");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Permission", b =>
+ {
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Application", "Application")
+ .WithMany()
+ .HasForeignKey("ApplicationId");
+
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "GrantedByUser")
+ .WithMany()
+ .HasForeignKey("GrantedBy")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "User")
+ .WithMany("Permissions")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Application");
+
+ b.Navigation("GrantedByUser");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.RolePermission", b =>
+ {
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Role", "Role")
+ .WithMany("Permissions")
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Role");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TaskAssignmentLabel", b =>
+ {
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "CreatedByUser")
+ .WithMany()
+ .HasForeignKey("CreatedBy")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "AssignedUser")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.Navigation("AssignedUser");
+
+ b.Navigation("CreatedByUser");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Template", b =>
+ {
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "CreatedByUser")
+ .WithMany()
+ .HasForeignKey("CreatedBy")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("CreatedByUser");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TemplatePermission", b =>
+ {
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "GrantedByUser")
+ .WithMany()
+ .HasForeignKey("GrantedBy")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Template", "Template")
+ .WithMany()
+ .HasForeignKey("TemplateId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "User")
+ .WithMany("TemplatePermissions")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("GrantedByUser");
+
+ b.Navigation("Template");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TemplateVersion", b =>
+ {
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "CreatedByUser")
+ .WithMany()
+ .HasForeignKey("CreatedBy")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "LastModifiedByUser")
+ .WithMany()
+ .HasForeignKey("LastModifiedBy")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Template", "Template")
+ .WithMany("TemplateVersions")
+ .HasForeignKey("TemplateId")
+ .OnDelete(DeleteBehavior.NoAction)
+ .IsRequired();
+
+ b.Navigation("CreatedByUser");
+
+ b.Navigation("LastModifiedByUser");
+
+ b.Navigation("Template");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TenantMembership", b =>
+ {
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Role", "Role")
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Role");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.User", b =>
+ {
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "CreatedByUser")
+ .WithMany()
+ .HasForeignKey("CreatedBy")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "LastModifiedByUser")
+ .WithMany()
+ .HasForeignKey("LastModifiedBy")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Role", "Role")
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("CreatedByUser");
+
+ b.Navigation("LastModifiedByUser");
+
+ b.Navigation("Role");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Application", b =>
+ {
+ b.Navigation("Files");
+
+ b.Navigation("Responses");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Role", b =>
+ {
+ b.Navigation("Permissions");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Template", b =>
+ {
+ b.Navigation("TemplateVersions");
+ });
+
+ modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.User", b =>
+ {
+ b.Navigation("Files");
+
+ b.Navigation("Permissions");
+
+ b.Navigation("TemplatePermissions");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/20260814170240_AddApplicationDeletionColumns.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/20260814170240_AddApplicationDeletionColumns.cs
new file mode 100644
index 00000000..adba9fd1
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/20260814170240_AddApplicationDeletionColumns.cs
@@ -0,0 +1,68 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace GovUK.Dfe.FlexForms.Infrastructure.Migrations
+{
+ ///
+ public partial class AddApplicationDeletionColumns : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "DeletedBy",
+ schema: "ea",
+ table: "Applications",
+ type: "uniqueidentifier",
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "DeletedOn",
+ schema: "ea",
+ table: "Applications",
+ type: "datetime2",
+ nullable: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Applications_DeletedBy",
+ schema: "ea",
+ table: "Applications",
+ column: "DeletedBy");
+
+ migrationBuilder.AddForeignKey(
+ name: "FK_Applications_Users_DeletedBy",
+ schema: "ea",
+ table: "Applications",
+ column: "DeletedBy",
+ principalSchema: "ea",
+ principalTable: "Users",
+ principalColumn: "UserId",
+ onDelete: ReferentialAction.Restrict);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "FK_Applications_Users_DeletedBy",
+ schema: "ea",
+ table: "Applications");
+
+ migrationBuilder.DropIndex(
+ name: "IX_Applications_DeletedBy",
+ schema: "ea",
+ table: "Applications");
+
+ migrationBuilder.DropColumn(
+ name: "DeletedBy",
+ schema: "ea",
+ table: "Applications");
+
+ migrationBuilder.DropColumn(
+ name: "DeletedOn",
+ schema: "ea",
+ table: "Applications");
+ }
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/ExternalApplicationsContextModelSnapshot.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/ExternalApplicationsContextModelSnapshot.cs
index 61076109..8dcf24eb 100644
--- a/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/ExternalApplicationsContextModelSnapshot.cs
+++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/ExternalApplicationsContextModelSnapshot.cs
@@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
- .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("ProductVersion", "10.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
@@ -45,6 +45,14 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnName("CreatedOn")
.HasDefaultValueSql("GETDATE()");
+ b.Property("DeletedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("DeletedBy");
+
+ b.Property("DeletedOn")
+ .HasColumnType("datetime2")
+ .HasColumnName("DeletedOn");
+
b.Property("LastModifiedBy")
.HasColumnType("uniqueidentifier")
.HasColumnName("LastModifiedBy");
@@ -82,6 +90,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasIndex("CreatedOn")
.HasDatabaseName("IX_Applications_CreatedOn");
+ b.HasIndex("DeletedBy");
+
b.HasIndex("LastModifiedBy");
b.HasIndex("TemplateVersionId")
@@ -884,6 +894,11 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
+ b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "DeletedByUser")
+ .WithMany()
+ .HasForeignKey("DeletedBy")
+ .OnDelete(DeleteBehavior.Restrict);
+
b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "LastModifiedByUser")
.WithMany()
.HasForeignKey("LastModifiedBy")
@@ -897,6 +912,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("CreatedByUser");
+ b.Navigation("DeletedByUser");
+
b.Navigation("LastModifiedByUser");
b.Navigation("TemplateVersion");
diff --git a/src/Tests/GovUK.Dfe.FlexForms.Api.Tests.Integration/Controllers/ApplicationsControllerTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Api.Tests.Integration/Controllers/ApplicationsControllerTests.cs
index 582d3233..d0400f9f 100644
--- a/src/Tests/GovUK.Dfe.FlexForms.Api.Tests.Integration/Controllers/ApplicationsControllerTests.cs
+++ b/src/Tests/GovUK.Dfe.FlexForms.Api.Tests.Integration/Controllers/ApplicationsControllerTests.cs
@@ -186,7 +186,7 @@ public async Task AddApplicationResponseAsync_ShouldReturnBadRequest_WhenBodyIsN
{
ResponseBody = "this is not base64"
};
-
+
// Act
var ex = await Assert.ThrowsAsync>(
() => applicationsClient.AddApplicationResponseAsync(new Guid(EaContextSeeder.ApplicationId), request));
@@ -378,158 +378,253 @@ public async Task GetApplicationByReferenceAsync_ShouldReturnNotFound_WhenApplic
new AuthenticationHeaderValue("Bearer", "user-token");
// Act
- var ex = await Assert.ThrowsAsync>(
- () => applicationsClient.GetApplicationByReferenceAsync("InvalidAppRef"));
- Assert.Equal(404, ex.StatusCode);
- }
-
- [Theory]
- [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))]
- public async Task SubmitApplicationAsync_ShouldSubmitApplication_WhenValidRequest(
+ var ex = await Assert.ThrowsAsync>(
+ () => applicationsClient.GetApplicationByReferenceAsync("InvalidAppRef"));
+ Assert.Equal(404, ex.StatusCode);
+ }
+
+ [Theory]
+ [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))]
+ public async Task DeleteApplicationAsync_ShouldDeleteApplication_WhenValidRequest(
CustomWebApplicationDbContextFactory factory,
IApplicationsClient applicationsClient,
HttpClient httpClient)
- {
- // Arrange
- factory.TestClaims = new List
+ {
+ // Arrange
+ factory.TestClaims = new List
{
new(ClaimTypes.Email, EaContextSeeder.BobEmail),
- new("permission", $"Application:{EaContextSeeder.ApplicationId}:Write")
+ new(ClaimTypes.Role, "Admin")
};
- httpClient.DefaultRequestHeaders.Authorization =
- new AuthenticationHeaderValue("Bearer", "user-token");
+ httpClient.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", "user-token");
- var applicationId = Guid.Parse(EaContextSeeder.ApplicationId);
+ var applicationId = Guid.Parse(EaContextSeeder.ApplicationId);
- // Act
- var result = await applicationsClient.SubmitApplicationAsync(applicationId);
+ // Act
+ var result = await applicationsClient.DeleteApplicationAsync(applicationId);
- // Assert
- Assert.NotNull(result);
- Assert.Equal(applicationId, result.ApplicationId);
- Assert.Equal(ApplicationStatus.Submitted, result.Status);
- Assert.NotNull(result.DateSubmitted);
- }
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(applicationId, result.ApplicationId);
+ Assert.Equal(ApplicationStatus.Deleted, result.Status);
+ Assert.NotNull(result.DateDeleted);
+ }
- [Theory]
- [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))]
- public async Task SubmitApplicationAsync_ShouldReturnUnauthorized_WhenTokenMissing(
- CustomWebApplicationDbContextFactory factory,
- IApplicationsClient applicationsClient,
- HttpClient httpClient)
- {
- // Arrange
- var applicationId = Guid.Parse(EaContextSeeder.ApplicationId);
-
- // Act
- var ex = await Assert.ThrowsAsync>(
- () => applicationsClient.SubmitApplicationAsync(applicationId));
- Assert.Equal(403, ex.StatusCode);
- }
-
- [Theory]
- [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))]
- public async Task SubmitApplicationAsync_ShouldReturnForbidden_WhenPermissionMissing(
+ [Theory]
+ [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))]
+ public async Task DeleteApplicationAsync_ShouldReturnUnauthorized_WhenTokenMissing(
+ CustomWebApplicationDbContextFactory factory,
+ IApplicationsClient applicationsClient,
+ HttpClient httpClient)
+ {
+ // Arrange
+ var applicationId = Guid.Parse(EaContextSeeder.ApplicationId);
+
+ // Act
+ var ex = await Assert.ThrowsAsync>(
+ () => applicationsClient.DeleteApplicationAsync(applicationId));
+ Assert.Equal(403, ex.StatusCode);
+ }
+
+ [Theory]
+ [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))]
+ public async Task DeleteApplicationAsync_ShouldReturnForbidden_WhenPermissionMissing(
+ CustomWebApplicationDbContextFactory factory,
+ IApplicationsClient applicationsClient,
+ HttpClient httpClient)
+ {
+ // Arrange
+ factory.TestClaims = new List
+ {
+ new(ClaimTypes.Email, EaContextSeeder.BobEmail)
+ // No Admin permission for this application
+ };
+
+ httpClient.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", "user-token");
+
+ var applicationId = Guid.Parse(EaContextSeeder.ApplicationId);
+
+ // Act
+ var ex = await Assert.ThrowsAsync>(
+ () => applicationsClient.DeleteApplicationAsync(applicationId));
+ Assert.Equal(403, ex.StatusCode);
+ }
+
+ [Theory]
+ [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))]
+ public async Task DeleteApplicationAsync_ShouldReturnNotFound_WhenApplicationNotExists(
+ CustomWebApplicationDbContextFactory factory,
+ IApplicationsClient applicationsClient,
+ HttpClient httpClient)
+ {
+ // Arrange
+ var nonExistentApplicationId = Guid.NewGuid();
+
+ factory.TestClaims = new List
+ {
+ new(ClaimTypes.Email, EaContextSeeder.BobEmail),
+ new(ClaimTypes.Role, "Admin") // Give permission for the specific non-existent app
+ };
+
+ httpClient.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", "user-token");
+
+ // Act
+ var ex = await Assert.ThrowsAsync>(
+ () => applicationsClient.DeleteApplicationAsync(nonExistentApplicationId));
+ Assert.Equal(404, ex.StatusCode);
+ }
+
+ [Theory]
+ [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))]
+ public async Task SubmitApplicationAsync_ShouldSubmitApplication_WhenValidRequest(
CustomWebApplicationDbContextFactory factory,
IApplicationsClient applicationsClient,
HttpClient httpClient)
- {
- // Arrange
- factory.TestClaims = new List
+ {
+ // Arrange
+ factory.TestClaims = new List
+ {
+ new(ClaimTypes.Email, EaContextSeeder.BobEmail),
+ new("permission", $"Application:{EaContextSeeder.ApplicationId}:Write")
+ };
+
+ httpClient.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", "user-token");
+
+ var applicationId = Guid.Parse(EaContextSeeder.ApplicationId);
+
+ // Act
+ var result = await applicationsClient.SubmitApplicationAsync(applicationId);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(applicationId, result.ApplicationId);
+ Assert.Equal(ApplicationStatus.Submitted, result.Status);
+ Assert.NotNull(result.DateSubmitted);
+ }
+
+ [Theory]
+ [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))]
+ public async Task SubmitApplicationAsync_ShouldReturnUnauthorized_WhenTokenMissing(
+ CustomWebApplicationDbContextFactory factory,
+ IApplicationsClient applicationsClient,
+ HttpClient httpClient)
+ {
+ // Arrange
+ var applicationId = Guid.Parse(EaContextSeeder.ApplicationId);
+
+ // Act
+ var ex = await Assert.ThrowsAsync>(
+ () => applicationsClient.SubmitApplicationAsync(applicationId));
+ Assert.Equal(403, ex.StatusCode);
+ }
+
+ [Theory]
+ [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))]
+ public async Task SubmitApplicationAsync_ShouldReturnForbidden_WhenPermissionMissing(
+ CustomWebApplicationDbContextFactory factory,
+ IApplicationsClient applicationsClient,
+ HttpClient httpClient)
+ {
+ // Arrange
+ factory.TestClaims = new List
{
new(ClaimTypes.Email, EaContextSeeder.BobEmail)
// No Write permission for this application
};
- httpClient.DefaultRequestHeaders.Authorization =
- new AuthenticationHeaderValue("Bearer", "user-token");
+ httpClient.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", "user-token");
+
+ var applicationId = Guid.Parse(EaContextSeeder.ApplicationId);
- var applicationId = Guid.Parse(EaContextSeeder.ApplicationId);
+ // Act
+ var ex = await Assert.ThrowsAsync>(
+ () => applicationsClient.SubmitApplicationAsync(applicationId));
+ Assert.Equal(403, ex.StatusCode);
+ }
- // Act
- var ex = await Assert.ThrowsAsync>(
- () => applicationsClient.SubmitApplicationAsync(applicationId));
- Assert.Equal(403, ex.StatusCode);
- }
+ [Theory]
+ [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))]
+ public async Task SubmitApplicationAsync_ShouldReturnNotFound_WhenApplicationNotExists(
+ CustomWebApplicationDbContextFactory factory,
+ IApplicationsClient applicationsClient,
+ HttpClient httpClient)
+ {
+ // Arrange
+ var nonExistentApplicationId = Guid.NewGuid();
- [Theory]
- [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))]
- public async Task SubmitApplicationAsync_ShouldReturnNotFound_WhenApplicationNotExists(
- CustomWebApplicationDbContextFactory factory,
- IApplicationsClient applicationsClient,
- HttpClient httpClient)
- {
- // Arrange
- var nonExistentApplicationId = Guid.NewGuid();
-
- factory.TestClaims = new List
+ factory.TestClaims = new List
{
new(ClaimTypes.Email, EaContextSeeder.BobEmail),
new("permission", $"Application:{nonExistentApplicationId}:Write") // Give permission for the specific non-existent app
};
- httpClient.DefaultRequestHeaders.Authorization =
- new AuthenticationHeaderValue("Bearer", "user-token");
+ httpClient.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", "user-token");
- // Act
- var ex = await Assert.ThrowsAsync>(
- () => applicationsClient.SubmitApplicationAsync(nonExistentApplicationId));
- Assert.Equal(404, ex.StatusCode);
- }
+ // Act
+ var ex = await Assert.ThrowsAsync>(
+ () => applicationsClient.SubmitApplicationAsync(nonExistentApplicationId));
+ Assert.Equal(404, ex.StatusCode);
+ }
- [Theory]
- [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))]
- public async Task SubmitApplicationAsync_ShouldReturnBadRequest_WhenApplicationAlreadySubmitted(
- CustomWebApplicationDbContextFactory factory,
- IApplicationsClient applicationsClient,
- HttpClient httpClient)
- {
- // Arrange
- factory.TestClaims = new List
+ [Theory]
+ [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))]
+ public async Task SubmitApplicationAsync_ShouldReturnBadRequest_WhenApplicationAlreadySubmitted(
+ CustomWebApplicationDbContextFactory factory,
+ IApplicationsClient applicationsClient,
+ HttpClient httpClient)
+ {
+ // Arrange
+ factory.TestClaims = new List
{
new(ClaimTypes.Email, EaContextSeeder.BobEmail),
new("permission", $"Application:{EaContextSeeder.ApplicationId}:Write")
};
- httpClient.DefaultRequestHeaders.Authorization =
- new AuthenticationHeaderValue("Bearer", "user-token");
+ httpClient.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", "user-token");
- var applicationId = Guid.Parse(EaContextSeeder.ApplicationId);
+ var applicationId = Guid.Parse(EaContextSeeder.ApplicationId);
- // First submission
- await applicationsClient.SubmitApplicationAsync(applicationId);
+ // First submission
+ await applicationsClient.SubmitApplicationAsync(applicationId);
- // Act - Try to submit again
- var ex = await Assert.ThrowsAsync>(
- () => applicationsClient.SubmitApplicationAsync(applicationId));
- Assert.Equal(400, ex.StatusCode);
- }
+ // Act - Try to submit again
+ var ex = await Assert.ThrowsAsync>(
+ () => applicationsClient.SubmitApplicationAsync(applicationId));
+ Assert.Equal(400, ex.StatusCode);
+ }
- [Theory]
- [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))]
- public async Task SubmitApplicationAsync_ShouldReturnForbidden_WhenUserIsNotApplicationCreator(
- CustomWebApplicationDbContextFactory factory,
- IApplicationsClient applicationsClient,
- HttpClient httpClient)
- {
- // Arrange - Use Alice's email (Alice exists but didn't create the application - Bob did)
- factory.TestClaims = new List
+ [Theory]
+ [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))]
+ public async Task SubmitApplicationAsync_ShouldReturnForbidden_WhenUserIsNotApplicationCreator(
+ CustomWebApplicationDbContextFactory factory,
+ IApplicationsClient applicationsClient,
+ HttpClient httpClient)
+ {
+ // Arrange - Use Alice's email (Alice exists but didn't create the application - Bob did)
+ factory.TestClaims = new List
{
new(ClaimTypes.Email, "alice@example.com"), // Alice exists but didn't create the application
new("permission", $"Application:{EaContextSeeder.ApplicationId}:Write")
};
- httpClient.DefaultRequestHeaders.Authorization =
- new AuthenticationHeaderValue("Bearer", "user-token");
+ httpClient.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", "user-token");
- var applicationId = Guid.Parse(EaContextSeeder.ApplicationId);
+ var applicationId = Guid.Parse(EaContextSeeder.ApplicationId);
- // Act
- var ex = await Assert.ThrowsAsync>(
- () => applicationsClient.SubmitApplicationAsync(applicationId));
- Assert.Equal(403, ex.StatusCode);
- }
+ // Act
+ var ex = await Assert.ThrowsAsync>(
+ () => applicationsClient.SubmitApplicationAsync(applicationId));
+ Assert.Equal(403, ex.StatusCode);
+ }
[Theory]
[CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))]
@@ -645,7 +740,7 @@ public async Task GetApplicationsForUserAsync_ShouldReturnApplicationsWithSchema
// Assert
Assert.NotNull(result);
Assert.NotEmpty(result.Items);
- Assert.All(result.Items, app =>
+ Assert.All(result.Items, app =>
{
Assert.NotNull(app.TemplateSchema);
Assert.NotEqual(Guid.Empty, app.TemplateSchema.TemplateId);
@@ -678,11 +773,11 @@ public async Task GetApplicationsForUserAsync_ShouldReturnApplicationsWithoutSch
// Assert
Assert.NotNull(result);
Assert.NotEmpty(result.Items);
- Assert.All(result.Items, app =>
+ Assert.All(result.Items, app =>
{
Assert.Null(app.TemplateSchema);
});
- }
+ }
[Theory]
[CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))]
@@ -985,11 +1080,11 @@ public async Task RemoveContributorAsync_ShouldRemoveContributor_WhenValidReques
// The endpoint should return 200 OK if successful
// We can also verify the contributor was removed by trying to get contributors
var contributors = await applicationsClient.GetContributorsAsync(new Guid(EaContextSeeder.ApplicationId));
-
+
// Check if our specific contributor was removed
var removedContributor = contributors.FirstOrDefault(c => c.UserId == addedContributor.UserId);
Assert.Null(removedContributor);
-
+
// Also verify that other contributors (if any) are still there
var otherContributors = contributors.Where(c => c.UserId != addedContributor.UserId).ToList();
Assert.Contains(otherContributors, c => c.Email == "alice@example.com");
@@ -1421,12 +1516,12 @@ public async Task UploadFileAsync_ShouldHandleDifferentFileTypes_WhenFileTypeIsV
// Act - Create a new stream for each test case to ensure it's readable
// Create a copy of the content bytes to ensure the stream can be read multiple times
var contentBytes = System.Text.Encoding.UTF8.GetBytes(testCase.Content);
-
+
// Ensure we have a valid filename - use the test case filename as both name and file parameter filename
var fileName = testCase.FileName ?? "test-file.pdf";
if (string.IsNullOrWhiteSpace(fileName))
fileName = "test-file.pdf";
-
+
// Create the stream fresh for each iteration with position at start
var stream = new MemoryStream(contentBytes);
var fileParameter = new FileParameter(stream, fileName, testCase.ContentType);
@@ -1655,17 +1750,17 @@ public async Task DownloadApplicationPreviewHtmlAsync_ShouldReturnHtmlFile_WhenV
Assert.NotNull(response);
Assert.True(response.IsSuccessStatusCode);
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
-
+
// Verify content type
Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType);
-
+
// Verify content disposition header
var contentDisposition = response.Content.Headers.ContentDisposition;
Assert.NotNull(contentDisposition);
Assert.Equal("attachment", contentDisposition.DispositionType);
Assert.Contains(EaContextSeeder.ApplicationReference, contentDisposition.FileName);
Assert.EndsWith(".html", contentDisposition.FileName);
-
+
// Verify content
var content = await response.Content.ReadAsStringAsync();
Assert.NotEmpty(content);
@@ -1760,10 +1855,10 @@ public async Task DownloadApplicationPreviewHtmlAsync_ShouldIncludeApplicationRe
// Assert
Assert.True(response.IsSuccessStatusCode);
-
+
var contentDisposition = response.Content.Headers.ContentDisposition;
Assert.NotNull(contentDisposition);
-
+
// Verify filename format: application-{reference}-preview.html
var expectedFileName = $"application-{EaContextSeeder.ApplicationReference}-preview.html";
Assert.Contains(expectedFileName, contentDisposition.FileName);
@@ -1790,16 +1885,16 @@ public async Task DownloadApplicationPreviewHtmlAsync_ShouldReturnValidHtmlConte
// Assert
Assert.True(response.IsSuccessStatusCode);
-
+
var htmlContent = await response.Content.ReadAsStringAsync();
-
+
// Verify it's valid HTML
Assert.Contains("", htmlContent);
Assert.Contains("", htmlContent);
Assert.Contains("", htmlContent);
Assert.Contains(" applicationRepo,
+ IPermissionCheckerService permissionCheckerService,
+ IUnitOfWork unitOfWork)
+ {
+ var externalId = "test-app-id";
+ var userWithExternalId = new User(
+ user.Id!,
+ user.RoleId,
+ user.Name,
+ user.Email,
+ user.CreatedOn,
+ user.CreatedBy,
+ user.LastModifiedOn,
+ user.LastModifiedBy,
+ externalId);
+
+ var applicationId = new ApplicationId(command.ApplicationId);
+ var templateVersionId = new TemplateVersionId(Guid.NewGuid());
+ var application = new Domain.Entities.Application(
+ applicationId,
+ "APP-001",
+ templateVersionId,
+ DateTime.UtcNow,
+ userWithExternalId.Id!,
+ ApplicationStatus.InProgress);
+
+ var templateVersion = new TemplateVersion(
+ templateVersionId,
+ new TemplateId(Guid.NewGuid()),
+ "1.0.0",
+ "{}",
+ DateTime.UtcNow,
+ userWithExternalId.Id!);
+ application.GetType().GetProperty("TemplateVersion")?.SetValue(application, templateVersion);
+
+ var applications = new[] { application }.AsQueryable().BuildMockDbSet();
+ applicationRepo.Query().Returns(applications);
+
+ permissionCheckerService.HasPermission(
+ ResourceType.Application,
+ command.ApplicationId.ToString(),
+ AccessType.Write)
+ .Returns(true);
+
+ var handler = CreateHandler(
+ applicationRepo,
+ AuthenticatedUserServiceTestHelper.MockReturningUser(userWithExternalId),
+ permissionCheckerService,
+ unitOfWork);
+
+ var result = await handler.Handle(command, CancellationToken.None);
+
+ Assert.True(result.IsSuccess);
+ Assert.NotNull(result.Value);
+ Assert.Equal(command.ApplicationId, result.Value.ApplicationId);
+ Assert.Equal(ApplicationStatus.Deleted, result.Value.Status);
+ await unitOfWork.Received(1).CommitAsync(Arg.Any());
+ }
+
+ [Theory]
+ [CustomAutoData(typeof(ApplicationCustomization), typeof(UserCustomization))]
+ public async Task Handle_ShouldDeleteApplication_WhenValidRequestWithEmail(
+ DeleteApplicationCommand command,
+ User user,
+ IEaRepository applicationRepo,
+ IPermissionCheckerService permissionCheckerService,
+ IUnitOfWork unitOfWork)
+ {
+ var email = "test@example.com";
+ var testUser = new User(
+ user.Id!,
+ user.RoleId,
+ user.Name,
+ email,
+ user.CreatedOn,
+ user.CreatedBy,
+ user.LastModifiedOn,
+ user.LastModifiedBy);
+
+ var applicationId = new ApplicationId(command.ApplicationId);
+ var templateVersionId = new TemplateVersionId(Guid.NewGuid());
+ var application = new Domain.Entities.Application(
+ applicationId,
+ "APP-001",
+ templateVersionId,
+ DateTime.UtcNow,
+ testUser.Id!,
+ ApplicationStatus.InProgress);
+
+ var templateVersion = new TemplateVersion(
+ templateVersionId,
+ new TemplateId(Guid.NewGuid()),
+ "1.0.0",
+ "{}",
+ DateTime.UtcNow,
+ testUser.Id!);
+ application.GetType().GetProperty("TemplateVersion")?.SetValue(application, templateVersion);
+
+ var applications = new[] { application }.AsQueryable().BuildMockDbSet();
+ applicationRepo.Query().Returns(applications);
+
+ permissionCheckerService.HasPermission(
+ ResourceType.Application,
+ command.ApplicationId.ToString(),
+ AccessType.Write)
+ .Returns(true);
+
+ var handler = CreateHandler(
+ applicationRepo,
+ AuthenticatedUserServiceTestHelper.MockReturningUser(testUser),
+ permissionCheckerService,
+ unitOfWork);
+
+ var result = await handler.Handle(command, CancellationToken.None);
+
+ Assert.True(result.IsSuccess);
+ Assert.NotNull(result.Value);
+ Assert.Equal(ApplicationStatus.Deleted, result.Value.Status);
+ }
+
+ [Theory]
+ [CustomAutoData(typeof(ApplicationCustomization))]
+ public async Task Handle_ShouldReturnUnauthorized_WhenUserNotAuthenticated(
+ DeleteApplicationCommand command,
+ IEaRepository applicationRepo,
+ IPermissionCheckerService permissionCheckerService,
+ IUnitOfWork unitOfWork)
+ {
+ permissionCheckerService.HasPermission(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any())
+ .Returns(false);
+
+ var handler = CreateHandler(
+ applicationRepo,
+ AuthenticatedUserServiceTestHelper.MockReturning(Result.Forbid("Not authenticated")),
+ permissionCheckerService,
+ unitOfWork);
+
+ var result = await handler.Handle(command, CancellationToken.None);
+
+ Assert.False(result.IsSuccess);
+ Assert.Equal("Not authenticated", result.Error);
+ }
+
+ [Theory]
+ [CustomAutoData(typeof(ApplicationCustomization))]
+ public async Task Handle_ShouldReturnApplicationNotFound_WhenApplicationDoesNotExist(
+ DeleteApplicationCommand command,
+ IEaRepository applicationRepo,
+ IPermissionCheckerService permissionCheckerService,
+ IUnitOfWork unitOfWork)
+ {
+ var user = new User(
+ new UserId(Guid.NewGuid()),
+ new RoleId(Guid.NewGuid()),
+ "Test User",
+ "test@example.com",
+ DateTime.UtcNow,
+ null,
+ null,
+ null);
+
+ var applications = Array.Empty().AsQueryable().BuildMockDbSet();
+ applicationRepo.Query().Returns(applications);
+
+ permissionCheckerService.HasPermission(
+ ResourceType.Application,
+ command.ApplicationId.ToString(),
+ AccessType.Write)
+ .Returns(true);
+
+ var handler = CreateHandler(
+ applicationRepo,
+ AuthenticatedUserServiceTestHelper.MockReturningUser(user),
+ permissionCheckerService,
+ unitOfWork);
+
+ var result = await handler.Handle(command, CancellationToken.None);
+
+ Assert.False(result.IsSuccess);
+ Assert.Equal("Application not found", result.Error);
+ }
+
+ [Theory]
+ [CustomAutoData(typeof(ApplicationCustomization), typeof(UserCustomization))]
+ public async Task Handle_ShouldReturnForbidden_WhenUserHasNoPermission(
+ DeleteApplicationCommand command,
+ User user,
+ IEaRepository applicationRepo,
+ IPermissionCheckerService permissionCheckerService,
+ IUnitOfWork unitOfWork)
+ {
+ var email = "test@example.com";
+ var testUser = new User(
+ user.Id!,
+ user.RoleId,
+ user.Name,
+ email,
+ user.CreatedOn,
+ user.CreatedBy,
+ user.LastModifiedOn,
+ user.LastModifiedBy);
+
+ var applicationId = new ApplicationId(command.ApplicationId);
+ var application = new Domain.Entities.Application(
+ applicationId,
+ "APP-001",
+ new TemplateVersionId(Guid.NewGuid()),
+ DateTime.UtcNow,
+ testUser.Id!,
+ ApplicationStatus.InProgress);
+
+ var applications = new[] { application }.AsQueryable().BuildMockDbSet();
+ applicationRepo.Query().Returns(applications);
+
+ permissionCheckerService.HasPermission(
+ ResourceType.Application,
+ command.ApplicationId.ToString(),
+ AccessType.Write)
+ .Returns(false);
+
+ var handler = CreateHandler(
+ applicationRepo,
+ AuthenticatedUserServiceTestHelper.MockReturningUser(testUser),
+ permissionCheckerService,
+ unitOfWork);
+
+ var result = await handler.Handle(command, CancellationToken.None);
+
+ Assert.False(result.IsSuccess);
+ Assert.Equal("User does not have permission to delete this application", result.Error);
+ }
+
+ [Theory]
+ [CustomAutoData(typeof(ApplicationCustomization), typeof(UserCustomization))]
+ public async Task Handle_ShouldReturnError_WhenApplicationAlreadyDeleted(
+ DeleteApplicationCommand command,
+ User user,
+ IEaRepository applicationRepo,
+ IPermissionCheckerService permissionCheckerService,
+ IUnitOfWork unitOfWork)
+ {
+ var email = "test@example.com";
+ var testUser = new User(
+ user.Id!,
+ user.RoleId,
+ user.Name,
+ email,
+ user.CreatedOn,
+ user.CreatedBy,
+ user.LastModifiedOn,
+ user.LastModifiedBy);
+
+ var applicationId = new ApplicationId(command.ApplicationId);
+ var application = new Domain.Entities.Application(
+ applicationId,
+ "APP-001",
+ new TemplateVersionId(Guid.NewGuid()),
+ DateTime.UtcNow,
+ testUser.Id!,
+ ApplicationStatus.Deleted);
+
+ var applications = new[] { application }.AsQueryable().BuildMockDbSet();
+ applicationRepo.Query().Returns(applications);
+
+ permissionCheckerService.HasPermission(
+ ResourceType.Application,
+ command.ApplicationId.ToString(),
+ AccessType.Write)
+ .Returns(true);
+
+ var handler = CreateHandler(
+ applicationRepo,
+ AuthenticatedUserServiceTestHelper.MockReturningUser(testUser),
+ permissionCheckerService,
+ unitOfWork);
+
+ var result = await handler.Handle(command, CancellationToken.None);
+
+ Assert.False(result.IsSuccess);
+ Assert.Contains("Application has already been deleted", result.Error!);
+ }
+
+ private static DeleteApplicationCommandHandler CreateHandler(
+ IEaRepository applicationRepo,
+ IAuthenticatedUserService authenticatedUserService,
+ IPermissionCheckerService permissionCheckerService,
+ IUnitOfWork unitOfWork,
+ IEaRepository? fileRepository = null,
+ IFileValidationModeResolver? modeResolver = null,
+ IApplicationFileValidationPolicy? policy = null)
+ {
+ var files = fileRepository ?? Substitute.For>();
+ var emptyFiles = Array.Empty().AsQueryable().BuildMockDbSet();
+ files.Query().Returns(emptyFiles);
+
+ var resolver = modeResolver ?? Substitute.For();
+ resolver.Resolve(Arg.Any()).Returns(FileValidationMode.Off);
+
+ return new DeleteApplicationCommandHandler(
+ applicationRepo,
+ authenticatedUserService,
+ permissionCheckerService,
+ Substitute.For(),
+ unitOfWork);
+ }
+}
diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/CommandValidators/Applications/DeleteApplicationCommandValidatorTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/CommandValidators/Applications/DeleteApplicationCommandValidatorTests.cs
new file mode 100644
index 00000000..e6098050
--- /dev/null
+++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/CommandValidators/Applications/DeleteApplicationCommandValidatorTests.cs
@@ -0,0 +1,41 @@
+using GovUK.Dfe.FlexForms.Application.Applications.Commands;
+
+namespace GovUK.Dfe.FlexForms.Application.Tests.CommandValidators.Applications;
+
+public class DeleteApplicationCommandValidatorTests
+{
+ [Theory]
+ [InlineData("12345678-1234-1234-1234-123456789abc")]
+ [InlineData("87654321-4321-4321-4321-ba9876543210")]
+ public void Validate_ShouldSucceed_WhenApplicationIdValid(string applicationIdString)
+ {
+ // Arrange
+ var applicationId = Guid.Parse(applicationIdString);
+ var command = new DeleteApplicationCommand(applicationId);
+ var validator = new DeleteApplicationCommandValidator();
+
+ // Act
+ var result = validator.Validate(command);
+
+ // Assert
+ Assert.True(result.IsValid);
+ Assert.Empty(result.Errors);
+ }
+
+ [Fact]
+ public void Validate_ShouldFail_WhenApplicationIdEmpty()
+ {
+ // Arrange
+ var command = new DeleteApplicationCommand(Guid.Empty);
+ var validator = new DeleteApplicationCommandValidator();
+
+ // Act
+ var result = validator.Validate(command);
+
+ // Assert
+ Assert.False(result.IsValid);
+ Assert.Single(result.Errors);
+ Assert.Equal("Application ID is required", result.Errors[0].ErrorMessage);
+ Assert.Equal("ApplicationId", result.Errors[0].PropertyName);
+ }
+}
\ No newline at end of file
diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Helpers/ApplicationListingTestHelper.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Helpers/ApplicationListingTestHelper.cs
index 1d112d14..8bd6d29c 100644
--- a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Helpers/ApplicationListingTestHelper.cs
+++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Helpers/ApplicationListingTestHelper.cs
@@ -11,6 +11,7 @@
using Microsoft.Extensions.Logging;
using NSubstitute;
using ApplicationId = GovUK.Dfe.FlexForms.Domain.ValueObjects.ApplicationId;
+using GovUK.Dfe.FlexForms.Domain.Services;
namespace GovUK.Dfe.FlexForms.Application.Tests.Helpers;
@@ -114,6 +115,7 @@ internal static IApplicationRepository CreateApplicationRepository()
internal static GetApplicationsForUserQueryHandler CreateGetApplicationsForUserQueryHandler(
IEaRepository userRepo,
+ IPermissionCheckerService permissionCheckerService,
IEaRepository appRepo,
ITenantContextAccessor tenantContextAccessor,
IUserAccessibleTemplateService accessibleTemplateService,
@@ -125,6 +127,7 @@ internal static GetApplicationsForUserQueryHandler CreateGetApplicationsForUserQ
return new GetApplicationsForUserQueryHandler(
userRepo,
appRepo,
+ permissionCheckerService,
CreateApplicationRepository(),
cache,
tenantContextAccessor,
@@ -168,10 +171,10 @@ internal static GetApplicationsByTemplateQueryHandler CreateGetApplicationsByTem
httpContextAccessor,
userRepo,
appRepo,
+ permissionCheckerService,
CreateApplicationRepository(),
cache,
tenantContextAccessor,
- tenantTemplateResolver,
- permissionCheckerService);
+ tenantTemplateResolver);
}
}
diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/QueryHandlers/Applications/GetApplicationsForUserQueryHandlerTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/QueryHandlers/Applications/GetApplicationsForUserQueryHandlerTests.cs
index 5eb82f49..a986ae7f 100644
--- a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/QueryHandlers/Applications/GetApplicationsForUserQueryHandlerTests.cs
+++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/QueryHandlers/Applications/GetApplicationsForUserQueryHandlerTests.cs
@@ -9,6 +9,7 @@
using GovUK.Dfe.FlexForms.Application.Tests.Helpers;
using GovUK.Dfe.FlexForms.Domain.Entities;
using GovUK.Dfe.FlexForms.Domain.Interfaces.Repositories;
+using GovUK.Dfe.FlexForms.Domain.Services;
using GovUK.Dfe.FlexForms.Domain.Tenancy;
using GovUK.Dfe.FlexForms.Domain.ValueObjects;
using GovUK.Dfe.FlexForms.Tests.Common.Customizations.Entities;
@@ -27,6 +28,7 @@ public async Task Handle_ShouldReturnApplications_WhenUserHasPermissions(
PermissionCustomization permCustom,
ApplicationCustomization appCustom,
[Frozen] IEaRepository userRepo,
+ [Frozen] IPermissionCheckerService permissionCheckerService,
[Frozen] IEaRepository appRepo,
[Frozen] ITenantContextAccessor tenantContextAccessor)
{
@@ -67,6 +69,7 @@ public async Task Handle_ShouldReturnApplications_WhenUserHasPermissions(
var handler = ApplicationListingTestHelper.CreateGetApplicationsForUserQueryHandler(
userRepo,
+ permissionCheckerService,
appRepo,
tenantContextAccessor,
ApplicationListingTestHelper.CreateAccessibleTemplateService(template.Id!));
@@ -90,7 +93,8 @@ public async Task Handle_ShouldReturnApplicationsWithoutSchema_WhenIncludeSchema
ApplicationCustomization appCustom,
[Frozen] IEaRepository userRepo,
[Frozen] IEaRepository appRepo,
- [Frozen] ITenantContextAccessor tenantContextAccessor)
+ [Frozen] ITenantContextAccessor tenantContextAccessor,
+ [Frozen] IPermissionCheckerService permissionCheckerService)
{
userCustom.OverrideEmail = rawEmail;
userCustom.OverridePermissions = Array.Empty();
@@ -118,6 +122,7 @@ public async Task Handle_ShouldReturnApplicationsWithoutSchema_WhenIncludeSchema
var handler = ApplicationListingTestHelper.CreateGetApplicationsForUserQueryHandler(
userRepo,
+ permissionCheckerService,
appRepo,
tenantContextAccessor,
ApplicationListingTestHelper.CreateAccessibleTemplateService(template.Id!));
@@ -137,7 +142,8 @@ public async Task Handle_ShouldReturnApplicationsWithoutSchema_WhenIncludeSchema
ApplicationCustomization appCustom,
[Frozen] IEaRepository userRepo,
[Frozen] IEaRepository appRepo,
- [Frozen] ITenantContextAccessor tenantContextAccessor)
+ [Frozen] ITenantContextAccessor tenantContextAccessor,
+ [Frozen] IPermissionCheckerService permissionCheckerService)
{
userCustom.OverrideEmail = rawEmail;
userCustom.OverridePermissions = Array.Empty();
@@ -165,6 +171,7 @@ public async Task Handle_ShouldReturnApplicationsWithoutSchema_WhenIncludeSchema
var handler = ApplicationListingTestHelper.CreateGetApplicationsForUserQueryHandler(
userRepo,
+ permissionCheckerService,
appRepo,
tenantContextAccessor,
ApplicationListingTestHelper.CreateAccessibleTemplateService(template.Id!));
@@ -182,7 +189,8 @@ public async Task Handle_ShouldReturnEmpty_WhenUserNotFound(
UserCustomization userCustom,
[Frozen] IEaRepository userRepo,
[Frozen] IEaRepository appRepo,
- [Frozen] ITenantContextAccessor tenantContextAccessor)
+ [Frozen] ITenantContextAccessor tenantContextAccessor,
+ [Frozen] IPermissionCheckerService permissionCheckerService)
{
var userQ = new List().AsQueryable().BuildMock();
userRepo.Query().Returns(userQ);
@@ -190,6 +198,7 @@ public async Task Handle_ShouldReturnEmpty_WhenUserNotFound(
var handler = ApplicationListingTestHelper.CreateGetApplicationsForUserQueryHandler(
userRepo,
+ permissionCheckerService,
appRepo,
tenantContextAccessor,
ApplicationListingTestHelper.CreateAccessibleTemplateService(new TemplateId(Guid.NewGuid())));
@@ -207,7 +216,8 @@ public async Task Handle_ShouldReturnAllResults_WithDefaultPageMetadata_WhenNoPa
ApplicationCustomization appCustom,
[Frozen] IEaRepository userRepo,
[Frozen] IEaRepository appRepo,
- [Frozen] ITenantContextAccessor tenantContextAccessor)
+ [Frozen] ITenantContextAccessor tenantContextAccessor,
+ [Frozen] IPermissionCheckerService permissionCheckerService)
{
userCustom.OverrideEmail = rawEmail;
userCustom.OverridePermissions = Array.Empty();
@@ -234,6 +244,7 @@ public async Task Handle_ShouldReturnAllResults_WithDefaultPageMetadata_WhenNoPa
var handler = ApplicationListingTestHelper.CreateGetApplicationsForUserQueryHandler(
userRepo,
+ permissionCheckerService,
appRepo,
tenantContextAccessor,
ApplicationListingTestHelper.CreateAccessibleTemplateService(templateId));
@@ -255,7 +266,8 @@ public async Task Handle_ShouldReturnPagedResults_WhenPageNumberAndPageSizeProvi
ApplicationCustomization appCustom,
[Frozen] IEaRepository userRepo,
[Frozen] IEaRepository