Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ namespace DfE.CheckPerformanceData.Application.AmendmentRequests;

public sealed class AmendmentRequestData
{
public Guid? PupilId { get; init; }
public string? PupilFirstname { get; init; }
public string? PupilSurname { get; init; }
public required RequestType RequestType { get; init; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ public async Task<AmendmentRequestsResult> GetAmendmentRequestsAsync(Guid window
WindowTitle = window.Title,
Rows = requests.Select(r => new AmendmentRequestDto
{
PupilName = BuildPupilName(r.PupilFirstname, r.PupilSurname),
PupilName = PupilNameFormatter.Format(r.PupilFirstname, r.PupilSurname),
RequestType = r.RequestType,
RequestTypeDescription = r.RequestTypeDescription,
Status = r.Status,
ReferenceNumber = r.ReferenceNumber
}).ToList(),
SubmittedRows = submitted.Select(r => new SubmittedRequestDto
{
PupilName = BuildPupilName(r.PupilFirstname, r.PupilSurname),
PupilName = PupilNameFormatter.Format(r.PupilFirstname, r.PupilSurname),
RequestType = r.RequestType,
RequestTypeDescription = r.RequestTypeDescription,
ReferenceNumber = r.ReferenceNumber,
Expand All @@ -39,10 +39,4 @@ public async Task<AmendmentRequestsResult> GetAmendmentRequestsAsync(Guid window
}).ToList()
};
}

private static string BuildPupilName(string? firstname, string? surname)
{
var name = $"{firstname} {surname}".Trim();
return string.IsNullOrWhiteSpace(name) ? "N/A" : name;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace DfE.CheckPerformanceData.Application.AmendmentRequests;

/// <summary>One selected request as classified for the bulk review page.</summary>
public sealed class BulkReviewItem
{
public required string ReferenceNumber { get; init; }
public required string PupilName { get; init; }
public required string RequestTypeDescription { get; init; }
/// <summary>Set only for duplicates; explains why the item is excluded from submission.</summary>
public string? DuplicateReason { get; init; }
}

/// <summary>The classified selection shown on the bulk review page.</summary>
public sealed class BulkReviewResult
{
public required IReadOnlyList<BulkReviewItem> Submittable { get; init; }
public required IReadOnlyList<BulkReviewItem> Duplicates { get; init; }
}

/// <summary>Outcome of a bulk submit: which references were submitted and which were skipped as duplicates.</summary>
public sealed class BulkSubmissionResult
{
public required IReadOnlyList<string> Submitted { get; init; }
public required IReadOnlyList<string> Skipped { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using DfE.CheckPerformanceData.Application.Analytics;
using DfE.CheckPerformanceData.Application.CheckYourPupilData;
using DfE.CheckPerformanceData.Application.CurrentUser;
using DfE.CheckPerformanceData.Application.Notify;
using DfE.CheckPerformanceData.Application.RequestSubmission;
using DfE.CheckPerformanceData.Domain.Enums;

namespace DfE.CheckPerformanceData.Application.AmendmentRequests;

public sealed class BulkSubmissionService(
IRequestRepository requestRepository,
IRequestService requestService,
IRequestNotificationService requestNotificationService,
ICheckYourPupilDataService checkYourPupilDataService,
ICurrentUserService currentUserService,
IAnalyticsService analytics) : IBulkSubmissionService
{
private const string AlreadySubmittedReason = "A request for this pupil has already been submitted.";
private const string SelectedMoreThanOnceReason = "You selected more than one request for this pupil.";

private long OrganisationUrn => long.Parse(currentUserService.OrganisationUrn);

public async Task<BulkReviewResult> BuildReviewAsync(Guid windowId, IReadOnlyList<string> selectedReferences)
{
var selected = new HashSet<string>(selectedReferences, StringComparer.Ordinal);
var urn = OrganisationUrn;

var rows = await requestRepository.GetAmendmentRequestsAsync(windowId, urn);
var kept = rows
.Where(r => r.Status == RequestStatus.ReadyToSubmit && selected.Contains(r.ReferenceNumber))
.ToList();

var alreadySubmitted = (await requestRepository.GetSubmittedPupilIdsAsync(windowId, urn)).ToHashSet();

// Pupils appearing more than once across the kept selection.
// A ReadyToSubmit draft always has a selected pupil, so PupilId is expected non-null here.
// A row with a null PupilId (only plausible for legacy rows) can't be duplicate-matched and
// is therefore treated as submittable — an accepted gap, not a silent data drop.
var pupilCounts = kept
.Where(r => r.PupilId is not null)
.GroupBy(r => r.PupilId!.Value)
.ToDictionary(g => g.Key, g => g.Count());

var submittable = new List<BulkReviewItem>();
var duplicates = new List<BulkReviewItem>();

foreach (var row in kept)
{
var pupilId = row.PupilId;
string? reason = null;
if (pupilId is not null && alreadySubmitted.Contains(pupilId.Value))
reason = AlreadySubmittedReason;
else if (pupilId is not null && pupilCounts[pupilId.Value] > 1)
reason = SelectedMoreThanOnceReason;

var item = new BulkReviewItem
{
ReferenceNumber = row.ReferenceNumber,
PupilName = PupilNameFormatter.Format(row.PupilFirstname, row.PupilSurname),
RequestTypeDescription = row.RequestTypeDescription,
DuplicateReason = reason
};

if (reason is null) submittable.Add(item);
else duplicates.Add(item);
}

return new BulkReviewResult { Submittable = submittable, Duplicates = duplicates };
}

public async Task<BulkSubmissionResult> SubmitAsync(Guid windowId, IReadOnlyList<string> references)
{
// Re-run classification defensively so a tampered/stale POST cannot submit a duplicate.
var review = await BuildReviewAsync(windowId, references);
var toSubmit = review.Submittable.Select(i => i.ReferenceNumber).ToList();

var submitted = new List<string>();
var skipped = new List<string>();

foreach (var reference in toSubmit)
{
var journey = await requestService.ResumeDraftAsync(windowId, reference);
if (journey is null) { skipped.Add(reference); continue; }

try
{
await requestService.SubmitRequestAsync(windowId, journey);
submitted.Add(reference);
await analytics.TrackSafeAsync(new RequestSubmittedEvent
{
WhatToChange = journey.SelectedWhatToChange?.ToString() ?? "",
CheckingWindowType = journey.CheckingWindow?.CheckingWindowType.ToString() ?? "",
ReferenceNumber = reference,
});
}
catch (DuplicateRequestException)
{
skipped.Add(reference);
}
}

if (submitted.Count > 0)
{
var window = await checkYourPupilDataService.GetCheckingWindowAsync(windowId);
await requestNotificationService.NotifyBulkSubmissionConfirmedAsync(windowId, window.EndDate, submitted);
}

return new BulkSubmissionResult { Submitted = submitted, Skipped = skipped };
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace DfE.CheckPerformanceData.Application.AmendmentRequests;

public interface IBulkSubmissionService
{
/// <summary>
/// Classifies the user's selected references (scoped to the current org, kept only if
/// ReadyToSubmit) into submittable vs duplicate. A request is a duplicate if its pupil already
/// has a submitted request, or if the pupil appears more than once in the selection (in which
/// case all of that pupil's selected drafts are flagged and none are submittable).
/// </summary>
Task<BulkReviewResult> BuildReviewAsync(Guid windowId, IReadOnlyList<string> selectedReferences);

/// <summary>
/// Submits every submittable reference (best-effort; conflicts are skipped) and sends the
/// batch email(s) per the consolidation threshold. Re-runs classification defensively.
/// </summary>
Task<BulkSubmissionResult> SubmitAsync(Guid windowId, IReadOnlyList<string> references);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace DfE.CheckPerformanceData.Application.AmendmentRequests;

/// <summary>Formats a pupil's display name from first/surname, falling back to "N/A" when blank.</summary>
public static class PupilNameFormatter
{
public static string Format(string? firstname, string? surname)
{
var name = $"{firstname} {surname}".Trim();
return string.IsNullOrWhiteSpace(name) ? "N/A" : name;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public static IServiceCollection AddApplicationDependencies(this IServiceCollect
services.AddScoped<IJourneyCondition, SchoolIsIndependentCondition>();
services.AddScoped<IFormatValidator, DfeNumberFormatValidator>();
services.AddScoped<IAmendmentRequestsService, AmendmentRequestsService>();
services.AddScoped<IBulkSubmissionService, BulkSubmissionService>();
services.AddScoped<ISubmittedRequestService, SubmittedRequestService>();
services.AddScoped<IEditAdviceService, EditAdviceService>();
services.AddScoped<UncommittedRequests.IAdminRequestsService, UncommittedRequests.AdminRequestsService>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,10 @@ public sealed record EmailNotification
/// set in addition to the originator. When false, only the originator is notified.
/// </summary>
public bool IncludeOrganisationUsers { get; init; }

/// <summary>
/// For a consolidated bulk submission email: every reference in the batch. Null/empty for
/// single-reference notifications (which use <see cref="ReferenceNumber"/>).
/// </summary>
public IReadOnlyList<string>? ReferenceNumbers { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace DfE.CheckPerformanceData.Application.Notify;
public enum NotificationType
{
SubmissionConfirmed,
BulkSubmissionConfirmed,
DataCheckConfirmed,
AmendmentWithdrawn,
DataCheckWithdrawn
Expand All @@ -26,12 +27,18 @@ public interface INotifyService
/// <param name="recipientEmails">Deduplicated recipient email addresses.</param>
/// <param name="notificationType">Which notification template to use.</param>
/// <param name="url">Optional URL (e.g. "submit others" or withdrawal link).</param>
/// <param name="referenceNumbers">
/// For a consolidated bulk submission email: every reference in the batch, listed in the
/// email body. Null/empty for single-reference notifications (which use
/// <paramref name="referenceNumber"/>).
/// </param>
Task SendNotificationsAsync(
string referenceNumber,
string deadline,
IReadOnlyCollection<string> recipientEmails,
NotificationType notificationType,
string? url = null);
string? url = null,
IReadOnlyCollection<string>? referenceNumbers = null);

/// <summary>
/// Sends a dead-letter queue threshold alert.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace DfE.CheckPerformanceData.Application.Notify;

public interface IRequestNotificationService
{
Task NotifySubmissionConfirmedAsync(Guid windowId, DateTime deadlineDate, string referenceNumber);
Task NotifyBulkSubmissionConfirmedAsync(Guid windowId, DateTime deadlineDate, IReadOnlyList<string> referenceNumbers);
Task NotifyDataCheckConfirmedAsync(DateTime deadlineDate, string referenceNumber);

Task NotifyAmendmentWithdrawnAsync(string referenceNumber, DateTime deadlineDate);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ public class NotifySettings
public string WithdrawNotificationTemplateId { get; set; } = null!;
public string DlqThresholdTemplateId { get; set; } = null!;

public string BulkSubmissionNotificationTemplateId { get; set; } = null!;

/// <summary>
/// Batch size at or above which a single consolidated submission email is sent instead of one
/// email per request. Below it, individual emails are sent (parity with single submissions).
/// </summary>
public int BulkConsolidationThreshold { get; set; } = 5;


public string? LinkBaseUrl { get; set; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,10 @@ public interface IRequestRepository
/// Scoped by window + org + reference so a school cannot delete another school's request.
/// </summary>
Task DeleteAsync(Guid windowId, long organisationUrn, string referenceNumber);

/// <summary>
/// Returns the distinct pupil ids that already have a submitted (SubmittedUnCommitted)
/// request for the window/org. Used to flag bulk-selected drafts whose pupil is already submitted.
/// </summary>
Task<IReadOnlyList<Guid>> GetSubmittedPupilIdsAsync(Guid windowId, long organisationUrn);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ 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>
/// Submits a request without sending the confirmation email: conflict check, upsert the
/// ChangeRequests row (SubmittedUnCommitted), enqueue the rules-engine document, and persist
/// the journey blob. Throws <see cref="DuplicateRequestException"/> on a conflicting request.
/// The email is the caller's responsibility (single path sends one; bulk path batches).
/// </summary>
Task SubmitRequestAsync(Guid windowId, RequestState journey);

Task ConfirmRequestAsync(Guid windowId, RequestState journey);
Task SaveDraftAsync(Guid windowId, RequestState journey, RequestStatus status);
Task<RequestState?> ResumeDraftAsync(Guid windowId, string referenceNumber);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public sealed class RequestService(
public Task<string?> HasSubmittedRequestAsync(Guid windowId, Guid pupilId, long organisationUrn) =>
requestRepository.HasSubmittedRequestAsync(windowId, pupilId, organisationUrn);

public async Task ConfirmRequestAsync(Guid windowId, RequestState journey)
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.");
Expand Down Expand Up @@ -61,15 +61,19 @@ public async Task ConfirmRequestAsync(Guid windowId, RequestState journey)
// picks it up, evaluates it and writes the decision back to the row.
await queueService.EnqueueAsync(QueueOptions.RulesEngineQueue, document);

await requestNotificationService.NotifySubmissionConfirmedAsync(
windowId, journey.CheckingWindow.EndDate, refNum);

// Persist the stamped journey so the read-only submitted-request view can
// rebuild its summary (and "Submitted by" section) from the journey alone —
// the enqueued RequestDocument is bound for the queue and not retained.
await requestStateBlobClient.SaveAsync(windowId, journey.ReferenceNumber ?? string.Empty, journey);
}

public async Task ConfirmRequestAsync(Guid windowId, RequestState journey)
{
await SubmitRequestAsync(windowId, journey);
await requestNotificationService.NotifySubmissionConfirmedAsync(
windowId, journey.CheckingWindow!.EndDate, journey.ReferenceNumber ?? string.Empty);
}

public async Task ConfirmDataCorrectAsync(Guid windowId, string referenceNumber, DateTime endDate)
{
await requestRepository.UpsertAsync(new ChangeRequestData
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,9 @@ public static IServiceCollection AddNotifyService(this IServiceCollection servic
if (NotifyServiceRegistration.ShouldUseFake(config))
{
services.AddSingleton<INotifyService, DevConsoleNotifyService>();
// Bind settings without validation so bulk-email threshold/config resolves in dev/fake mode.
services.AddOptions<NotifySettings>()
.Bind(config.GetSection(NotifySettings.SectionName));
return services;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@ public Task SendNotificationsAsync(
string deadline,
IReadOnlyCollection<string> recipientEmails,
NotificationType notificationType,
string? url = null)
string? url = null,
IReadOnlyCollection<string>? referenceNumbers = null)
{
var recipientCount = recipientEmails.Count;
var recipientList = string.Join(", ", recipientEmails);
var refs = referenceNumbers is { Count: > 0 } ? string.Join(", ", referenceNumbers) : referenceNumber;

_logger.LogInformation(
"[Notify:Fake] Sending {NotificationType} notification\n Reference: {ReferenceNumber}\n Deadline: {Deadline}\n Recipients: {RecipientCount} — [{RecipientEmails}]{Url}",
notificationType, referenceNumber, deadline, recipientCount, recipientList,
"[Notify:Fake] Sending {NotificationType} notification\n Reference(s): {References}\n Deadline: {Deadline}\n Recipients: {RecipientCount} — [{RecipientEmails}]{Url}",
notificationType, refs, deadline, recipientCount, recipientList,
string.IsNullOrEmpty(url) ? "" : $"\n URL: {url}");

return Task.CompletedTask;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ await notifyService.SendNotificationsAsync(
notification.Deadline,
recipients,
notification.Type,
notification.LinkUrl);
notification.LinkUrl,
notification.ReferenceNumbers);
}
}
Loading
Loading