Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -514,3 +514,5 @@ opencode.json
specs/
tests/DfE.CheckPerformanceData.E2ETests/Snapshots/failures/
.spec-context.json
/.ai-codex
/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ await analytics.TrackSafeAsync(new RequestSubmittedEvent
if (submitted.Count > 0)
{
var window = await checkYourPupilDataService.GetCheckingWindowAsync(windowId);
await requestNotificationService.NotifyBulkSubmissionConfirmedAsync(windowId, window.EndDate, submitted);
await requestNotificationService.NotifyBulkSubmissionConfirmedAsync(
windowId, window.EndDate, submitted, EmailSubstitutions.From(window));
}

return new BulkSubmissionResult { Submitted = submitted, Skipped = skipped };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@ public sealed class CheckingWindowDto
public required CheckingWindowType CheckingWindowType { get; init; }
public bool HasPupilData { get; init; }
public required DateTime StartDate { get; init; }
public string TurnaroundCommitment { get; init; } = string.Empty;
}

Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,16 @@ public sealed record EmailNotification
/// single-reference notifications (which use <see cref="ReferenceNumber"/>).
/// </summary>
public IReadOnlyList<string>? ReferenceNumbers { get; init; }

/// <summary>Checking-exercise name shown in the email body (template <c>((ce name))</c>).</summary>
public string CeName { get; init; } = string.Empty;

/// <summary>"Student" for Post 16 windows, otherwise "Pupil" (template <c>((learner noun))</c>).</summary>
public string LearnerNoun { get; init; } = string.Empty;

/// <summary>
/// Per-window turnaround commitment phrase (template <c>((turnaround commitment))</c>).
/// Empty = not configured; the personalisation key is omitted (FR-006).
/// </summary>
public string TurnaroundCommitment { get; init; } = string.Empty;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System.ComponentModel.DataAnnotations;
using System.Reflection;
using DfE.CheckPerformanceData.Application.LandingPage;
using DfE.CheckPerformanceData.Domain.Enums;

namespace DfE.CheckPerformanceData.Application.Notify;

/// <summary>
/// Immutable carrier for the three checking-exercise-specific email substitution
/// values (<c>ce name</c>, <c>learner noun</c>, <c>turnaround commitment</c>).
/// Built by callers from the checking window they already hold, then passed through
/// <see cref="IRequestNotificationService"/> → <see cref="EmailNotification"/> →
/// <see cref="INotifyService"/>. Not persisted.
/// </summary>
public sealed record EmailSubstitutions(string CeName, string LearnerNoun, string TurnaroundCommitment)
{
/// <summary>
/// Derives the three substitution values from a checking window.
/// CeName: window.Title, falling back to the [Display(Name)] of CheckingWindowType
/// when Title is null/whitespace.
/// LearnerNoun: "Student" when KeyStage is Post16, else "Pupil".
/// TurnaroundCommitment: window.TurnaroundCommitment (may be empty; empty means the
/// personalisation key is omitted, per FR-006).
/// </summary>
public static EmailSubstitutions From(CheckingWindowDto window)
{
var ceName = string.IsNullOrWhiteSpace(window.Title)
? DisplayNameOf(window.CheckingWindowType)
: window.Title;

var learnerNoun = window.KeyStage == KeyStages.Post16 ? "Student" : "Pupil";

return new EmailSubstitutions(ceName, learnerNoun, window.TurnaroundCommitment);
}

private static string DisplayNameOf(CheckingWindowType type)
{
var attribute = type.GetType().GetField(type.ToString())
?.GetCustomAttribute<DisplayAttribute>();
return attribute?.Name ?? type.ToString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ public interface INotifyService
/// <param name="deadline">Display-friendly deadline text.</param>
/// <param name="recipientEmails">Deduplicated recipient email addresses.</param>
/// <param name="notificationType">Which notification template to use.</param>
/// <param name="substitutions">Checking-exercise-specific values (<c>ce name</c>, <c>learner noun</c>,
/// <c>turnaround commitment</c>) gated per notification type.</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
Expand All @@ -40,6 +42,7 @@ Task SendNotificationsAsync(
string deadline,
IReadOnlyCollection<string> recipientEmails,
NotificationType notificationType,
EmailSubstitutions substitutions,
string? url = null,
IReadOnlyCollection<string>? referenceNumbers = null);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ 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 NotifySubmissionConfirmedAsync(Guid windowId, DateTime deadlineDate, string referenceNumber, EmailSubstitutions substitutions);
Task NotifyBulkSubmissionConfirmedAsync(Guid windowId, DateTime deadlineDate, IReadOnlyList<string> referenceNumbers, EmailSubstitutions substitutions);
Task NotifyDataCheckConfirmedAsync(DateTime deadlineDate, string referenceNumber, EmailSubstitutions substitutions);

/// <summary>
/// Confirms a submitted 16-19 results enquiry to the person who submitted it (AB#296648).
Expand All @@ -19,7 +19,7 @@ public interface IRequestNotificationService
/// </summary>
Task NotifyResultsEnquirySubmittedAsync(string referenceNumber);

Task NotifyAmendmentWithdrawnAsync(string referenceNumber, DateTime deadlineDate);
Task NotifyAmendmentWithdrawnAsync(string referenceNumber, DateTime deadlineDate, EmailSubstitutions substitutions);

Task NotifyDataCheckWithdrawnAsync(string referenceNumber, DateTime deadlineDate);
Task NotifyDataCheckWithdrawnAsync(string referenceNumber, DateTime deadlineDate, EmailSubstitutions substitutions);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using DfE.CheckPerformanceData.Application.Journey;
using DfE.CheckPerformanceData.Application.Notify;
using DfE.CheckPerformanceData.Domain.Enums;

namespace DfE.CheckPerformanceData.Application.RequestSubmission;
Expand Down Expand Up @@ -32,7 +33,7 @@ public interface IRequestService
Task ConfirmRequestAsync(Guid windowId, RequestState journey);
Task SaveDraftAsync(Guid windowId, RequestState journey, RequestStatus status);
Task<RequestState?> ResumeDraftAsync(Guid windowId, string referenceNumber);
Task ConfirmDataCorrectAsync(Guid windowId, string referenceNumber, DateTime endDate);
Task ConfirmDataCorrectAsync(Guid windowId, string referenceNumber, DateTime endDate, EmailSubstitutions substitutions);

/// <summary>
/// Deletes a request, scoped to the current user's organisation. Drafts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,12 @@ public async Task ConfirmRequestAsync(Guid windowId, RequestState journey)
{
await SubmitRequestAsync(windowId, journey);
await requestNotificationService.NotifySubmissionConfirmedAsync(
windowId, journey.CheckingWindow!.EndDate, journey.ReferenceNumber ?? string.Empty);
windowId, journey.CheckingWindow!.EndDate, journey.ReferenceNumber ?? string.Empty,
EmailSubstitutions.From(journey.CheckingWindow));
}

public async Task ConfirmDataCorrectAsync(Guid windowId, string referenceNumber, DateTime endDate)
public async Task ConfirmDataCorrectAsync(
Guid windowId, string referenceNumber, DateTime endDate, EmailSubstitutions substitutions)
{
await requestRepository.UpsertAsync(new ChangeRequestData
{
Expand All @@ -167,7 +169,8 @@ await requestRepository.UpsertAsync(new ChangeRequestData
RequestTypeDescription = "Confirm Pupil Data Declaration"
});

await requestNotificationService.NotifyDataCheckConfirmedAsync(endDate, referenceNumber);
await requestNotificationService.NotifyDataCheckConfirmedAsync(
endDate, referenceNumber, substitutions);
}

public async Task SaveDraftAsync(Guid windowId, RequestState journey, RequestStatus status)
Expand Down Expand Up @@ -216,11 +219,13 @@ public async Task<RequestDeletionResult> DeleteAsync(Guid windowId, string refer

if (row?.RequestType == RequestType.Amendment)
{
await requestNotificationService.NotifyAmendmentWithdrawnAsync(referenceNumber, deadline);
await requestNotificationService.NotifyAmendmentWithdrawnAsync(
referenceNumber, deadline, EmailSubstitutions.From(window));
}
else if (row?.RequestType == RequestType.ConfirmCorrect)
{
await requestNotificationService.NotifyDataCheckWithdrawnAsync(referenceNumber, deadline);
await requestNotificationService.NotifyDataCheckWithdrawnAsync(
referenceNumber, deadline, EmailSubstitutions.From(window));
}
else
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public sealed class CheckingWindowDto
public bool Validated { get; set; }
public DateTime? ValidatedAt { get; set; }
public bool IsOpen { get; set; }
public string TurnaroundCommitment { get; set; } = string.Empty;

/// <summary>
/// The CSV + schema pairs ingested for this window, in sort order. A Post16 window has two
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public Task SendNotificationsAsync(
string deadline,
IReadOnlyCollection<string> recipientEmails,
NotificationType notificationType,
EmailSubstitutions substitutions,
string? url = null,
IReadOnlyCollection<string>? referenceNumbers = null)
{
Expand All @@ -27,9 +28,10 @@ public Task SendNotificationsAsync(
var refs = referenceNumbers is { Count: > 0 } ? string.Join(", ", referenceNumbers) : referenceNumber;

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

return Task.CompletedTask;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ await notifyService.SendNotificationsAsync(
notification.Deadline,
recipients,
notification.Type,
new EmailSubstitutions(
notification.CeName,
notification.LearnerNoun,
notification.TurnaroundCommitment),
notification.LinkUrl,
notification.ReferenceNumbers);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public async Task SendNotificationsAsync(
string deadline,
IReadOnlyCollection<string> recipientEmails,
NotificationType notificationType,
EmailSubstitutions substitutions,
string? url = null,
IReadOnlyCollection<string>? referenceNumbers = null)
{
Expand Down Expand Up @@ -68,7 +69,7 @@ public async Task SendNotificationsAsync(
{
try
{
await SendEmailAsync(email, referenceNumber, deadline, templateId, url, referenceNumbers);
await SendEmailAsync(email, referenceNumber, deadline, templateId, url, referenceNumbers, notificationType, substitutions);
}
catch (Exception ex)
{
Expand Down Expand Up @@ -114,7 +115,9 @@ private async Task SendEmailAsync(
string deadline,
string templateId,
string? url,
IReadOnlyCollection<string>? referenceNumbers = null)
IReadOnlyCollection<string>? referenceNumbers,
NotificationType notificationType,
EmailSubstitutions substitutions)
{
var personalisation = new Dictionary<string, object>
{
Expand All @@ -134,6 +137,25 @@ private async Task SendEmailAsync(
personalisation["references"] = string.Join("\n", referenceNumbers);
}

// GOV.UK Notify raises on BOTH missing and extra personalisation keys, so each key is
// gated per notification type: the keys added here must exactly match the templates.
if (notificationType is not NotificationType.ResultsEnquirySubmitted)
{
personalisation["ce name"] = substitutions.CeName;
}

if (notificationType is NotificationType.DataCheckConfirmed
or NotificationType.DataCheckWithdrawn
or NotificationType.AmendmentWithdrawn)
{
personalisation["learner noun"] = substitutions.LearnerNoun;
}

if (!string.IsNullOrEmpty(substitutions.TurnaroundCommitment))
{
personalisation["turnaround commitment"] = substitutions.TurnaroundCommitment;
}

if (_resiliencePipeline is not null)
{
await _resiliencePipeline.ExecuteAsync(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ public sealed class RequestNotificationService(
INotificationDispatcher dispatcher,
IOptions<NotifySettings> notifySettings) : IRequestNotificationService
{
public async Task NotifySubmissionConfirmedAsync(Guid windowId, DateTime deadlineDate, string referenceNumber)
public async Task NotifySubmissionConfirmedAsync(
Guid windowId, DateTime deadlineDate, string referenceNumber, EmailSubstitutions substitutions)
{
var linkUrl = emailLinkGenerator.GenerateLink(
"WhatToChange", "Index", new { windowId }, "SubmissionNotification");
Expand All @@ -31,7 +32,10 @@ await dispatcher.EnqueueAsync(new EmailNotification
LinkUrl = linkUrl,
Ukprn = currentUserService.Ukprn,
OriginatorEmail = currentUserService.Email,
IncludeOrganisationUsers = true
IncludeOrganisationUsers = true,
CeName = substitutions.CeName,
LearnerNoun = substitutions.LearnerNoun,
TurnaroundCommitment = substitutions.TurnaroundCommitment
});
}

Expand All @@ -53,7 +57,7 @@ await dispatcher.EnqueueAsync(new EmailNotification
}

public async Task NotifyBulkSubmissionConfirmedAsync(
Guid windowId, DateTime deadlineDate, IReadOnlyList<string> referenceNumbers)
Guid windowId, DateTime deadlineDate, IReadOnlyList<string> referenceNumbers, EmailSubstitutions substitutions)
{
if (referenceNumbers.Count == 0) return;

Expand All @@ -75,7 +79,10 @@ await dispatcher.EnqueueAsync(new EmailNotification
LinkUrl = linkUrl,
Ukprn = currentUserService.Ukprn,
OriginatorEmail = currentUserService.Email,
IncludeOrganisationUsers = true
IncludeOrganisationUsers = true,
CeName = substitutions.CeName,
LearnerNoun = substitutions.LearnerNoun,
TurnaroundCommitment = substitutions.TurnaroundCommitment
});
}
return;
Expand All @@ -91,11 +98,15 @@ await dispatcher.EnqueueAsync(new EmailNotification
LinkUrl = linkUrl,
Ukprn = currentUserService.Ukprn,
OriginatorEmail = currentUserService.Email,
IncludeOrganisationUsers = true
IncludeOrganisationUsers = true,
CeName = substitutions.CeName,
LearnerNoun = substitutions.LearnerNoun,
TurnaroundCommitment = substitutions.TurnaroundCommitment
});
}

public async Task NotifyDataCheckConfirmedAsync(DateTime deadlineDate, string referenceNumber)
public async Task NotifyDataCheckConfirmedAsync(
DateTime deadlineDate, string referenceNumber, EmailSubstitutions substitutions)
{
await dispatcher.EnqueueAsync(new EmailNotification
{
Expand All @@ -104,11 +115,15 @@ await dispatcher.EnqueueAsync(new EmailNotification
Deadline = FormatDeadline(deadlineDate),
Ukprn = currentUserService.Ukprn,
OriginatorEmail = currentUserService.Email,
IncludeOrganisationUsers = true
IncludeOrganisationUsers = true,
CeName = substitutions.CeName,
LearnerNoun = substitutions.LearnerNoun,
TurnaroundCommitment = substitutions.TurnaroundCommitment
});
}

public async Task NotifyAmendmentWithdrawnAsync(string referenceNumber, DateTime deadlineDate)
public async Task NotifyAmendmentWithdrawnAsync(
string referenceNumber, DateTime deadlineDate, EmailSubstitutions substitutions)
{
await dispatcher.EnqueueAsync(new EmailNotification
{
Expand All @@ -117,11 +132,15 @@ await dispatcher.EnqueueAsync(new EmailNotification
Deadline = FormatDeadline(deadlineDate),
Ukprn = currentUserService.Ukprn,
OriginatorEmail = currentUserService.Email,
IncludeOrganisationUsers = false
IncludeOrganisationUsers = false,
CeName = substitutions.CeName,
LearnerNoun = substitutions.LearnerNoun,
TurnaroundCommitment = substitutions.TurnaroundCommitment
});
}

public async Task NotifyDataCheckWithdrawnAsync(string referenceNumber, DateTime deadlineDate)
public async Task NotifyDataCheckWithdrawnAsync(
string referenceNumber, DateTime deadlineDate, EmailSubstitutions substitutions)
{
await dispatcher.EnqueueAsync(new EmailNotification
{
Expand All @@ -130,7 +149,10 @@ await dispatcher.EnqueueAsync(new EmailNotification
Deadline = FormatDeadline(deadlineDate),
Ukprn = currentUserService.Ukprn,
OriginatorEmail = currentUserService.Email,
IncludeOrganisationUsers = true
IncludeOrganisationUsers = true,
CeName = substitutions.CeName,
LearnerNoun = substitutions.LearnerNoun,
TurnaroundCommitment = substitutions.TurnaroundCommitment
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public sealed class CheckingWindow
public KeyStages KeyStage { get; init; }
public CheckingWindowType CheckingWindowType { get; init; }
public string Title { get; init; } = string.Empty;
public string TurnaroundCommitment { get; init; } = string.Empty;
public bool Published { get; init; } = false;
public string IngressFile { get; init; } = string.Empty;
public string SchemaFile { get; init; } = string.Empty;
Expand Down Expand Up @@ -62,6 +63,9 @@ public void Configure(EntityTypeBuilder<CheckingWindow> builder)
builder.Property(x => x.Title)
.IsRequired()
.HasMaxLength(200);

builder.Property(x => x.TurnaroundCommitment)
.HasMaxLength(200);

builder.Property(x => x.IngressFile)
.HasMaxLength(255);
Expand Down
Loading
Loading