diff --git a/.gitignore b/.gitignore index 807c333c..4d795085 100644 --- a/.gitignore +++ b/.gitignore @@ -507,3 +507,8 @@ docs/superpowers/ CLAUDE.md .claude/ /.playwright-mcp +.specify/ +.clinerules/ +.opencode/ +opencode.json +specs/ \ No newline at end of file diff --git a/docker-compose.vscode.yml b/docker-compose.vscode.yml new file mode 100644 index 00000000..a4b31f8a --- /dev/null +++ b/docker-compose.vscode.yml @@ -0,0 +1,25 @@ +# VS Code Docker Compose debug override — targets the vscode-debug stage in the +# Dockerfile so the container includes vsdbg for .NET debugging. Add this to +# COMPOSE_FILE or reference explicitly when attaching VS Code's debugger. +# +# Usage via launch.json (automatic): +# The "Docker Compose: Debug Web" launch configuration in .vscode/launch.json +# merges this file automatically when starting the debug session. +# +# Usage via CLI (manual): +# docker compose -f docker-compose.yaml -f docker-compose.vscode.yml up -d web +services: + web: + build: + target: debug-runtime + ports: + - "8080:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - Dev__ToolsEnabled=true + healthcheck: + test: ["CMD", "dotnet", "--info"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 30s diff --git a/docker-compose.yaml b/docker-compose.yaml index ec88a7bc..73ee7915 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,6 +8,7 @@ services: build: context: ./src dockerfile: DfE.CheckPerformanceData.Web/Dockerfile + target: runtime image: cypd_web:latest ports: - "8080:8080" diff --git a/docs/request-journey.md b/docs/request-journey.md index 61c2cdcc..0fa4d526 100644 --- a/docs/request-journey.md +++ b/docs/request-journey.md @@ -290,7 +290,34 @@ From the Summary, the user can: ### What happens -1. **Idempotency check** — `IRequestRepository.IsSubmittedAsync(referenceNumber)` — if a `ChangeRequest` row with this reference number already exists with `Status = Submitted`, return silently. This handles double-taps and back-button resubmits. +1. **Duplicate-request check** — `HasSubmittedRequestAsync` (called from `PupilSearchPost`) / `CheckForConflictAsync` (called from `ConfirmRequestAsync`) queries `ChangeRequests` for an existing `SubmittedUnCommitted` row matching `WindowId + PupilId + OrganisationUrn` (excluding the current `ReferenceNumber` when one exists). Returns a `DuplicateCheckResult` discriminated record: + + - `NoConflict` — no conflicting request exists, proceed + - `SelfSubmitted(ReferenceNumber, ConflictingReasonType, ConflictingRequestCategory, ConflictingUserName)` — the current user already has a submitted request + - `OtherSubmitted(ReferenceNumber, ConflictingReasonType, ConflictingRequestCategory, ConflictingUserName)` — a colleague has a submitted request (identity revealed via `ConflictingUserName`) + + The check runs at two points: + + 1. **Pupil selection** (`PupilSearchPost`) — after the user selects a pupil and clicks Continue. Only runs on non-`MatchKey` pages (skipped for the second pupil selector in Merge flows). On conflict, re-renders the page with: + - A GDS error summary: **"A request has already been submitted for this pupil"** (top-level) + **"Choose another pupil"** (field-level) + - A MOJ attention banner with a contextual message and a link to the existing request + + 2. **Final submission** (`SummaryConfirm`) — catches conflicts that arose between pupil selection and submission (e.g. another user submitted in a different tab). On conflict, re-renders the Summary page with a contextual error message (no banner). + + Both check points produce messages from a 2×2 matrix of **`isSelf` × `reasonsMatch`** (whether the current request's reason type matches the conflicting request's): + + | Scenario | Pupil-search attention banner | Summary error message | + |---|---|---| + | **Self + same reason** | "You have already submitted a {topLevelRequest} for {pupilName}. Reference {refNum} [link]. To raise a new request, delete the previously submitted request. Then return to this page to continue." | "You have already submitted a {topLevelRequest} for this pupil." | + | **Other + same reason** | "Your colleague {userName} has already submitted a {topLevelRequest} for {pupilName}. Reference {refNum} [link]. To raise a new request, delete the previously submitted request. Then return to this page to continue." | "A colleague at your school has already submitted a {topLevelRequest} for this pupil." | + | **Self + different reason** | "You have already submitted a request of a different type ({topLevelRequest}) for {pupilName}. Reference {refNum} [link]. To raise a new request, delete the previously submitted request. Then return to this page to continue." | "You have already submitted a request of a different type ({topLevelRequest}) for this pupil." | + | **Other + different reason** | "Your colleague {userName} has already submitted a request of a different type ({topLevelRequest}) for {pupilName}. Reference {refNum} [link]. To raise a new request check with your colleague, and if you want to proceed, delete the previously submitted request. Then return to this page to continue." | "A colleague at your school has already submitted a request of a different type ({topLevelRequest}) for this pupil." | + + `{topLevelRequest}` is mapped from the request category: `"Remove"` → `"pupil removal request"`, `"Include"` → `"pupil inclusion request"`, `"Merge"` → `"pupil merge request"`. + + The `DuplicateRequestException` carries `ConflictType` (`SelfSubmitted` / `OtherSubmitted`) plus `ConflictingReasonType`, `ConflictingRequestCategory`, `ConflictingUserName`, and `ReasonsMatch` so the error message is contextualised without re-querying. + + Idempotency is provided by the reference-number exclusion in `CheckForConflictAsync` (passing the actual `ReferenceNumber` at submit time avoids self-conflict) and the upsert's overwrite behaviour on the existing row. 2. **Build `RequestDocument`** — a structured document containing: - Reference number, submitted-at timestamp diff --git a/src/DfE.CheckPerformanceData.Application/RequestSubmission/DuplicateCheckResult.cs b/src/DfE.CheckPerformanceData.Application/RequestSubmission/DuplicateCheckResult.cs new file mode 100644 index 00000000..09bdaf01 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/RequestSubmission/DuplicateCheckResult.cs @@ -0,0 +1,10 @@ +namespace DfE.CheckPerformanceData.Application.RequestSubmission; + +public abstract record DuplicateCheckResult +{ + public sealed record NoConflict : DuplicateCheckResult; + + public sealed record SelfSubmitted(string ReferenceNumber, string ConflictingReasonType, string ConflictingRequestCategory, string ConflictingUserName) : DuplicateCheckResult; + + public sealed record OtherSubmitted(string ReferenceNumber, string ConflictingReasonType, string ConflictingRequestCategory, string ConflictingUserName) : DuplicateCheckResult; +} diff --git a/src/DfE.CheckPerformanceData.Application/RequestSubmission/DuplicateRequestException.cs b/src/DfE.CheckPerformanceData.Application/RequestSubmission/DuplicateRequestException.cs index 30ff7f79..644a7453 100644 --- a/src/DfE.CheckPerformanceData.Application/RequestSubmission/DuplicateRequestException.cs +++ b/src/DfE.CheckPerformanceData.Application/RequestSubmission/DuplicateRequestException.cs @@ -1,4 +1,31 @@ namespace DfE.CheckPerformanceData.Application.RequestSubmission; -public sealed class DuplicateRequestException() - : Exception("A request for this pupil already exists for this checking window."); +public enum ConflictType { SelfSubmitted, OtherSubmitted } + +public sealed class DuplicateRequestException : Exception +{ + public ConflictType ConflictType { get; } + public string ConflictingReasonType { get; } + public string ConflictingRequestCategory { get; } + public string ConflictingUserName { get; } + public bool ReasonsMatch { get; } + + public DuplicateRequestException(ConflictType conflictType) + : base("A request for this pupil already exists for this checking window.") + { + ConflictType = conflictType; + ConflictingReasonType = string.Empty; + ConflictingRequestCategory = string.Empty; + ConflictingUserName = string.Empty; + } + + public DuplicateRequestException(ConflictType conflictType, string conflictingReasonType, string conflictingRequestCategory, string conflictingUserName, bool reasonsMatch) + : base("A request for this pupil already exists for this checking window.") + { + ConflictType = conflictType; + ConflictingReasonType = conflictingReasonType; + ConflictingRequestCategory = conflictingRequestCategory; + ConflictingUserName = conflictingUserName; + ReasonsMatch = reasonsMatch; + } +} diff --git a/src/DfE.CheckPerformanceData.Application/RequestSubmission/IRequestRepository.cs b/src/DfE.CheckPerformanceData.Application/RequestSubmission/IRequestRepository.cs index 31c220db..71320e03 100644 --- a/src/DfE.CheckPerformanceData.Application/RequestSubmission/IRequestRepository.cs +++ b/src/DfE.CheckPerformanceData.Application/RequestSubmission/IRequestRepository.cs @@ -4,7 +4,7 @@ namespace DfE.CheckPerformanceData.Application.RequestSubmission; public interface IRequestRepository { - Task HasConflictingRequestAsync(Guid windowId, Guid pupilId, long organisationUrn, string currentReferenceNumber); + Task CheckForConflictAsync(Guid windowId, Guid pupilId, long organisationUrn, string currentReferenceNumber, Guid currentUserId); /// Returns the reference number of a submitted request for the given pupil, or null if none exists. Task HasSubmittedRequestAsync(Guid windowId, Guid pupilId, long organisationUrn); diff --git a/src/DfE.CheckPerformanceData.Application/RequestSubmission/IRequestService.cs b/src/DfE.CheckPerformanceData.Application/RequestSubmission/IRequestService.cs index 21441989..3e4cc4a8 100644 --- a/src/DfE.CheckPerformanceData.Application/RequestSubmission/IRequestService.cs +++ b/src/DfE.CheckPerformanceData.Application/RequestSubmission/IRequestService.cs @@ -5,8 +5,8 @@ namespace DfE.CheckPerformanceData.Application.RequestSubmission; public interface IRequestService { - /// Returns the reference number of a submitted request for the given pupil, or null if none exists. - Task HasSubmittedRequestAsync(Guid windowId, Guid pupilId, long organisationUrn); + /// Checks whether a submitted request already exists for the given pupil. Returns discriminating between no conflict, self-submitted, and other-submitted. + Task HasSubmittedRequestAsync(Guid windowId, Guid pupilId, long organisationUrn); /// /// Submits a request without sending the confirmation email: conflict check, upsert the diff --git a/src/DfE.CheckPerformanceData.Application/RequestSubmission/RequestService.cs b/src/DfE.CheckPerformanceData.Application/RequestSubmission/RequestService.cs index 478f7b56..8df1b6e0 100644 --- a/src/DfE.CheckPerformanceData.Application/RequestSubmission/RequestService.cs +++ b/src/DfE.CheckPerformanceData.Application/RequestSubmission/RequestService.cs @@ -21,20 +21,47 @@ public sealed class RequestService( { private long OrganisationUrnLong => long.Parse(currentUserService.OrganisationUrn); - public Task HasSubmittedRequestAsync(Guid windowId, Guid pupilId, long organisationUrn) => - requestRepository.HasSubmittedRequestAsync(windowId, pupilId, organisationUrn); + private string ExtractCurrentReasonType(RequestState journey, QuestionFlowConfig? config) + { + if (config is null) + return journey.SelectedWhatToChange?.ToString() ?? string.Empty; + + var detail = flowService.ResolveRequestType(config, journey); + return string.IsNullOrEmpty(detail) + ? journey.SelectedWhatToChange?.ToString() ?? string.Empty + : detail; + } + + public async Task HasSubmittedRequestAsync(Guid windowId, Guid pupilId, long organisationUrn) + { + var userId = Guid.Parse(currentUserService.UserId); + return await requestRepository.CheckForConflictAsync(windowId, pupilId, organisationUrn, string.Empty, userId); + } public async Task SubmitRequestAsync(Guid windowId, RequestState journey) { if (journey.SelectedWhatToChange is null || journey.CheckingWindow is null || journey.SelectedPupil is null) throw new InvalidOperationException("Session state is incomplete for request submission."); + var config = await flowService.GetConfigAsync(journey.SelectedWhatToChange.Value, journey.CheckingWindow.CheckingWindowType); + var urnLong = OrganisationUrnLong; var refNum = journey.ReferenceNumber ?? string.Empty; - if (await requestRepository.HasConflictingRequestAsync(windowId, journey.SelectedPupil.Id, urnLong, refNum)) - throw new DuplicateRequestException(); + var userId = Guid.Parse(currentUserService.UserId); + var conflict = await requestRepository.CheckForConflictAsync(windowId, journey.SelectedPupil.Id, urnLong, refNum, userId); + if (conflict is DuplicateCheckResult.SelfSubmitted { ConflictingReasonType: var conflictingReasonType, ConflictingRequestCategory: var selfCategory, ConflictingUserName: var selfUserName }) + { + var currentReasonType = ExtractCurrentReasonType(journey, config); + var reasonsMatch = string.Equals(currentReasonType, conflictingReasonType, StringComparison.OrdinalIgnoreCase); + throw new DuplicateRequestException(ConflictType.SelfSubmitted, conflictingReasonType, selfCategory, selfUserName, reasonsMatch); + } + if (conflict is DuplicateCheckResult.OtherSubmitted { ConflictingReasonType: var otherReasonType, ConflictingRequestCategory: var otherCategory, ConflictingUserName: var otherUserName }) + { + var currentReasonType = ExtractCurrentReasonType(journey, config); + var reasonsMatch = string.Equals(currentReasonType, otherReasonType, StringComparison.OrdinalIgnoreCase); + throw new DuplicateRequestException(ConflictType.OtherSubmitted, otherReasonType, otherCategory, otherUserName, reasonsMatch); + } - var config = await flowService.GetConfigAsync(journey.SelectedWhatToChange.Value, journey.CheckingWindow.CheckingWindowType); if (config is null) throw new InvalidOperationException( $"No question flow config found for {journey.SelectedWhatToChange}/{journey.CheckingWindow.CheckingWindowType}."); diff --git a/src/DfE.CheckPerformanceData.Persistence/Repositories/RequestRepository.cs b/src/DfE.CheckPerformanceData.Persistence/Repositories/RequestRepository.cs index 6171f9a9..4d9a0bc0 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Repositories/RequestRepository.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Repositories/RequestRepository.cs @@ -9,17 +9,44 @@ namespace DfE.CheckPerformanceData.Persistence.Repositories; public sealed class RequestRepository(IPortalDbContext db) : IRequestRepository { - // Keyed on the pupil's stable Id, not UPN: a UPN-less pupil has a blank UPN shared with every - // other UPN-less pupil, so UPN keying would both raise false conflicts between different pupils - // and (for null UPNs) fail to detect real ones. - public Task HasConflictingRequestAsync( - Guid windowId, Guid pupilId, long organisationUrn, string currentReferenceNumber) => - db.ChangeRequests.AnyAsync(r => - r.WindowId == windowId && - r.PupilId == pupilId && - r.OrganisationUrn == organisationUrn && - r.ReferenceNumber != currentReferenceNumber && - r.Status == RequestStatus.SubmittedUnCommitted); + private static string ExtractConflictingReasonType(string requestTypeDescription) + { + var separatorIndex = requestTypeDescription.IndexOf(" - ", StringComparison.Ordinal); + return separatorIndex >= 0 + ? requestTypeDescription[(separatorIndex + 3)..] + : requestTypeDescription; + } + + private static string ExtractRequestCategory(string requestTypeDescription) + { + var separatorIndex = requestTypeDescription.IndexOf(" - ", StringComparison.Ordinal); + return separatorIndex >= 0 + ? requestTypeDescription[..separatorIndex] + : requestTypeDescription; + } + + public async Task CheckForConflictAsync( + Guid windowId, Guid pupilId, long organisationUrn, string currentReferenceNumber, Guid currentUserId) + { + var conflict = await db.ChangeRequests + .Where(r => r.WindowId == windowId + && r.PupilId == pupilId + && r.OrganisationUrn == organisationUrn + && r.ReferenceNumber != currentReferenceNumber + && r.Status == RequestStatus.SubmittedUnCommitted) + .Select(r => new { r.SubmittedById, r.ReferenceNumber, r.RequestTypeDescription, r.SubmittedByName }) + .FirstOrDefaultAsync(); + + if (conflict is null) + return new DuplicateCheckResult.NoConflict(); + + var conflictingReasonType = ExtractConflictingReasonType(conflict.RequestTypeDescription); + var conflictingCategory = ExtractRequestCategory(conflict.RequestTypeDescription); + + return conflict.SubmittedById == currentUserId + ? new DuplicateCheckResult.SelfSubmitted(conflict.ReferenceNumber, conflictingReasonType, conflictingCategory, conflict.SubmittedByName ?? string.Empty) + : new DuplicateCheckResult.OtherSubmitted(conflict.ReferenceNumber, conflictingReasonType, conflictingCategory, conflict.SubmittedByName ?? string.Empty); + } public async Task HasSubmittedRequestAsync( Guid windowId, Guid pupilId, long organisationUrn) => @@ -52,6 +79,93 @@ public async Task UpsertAsync(ChangeRequestData data) .Select(r => r.Id) .FirstOrDefaultAsync(); + // For SubmittedUnCommitted, check for conflicts atomically within a serializable + // transaction covering both insert and update paths. This closes the TOCTOU gap + // between CheckForConflictAsync and UpsertAsync — two concurrent submissions for + // the same pupil cannot both succeed. + if (data.Status == RequestStatus.SubmittedUnCommitted) + { + var strategy = db.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable); + + var conflictQuery = db.ChangeRequests + .Where(r => r.WindowId == data.WindowId + && r.PupilId == data.PupilId + && r.OrganisationUrn == data.OrganisationUrn + && r.Status == RequestStatus.SubmittedUnCommitted); + + // When updating an existing row, exclude the current row from conflict + // detection so a user can re-submit or update their own draft. + if (existingId != Guid.Empty) + conflictQuery = conflictQuery.Where(r => r.Id != existingId); + + var conflict = await conflictQuery.FirstOrDefaultAsync(); + + if (conflict is not null) + { + var conflictingReasonType = ExtractConflictingReasonType(conflict.RequestTypeDescription); + var conflictingCategory = ExtractRequestCategory(conflict.RequestTypeDescription); + var reasonsMatch = conflictingReasonType.Equals( + ExtractConflictingReasonType(data.RequestTypeDescription), StringComparison.OrdinalIgnoreCase); + throw new DuplicateRequestException( + conflict.SubmittedById == data.SubmittedById + ? ConflictType.SelfSubmitted + : ConflictType.OtherSubmitted, + conflictingReasonType, conflictingCategory, conflict.SubmittedByName ?? string.Empty, reasonsMatch); + } + + Guid id; + if (existingId != Guid.Empty) + { + id = existingId; + await db.ChangeRequests + .Where(r => r.Id == existingId) + .ExecuteUpdateAsync(s => s + .SetProperty(r => r.Status, data.Status) + .SetProperty(r => r.Submitted, timestamp) + .SetProperty(r => r.OrganisationUrn, data.OrganisationUrn) + .SetProperty(r => r.PupilId, data.PupilId) + .SetProperty(r => r.PupilUpn, data.PupilUpn) + .SetProperty(r => r.PupilFirstname, data.PupilFirstname) + .SetProperty(r => r.PupilSurname, data.PupilSurname) + .SetProperty(r => r.SubmittedById, data.SubmittedById) + .SetProperty(r => r.SubmittedByName, data.SubmittedByName) + .SetProperty(r => r.SubmittedByEmail, data.SubmittedByEmail) + .SetProperty(r => r.RequestType, data.RequestType) + .SetProperty(r => r.RequestTypeDescription, data.RequestTypeDescription)); + } + else + { + id = Guid.NewGuid(); + db.ChangeRequests.Add(new ChangeRequest + { + Id = id, + WindowId = data.WindowId, + ReferenceNumber = data.ReferenceNumber, + OrganisationUrn = data.OrganisationUrn, + PupilId = data.PupilId, + PupilUpn = data.PupilUpn, + PupilFirstname = data.PupilFirstname, + PupilSurname = data.PupilSurname, + Submitted = timestamp, + SubmittedById = data.SubmittedById, + SubmittedByName = data.SubmittedByName, + SubmittedByEmail = data.SubmittedByEmail, + Status = data.Status, + RequestType = data.RequestType, + RequestTypeDescription = data.RequestTypeDescription + }); + await db.SaveChangesAsync(); + } + + await transaction.CommitAsync(); + return id; + }); + } + + // Draft save path (non-SubmittedUnCommitted) — no conflict guard needed. if (existingId != Guid.Empty) { await db.ChangeRequests @@ -72,10 +186,10 @@ await db.ChangeRequests return existingId; } - var id = Guid.NewGuid(); + var newId = Guid.NewGuid(); await db.ChangeRequests.AddAsync(new ChangeRequest { - Id = id, + Id = newId, WindowId = data.WindowId, ReferenceNumber = data.ReferenceNumber, OrganisationUrn = data.OrganisationUrn, @@ -92,7 +206,7 @@ await db.ChangeRequests.AddAsync(new ChangeRequest RequestTypeDescription = data.RequestTypeDescription }); await db.SaveChangesAsync(); - return id; + return newId; } public async Task> GetAmendmentRequestsAsync( diff --git a/src/DfE.CheckPerformanceData.Web/Common/DuplicateRequestMessages.cs b/src/DfE.CheckPerformanceData.Web/Common/DuplicateRequestMessages.cs new file mode 100644 index 00000000..13a05f29 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Common/DuplicateRequestMessages.cs @@ -0,0 +1,74 @@ +using System.Text.Encodings.Web; + +namespace DfE.CheckPerformanceData.Web.Common; + +public static class DuplicateRequestMessages +{ + private static string TopLevelRequestLabel(string requestCategory) => requestCategory switch + { + "Remove" => "pupil removal request", + "Include" => "pupil inclusion request", + "Merge" => "pupil merge request", + _ => requestCategory.ToLowerInvariant() + }; + + public static string AttentionBannerHtml( + bool isSelf, bool reasonsMatch, string requestCategory, string pupilName, + string referenceNumber, string linkUrl, string userName) + { + var enc = HtmlEncoder.Default; + var topLevelRequest = TopLevelRequestLabel(requestCategory); + var link = $"View submitted request (opens in new tab)"; + + var encPupilName = enc.Encode(pupilName); + var encUserName = enc.Encode(userName); + var encRefNum = enc.Encode(referenceNumber); + var encTopLevelRequest = enc.Encode(topLevelRequest); + + string message; + if (isSelf && reasonsMatch) + { + message = $"You have already submitted a {encTopLevelRequest} for {encPupilName}. Reference {encRefNum} {link}."; + message += "

To raise a new request, delete the previously submitted request. Then return to this page to continue."; + } + else if (!isSelf && reasonsMatch) + { + message = $"Your colleague {encUserName} has already submitted a {encTopLevelRequest} for {encPupilName}. Reference {encRefNum} {link}."; + message += "

To raise a new request, delete the previously submitted request. Then return to this page to continue."; + } + else if (isSelf && !reasonsMatch) + { + message = $"You have already submitted a request of a different type ({encTopLevelRequest}) for {encPupilName}. Reference {encRefNum} {link}."; + message += "

To raise a new request, delete the previously submitted request. Then return to this page to continue."; + } + else + { + message = $"Your colleague {encUserName} has already submitted a request of a different type ({encTopLevelRequest}) for {encPupilName}. Reference {encRefNum} {link}."; + message += "

To raise a new request check with your colleague, and if you want to proceed, delete the previously submitted request. Then return to this page to continue."; + } + + return message; + } + + public static string ErrorSummaryMessage => "A request has already been submitted for this pupil"; + + public static string FieldErrorMessage => "Choose another pupil"; + + public static string SummaryMessage(bool isSelf, bool reasonsMatch, string requestCategory, string userName) + { + var topLevelRequest = TopLevelRequestLabel(requestCategory); + + if (isSelf && reasonsMatch) + return $"You have already submitted a {topLevelRequest} for this pupil."; + + if (!isSelf && reasonsMatch) + return $"A colleague at your school has already submitted a {topLevelRequest} for this pupil."; + + if (isSelf && !reasonsMatch) + return $"You have already submitted a request of a different type ({topLevelRequest}) for this pupil."; + + return $"A colleague at your school has already submitted a request of a different type ({topLevelRequest}) for this pupil."; + } + + public static bool ShowLink() => true; +} diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/DevPipelineController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/DevPipelineController.cs index b49b2b8d..e56dfb8c 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/DevPipelineController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/DevPipelineController.cs @@ -1,3 +1,4 @@ +using DfE.CheckPerformanceData.Application.CheckYourPupilData; using DfE.CheckPerformanceData.Application.Observability; using DfE.CheckPerformanceData.Application.Queue; using DfE.CheckPerformanceData.Application.Settings; @@ -23,28 +24,37 @@ public sealed class DevPipelineController( IConfiguration configuration, IPortalDbContext dbContext, IQueueService queueService, + IPupilDataBlobClient pupilBlob, IHostEnvironment? hostEnvironment = null, SubmittedMetricRecorder? submittedMetrics = null) : Controller { - // The surface is gated on the config flag AND a hard production guard: even if a - // production deploy leaves Dev:ToolsEnabled true, IsProduction short-circuits to 404. private bool IsAllowed => configuration.GetValue(SettingKeys.DevToolsEnabled) && hostEnvironment?.IsProduction() != true; - // The Zendesk-styled preview additionally requires the fake Zendesk path to be active, so a - // captured outbox row is only ever rendered while no real Zendesk push is happening. private bool IsFakeZendesk => configuration.GetValue(SettingKeys.ZendeskUseFake); [HttpGet("dev/queues/submit-request")] [HttpPost("dev/queues/submit-request")] - public async Task SubmitRequest(string? outcome, CancellationToken cancellationToken) + public async Task SubmitRequest( + string? outcome, + Guid? windowId, + long? urn, + CancellationToken cancellationToken, + Guid? pupilId = null, + string? pupilUpn = null, + string? pupilFirstName = null, + string? pupilSurname = null, + string? requestType = null, + string? laestab = null, + string? userEmail = null, + Guid? userId = null) { if (!IsAllowed) return NotFound(); - var runner = new DevPipelineRunner(dbContext, queueService, submittedMetrics); - var result = await runner.SubmitAsync(outcome, cancellationToken); + var runner = new DevPipelineRunner(dbContext, queueService, pupilBlob, submittedMetrics); + var result = await runner.SubmitAsync(outcome, windowId, urn, cancellationToken, pupilId, pupilUpn, pupilFirstName, pupilSurname, requestType, laestab, userEmail, userId); return Json(new { @@ -71,11 +81,6 @@ public async Task Outbox(CancellationToken cancellationToken) return View("Outbox", rows); } - // Renders one captured outbox row as a faithful Zendesk-styled simulation (subject, - // requester, priority/status badges, body, custom fields, tags, attachments) rather than - // raw JSON — a "what we'll send" artefact until real Zendesk is wired. Gated on - // Dev:ToolsEnabled AND Zendesk:UseFake so it is only reachable when the pipeline is faking - // Zendesk; it reaches no real Zendesk instance. [HttpGet("dev/zendesk/preview/{id:guid}")] public async Task ZendeskPreview(Guid id, CancellationToken cancellationToken) { diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/DevPipelineRunner.cs b/src/DfE.CheckPerformanceData.Web/Controllers/DevPipelineRunner.cs index 8d997172..afaef654 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/DevPipelineRunner.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/DevPipelineRunner.cs @@ -1,4 +1,5 @@ using System.Globalization; +using DfE.CheckPerformanceData.Application.CheckYourPupilData; using DfE.CheckPerformanceData.Application.Observability; using DfE.CheckPerformanceData.Application.Queue; using DfE.CheckPerformanceData.Domain.Enums; @@ -17,20 +18,21 @@ public sealed class DevPipelineRunner { private readonly IPortalDbContext _dbContext; private readonly IQueueService _queueService; + private readonly IPupilDataBlobClient _pupilBlob; private readonly SubmittedMetricRecorder? _submittedMetrics; public DevPipelineRunner( IPortalDbContext dbContext, IQueueService queueService, + IPupilDataBlobClient pupilBlob, SubmittedMetricRecorder? submittedMetrics = null) { _dbContext = dbContext; _queueService = queueService; + _pupilBlob = pupilBlob; _submittedMetrics = submittedMetrics; } - // A stable dev checking window the synthetic requests hang off; upserted on demand so the - // ChangeRequest foreign key is always satisfied without manual seeding. private static readonly Guid DevWindowId = Guid.Parse("dddddddd-0000-0000-0000-000000000001"); public sealed record DriveResult(string Reference, string PresetName, string ExpectedDecision); @@ -38,35 +40,89 @@ public sealed record DriveResult(string Reference, string PresetName, string Exp // Creates one synthetic ChangeRequest for the resolved preset and enqueues its RequestDocument // for the rules consumer, recording the journey's first (Submitted) metric. Returns the minted // reference and the preset's expected outcome. - public async Task SubmitAsync(string? outcome, CancellationToken cancellationToken) + // Pupil parameters are optional — when omitted the old hardcoded "Bob Smith"/"UPN1" values + // are used and PupilId is left null (no conflict matching); supply them to target a real pupil + // for conflict-detection testing. + // When pupilUpn is provided together with windowId and laestab, the pupil is looked up from + // blob storage so PupilId and name fields reflect the real record (overriding any explicit + // pupilId/pupilFirstName/pupilSurname values). + public async Task SubmitAsync( + string? outcome, + Guid? windowId, + long? urn, + CancellationToken cancellationToken, + Guid? pupilId = null, + string? pupilUpn = null, + string? pupilFirstName = null, + string? pupilSurname = null, + string? requestType = null, + string? laestab = null, + string? userEmail = null, + Guid? userId = null) { var preset = OutcomePresets.Resolve(outcome); var reference = $"DEV-{Guid.NewGuid():N}"[..16]; var changeRequestId = Guid.NewGuid(); + var resolvedWindowId = windowId ?? DevWindowId; + var resolvedUrn = urn ?? 123456; + + // Derive laestab from the UPN when not explicitly provided. + // UPN format: ALLLEEEESSSSC (13 chars) — A + 3-digit LEA + 4-digit school + serial + check. + if (laestab is null && pupilUpn is not null && pupilUpn.Length >= 13) + laestab = $"{pupilUpn.Substring(1, 3)}/{pupilUpn.Substring(4, 4)}"; + + // Look up the real pupil from blob storage so we get the true PupilId (needed for + // conflict matching), verified name fields, etc. Supports both UPN and name-based + // matching — the caller can supply either. + PupilRecord? matchedPupil = null; + if (windowId is not null && laestab is not null) + { + var pupils = await _pupilBlob.GetPupilsAsync(resolvedWindowId, laestab); + if (pupils is not null) + { + if (pupilUpn is not null) + matchedPupil = pupils.FirstOrDefault(p => + p.Upn.Equals(pupilUpn, StringComparison.OrdinalIgnoreCase)); + else if (pupilFirstName is not null && pupilSurname is not null) + matchedPupil = pupils.FirstOrDefault(p => + p.Firstname.Equals(pupilFirstName, StringComparison.OrdinalIgnoreCase) && + p.Surname.Equals(pupilSurname, StringComparison.OrdinalIgnoreCase)); + } + } + + var resolvedPupilId = matchedPupil?.Id ?? pupilId; + var resolvedPupilUpn = matchedPupil?.Upn ?? pupilUpn ?? "UPN1"; + var resolvedPupilFirstname = matchedPupil?.Firstname ?? pupilFirstName ?? "Bob"; + var resolvedPupilSurname = matchedPupil?.Surname ?? pupilSurname ?? "Smith"; + var resolvedSubmittedById = userId ?? Guid.NewGuid(); + var resolvedUserEmail = userEmail ?? "dev.harness@education.gov.uk"; - await EnsureCheckingWindowAsync(cancellationToken); + if (windowId is null) + await EnsureCheckingWindowAsync(cancellationToken); _dbContext.ChangeRequests.Add(new ChangeRequest { Id = changeRequestId, - WindowId = DevWindowId, - OrganisationUrn = 123456, - PupilUpn = "UPN1", - PupilFirstname = "Bob", - PupilSurname = "Smith", + WindowId = resolvedWindowId, + OrganisationUrn = resolvedUrn, + PupilId = resolvedPupilId, + PupilUpn = resolvedPupilUpn, + PupilFirstname = resolvedPupilFirstname, + PupilSurname = resolvedPupilSurname, Submitted = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified), - SubmittedById = Guid.NewGuid(), + SubmittedById = resolvedSubmittedById, SubmittedByName = "Dev Harness", + SubmittedByEmail = resolvedUserEmail, Status = RequestStatus.SubmittedUnCommitted, ReferenceNumber = reference, - RequestType = RequestType.Amendment, - RequestTypeDescription = "Include" + RequestType = RequestType.Amendment, + RequestTypeDescription = requestType ?? preset.WhatToChange }); await _dbContext.SaveChangesAsync(cancellationToken); // The queue stores a string payload verbatim (it only JSON-serialises non-strings), so the // pre-built RequestDocument JSON reaches the consumer exactly as shaped here. - var messageBody = BuildMessageJson(reference, preset, changeRequestId); + var messageBody = BuildMessageJson(reference, preset, changeRequestId, resolvedWindowId, resolvedUrn, resolvedPupilId, resolvedPupilUpn, resolvedPupilFirstname, resolvedPupilSurname); var messageId = await _queueService.EnqueueAsync( QueueOptions.RulesEngineQueue, messageBody, cancellationToken); @@ -81,9 +137,6 @@ await _submittedMetrics.RecordAsync( private async Task EnsureCheckingWindowAsync(CancellationToken cancellationToken) { - // Look the dev window up by primary key (FindAsync) rather than a LINQ AnyAsync: the result - // is identical against the real context, and the by-key lookup is unit-testable without an - // async query provider. if (await _dbContext.CheckingWindows.FindAsync(new object?[] { DevWindowId }, cancellationToken) is not null) return; @@ -99,25 +152,24 @@ private async Task EnsureCheckingWindowAsync(CancellationToken cancellationToken await _dbContext.SaveChangesAsync(cancellationToken); } - // Builds the same queue-shaped RequestDocument the rules consumer expects. The Answers array - // drives the rule evaluation, so the preset's answers determine the outcome. - private static string BuildMessageJson(string reference, OutcomePreset preset, Guid changeRequestId) + private static string BuildMessageJson(string reference, OutcomePreset preset, Guid changeRequestId, Guid windowId, long urn, Guid? pupilId, string pupilUpn, string pupilFirstname, string pupilSurname) { var answersJson = string.Join(",\n ", preset.Answers.Select(a => $"{{ \"QuestionId\": \"{a.QuestionId}\", \"QuestionTitle\": \"{a.QuestionId}\", \"Type\": \"text\", \"Value\": \"{a.Value}\" }}")); - var windowId = DevWindowId.ToString(); + var windowIdStr = windowId.ToString(); var submittedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture); + var pupilIdStr = pupilId?.ToString() ?? "p1"; return $$""" { "ChangeRequestId": "{{changeRequestId}}", - "CheckingWindowId": "{{windowId}}", + "CheckingWindowId": "{{windowIdStr}}", "CheckingWindowType": "{{preset.CheckingWindowType}}", "RequestTypeCode": "{{preset.WhatToChange}}", - "School": { "Urn": "123456", "Name": "Dev Harness School" }, + "School": { "Urn": "{{urn}}", "Name": "Dev Harness School" }, "SubmittedBy": { "UserId": "dev", "DisplayName": "Dev Harness" }, - "Pupil": { "Id": "p1", "CypmdId": "c1", "Firstname": "Bob", "Surname": "Smith", "DateOfBirth": "01/01/2010", "Sex": "M", "Age": {{preset.PupilAge}}, "Upn": "UPN1", "Pincl": {{preset.Pincl}} }, + "Pupil": { "Id": "{{pupilIdStr}}", "CypmdId": "c1", "Firstname": "{{pupilFirstname}}", "Surname": "{{pupilSurname}}", "DateOfBirth": "01/01/2010", "Sex": "M", "Age": {{preset.PupilAge}}, "Upn": "{{pupilUpn}}", "Pincl": {{preset.Pincl}} }, "Answers": [ {{answersJson}} ], diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/DevUatController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/DevUatController.cs index 8ff83538..bf02954d 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/DevUatController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/DevUatController.cs @@ -74,7 +74,7 @@ public async Task Drive(string? outcome, int count, CancellationT string? lastReference = null; for (var i = 0; i < batch; i++) { - var result = await _runner.SubmitAsync(outcome, cancellationToken); + var result = await _runner.SubmitAsync(outcome, null, null, cancellationToken, null, null, null, null, null); lastReference = result.Reference; } diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/Journey/IJourneyViewModelBuilder.cs b/src/DfE.CheckPerformanceData.Web/Controllers/Journey/IJourneyViewModelBuilder.cs index ccc606db..20a349bc 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/Journey/IJourneyViewModelBuilder.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/Journey/IJourneyViewModelBuilder.cs @@ -12,7 +12,7 @@ public interface IJourneyViewModelBuilder { SummaryViewModel BuildSummaryVm( Guid windowId, RequestState journey, QuestionFlowConfig config, - string? conflictError = null, bool fromBulk = false, bool fromEdit = false); + string? conflictError = null, string? conflictErrorLink = null, bool fromBulk = false, bool fromEdit = false); PageViewModel BuildPageVm( Guid windowId, diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyController.cs index 245e5f70..6afe556f 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyController.cs @@ -2,6 +2,7 @@ using DfE.CheckPerformanceData.Application.CheckYourPupilData; using DfE.CheckPerformanceData.Application.CurrentUser; using DfE.CheckPerformanceData.Web.Analytics; +using DfE.CheckPerformanceData.Web.Common; using DfE.CheckPerformanceData.Application.FileStorage; using DfE.CheckPerformanceData.Application.Journey; using DfE.CheckPerformanceData.Application.RequestSubmission; @@ -128,11 +129,34 @@ await analytics.TrackSafeAsync(new ValidationErrorEvent if (page.PupilKey != JourneyPage.MatchKey) { - var conflictRef = await requestService.HasSubmittedRequestAsync(windowId, pupil.Id, long.Parse(currentUserService.OrganisationUrn)); - if (conflictRef is not null) + var result = await requestService.HasSubmittedRequestAsync(windowId, pupil.Id, long.Parse(currentUserService.OrganisationUrn)); + if (result is not DuplicateCheckResult.NoConflict) { - ModelState.AddModelError("selectedPupilId", - "A request for this pupil has already been submitted."); + var isSelf = result is DuplicateCheckResult.SelfSubmitted; + var conflictingReasonType = result switch + { + DuplicateCheckResult.SelfSubmitted { ConflictingReasonType: var rt } => rt, + DuplicateCheckResult.OtherSubmitted { ConflictingReasonType: var rt } => rt, + _ => string.Empty + }; + var conflictingCategory = result switch + { + DuplicateCheckResult.SelfSubmitted { ConflictingRequestCategory: var rc } => rc, + DuplicateCheckResult.OtherSubmitted { ConflictingRequestCategory: var rc } => rc, + _ => string.Empty + }; + var conflictingUserName = result switch + { + DuplicateCheckResult.SelfSubmitted { ConflictingUserName: var un } => un, + DuplicateCheckResult.OtherSubmitted { ConflictingUserName: var un } => un, + _ => string.Empty + }; + var currentReasonType = flowService.ResolveRequestType(config, journey); + var reasonsMatch = !string.IsNullOrEmpty(currentReasonType) + && string.Equals(currentReasonType, conflictingReasonType, StringComparison.OrdinalIgnoreCase); + + ModelState.AddModelError("selectedPupilId", DuplicateRequestMessages.FieldErrorMessage); + ModelState.AddModelError(string.Empty, DuplicateRequestMessages.ErrorSummaryMessage); await analytics.TrackSafeAsync(new ValidationErrorEvent { ErrorCount = 1, @@ -141,9 +165,22 @@ await analytics.TrackSafeAsync(new ValidationErrorEvent }); var pupilName = $"{pupil.Firstname} {pupil.Surname}".Trim(); var vm = viewModelBuilder.BuildPupilSearchVm(windowId, pageId, page, journey, config); - vm.ConflictErrorReference = conflictRef; - vm.ConflictErrorLink = $"/{windowId}/AmendmentRequests/{conflictRef}/view"; + + var refNum = result switch + { + DuplicateCheckResult.SelfSubmitted { ReferenceNumber: var r } => r, + DuplicateCheckResult.OtherSubmitted { ReferenceNumber: var r } => r, + _ => string.Empty + }; + var linkUrl = $"/{windowId}/AmendmentRequests/{refNum}/view"; + vm.ConflictErrorReference = refNum; + vm.ConflictErrorLink = linkUrl; vm.ConflictPupilName = pupilName; + vm.ConflictReasonType = conflictingReasonType; + vm.ConflictUserName = conflictingUserName; + vm.ConflictAttentionHtml = DuplicateRequestMessages.AttentionBannerHtml( + isSelf, reasonsMatch, conflictingCategory, pupilName, refNum, linkUrl, conflictingUserName); + return View("PupilSearch", vm); } } @@ -550,7 +587,7 @@ public async Task SummaryConfirm(Guid windowId) { await requestService.ConfirmRequestAsync(windowId, journey); } - catch (DuplicateRequestException) + catch (DuplicateRequestException ex) { await analytics.TrackSafeAsync(new RequestSubmissionFailedEvent { @@ -561,8 +598,19 @@ await analytics.TrackSafeAsync(new RequestSubmissionFailedEvent var config = await GetConfigAsync(journey); if (config is null) return RedirectToCheckYourData(windowId); + + var message = DuplicateRequestMessages.SummaryMessage( + ex.ConflictType == ConflictType.SelfSubmitted, ex.ReasonsMatch, + ex.ConflictingRequestCategory, ex.ConflictingUserName); + + string? conflictErrorLink = null; + if (DuplicateRequestMessages.ShowLink()) + { + conflictErrorLink = $"/{windowId}/AmendmentRequests/{journey.ReferenceNumber}/view"; + } + return View("Summary", viewModelBuilder.BuildSummaryVm(windowId, journey, config, - conflictError: "A request for this pupil has already been submitted. Select a different pupil.", + conflictError: message, conflictErrorLink: conflictErrorLink, fromBulk: HttpContext.Session.IsBulkEditMode(windowId), fromEdit: HttpContext.Session.IsSingleEditMode(windowId))); } diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyViewModelBuilder.cs b/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyViewModelBuilder.cs index 1b94a43e..19a853be 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyViewModelBuilder.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyViewModelBuilder.cs @@ -11,8 +11,7 @@ public sealed class JourneyViewModelBuilder( ICurrentUserService currentUserService) : IJourneyViewModelBuilder { public SummaryViewModel BuildSummaryVm( - Guid windowId, RequestState journey, QuestionFlowConfig config, - string? conflictError = null, bool fromBulk = false, bool fromEdit = false) + Guid windowId, RequestState journey, QuestionFlowConfig config, string? conflictError = null, string? conflictErrorLink = null, bool fromBulk = false, bool fromEdit = false) { var pupilName = GetPupilName(journey); var rows = new List(); @@ -69,6 +68,7 @@ public SummaryViewModel BuildSummaryVm( BackPageId = backPageId, MaxEvidencePages = journeyService.MaxEvidencePages, ConflictError = conflictError, + ConflictErrorLink = conflictErrorLink, FromBulk = fromBulk, FromEdit = fromEdit, PrimaryPupilPageId = primaryPupilPage?.Id, diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/Journey/PupilSearchViewModel.cs b/src/DfE.CheckPerformanceData.Web/Controllers/Journey/PupilSearchViewModel.cs index 8cb3096c..6b7a500d 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/Journey/PupilSearchViewModel.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/Journey/PupilSearchViewModel.cs @@ -17,4 +17,7 @@ public sealed class PupilSearchViewModel public string? ConflictErrorReference { get; set; } public string? ConflictErrorLink { get; set; } public string? ConflictPupilName { get; set; } + public string? ConflictReasonType { get; set; } + public string? ConflictUserName { get; set; } + public string? ConflictAttentionHtml { get; set; } } diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/Journey/SummaryViewModel.cs b/src/DfE.CheckPerformanceData.Web/Controllers/Journey/SummaryViewModel.cs index 2f839ea9..2359fad8 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/Journey/SummaryViewModel.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/Journey/SummaryViewModel.cs @@ -13,6 +13,7 @@ public sealed class SummaryViewModel public required string BackPageId { get; init; } public required int MaxEvidencePages { get; init; } public string? ConflictError { get; init; } + public string? ConflictErrorLink { get; init; } /// True when the summary was opened from the bulk review page: link back there and hide the submit/save actions. public bool FromBulk { get; init; } /// True when the summary was opened by editing a single request from the Amendment Requests page: link back there (submit/save actions stay). diff --git a/src/DfE.CheckPerformanceData.Web/Dockerfile b/src/DfE.CheckPerformanceData.Web/Dockerfile index c2d10764..724b1579 100644 --- a/src/DfE.CheckPerformanceData.Web/Dockerfile +++ b/src/DfE.CheckPerformanceData.Web/Dockerfile @@ -30,3 +30,28 @@ WORKDIR /app COPY --from=build /app/publish . ENTRYPOINT ["dotnet", "DfE.CheckPerformanceData.Web.dll"] + +# VS Code debug stage — builds in Debug configuration and installs vsdbg so the +# VS Code Docker extension can attach a .NET debugger to the running container. +# The docker-compose.vscode.yml override targets this stage. +FROM build AS vscode-debug +ENV NUGET_CERT_REVOCATION_MODE=offline + +RUN dotnet publish DfE.CheckPerformanceData.Web/DfE.CheckPerformanceData.Web.csproj \ + -c Debug -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS debug-runtime +WORKDIR /app + +# Install vsdbg — the .NET debugger used by VS Code Docker/C# Dev Kit +RUN apt-get update && apt-get install -y --no-install-recommends unzip wget && \ + rm -rf /var/lib/apt/lists/* && \ + printf 'Y\n' | wget -qO- https://aka.ms/getvsdbgsh | /bin/sh /dev/stdin -v latest -l /remote_debugger -r linux-x64 && \ + ln -s /remote_debugger/vsdbg /usr/local/bin/vsdbg + +COPY --from=vscode-debug /app/publish . + +# Disable debug wait so the app starts immediately; the debugger attaches on demand +ENV COMPlus_DebugWait=0 + +ENTRYPOINT ["dotnet", "DfE.CheckPerformanceData.Web.dll"] diff --git a/src/DfE.CheckPerformanceData.Web/Views/Journey/PupilSearch.cshtml b/src/DfE.CheckPerformanceData.Web/Views/Journey/PupilSearch.cshtml index e4900be8..28b78903 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/Journey/PupilSearch.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/Journey/PupilSearch.cshtml @@ -4,7 +4,7 @@ ViewBag.Title = Model.Title; Layout = "_Layout"; var hasError = !ViewData.ModelState.IsValid; - var errorMessage = ViewData.ModelState["selectedPupilId"]?.Errors.FirstOrDefault()?.ErrorMessage + var fieldErrorMessage = ViewData.ModelState["selectedPupilId"]?.Errors.FirstOrDefault()?.ErrorMessage ?? "Enter the name of the pupil"; } @@ -20,21 +20,38 @@ else Back } +@if (Model.ConflictAttentionHtml is not null) +{ +
+
+ + + +
+
+

Attention

+

@Html.Raw(Model.ConflictAttentionHtml)

+
+
+} + @if (hasError) { - - There is a problem - @errorMessage - - @if (Model.ConflictErrorReference is not null) + var modelError = ViewData.ModelState[string.Empty]?.Errors.FirstOrDefault()?.ErrorMessage; + if (!string.IsNullOrEmpty(modelError)) { - + + There is a problem + @modelError + @fieldErrorMessage + + } + else + { + + There is a problem + @fieldErrorMessage + } } @@ -50,7 +67,7 @@ else {

Error: - @errorMessage + @fieldErrorMessage

}
diff --git a/src/DfE.CheckPerformanceData.Web/Views/Journey/Summary.cshtml b/src/DfE.CheckPerformanceData.Web/Views/Journey/Summary.cshtml index 0327ac81..6c4489ac 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/Journey/Summary.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/Journey/Summary.cshtml @@ -27,6 +27,12 @@ else There is a problem @Model.ConflictError + @if (Model.ConflictErrorLink is not null) + { + + View existing request + + } } diff --git a/src/DfE.CheckPerformanceData.Web/Views/Shared/_Layout.cshtml b/src/DfE.CheckPerformanceData.Web/Views/Shared/_Layout.cshtml index ba5c9c09..38a56a32 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/Shared/_Layout.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/Shared/_Layout.cshtml @@ -74,7 +74,7 @@
- Check Performance Data + Check Performance Data