Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -507,3 +507,8 @@ docs/superpowers/
CLAUDE.md
.claude/
/.playwright-mcp
.specify/
.clinerules/
.opencode/
opencode.json
specs/
25 changes: 25 additions & 0 deletions docker-compose.vscode.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ services:
build:
context: ./src
dockerfile: DfE.CheckPerformanceData.Web/Dockerfile
target: runtime
image: cypd_web:latest
ports:
- "8080:8080"
Expand Down
18 changes: 17 additions & 1 deletion docs/request-journey.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,23 @@ 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** — Before saving, `CheckForConflictAsync` queries `ChangeRequests` for an existing `SubmittedUnCommitted` row matching `WindowId + PupilId + OrganisationUrn` (excluding the current `ReferenceNumber`). If a conflict exists, it compares `SubmittedById` against the current user's ID and returns a discriminated result:

- `NoConflict` — proceed with submission
- `SelfSubmitted` — the current user already has a pending request
- `OtherSubmitted` — another user at the same school has a pending request (identity not revealed)

The controller renders a contextual validation message:
- **Self-submitted**: "You already have a pending request for this pupil. You can view your existing request in your requests list."
- **Other-submitted**: "Another user at your school has a pending request for this pupil. Please coordinate with colleagues or contact support if this appears to be in error."

The check runs at two points in the journey:
1. **Pupil selection** (`PupilSearchPost`) — before a new request is started
2. **Final submission** (`SummaryConfirm`) — catches conflicts that arose between pupil selection and submission (e.g. another user submitted in a different browser tab)

The `DuplicateRequestException` carries a `ConflictType` enum (`SelfSubmitted` / `OtherSubmitted`) so the error-path message is contextualised without re-querying.

**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.

2. **Build `RequestDocument`** — a structured document containing:
- Reference number, submitted-at timestamp
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ namespace DfE.CheckPerformanceData.Application.RequestSubmission;

public interface IRequestRepository
{
Task<bool> HasConflictingRequestAsync(Guid windowId, Guid pupilId, long organisationUrn, string currentReferenceNumber);
Task<DuplicateCheckResult> CheckForConflictAsync(Guid windowId, Guid pupilId, long organisationUrn, string currentReferenceNumber, Guid currentUserId);

/// <summary>Returns the reference number of a submitted request for the given pupil, or null if none exists.</summary>
Task<string?> HasSubmittedRequestAsync(Guid windowId, Guid pupilId, long organisationUrn);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ namespace DfE.CheckPerformanceData.Application.RequestSubmission;

public interface IRequestService
{
/// <summary>Returns the reference number of a submitted request for the given pupil, or null if none exists.</summary>
Task<string?> HasSubmittedRequestAsync(Guid windowId, Guid pupilId, long organisationUrn);
/// <summary>Checks whether a submitted request already exists for the given pupil. Returns <see cref="DuplicateCheckResult"/> discriminating between no conflict, self-submitted, and other-submitted.</summary>
Task<DuplicateCheckResult> HasSubmittedRequestAsync(Guid windowId, Guid pupilId, long organisationUrn);

Task ConfirmRequestAsync(Guid windowId, RequestState journey);
Task SaveDraftAsync(Guid windowId, RequestState journey, RequestStatus status);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,47 @@ public sealed class RequestService(
{
private long OrganisationUrnLong => long.Parse(currentUserService.OrganisationUrn);

public Task<string?> 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<DuplicateCheckResult> 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 ConfirmRequestAsync(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}.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> 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<DuplicateCheckResult> 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<string?> HasSubmittedRequestAsync(
Guid windowId, Guid pupilId, long organisationUrn) =>
Expand Down Expand Up @@ -62,6 +89,63 @@ await db.ChangeRequests
}

var id = Guid.NewGuid();

// For SubmittedUnCommitted insertions, check for conflicts atomically within
// a serializable transaction. This prevents two concurrent submissions for the
// same pupil from both passing the TOCTOU gap between CheckForConflictAsync and
// UpsertAsync — one transaction will abort on commit if a concurrent one already
// inserted a conflicting row.
if (data.Status == RequestStatus.SubmittedUnCommitted)
{
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var transaction = await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable);

var conflict = await db.ChangeRequests
.Where(r => r.WindowId == data.WindowId
&& r.PupilId == data.PupilId
&& r.OrganisationUrn == data.OrganisationUrn
&& r.Status == RequestStatus.SubmittedUnCommitted)
.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);
}

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;
}

await db.ChangeRequests.AddAsync(new ChangeRequest
{
Id = id,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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 topLevelRequest = TopLevelRequestLabel(requestCategory);
var link = $"<a class=\"govuk-link\" href=\"{linkUrl}\" target=\"_blank\" rel=\"noreferrer noopener\">View submitted request (opens in a new browser window)</a>";

string message;
if (isSelf && reasonsMatch)
{
message = $"You have already submitted a {topLevelRequest} for {pupilName}. Reference {referenceNumber} {link}.";
message += "<br><br>To raise a new request, delete the previously submitted request. Then return to this page to continue.";
}
else if (!isSelf && reasonsMatch)
{
message = $"Your colleague {userName} has already submitted a {topLevelRequest} for {pupilName}. Reference {referenceNumber} {link}.";
message += "<br><br>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 ({topLevelRequest}) for {pupilName}. Reference {referenceNumber} {link}.";
message += "<br><br>To raise a new request, delete the previously submitted request. Then return to this page to continue.";
}
else
{
message = $"Your colleague {userName} has already submitted a request of a different type ({topLevelRequest}) for {pupilName}. Reference {referenceNumber} {link}.";
message += "<br><br>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;
}
Loading
Loading