Skip to content
Open
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
29 changes: 28 additions & 1 deletion docs/request-journey.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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);

/// <summary>
/// Submits a request without sending the confirmation email: conflict check, upsert the
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 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}.");
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 @@ -73,6 +100,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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The serializable-transaction guard only sits on the fresh-insert path — but by the time we get here we've already returned early on the existingId != Guid.Empty (update) branch above. Bulk submission and any "save draft then submit" flow both reach UpsertAsync with an existing row, so they take ExecuteUpdateAsync and never hit this atomic check. That means the concurrent case the comment describes (two users submitting the same pupil) isn't actually protected for our main submission paths — it falls back to the earlier CheckForConflictAsync pre-check, which is exactly the TOCTOU window this was meant to close. I'd move the conflict check so it covers the update path too.

{
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
Loading
Loading