diff --git a/.gitignore b/.gitignore index 2066c31cf..35bffa7a2 100644 --- a/.gitignore +++ b/.gitignore @@ -514,3 +514,5 @@ opencode.json specs/ tests/DfE.CheckPerformanceData.E2ETests/Snapshots/failures/ .spec-context.json +/.ai-codex +/AGENTS.md diff --git a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/BulkSubmissionService.cs b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/BulkSubmissionService.cs index e25c28682..6d6e3af1e 100644 --- a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/BulkSubmissionService.cs +++ b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/BulkSubmissionService.cs @@ -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 }; diff --git a/src/DfE.CheckPerformanceData.Application/LandingPage/ILandingPageService.cs b/src/DfE.CheckPerformanceData.Application/LandingPage/ILandingPageService.cs index 032cecaa2..8555b5d02 100644 --- a/src/DfE.CheckPerformanceData.Application/LandingPage/ILandingPageService.cs +++ b/src/DfE.CheckPerformanceData.Application/LandingPage/ILandingPageService.cs @@ -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; } diff --git a/src/DfE.CheckPerformanceData.Application/Notify/EmailNotification.cs b/src/DfE.CheckPerformanceData.Application/Notify/EmailNotification.cs index 728f6eba5..d7c4c4fdf 100644 --- a/src/DfE.CheckPerformanceData.Application/Notify/EmailNotification.cs +++ b/src/DfE.CheckPerformanceData.Application/Notify/EmailNotification.cs @@ -38,4 +38,16 @@ public sealed record EmailNotification /// single-reference notifications (which use ). /// public IReadOnlyList? ReferenceNumbers { get; init; } + + /// Checking-exercise name shown in the email body (template ((ce name))). + public string CeName { get; init; } = string.Empty; + + /// "Student" for Post 16 windows, otherwise "Pupil" (template ((learner noun))). + public string LearnerNoun { get; init; } = string.Empty; + + /// + /// Per-window turnaround commitment phrase (template ((turnaround commitment))). + /// Empty = not configured; the personalisation key is omitted (FR-006). + /// + public string TurnaroundCommitment { get; init; } = string.Empty; } diff --git a/src/DfE.CheckPerformanceData.Application/Notify/EmailSubstitutions.cs b/src/DfE.CheckPerformanceData.Application/Notify/EmailSubstitutions.cs new file mode 100644 index 000000000..94e020e44 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/Notify/EmailSubstitutions.cs @@ -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; + +/// +/// Immutable carrier for the three checking-exercise-specific email substitution +/// values (ce name, learner noun, turnaround commitment). +/// Built by callers from the checking window they already hold, then passed through +/// → +/// . Not persisted. +/// +public sealed record EmailSubstitutions(string CeName, string LearnerNoun, string TurnaroundCommitment) +{ + /// + /// 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). + /// + 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(); + return attribute?.Name ?? type.ToString(); + } +} diff --git a/src/DfE.CheckPerformanceData.Application/Notify/INotifyService.cs b/src/DfE.CheckPerformanceData.Application/Notify/INotifyService.cs index 2a30f3b26..e11f30e70 100644 --- a/src/DfE.CheckPerformanceData.Application/Notify/INotifyService.cs +++ b/src/DfE.CheckPerformanceData.Application/Notify/INotifyService.cs @@ -29,6 +29,8 @@ public interface INotifyService /// Display-friendly deadline text. /// Deduplicated recipient email addresses. /// Which notification template to use. + /// Checking-exercise-specific values (ce name, learner noun, + /// turnaround commitment) gated per notification type. /// Optional URL (e.g. "submit others" or withdrawal link). /// /// For a consolidated bulk submission email: every reference in the batch, listed in the @@ -40,6 +42,7 @@ Task SendNotificationsAsync( string deadline, IReadOnlyCollection recipientEmails, NotificationType notificationType, + EmailSubstitutions substitutions, string? url = null, IReadOnlyCollection? referenceNumbers = null); diff --git a/src/DfE.CheckPerformanceData.Application/Notify/IRequestNotificationService.cs b/src/DfE.CheckPerformanceData.Application/Notify/IRequestNotificationService.cs index c97580157..ae91f7da8 100644 --- a/src/DfE.CheckPerformanceData.Application/Notify/IRequestNotificationService.cs +++ b/src/DfE.CheckPerformanceData.Application/Notify/IRequestNotificationService.cs @@ -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 referenceNumbers); - Task NotifyDataCheckConfirmedAsync(DateTime deadlineDate, string referenceNumber); + Task NotifySubmissionConfirmedAsync(Guid windowId, DateTime deadlineDate, string referenceNumber, EmailSubstitutions substitutions); + Task NotifyBulkSubmissionConfirmedAsync(Guid windowId, DateTime deadlineDate, IReadOnlyList referenceNumbers, EmailSubstitutions substitutions); + Task NotifyDataCheckConfirmedAsync(DateTime deadlineDate, string referenceNumber, EmailSubstitutions substitutions); /// /// Confirms a submitted 16-19 results enquiry to the person who submitted it (AB#296648). @@ -19,7 +19,7 @@ public interface IRequestNotificationService /// 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); } diff --git a/src/DfE.CheckPerformanceData.Application/RequestSubmission/IRequestService.cs b/src/DfE.CheckPerformanceData.Application/RequestSubmission/IRequestService.cs index 5701f1c1d..722192389 100644 --- a/src/DfE.CheckPerformanceData.Application/RequestSubmission/IRequestService.cs +++ b/src/DfE.CheckPerformanceData.Application/RequestSubmission/IRequestService.cs @@ -1,4 +1,5 @@ using DfE.CheckPerformanceData.Application.Journey; +using DfE.CheckPerformanceData.Application.Notify; using DfE.CheckPerformanceData.Domain.Enums; namespace DfE.CheckPerformanceData.Application.RequestSubmission; @@ -32,7 +33,7 @@ public interface IRequestService Task ConfirmRequestAsync(Guid windowId, RequestState journey); Task SaveDraftAsync(Guid windowId, RequestState journey, RequestStatus status); Task ResumeDraftAsync(Guid windowId, string referenceNumber); - Task ConfirmDataCorrectAsync(Guid windowId, string referenceNumber, DateTime endDate); + Task ConfirmDataCorrectAsync(Guid windowId, string referenceNumber, DateTime endDate, EmailSubstitutions substitutions); /// /// Deletes a request, scoped to the current user's organisation. Drafts diff --git a/src/DfE.CheckPerformanceData.Application/RequestSubmission/RequestService.cs b/src/DfE.CheckPerformanceData.Application/RequestSubmission/RequestService.cs index 4a678dff1..740e84135 100644 --- a/src/DfE.CheckPerformanceData.Application/RequestSubmission/RequestService.cs +++ b/src/DfE.CheckPerformanceData.Application/RequestSubmission/RequestService.cs @@ -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 { @@ -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) @@ -216,11 +219,13 @@ public async Task 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 { diff --git a/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs b/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs index eea084e34..436978903 100644 --- a/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs +++ b/src/DfE.CheckPerformanceData.Application/WindowManagement/IWindowService.cs @@ -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; /// /// The CSV + schema pairs ingested for this window, in sort order. A Post16 window has two diff --git a/src/DfE.CheckPerformanceData.Infrastructure/Notify/DevConsoleNotifyService.cs b/src/DfE.CheckPerformanceData.Infrastructure/Notify/DevConsoleNotifyService.cs index 9ea436035..a58d7aa4c 100644 --- a/src/DfE.CheckPerformanceData.Infrastructure/Notify/DevConsoleNotifyService.cs +++ b/src/DfE.CheckPerformanceData.Infrastructure/Notify/DevConsoleNotifyService.cs @@ -19,6 +19,7 @@ public Task SendNotificationsAsync( string deadline, IReadOnlyCollection recipientEmails, NotificationType notificationType, + EmailSubstitutions substitutions, string? url = null, IReadOnlyCollection? referenceNumbers = null) { @@ -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; } diff --git a/src/DfE.CheckPerformanceData.Infrastructure/Notify/NotificationSender.cs b/src/DfE.CheckPerformanceData.Infrastructure/Notify/NotificationSender.cs index ad32941d5..f2b8efd7e 100644 --- a/src/DfE.CheckPerformanceData.Infrastructure/Notify/NotificationSender.cs +++ b/src/DfE.CheckPerformanceData.Infrastructure/Notify/NotificationSender.cs @@ -36,6 +36,10 @@ await notifyService.SendNotificationsAsync( notification.Deadline, recipients, notification.Type, + new EmailSubstitutions( + notification.CeName, + notification.LearnerNoun, + notification.TurnaroundCommitment), notification.LinkUrl, notification.ReferenceNumbers); } diff --git a/src/DfE.CheckPerformanceData.Infrastructure/Notify/NotifyService.cs b/src/DfE.CheckPerformanceData.Infrastructure/Notify/NotifyService.cs index 0d71475d7..c663b12ec 100644 --- a/src/DfE.CheckPerformanceData.Infrastructure/Notify/NotifyService.cs +++ b/src/DfE.CheckPerformanceData.Infrastructure/Notify/NotifyService.cs @@ -33,6 +33,7 @@ public async Task SendNotificationsAsync( string deadline, IReadOnlyCollection recipientEmails, NotificationType notificationType, + EmailSubstitutions substitutions, string? url = null, IReadOnlyCollection? referenceNumbers = null) { @@ -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) { @@ -114,7 +115,9 @@ private async Task SendEmailAsync( string deadline, string templateId, string? url, - IReadOnlyCollection? referenceNumbers = null) + IReadOnlyCollection? referenceNumbers, + NotificationType notificationType, + EmailSubstitutions substitutions) { var personalisation = new Dictionary { @@ -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( diff --git a/src/DfE.CheckPerformanceData.Infrastructure/Notify/RequestNotificationService.cs b/src/DfE.CheckPerformanceData.Infrastructure/Notify/RequestNotificationService.cs index ad5c45262..43bbcf095 100644 --- a/src/DfE.CheckPerformanceData.Infrastructure/Notify/RequestNotificationService.cs +++ b/src/DfE.CheckPerformanceData.Infrastructure/Notify/RequestNotificationService.cs @@ -18,7 +18,8 @@ public sealed class RequestNotificationService( INotificationDispatcher dispatcher, IOptions 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"); @@ -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 }); } @@ -53,7 +57,7 @@ await dispatcher.EnqueueAsync(new EmailNotification } public async Task NotifyBulkSubmissionConfirmedAsync( - Guid windowId, DateTime deadlineDate, IReadOnlyList referenceNumbers) + Guid windowId, DateTime deadlineDate, IReadOnlyList referenceNumbers, EmailSubstitutions substitutions) { if (referenceNumbers.Count == 0) return; @@ -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; @@ -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 { @@ -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 { @@ -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 { @@ -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 }); } diff --git a/src/DfE.CheckPerformanceData.Persistence/Entities/CheckingWindow.cs b/src/DfE.CheckPerformanceData.Persistence/Entities/CheckingWindow.cs index 6c4b875bd..69d30bf41 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Entities/CheckingWindow.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Entities/CheckingWindow.cs @@ -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; @@ -62,6 +63,9 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Title) .IsRequired() .HasMaxLength(200); + + builder.Property(x => x.TurnaroundCommitment) + .HasMaxLength(200); builder.Property(x => x.IngressFile) .HasMaxLength(255); diff --git a/src/DfE.CheckPerformanceData.Persistence/Migrations/20260818095733_AddCheckingWindowTurnaroundCommitment.Designer.cs b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260818095733_AddCheckingWindowTurnaroundCommitment.Designer.cs new file mode 100644 index 000000000..3bfc147f8 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260818095733_AddCheckingWindowTurnaroundCommitment.Designer.cs @@ -0,0 +1,1351 @@ +// +using System; +using DfE.CheckPerformanceData.Persistence.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using NpgsqlTypes; + +#nullable disable + +namespace DfE.CheckPerformanceData.Persistence.Migrations +{ + [DbContext(typeof(PortalDbContext))] + [Migration("20260818095733_AddCheckingWindowTurnaroundCommitment")] + partial class AddCheckingWindowTurnaroundCommitment + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ChangedColumns") + .HasColumnType("text"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NewValues") + .HasColumnType("text"); + + b.Property("OldValues") + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.QueueMetricEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DecisionStatus") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("decision_status"); + + b.Property("LatencyMs") + .HasColumnType("double precision") + .HasColumnName("latency_ms"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("queue_name"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at_utc"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("reference_number"); + + b.Property("RulesVersion") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("rules_version"); + + b.Property("Stage") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("stage"); + + b.HasKey("Id"); + + b.HasIndex("RecordedAtUtc") + .HasDatabaseName("ix_queue_metrics_events_recorded_at"); + + b.HasIndex("QueueName", "RecordedAtUtc") + .HasDatabaseName("ix_queue_metrics_events_queue_recorded"); + + b.HasIndex("ReferenceNumber", "RecordedAtUtc") + .HasDatabaseName("ix_queue_metrics_events_reference"); + + b.ToTable("queue_metrics_events", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsSeeded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_seeded"); + + b.Property("JobId") + .HasColumnType("text") + .HasColumnName("job_id"); + + b.Property("LatencyMs") + .HasColumnType("integer") + .HasColumnName("latency_ms"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at_utc"); + + b.Property("QueryNormalised") + .HasColumnType("text") + .HasColumnName("query_normalised"); + + b.Property("QueryRaw") + .HasColumnType("text") + .HasColumnName("query_raw"); + + b.Property("ResultsBlocks") + .HasColumnType("integer") + .HasColumnName("results_blocks"); + + b.Property("ResultsPages") + .HasColumnType("integer") + .HasColumnName("results_pages"); + + b.Property("ResultsTotal") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("integer") + .HasColumnName("results_total") + .HasComputedColumnSql("results_pages + results_blocks", true); + + b.Property("Scope") + .HasColumnType("text") + .HasColumnName("scope"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("session_id"); + + b.Property("ZeroResults") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("boolean") + .HasColumnName("zero_results") + .HasComputedColumnSql("(results_pages + results_blocks) = 0", true); + + b.HasKey("Id"); + + b.HasIndex("JobId") + .HasDatabaseName("ix_search_events_job_id") + .HasFilter("job_id IS NOT NULL"); + + b.HasIndex("OccurredAtUtc") + .HasDatabaseName("ix_search_events_occurred_at"); + + b.HasIndex("QueryNormalised") + .HasDatabaseName("ix_search_events_query_normalised"); + + b.HasIndex("SessionId") + .HasDatabaseName("ix_search_events_session_id"); + + b.HasIndex("OccurredAtUtc", "QueryNormalised") + .HasDatabaseName("ix_search_events_occurred_at_query_normalised") + .HasFilter("query_normalised IS NOT NULL"); + + b.HasIndex("OccurredAtUtc", "SessionId") + .HasDatabaseName("ix_search_events_occurred_at_session_id"); + + b.HasIndex("ZeroResults", "OccurredAtUtc") + .HasDatabaseName("ix_search_events_zero_results_occurred_at") + .HasFilter("zero_results = true"); + + b.ToTable("search_events", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchEventResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsSeeded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_seeded"); + + b.Property("JobId") + .HasColumnType("text") + .HasColumnName("job_id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Rank") + .HasColumnType("real") + .HasColumnName("rank"); + + b.Property("ResultKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("result_key"); + + b.Property("ResultKind") + .IsRequired() + .HasColumnType("text") + .HasColumnName("result_kind"); + + b.Property("SearchEventId") + .HasColumnType("bigint") + .HasColumnName("search_event_id"); + + b.HasKey("Id"); + + b.HasIndex("JobId") + .HasDatabaseName("ix_search_event_results_job_id") + .HasFilter("job_id IS NOT NULL"); + + b.HasIndex("SearchEventId") + .HasDatabaseName("ix_search_event_results_search_event_id"); + + b.ToTable("search_event_results", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Email") + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_read"); + + b.Property("IsSeeded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_seeded"); + + b.Property("JobId") + .HasColumnType("text") + .HasColumnName("job_id"); + + b.Property("ReadAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("read_at_utc"); + + b.Property("ReadByAdminSub") + .HasColumnType("text") + .HasColumnName("read_by_admin_sub"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("session_id"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at_utc"); + + b.Property("WhatGot") + .HasColumnType("text") + .HasColumnName("what_got"); + + b.Property("WhatLookingFor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("what_looking_for"); + + b.HasKey("Id"); + + b.HasIndex("IsRead") + .HasDatabaseName("ix_search_messages_is_read"); + + b.HasIndex("JobId") + .HasDatabaseName("ix_search_messages_job_id") + .HasFilter("job_id IS NOT NULL"); + + b.HasIndex("SessionId") + .HasDatabaseName("ix_search_messages_session_id"); + + b.HasIndex("SubmittedAtUtc") + .HasDatabaseName("ix_search_messages_submitted_at"); + + b.ToTable("search_messages", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.ShareToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("label"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at_utc"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("surface"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .HasDatabaseName("ix_share_tokens_token_hash"); + + b.ToTable("share_tokens", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.AdminSectionAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("RoleName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SectionKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("RoleName", "SectionKey") + .IsUnique(); + + b.ToTable("AdminSectionAccesses"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.AppLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("EventId") + .HasColumnType("integer"); + + b.Property("Exception") + .HasColumnType("text"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestPath") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("StateJson") + .HasColumnType("jsonb"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("Level"); + + b.HasIndex("Timestamp") + .IsDescending(); + + b.ToTable("AppLogs"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ChangeRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AmendmentType") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CrmId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MatchedRuleId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganisationUrn") + .HasColumnType("bigint"); + + b.Property("Outcome") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("OutcomeKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PupilFirstname") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PupilId") + .HasColumnType("uuid"); + + b.Property("PupilSurname") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PupilUpn") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("RequestTypeDescription") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RulesVersion") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Submitted") + .HasColumnType("timestamp without time zone"); + + b.Property("SubmittedByEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SubmittedById") + .HasColumnType("uuid"); + + b.Property("SubmittedByName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("WindowId") + .HasColumnType("uuid"); + + b.Property("WithdrawnAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WithdrawnByEmail") + .HasColumnType("text"); + + b.Property("WorkerStatus") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CrmId") + .IsUnique() + .HasFilter("\"CrmId\" IS NOT NULL"); + + b.HasIndex("ReferenceNumber") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WindowId", "OrganisationUrn"); + + b.ToTable("ChangeRequests"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckingWindowType") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("IngressFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IngressFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("KeyStage") + .IsRequired() + .HasColumnType("text"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("SchemaFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SchemaFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TurnaroundCommitment") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("CheckingWindows"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindowDataset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckingWindowId") + .HasColumnType("uuid"); + + b.Property("Included") + .HasColumnType("boolean"); + + b.Property("IngressFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IngressFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SchemaFile") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SchemaFileChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckingWindowId", "Name") + .IsUnique(); + + b.ToTable("CheckingWindowDatasets"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AppearInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BlockType") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Keywords") + .HasColumnType("text"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenPath") + .HasColumnType("text"); + + b.Property("SearchVector") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Keywords\", '')), 'A') || setweight(to_tsvector('english', coalesce(\"ValuePlainText\", '')), 'B')", true); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.Property("ValuePlainText") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.HasKey("Id"); + + b.HasIndex("ContentId") + .IsUnique(); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "gin"); + + b.ToTable("ContentBlocks"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlockVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContentBlockId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.Property("VersionNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ContentBlockId", "VersionNumber") + .IsUnique(); + + b.ToTable("ContentBlockVersions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.Country", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OfficialName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Name"); + + b.ToTable("Countries"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.DeadLetterEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("DeadLetteredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dead_lettered_at_utc"); + + b.Property("EnqueuedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("enqueued_at_utc"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text") + .HasColumnName("payload"); + + b.Property("PayloadHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("payload_hash"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("queue_name"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("reason"); + + b.HasKey("Id"); + + b.HasIndex("DeadLetteredAtUtc") + .HasDatabaseName("ix_queue_dead_letters_dead_lettered_at"); + + b.ToTable("queue_dead_letters", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.DevZendeskTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("priority"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("raw_json"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("reference_number"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("status"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("subject"); + + b.Property("TicketId") + .HasColumnType("bigint") + .HasColumnName("ticket_id"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAtUtc") + .HasDatabaseName("ix_dev_zendesk_outbox_created_at"); + + b.ToTable("dev_zendesk_outbox", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.OrganisationLogin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Laestab") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("LoggedInAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganisationName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OrganisationUrn") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("LoggedInAtUtc"); + + b.HasIndex("OrganisationUrn", "LoggedInAtUtc"); + + b.ToTable("OrganisationLogins"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppearInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Keywords") + .HasColumnType("text"); + + b.Property("PageName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PageType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SearchVector") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Keywords\", '')), 'A') || setweight(to_tsvector('english', coalesce(\"Title\", '')), 'B') || setweight(to_tsvector('english', coalesce(\"Subtitle\", '')), 'C')", true); + + b.Property("Segment") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ShowInMenu") + .HasColumnType("boolean"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path") + .IsUnique() + .HasFilter("\"DeletedDate\" IS NULL"); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "gin"); + + b.ToTable("PageNodes"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNodeVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BodyPlainText") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("MinorVersion") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("PageNodeId") + .HasColumnType("uuid"); + + b.Property("PublishFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("PublishTo") + .HasColumnType("timestamp with time zone"); + + b.Property("SearchVector") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"BodyPlainText\", '')), 'D')", true); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("VersionId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "gin"); + + b.HasIndex("PageNodeId", "IsCurrent"); + + b.HasIndex("PageNodeId", "VersionId") + .IsUnique(); + + b.ToTable("PageNodeVersions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.QueueMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("EnqueuedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("enqueued_at_utc"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text") + .HasColumnName("payload"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("queue_name"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("VisibleAfterUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("visible_after_utc"); + + b.HasKey("Id"); + + b.HasIndex("QueueName", "Status", "VisibleAfterUtc") + .HasDatabaseName("ix_queue_messages_claim"); + + b.ToTable("queue_messages", (string)null); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.RulesConfigVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("VersionNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ConfigType", "VersionNumber") + .IsUnique(); + + b.ToTable("RulesConfigVersions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.Setting", b => + { + b.Property("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Value") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("DfE.CheckPerformance.Persistence.Entities.SearchEventResult", b => + { + b.HasOne("DfE.CheckPerformance.Persistence.Entities.SearchEvent", null) + .WithMany() + .HasForeignKey("SearchEventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ChangeRequest", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", null) + .WithMany() + .HasForeignKey("WindowId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", b => + { + b.OwnsOne("DfE.CheckPerformanceData.Persistence.Entities.WindowValidated", "Validated", b1 => + { + b1.Property("CheckingWindowId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b1.Property("IngressValidationChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b1.Property("SchemaValidationChecksum") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b1.Property("ValidatedAt") + .HasColumnType("timestamp with time zone"); + + b1.HasKey("CheckingWindowId"); + + b1.ToTable("CheckingWindows"); + + b1.WithOwner() + .HasForeignKey("CheckingWindowId"); + }); + + b.Navigation("Validated"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindowDataset", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", null) + .WithMany("Datasets") + .HasForeignKey("CheckingWindowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlockVersion", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.ContentBlock", "ContentBlock") + .WithMany("Versions") + .HasForeignKey("ContentBlockId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ContentBlock"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNode", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.PageNode", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_PageNode_PageNode_ParentId"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNodeVersion", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.PageNode", "PageNode") + .WithMany("Versions") + .HasForeignKey("PageNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PageNode"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", b => + { + b.Navigation("Datasets"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.ContentBlock", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.PageNode", b => + { + b.Navigation("Versions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/DfE.CheckPerformanceData.Persistence/Migrations/20260818095733_AddCheckingWindowTurnaroundCommitment.cs b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260818095733_AddCheckingWindowTurnaroundCommitment.cs new file mode 100644 index 000000000..989ad1b7d --- /dev/null +++ b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260818095733_AddCheckingWindowTurnaroundCommitment.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DfE.CheckPerformanceData.Persistence.Migrations +{ + /// + public partial class AddCheckingWindowTurnaroundCommitment : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TurnaroundCommitment", + table: "CheckingWindows", + type: "character varying(200)", + maxLength: 200, + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "TurnaroundCommitment", + table: "CheckingWindows"); + } + } +} diff --git a/src/DfE.CheckPerformanceData.Persistence/Migrations/PortalDbContextModelSnapshot.cs b/src/DfE.CheckPerformanceData.Persistence/Migrations/PortalDbContextModelSnapshot.cs index 881c2edbd..35112de5c 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Migrations/PortalDbContextModelSnapshot.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Migrations/PortalDbContextModelSnapshot.cs @@ -651,6 +651,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("TurnaroundCommitment") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + b.HasKey("Id"); b.ToTable("CheckingWindows"); diff --git a/src/DfE.CheckPerformanceData.Persistence/Repositories/CheckYourPupilDataRepository.cs b/src/DfE.CheckPerformanceData.Persistence/Repositories/CheckYourPupilDataRepository.cs index bb0e13326..dfa290be5 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Repositories/CheckYourPupilDataRepository.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Repositories/CheckYourPupilDataRepository.cs @@ -43,7 +43,7 @@ public async Task GetCheckingWindowAsync(Guid windowId) => await dbContext.CheckingWindows .AsNoTracking() .Where(w => w.Id == windowId) - .Select(w => new CheckingWindowDto { EndDate = w.EndDate, Title = w.Title, KeyStage = w.KeyStage, CheckingWindowType = w.CheckingWindowType, StartDate = w.StartDate }) + .Select(w => new CheckingWindowDto { EndDate = w.EndDate, Title = w.Title, KeyStage = w.KeyStage, CheckingWindowType = w.CheckingWindowType, StartDate = w.StartDate, TurnaroundCommitment = w.TurnaroundCommitment }) .SingleAsync(); public async Task GetPupilAsync(Guid windowId, string laestab, Guid pupilId) diff --git a/src/DfE.CheckPerformanceData.Persistence/Repositories/LandingPageRepository.cs b/src/DfE.CheckPerformanceData.Persistence/Repositories/LandingPageRepository.cs index 5503e2b97..8e64a6604 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Repositories/LandingPageRepository.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Repositories/LandingPageRepository.cs @@ -27,6 +27,7 @@ public async Task> GetOpenWindowsAsync(DateTime now, str w.KeyStage, w.CheckingWindowType, w.Title, + w.TurnaroundCommitment, w.Id }) .ToListAsync(cancellationToken); @@ -54,6 +55,7 @@ public async Task> GetOpenWindowsAsync(DateTime now, str KeyStage = w.KeyStage, CheckingWindowType = w.CheckingWindowType, Title = w.Title, + TurnaroundCommitment = w.TurnaroundCommitment, Id = w.Id, HasPupilData = await pupilDataBlobClient.HasPupilDataAsync(w.Id, laestab) }); diff --git a/src/DfE.CheckPerformanceData.Persistence/Repositories/WindowRepository.cs b/src/DfE.CheckPerformanceData.Persistence/Repositories/WindowRepository.cs index 75d4d530f..96a950165 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Repositories/WindowRepository.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Repositories/WindowRepository.cs @@ -18,6 +18,7 @@ await dbContext.CheckingWindows KeyStage = w.KeyStage, CheckingWindowType = w.CheckingWindowType, Title = w.Title, + TurnaroundCommitment = w.TurnaroundCommitment, Id = w.Id, IngressFile = w.IngressFile, IngressFileChecksum = w.IngressFileChecksum, @@ -53,6 +54,7 @@ await dbContext.CheckingWindows KeyStage = w.KeyStage, CheckingWindowType = w.CheckingWindowType, Title = w.Title, + TurnaroundCommitment = w.TurnaroundCommitment, Id = w.Id, IngressFile = w.IngressFile, IngressFileChecksum = w.IngressFileChecksum, @@ -92,6 +94,7 @@ public async Task UpdateAsync(CheckingWindowDto window, CancellationToken cancel window.KeyStage, window.CheckingWindowType, window.Title, + window.TurnaroundCommitment, window.IngressFile, window.IngressFileChecksum, window.SchemaFile, @@ -156,6 +159,7 @@ public async Task CreateAsync(CheckingWindowDto window, Cance KeyStage = window.KeyStage, CheckingWindowType = window.CheckingWindowType, Title = window.Title, + TurnaroundCommitment = window.TurnaroundCommitment, IngressFile = window.IngressFile, IngressFileChecksum = window.IngressFileChecksum, SchemaFile = window.SchemaFile, @@ -189,6 +193,7 @@ public async Task CreateAsync(CheckingWindowDto window, Cance KeyStage = entity.KeyStage, CheckingWindowType = entity.CheckingWindowType, Title = entity.Title, + TurnaroundCommitment = entity.TurnaroundCommitment, IngressFile = entity.IngressFile, IngressFileChecksum = entity.IngressFileChecksum, SchemaFile = entity.SchemaFile, diff --git a/src/DfE.CheckPerformanceData.Persistence/Seeding/SeedCheckingWindows.cs b/src/DfE.CheckPerformanceData.Persistence/Seeding/SeedCheckingWindows.cs index 2b5a14c48..375627033 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Seeding/SeedCheckingWindows.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Seeding/SeedCheckingWindows.cs @@ -31,6 +31,7 @@ public static async Task ExecuteSeed(IPortalDbContext dbContext, Guid openKs4Win KeyStage = KeyStages.KS4, CheckingWindowType = CheckingWindowType.KS4June, Title = "Key Stage 4 June", + TurnaroundCommitment = "updated in the Autumn", Datasets = DatasetsFor(CheckingWindowType.KS4June) }; @@ -42,6 +43,7 @@ public static async Task ExecuteSeed(IPortalDbContext dbContext, Guid openKs4Win KeyStage = KeyStages.KS4, CheckingWindowType = CheckingWindowType.KS4June, Title = "KS4 June", + TurnaroundCommitment = "updated in the Autumn", Datasets = DatasetsFor(CheckingWindowType.KS4June) }; @@ -53,6 +55,7 @@ public static async Task ExecuteSeed(IPortalDbContext dbContext, Guid openKs4Win KeyStage = KeyStages.Post16, CheckingWindowType = CheckingWindowType.Post16, Title = "16 to 19", + TurnaroundCommitment = "updated in the Spring", Datasets = DatasetsFor(CheckingWindowType.Post16) }; diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/ConfirmCorrectController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/ConfirmCorrectController.cs index f0e6a9119..d9317cc8c 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/ConfirmCorrectController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/ConfirmCorrectController.cs @@ -1,6 +1,7 @@ using DfE.CheckPerformanceData.Application.Analytics; using DfE.CheckPerformanceData.Application.CheckYourPupilData; using DfE.CheckPerformanceData.Application.Journey; +using DfE.CheckPerformanceData.Application.Notify; using DfE.CheckPerformanceData.Application.RequestSubmission; using DfE.CheckPerformanceData.Web.Controllers.ViewModels; using Microsoft.AspNetCore.Mvc; @@ -27,7 +28,7 @@ public async Task Confirm(Guid windowId) { var window = await service.GetCheckingWindowAsync(windowId); var reference = journeyService.GenerateReference(window.CheckingWindowType); - await requestService.ConfirmDataCorrectAsync(windowId, reference, window.EndDate); + await requestService.ConfirmDataCorrectAsync(windowId, reference, window.EndDate, EmailSubstitutions.From(window)); await analytics.TrackSafeAsync(new CorrectDataConfirmedEvent { diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/WindowAdmin/WindowTurnaroundCommitmentEditItem.cs b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/WindowAdmin/WindowTurnaroundCommitmentEditItem.cs new file mode 100644 index 000000000..9ae74049c --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/WindowAdmin/WindowTurnaroundCommitmentEditItem.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace DfE.CheckPerformanceData.Web.Controllers.ViewModels.WindowAdmin; + +public sealed class WindowTurnaroundCommitmentEditItem : AdminPage +{ + [MaxLength(200, ErrorMessage = "Turnaround commitment must be 200 characters or less")] + public string? TurnaroundCommitment { get; init; } +} \ No newline at end of file diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/WindowViewModel.cs b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/WindowViewModel.cs index b8867247c..c72cdaf41 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/WindowViewModel.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/WindowViewModel.cs @@ -24,6 +24,11 @@ public string TitleLink { get => $"{BaseEditUrl}/title"; } + public string TurnaroundCommitment { get; set; } = string.Empty; + public string TurnaroundCommitmentLink + { + get => $"{BaseEditUrl}/turnaround-commitment"; + } public bool IsOpen { get; set; } = false; public required DateTime StartDate { get; set; } public string StartDateLink { diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/SummaryController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/SummaryController.cs index 4dfa6614c..8eab8f5a2 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/SummaryController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/SummaryController.cs @@ -15,6 +15,7 @@ public async Task Index(Guid id, CancellationToken cancellationTo { WindowId = w.Id, Title = w.Title, + TurnaroundCommitment = w.TurnaroundCommitment, StartDate = w.StartDate, EndDate = w.EndDate, KeyStage = w.KeyStage, diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/TurnaroundCommitmentController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/TurnaroundCommitmentController.cs new file mode 100644 index 000000000..37a4f2476 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Controllers/WindowAdmin/TurnaroundCommitmentController.cs @@ -0,0 +1,57 @@ +using DfE.CheckPerformanceData.Application.WindowManagement; +using DfE.CheckPerformanceData.Web.Controllers.ViewModels.WindowAdmin; +using Microsoft.AspNetCore.Mvc; + +namespace DfE.CheckPerformanceData.Web.Controllers.WindowAdmin; + +public sealed class TurnaroundCommitmentController(IWindowService windowService): Controller +{ + private const string PageView = "~/Views/WindowAdmin/TurnaroundCommitment.cshtml"; + + [HttpGet("admin/windows/{id:guid}/turnaround-commitment")] + public async Task Edit(Guid id, CancellationToken cancellationToken) + { + CheckingWindowDto? window = await windowService.GetByIdAsync(id, cancellationToken); + + if (window is null) + { + return NotFound(); + } + + WindowTurnaroundCommitmentEditItem model = new WindowTurnaroundCommitmentEditItem + { + WindowId = window.Id, + TurnaroundCommitment = window.TurnaroundCommitment, + PostUrl = Url.Action("Update", "TurnaroundCommitment", new { id = window.Id }), + CancelUrl = Url.Action("Index", "Summary", new { id = window.Id }) + }; + + return View(PageView, model); + } + + [HttpPost("admin/windows/{id:guid}/turnaround-commitment")] + [ValidateAntiForgeryToken] + public async Task Update(Guid id, WindowTurnaroundCommitmentEditItem model, CancellationToken cancellationToken) + { + if (!ModelState.IsValid) + { + return View(PageView, model); + } + + if (id != model.WindowId) + { + return BadRequest(); + } + + CheckingWindowDto? window = await windowService.GetByIdAsync(id, cancellationToken); + if (window is null) + { + return NotFound(); + } + + window.TurnaroundCommitment = model.TurnaroundCommitment ?? string.Empty; + await windowService.UpdateAsync(window, cancellationToken); + + return RedirectToAction("Index", "Summary", new { id = id }); + } +} \ No newline at end of file diff --git a/src/DfE.CheckPerformanceData.Web/Views/WindowAdmin/Summary.cshtml b/src/DfE.CheckPerformanceData.Web/Views/WindowAdmin/Summary.cshtml index ee2102020..9fb0f6b02 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/WindowAdmin/Summary.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/WindowAdmin/Summary.cshtml @@ -43,6 +43,13 @@ Change + + Turnaround commitment + @(string.IsNullOrWhiteSpace(Model.TurnaroundCommitment) ? "Not set" : Model.TurnaroundCommitment) + + Change + + diff --git a/src/DfE.CheckPerformanceData.Web/Views/WindowAdmin/TurnaroundCommitment.cshtml b/src/DfE.CheckPerformanceData.Web/Views/WindowAdmin/TurnaroundCommitment.cshtml new file mode 100644 index 000000000..a96a7f7fc --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Views/WindowAdmin/TurnaroundCommitment.cshtml @@ -0,0 +1,15 @@ +@model DfE.CheckPerformanceData.Web.Controllers.ViewModels.WindowAdmin.WindowTurnaroundCommitmentEditItem + +
+ @Html.AntiForgeryToken() + + + + Edit turnaround commitment + Enter the turnaround commitment for the window. + + + + Save and continue + Cancel +
\ No newline at end of file diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/adaptive-charts/admin-search-30d.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/adaptive-charts/admin-search-30d.png index 920d0aac3..304150be3 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/adaptive-charts/admin-search-30d.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/adaptive-charts/admin-search-30d.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/adaptive-charts/admin-search-aggregated-hover.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/adaptive-charts/admin-search-aggregated-hover.png index 6922d4eea..eeb845528 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/adaptive-charts/admin-search-aggregated-hover.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/adaptive-charts/admin-search-aggregated-hover.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/adaptive-charts/admin-search-aggregated.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/adaptive-charts/admin-search-aggregated.png index 6b37caa58..7d27fdfe5 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/adaptive-charts/admin-search-aggregated.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/adaptive-charts/admin-search-aggregated.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/adaptive-charts/admin-search-hover-crosshair.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/adaptive-charts/admin-search-hover-crosshair.png index 4ebfb31c4..e9a9da4ac 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/adaptive-charts/admin-search-hover-crosshair.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/adaptive-charts/admin-search-hover-crosshair.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-dashboard/admin-dashboard-stop-refresh.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-dashboard/admin-dashboard-stop-refresh.png index 545d1a018..108699180 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-dashboard/admin-dashboard-stop-refresh.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-dashboard/admin-dashboard-stop-refresh.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-dashboard/admin-dashboard.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-dashboard/admin-dashboard.png index a852f3f3a..975de5325 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-dashboard/admin-dashboard.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-dashboard/admin-dashboard.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-search-aggregate-apply-filters-roundtrip.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-search-aggregate-apply-filters-roundtrip.png index ab6990597..1148a4c1d 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-search-aggregate-apply-filters-roundtrip.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-search-aggregate-apply-filters-roundtrip.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-search-aggregate-right-y-non-zero.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-search-aggregate-right-y-non-zero.png index 7f75ff889..cefd9ef94 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-search-aggregate-right-y-non-zero.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-search-aggregate-right-y-non-zero.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-search-latency-tile.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-search-latency-tile.png index 30c8d9d42..03550dc24 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-search-latency-tile.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-search-latency-tile.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-search-volume-drill-in.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-search-volume-drill-in.png index e3fb16f4f..5db258ceb 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-search-volume-drill-in.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-search-volume-drill-in.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-search.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-search.png index 6153f986e..46a83cb07 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-search.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/admin-search.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/analytics-drill-ins/admin-search-heatmap.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/analytics-drill-ins/admin-search-heatmap.png index 5ea9059dd..821bc01a6 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/analytics-drill-ins/admin-search-heatmap.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/analytics-drill-ins/admin-search-heatmap.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/analytics-drill-ins/admin-search-latency-tile-timings.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/analytics-drill-ins/admin-search-latency-tile-timings.png index 6d54ba889..03550dc24 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/analytics-drill-ins/admin-search-latency-tile-timings.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/analytics-drill-ins/admin-search-latency-tile-timings.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/analytics-drill-ins/admin-search-request-timings-drill.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/analytics-drill-ins/admin-search-request-timings-drill.png index 334c664ff..7341ab2f8 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/analytics-drill-ins/admin-search-request-timings-drill.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/analytics-drill-ins/admin-search-request-timings-drill.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/analytics-drill-ins/admin-search-zero-result-funnel.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/analytics-drill-ins/admin-search-zero-result-funnel.png index c18aa42ca..03f915a7b 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/analytics-drill-ins/admin-search-zero-result-funnel.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/analytics-drill-ins/admin-search-zero-result-funnel.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/danger-zone/delete-all-modal-typed.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/danger-zone/delete-all-modal-typed.png index 6fb42554e..97f432dbb 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/danger-zone/delete-all-modal-typed.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/danger-zone/delete-all-modal-typed.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/danger-zone/delete-seeded-modal.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/danger-zone/delete-seeded-modal.png index 7f78bf291..73c8ac2a7 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/danger-zone/delete-seeded-modal.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/danger-zone/delete-seeded-modal.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/danger-zone/test-data-danger-zone.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/danger-zone/test-data-danger-zone.png index ae558bc8a..0311edf07 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/danger-zone/test-data-danger-zone.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/danger-zone/test-data-danger-zone.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/feedback-get.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/feedback-get.png index 4ab6e878e..ae14c5ff9 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/feedback-get.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/feedback-get.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/feedback-post-error.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/feedback-post-error.png index 62c0c8960..ececced01 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/feedback-post-error.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/feedback-post-error.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/messages/admin-message-detail.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/messages/admin-message-detail.png index c13239cd0..ff7ed5a90 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/messages/admin-message-detail.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/messages/admin-message-detail.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/messages/admin-messages-group-landing.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/messages/admin-messages-group-landing.png index 292cf14b5..1a73402e2 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/messages/admin-messages-group-landing.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/messages/admin-messages-group-landing.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/messages/admin-nav-messages-group.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/messages/admin-nav-messages-group.png index 11b6890c6..b0efcc828 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/messages/admin-nav-messages-group.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/messages/admin-nav-messages-group.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/pagination/admin-pager-first.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/pagination/admin-pager-first.png index 47618b73c..16811f763 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/pagination/admin-pager-first.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/pagination/admin-pager-first.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/pagination/admin-pager-last.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/pagination/admin-pager-last.png index 59bd9e0a6..d5ea62cbc 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/pagination/admin-pager-last.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/pagination/admin-pager-last.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/pagination/admin-pager-middle.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/pagination/admin-pager-middle.png index e3fb16f4f..5db258ceb 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/pagination/admin-pager-middle.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/pagination/admin-pager-middle.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/search-ux/admin-search-scatter-hover.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/search-ux/admin-search-scatter-hover.png index d72c1a91f..874a8553d 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/search-ux/admin-search-scatter-hover.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/search-ux/admin-search-scatter-hover.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-admin/admin-nav-test-data-group.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-admin/admin-nav-test-data-group.png index 8d0aa1953..ddf5e4d1c 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-admin/admin-nav-test-data-group.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-admin/admin-nav-test-data-group.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-admin/admin-search-with-seeded-data.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-admin/admin-search-with-seeded-data.png index 2f0383ea2..79efa02d3 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-admin/admin-search-with-seeded-data.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-admin/admin-search-with-seeded-data.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-admin/seed-sample-search-data-form.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-admin/seed-sample-search-data-form.png index 3a88d5935..3da411eba 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-admin/seed-sample-search-data-form.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-admin/seed-sample-search-data-form.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-admin/seed-sample-search-data-success.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-admin/seed-sample-search-data-success.png index c324f26f2..401bf57bf 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-admin/seed-sample-search-data-success.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-admin/seed-sample-search-data-success.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-progress/seed-cancelled-modal.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-progress/seed-cancelled-modal.png index 1e4b5ef04..c47c22470 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-progress/seed-cancelled-modal.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-progress/seed-cancelled-modal.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-progress/seed-completed-modal.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-progress/seed-completed-modal.png index beb65365b..27331d3b2 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-progress/seed-completed-modal.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-progress/seed-completed-modal.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-progress/seed-in-progress-modal.png b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-progress/seed-in-progress-modal.png index 44f1614ac..1907b6bec 100644 Binary files a/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-progress/seed-in-progress-modal.png and b/tests/DfE.CheckPerformanceData.E2ETests/Snapshots/search-ux/seed-progress/seed-in-progress-modal.png differ diff --git a/tests/DfE.CheckPerformanceData.E2ETests/WindowAdmin/TurnaroundCommitmentTests.cs b/tests/DfE.CheckPerformanceData.E2ETests/WindowAdmin/TurnaroundCommitmentTests.cs new file mode 100644 index 000000000..a7751dfe6 --- /dev/null +++ b/tests/DfE.CheckPerformanceData.E2ETests/WindowAdmin/TurnaroundCommitmentTests.cs @@ -0,0 +1,80 @@ +using DfE.CheckPerformanceData.E2ETests.Fixtures; +using Microsoft.Playwright; + +namespace DfE.CheckPerformanceData.E2ETests.WindowAdmin; + +/// +/// Exercises the window turnaround-commitment edit page end to end: the value is surfaced on the +/// window Summary, editable via a one-field form, persisted back to the database, and an empty +/// submission is allowed (no required validation). +/// +[Collection("E2E")] +public sealed class TurnaroundCommitmentTests(PlaywrightFixture fixture) : SeedingPageTest(fixture) +{ + // The seeded KS4 June window (see SeedCheckingWindows in DevDataSeeder). + private static readonly Guid SeededWindowId = Guid.Parse("F34D285B-8660-4D12-9C30-787328DEAA0A"); + + private string SummaryUrl => $"{Fixture.BaseUrl}/admin/windows/summary/{SeededWindowId}"; + private string EditUrl => $"{Fixture.BaseUrl}/admin/windows/{SeededWindowId}/turnaround-commitment"; + + [Fact] + public async Task Summary_ShowsTurnaroundCommitmentRow_WithChangeLink() + { + await Page.GotoAsync(SummaryUrl); + await Expect(Page.Locator("h1.govuk-heading-xl")).ToBeVisibleAsync(); + + var row = Page.Locator(".govuk-summary-list__row").Filter( + new() { Has = Page.Locator(".govuk-summary-list__key", new() { HasText = "Turnaround commitment" }) }); + await Expect(row).ToHaveCountAsync(1); + + var changeLink = row.GetByRole(AriaRole.Link, new() { Name = "Change Turnaround commitment" }); + await Expect(changeLink).ToBeVisibleAsync(); + await Expect(changeLink).ToHaveAttributeAsync("href", $"/admin/windows/{SeededWindowId}/turnaround-commitment"); + } + + [Fact] + public async Task EditPage_PrefillsCurrentValue() + { + // Read the value currently shown on the Summary first, so the assertion holds + // regardless of what earlier tests left in the database. + await Page.GotoAsync(SummaryUrl); + var value = await CurrentSummaryValueAsync(); + + await Page.GotoAsync(EditUrl); + var input = Page.Locator("#TurnaroundCommitment"); + await Expect(input).ToHaveValueAsync(value); + } + + [Fact] + public async Task Save_PersistsValue_AndSummaryReflectsIt() + { + const string expected = "updated in the Spring"; + + await Page.GotoAsync(EditUrl); + await Page.Locator("#TurnaroundCommitment").FillAsync(expected); + await Page.GetByRole(AriaRole.Button, new() { Name = "Save and continue" }).ClickAsync(); + + await Page.WaitForURLAsync("**/admin/windows/summary/**"); + Assert.Equal(expected, await CurrentSummaryValueAsync()); + } + + [Fact] + public async Task EmptySubmission_IsAllowed_AndShowsNotSet() + { + await Page.GotoAsync(EditUrl); + await Page.Locator("#TurnaroundCommitment").FillAsync(string.Empty); + await Page.GetByRole(AriaRole.Button, new() { Name = "Save and continue" }).ClickAsync(); + + await Page.WaitForURLAsync("**/admin/windows/summary/**"); + + await Expect(Page.Locator(".govuk-error-summary")).ToHaveCountAsync(0); + Assert.Equal("Not set", await CurrentSummaryValueAsync()); + } + + private async Task CurrentSummaryValueAsync() + { + var row = Page.Locator(".govuk-summary-list__row").Filter( + new() { Has = Page.Locator(".govuk-summary-list__key", new() { HasText = "Turnaround commitment" }) }); + return (await row.Locator(".govuk-summary-list__value").InnerTextAsync()).Trim(); + } +} \ No newline at end of file diff --git a/tests/DfE.CheckPerformanceData.IntegrationTests/Persistence/WindowRepositoryTurnaroundCommitmentTests.cs b/tests/DfE.CheckPerformanceData.IntegrationTests/Persistence/WindowRepositoryTurnaroundCommitmentTests.cs new file mode 100644 index 000000000..61b25bf04 --- /dev/null +++ b/tests/DfE.CheckPerformanceData.IntegrationTests/Persistence/WindowRepositoryTurnaroundCommitmentTests.cs @@ -0,0 +1,108 @@ +using DfE.CheckPerformanceData.Application.WindowManagement; +using DfE.CheckPerformanceData.IntegrationTests.Fixtures; +using DfE.CheckPerformanceData.Persistence.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace DfE.CheckPerformanceData.IntegrationTests.Persistence; + +/// +/// Pins the TurnaroundCommitment column mapping through the window repository: the value +/// must round-trip through / +/// and survive so the admin edit page can set it and the +/// email substitutions can read it back. +/// +[Collection(nameof(PostgresCollection))] +public sealed class WindowRepositoryTurnaroundCommitmentTests(PostgresFixture fixture) : IAsyncLifetime +{ + private readonly Guid _windowId = Guid.NewGuid(); + private readonly List _createdWindowIds = []; + + public Task InitializeAsync() => Task.CompletedTask; + + public async Task DisposeAsync() + { + await using var ctx = fixture.CreateContext(); + var ids = _createdWindowIds.Concat([_windowId]).ToList(); + await ctx.CheckingWindows.Where(w => ids.Contains(w.Id)).ExecuteDeleteAsync(); + } + + [Fact] + public async Task GetByIdAsync_ReturnsSeededTurnaroundCommitment() + { + await using var seedCtx = fixture.CreateContext(); + seedCtx.CheckingWindows.Add(new() + { + Id = _windowId, + Title = "KS4 June", + KeyStage = Domain.Enums.KeyStages.KS4, + CheckingWindowType = Domain.Enums.CheckingWindowType.KS4June, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddDays(14), + TurnaroundCommitment = "updated in the Autumn" + }); + await seedCtx.SaveChangesAsync(); + + await using var ctx = fixture.CreateContext(); + var sut = new WindowRepository(ctx); + + var window = await sut.GetByIdAsync(_windowId, CancellationToken.None); + + Assert.NotNull(window); + Assert.Equal("updated in the Autumn", window!.TurnaroundCommitment); + } + + [Fact] + public async Task UpdateAsync_PersistsChangedTurnaroundCommitment() + { + await using var seedCtx = fixture.CreateContext(); + seedCtx.CheckingWindows.Add(new() + { + Id = _windowId, + Title = "KS4 June", + KeyStage = Domain.Enums.KeyStages.KS4, + CheckingWindowType = Domain.Enums.CheckingWindowType.KS4June, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddDays(14), + TurnaroundCommitment = string.Empty + }); + await seedCtx.SaveChangesAsync(); + + await using var ctx = fixture.CreateContext(); + var sut = new WindowRepository(ctx); + var window = await sut.GetByIdAsync(_windowId, CancellationToken.None); + Assert.NotNull(window); + window!.TurnaroundCommitment = "updated in the Spring"; + + await sut.UpdateAsync(window, CancellationToken.None); + + await using var readCtx = fixture.CreateContext(); + var reloaded = await new WindowRepository(readCtx) + .GetByIdAsync(_windowId, CancellationToken.None); + Assert.Equal("updated in the Spring", reloaded!.TurnaroundCommitment); + } + + [Fact] + public async Task CreateAsync_PersistsTurnaroundCommitment() + { + var created = await new WindowRepository(fixture.CreateContext()) + .CreateAsync(new CheckingWindowDto + { + Id = _windowId, + Title = "16 to 19", + KeyStage = Domain.Enums.KeyStages.Post16, + CheckingWindowType = Domain.Enums.CheckingWindowType.Post16, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddDays(14), + TurnaroundCommitment = "updated in the Spring", + Datasets = [] + }, CancellationToken.None); + + Assert.Equal("updated in the Spring", created.TurnaroundCommitment); + _createdWindowIds.Add(created.Id); + + await using var ctx = fixture.CreateContext(); + var reloaded = await new WindowRepository(ctx) + .GetByIdAsync(created.Id, CancellationToken.None); + Assert.Equal("updated in the Spring", reloaded!.TurnaroundCommitment); + } +} diff --git a/tests/DfE.CheckPerformanceData.IntegrationTests/RequestSubmission/NotifyServiceTests.cs b/tests/DfE.CheckPerformanceData.IntegrationTests/RequestSubmission/NotifyServiceTests.cs index 84fd6c3ae..5f8f98add 100644 --- a/tests/DfE.CheckPerformanceData.IntegrationTests/RequestSubmission/NotifyServiceTests.cs +++ b/tests/DfE.CheckPerformanceData.IntegrationTests/RequestSubmission/NotifyServiceTests.cs @@ -12,6 +12,7 @@ public sealed class NotifyServiceTests private const string NotifyApiKeyEnvVar = "NOTIFY_API_KEY"; private const string ConfirmTemplateIdEnvVar = "NOTIFY_CONFIRM_TEMPLATE_ID"; private const string SubmissionTemplateIdEnvVar = "NOTIFY_SUBMISSION_TEMPLATE_ID"; + private const string WithdrawTemplateIdEnvVar = "NOTIFY_WITHDRAW_TEMPLATE_ID"; private const string TestRefNumber = "INT-TEST-REF-001"; private const string TestEmail = "test@example.com"; private const string FallbackTemplateId = "test-template-id"; @@ -31,7 +32,9 @@ public async Task SendNotificationsAsync_WithSubmissionConfirmed_SendsEmail() var recipients = new[] { TestEmail }; var exception = await Record.ExceptionAsync(() => - service.SendNotificationsAsync(TestRefNumber, "5pm on Friday 26 June 2026", recipients, NotificationType.SubmissionConfirmed, "https://example.com/submit-others")); + service.SendNotificationsAsync( + TestRefNumber, "5pm on Friday 26 June 2026", recipients, NotificationType.SubmissionConfirmed, + new EmailSubstitutions("KS4 June", "Pupil", ""), "https://example.com/submit-others")); Assert.Null(exception); } @@ -51,7 +54,31 @@ public async Task SendNotificationsAsync_WithDataCheckConfirmed_SendsEmail() var recipients = new[] { TestEmail }; var exception = await Record.ExceptionAsync(() => - service.SendNotificationsAsync(TestRefNumber, "5pm on Friday 26 June 2026", recipients, NotificationType.DataCheckConfirmed)); + service.SendNotificationsAsync( + TestRefNumber, "5pm on Friday 26 June 2026", recipients, NotificationType.DataCheckConfirmed, + new EmailSubstitutions("KS4 June", "Pupil", ""))); + + Assert.Null(exception); + } + + [Fact] + public async Task SendNotificationsAsync_WithAmendmentWithdrawn_SendsEmail() + { + var apiKey = Environment.GetEnvironmentVariable(NotifyApiKeyEnvVar); + if (string.IsNullOrEmpty(apiKey)) + return; + + var settings = CreateSettings(apiKey); + settings.WithdrawNotificationTemplateId = Environment.GetEnvironmentVariable(WithdrawTemplateIdEnvVar) ?? FallbackTemplateId; + + var service = CreateService(settings); + + var recipients = new[] { TestEmail }; + + var exception = await Record.ExceptionAsync(() => + service.SendNotificationsAsync( + TestRefNumber, "5pm on Friday 26 June 2026", recipients, NotificationType.AmendmentWithdrawn, + new EmailSubstitutions("KS4 June", "Pupil", ""))); Assert.Null(exception); } diff --git a/tests/DfE.CheckPerformanceData.UnitTests/AmendmentRequests/BulkSubmissionServiceTests.cs b/tests/DfE.CheckPerformanceData.UnitTests/AmendmentRequests/BulkSubmissionServiceTests.cs index b10057fc4..3435bb2c5 100644 --- a/tests/DfE.CheckPerformanceData.UnitTests/AmendmentRequests/BulkSubmissionServiceTests.cs +++ b/tests/DfE.CheckPerformanceData.UnitTests/AmendmentRequests/BulkSubmissionServiceTests.cs @@ -152,7 +152,7 @@ public async Task Submit_SubmitsEachSubmittable_AndReturnsReferences() await _requestService.Received(1).SubmitRequestAsync(WindowId, Arg.Is(s => s.ReferenceNumber == "R1")); await _requestService.Received(1).SubmitRequestAsync(WindowId, Arg.Is(s => s.ReferenceNumber == "R2")); await _notify.Received(1).NotifyBulkSubmissionConfirmedAsync( - WindowId, Arg.Any(), Arg.Is>(l => l.SequenceEqual(new[] { "R1", "R2" }))); + WindowId, Arg.Any(), Arg.Is>(l => l.SequenceEqual(new[] { "R1", "R2" })), Arg.Any()); } [Fact] @@ -175,7 +175,7 @@ public async Task Submit_SkipsConflictingRequest_AndSubmitsTheRest() Assert.Equal(new[] { "R2" }, result.Submitted); Assert.Equal(new[] { "R1" }, result.Skipped); await _notify.Received(1).NotifyBulkSubmissionConfirmedAsync( - WindowId, Arg.Any(), Arg.Is>(l => l.SequenceEqual(new[] { "R2" }))); + WindowId, Arg.Any(), Arg.Is>(l => l.SequenceEqual(new[] { "R2" })), Arg.Any()); } [Fact] @@ -208,7 +208,7 @@ public async Task Submit_EmptySelection_SubmitsNothingAndSendsNoEmail() Assert.Empty(result.Submitted); await _notify.DidNotReceive().NotifyBulkSubmissionConfirmedAsync( - Arg.Any(), Arg.Any(), Arg.Any>()); + Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()); } [Fact] @@ -252,6 +252,6 @@ public async Task Submit_ExcludesDuplicatesFromClassification() Assert.Empty(result.Submitted); await _requestService.DidNotReceive().SubmitRequestAsync(Arg.Any(), Arg.Any()); await _notify.DidNotReceive().NotifyBulkSubmissionConfirmedAsync( - Arg.Any(), Arg.Any(), Arg.Any>()); + Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()); } } diff --git a/tests/DfE.CheckPerformanceData.UnitTests/AmendmentRequests/ConfirmCorrectControllerTests.cs b/tests/DfE.CheckPerformanceData.UnitTests/AmendmentRequests/ConfirmCorrectControllerTests.cs index efe223ea4..9ba107b57 100644 --- a/tests/DfE.CheckPerformanceData.UnitTests/AmendmentRequests/ConfirmCorrectControllerTests.cs +++ b/tests/DfE.CheckPerformanceData.UnitTests/AmendmentRequests/ConfirmCorrectControllerTests.cs @@ -2,6 +2,7 @@ using DfE.CheckPerformanceData.Application.CheckYourPupilData; using DfE.CheckPerformanceData.Application.Journey; using DfE.CheckPerformanceData.Application.LandingPage; +using DfE.CheckPerformanceData.Application.Notify; using DfE.CheckPerformanceData.Application.RequestSubmission; using DfE.CheckPerformanceData.Domain.Enums; using DfE.CheckPerformanceData.Web.Controllers; @@ -42,7 +43,7 @@ public async Task Confirm_PersistsConfirmation_AndEmitsCorrectDataConfirmedEvent var result = await _sut.Confirm(WindowId); Assert.IsType(result); - await _requestService.Received(1).ConfirmDataCorrectAsync(WindowId, Reference, Arg.Any()); + await _requestService.Received(1).ConfirmDataCorrectAsync(WindowId, Reference, Arg.Any(), Arg.Any()); await _analytics.Received(1).TrackAsync( Arg.Is(e => e.ReferenceNumber == Reference && @@ -52,7 +53,7 @@ await _analytics.Received(1).TrackAsync( // The event must fire only after the confirmation is persisted. Received.InOrder(() => { - _ = _requestService.ConfirmDataCorrectAsync(WindowId, Reference, Arg.Any()); + _ = _requestService.ConfirmDataCorrectAsync(WindowId, Reference, Arg.Any(), Arg.Any()); _ = _analytics.TrackAsync(Arg.Any(), Arg.Any()); }); } diff --git a/tests/DfE.CheckPerformanceData.UnitTests/Journey/RequestServiceResultsEnquiryTests.cs b/tests/DfE.CheckPerformanceData.UnitTests/Journey/RequestServiceResultsEnquiryTests.cs index e636698a2..15cd0e809 100644 --- a/tests/DfE.CheckPerformanceData.UnitTests/Journey/RequestServiceResultsEnquiryTests.cs +++ b/tests/DfE.CheckPerformanceData.UnitTests/Journey/RequestServiceResultsEnquiryTests.cs @@ -218,7 +218,7 @@ public async Task Submitting_does_not_send_the_email_itself() // whether a failure to email should fail the submission (it must not). await _sut.SubmitResultsEnquiryAsync(WindowId, Journey()); - await _notifications.DidNotReceiveWithAnyArgs().NotifySubmissionConfirmedAsync(default, default, default!); + await _notifications.DidNotReceiveWithAnyArgs().NotifySubmissionConfirmedAsync(default, default, default!, default!); } [Fact] diff --git a/tests/DfE.CheckPerformanceData.UnitTests/Journey/RequestServiceTests.cs b/tests/DfE.CheckPerformanceData.UnitTests/Journey/RequestServiceTests.cs index 0658bcdb6..4523a8a62 100644 --- a/tests/DfE.CheckPerformanceData.UnitTests/Journey/RequestServiceTests.cs +++ b/tests/DfE.CheckPerformanceData.UnitTests/Journey/RequestServiceTests.cs @@ -15,6 +15,7 @@ namespace DfE.CheckPerformanceData.Application.UnitTests.Journey; public class RequestServiceTests { private static readonly Guid WindowId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + private static readonly EmailSubstitutions Substitutions = new("KS4 June", "Pupil", ""); private readonly IQuestionFlowService _flowService = Substitute.For(); private readonly IRequestStateBlobClient _requestStateBlobClient = Substitute.For(); @@ -517,7 +518,7 @@ public async Task ConfirmDataCorrectAsync_LeavesAmendmentTypeNull() ChangeRequestData? captured = null; _requestRepository.UpsertAsync(Arg.Do(d => captured = d)); - await _sut.ConfirmDataCorrectAsync(WindowId, "CYPMD_KS4June_ABC1234", DateTime.UtcNow.AddDays(20)); + await _sut.ConfirmDataCorrectAsync(WindowId, "CYPMD_KS4June_ABC1234", DateTime.UtcNow.AddDays(20), Substitutions); Assert.Equal(RequestType.ConfirmCorrect, captured!.RequestType); Assert.Null(captured.AmendmentType); @@ -564,7 +565,7 @@ public async Task ConfirmDataCorrectAsync_WritesConfirmCorrectRequestType() ChangeRequestData? captured = null; _requestRepository.UpsertAsync(Arg.Do(d => captured = d)); - await _sut.ConfirmDataCorrectAsync(WindowId, "REF999", new DateTime(2026, 6, 26, 17, 0, 0)); + await _sut.ConfirmDataCorrectAsync(WindowId, "REF999", new DateTime(2026, 6, 26, 17, 0, 0), Substitutions); Assert.Equal(RequestType.ConfirmCorrect, captured!.RequestType); Assert.Equal("Confirm Pupil Data Declaration", captured.RequestTypeDescription); @@ -635,7 +636,7 @@ public async Task ConfirmRequestAsync_DelegatesSubmissionNotification() await _sut.ConfirmRequestAsync(WindowId, journey); await _requestNotificationService.Received(1).NotifySubmissionConfirmedAsync( - WindowId, journey.CheckingWindow.EndDate, journey.ReferenceNumber); + WindowId, journey.CheckingWindow.EndDate, journey.ReferenceNumber, Substitutions); } [Fact] @@ -647,7 +648,7 @@ public async Task SubmitRequestAsync_DoesNotSendSubmissionEmail() await _sut.SubmitRequestAsync(WindowId, journey); await _requestNotificationService.DidNotReceive() - .NotifySubmissionConfirmedAsync(Arg.Any(), Arg.Any(), Arg.Any()); + .NotifySubmissionConfirmedAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] @@ -662,7 +663,7 @@ public async Task ConfirmRequestAsync_SavesJourneyBlobBeforeSendingEmail() { _requestStateBlobClient.SaveAsync(WindowId, Arg.Any(), Arg.Any()); _requestNotificationService.NotifySubmissionConfirmedAsync( - WindowId, Arg.Any(), Arg.Any()); + WindowId, Arg.Any(), Arg.Any(), Arg.Any()); }); } @@ -674,9 +675,9 @@ public async Task ConfirmDataCorrectAsync_DelegatesNotification() var refNum = "CYPMD_KS4June_ABC1234"; var endDate = new DateTime(2026, 6, 26, 17, 0, 0); - await _sut.ConfirmDataCorrectAsync(WindowId, refNum, endDate); + await _sut.ConfirmDataCorrectAsync(WindowId, refNum, endDate, Substitutions); - await _requestNotificationService.Received(1).NotifyDataCheckConfirmedAsync(endDate, refNum); + await _requestNotificationService.Received(1).NotifyDataCheckConfirmedAsync(endDate, refNum, Substitutions); } [Fact] @@ -780,7 +781,7 @@ public async Task DeleteAsync_WhenAmendment_DelegatesAmendmentWithdrawnNotificat await _sut.DeleteAsync(WindowId, "REF001"); - await _requestNotificationService.Received(1).NotifyAmendmentWithdrawnAsync("REF001", new(2026, 6, 26, 17, 0, 0)); + await _requestNotificationService.Received(1).NotifyAmendmentWithdrawnAsync("REF001", new(2026, 6, 26, 17, 0, 0), Substitutions); } [Fact] @@ -802,7 +803,7 @@ public async Task DeleteAsync_WhenConfirmCorrect_DelegatesDataCheckWithdrawnNoti await _sut.DeleteAsync(WindowId, "REF001"); - await _requestNotificationService.Received(1).NotifyDataCheckWithdrawnAsync("REF001", new(2026, 6, 26, 17, 0, 0)); + await _requestNotificationService.Received(1).NotifyDataCheckWithdrawnAsync("REF001", new(2026, 6, 26, 17, 0, 0), Substitutions); } // ── Helpers ───────────────────────────────────────────────────────────── diff --git a/tests/DfE.CheckPerformanceData.UnitTests/Notify/DevConsoleNotifyServiceTests.cs b/tests/DfE.CheckPerformanceData.UnitTests/Notify/DevConsoleNotifyServiceTests.cs index f45bb15de..23e9bf5d6 100644 --- a/tests/DfE.CheckPerformanceData.UnitTests/Notify/DevConsoleNotifyServiceTests.cs +++ b/tests/DfE.CheckPerformanceData.UnitTests/Notify/DevConsoleNotifyServiceTests.cs @@ -12,6 +12,7 @@ public sealed class DevConsoleNotifyServiceTests { private readonly ILogger _logger = Substitute.For>(); private readonly DevConsoleNotifyService _sut; + private static readonly EmailSubstitutions Substitutions = new("KS4 June", "Pupil", ""); public DevConsoleNotifyServiceTests() { @@ -28,6 +29,7 @@ await _sut.SendNotificationsAsync( "28 February 2025", recipients, NotificationType.SubmissionConfirmed, + Substitutions, "https://example.com/submit"); _logger.Received(1).Log( @@ -38,6 +40,29 @@ await _sut.SendNotificationsAsync( Arg.Any>()); } + [Fact] + public async Task SendNotificationsAsync_LogsSubstitutionValues() + { + var recipients = new[] { "alice@school.edu" }; + + await _sut.SendNotificationsAsync( + "REF-001", + "28 February 2025", + recipients, + NotificationType.SubmissionConfirmed, + new EmailSubstitutions("KS4 June", "Pupil", "updated in the Autumn")); + + _logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => + o.ToString()!.Contains("KS4 June") && + o.ToString()!.Contains("Pupil") && + o.ToString()!.Contains("updated in the Autumn")), + Arg.Any(), + Arg.Any>()); + } + [Fact] public async Task SendDlqThresholdEmailAsync_LogsParameters() { @@ -60,7 +85,8 @@ public void SendNotificationsAsync_MakesNoOutboundCalls() "REF-001", "28 February 2025", recipients, - NotificationType.SubmissionConfirmed); + NotificationType.SubmissionConfirmed, + Substitutions); Assert.True(true, "No exception means no outbound call dependency was required"); } @@ -74,7 +100,8 @@ await _sut.SendNotificationsAsync( "REF-001", "28 February 2025", recipients, - NotificationType.DataCheckConfirmed); + NotificationType.DataCheckConfirmed, + Substitutions); _logger.Received(1).Log( LogLevel.Information, @@ -89,7 +116,7 @@ public sealed class NotifyServiceRegistrationTests { private sealed class StubNotifyService : INotifyService { - public Task SendNotificationsAsync(string referenceNumber, string deadline, IReadOnlyCollection recipientEmails, NotificationType notificationType, string? url = null, IReadOnlyCollection? referenceNumbers = null) => + public Task SendNotificationsAsync(string referenceNumber, string deadline, IReadOnlyCollection recipientEmails, NotificationType notificationType, EmailSubstitutions substitutions, string? url = null, IReadOnlyCollection? referenceNumbers = null) => Task.CompletedTask; public Task SendDlqThresholdEmailAsync(string toEmail, int dlqDepth, int threshold) => Task.CompletedTask; diff --git a/tests/DfE.CheckPerformanceData.UnitTests/Notify/EmailSubstitutionsTests.cs b/tests/DfE.CheckPerformanceData.UnitTests/Notify/EmailSubstitutionsTests.cs new file mode 100644 index 000000000..fc93d8766 --- /dev/null +++ b/tests/DfE.CheckPerformanceData.UnitTests/Notify/EmailSubstitutionsTests.cs @@ -0,0 +1,93 @@ +using DfE.CheckPerformanceData.Application.LandingPage; +using DfE.CheckPerformanceData.Application.Notify; +using DfE.CheckPerformanceData.Domain.Enums; + +namespace DfE.CheckPerformanceData.UnitTests.Notify; + +public sealed class EmailSubstitutionsTests +{ + private static CheckingWindowDto Window( + string? title, + KeyStages keyStage = KeyStages.KS4, + CheckingWindowType type = CheckingWindowType.KS4June, + string turnaroundCommitment = "") => + new() + { + Id = Guid.NewGuid(), + Title = title ?? string.Empty, + KeyStage = keyStage, + CheckingWindowType = type, + StartDate = new DateTime(2026, 6, 1), + EndDate = new DateTime(2026, 6, 30, 17, 0, 0), + TurnaroundCommitment = turnaroundCommitment + }; + + // ── CeName ─────────────────────────────────────────────────────────────── + + [Fact] + public void From_CeName_UsesWindowTitleWhenSet() + { + var result = EmailSubstitutions.From(Window("KS4 June")); + + Assert.Equal("KS4 June", result.CeName); + } + + [Fact] + public void From_CeName_FallsBackToWindowTypeDisplayName_WhenTitleIsWhitespace() + { + var result = EmailSubstitutions.From(Window(" ", type: CheckingWindowType.KS4June)); + + Assert.Equal("Key Stage 4 June", result.CeName); + } + + [Theory] + [InlineData(CheckingWindowType.KS2, "Key Stage 2")] + [InlineData(CheckingWindowType.KS4June, "Key Stage 4 June")] + [InlineData(CheckingWindowType.KS4Autumn, "Key Stage 4 Autumn")] + [InlineData(CheckingWindowType.Post16, "Post 16")] + public void From_CeName_FallsBackToDisplayNameForEveryWindowType( + CheckingWindowType type, string expected) + { + var result = EmailSubstitutions.From(Window("", type: type)); + + Assert.Equal(expected, result.CeName); + } + + // ── LearnerNoun ────────────────────────────────────────────────────────── + + [Fact] + public void From_LearnerNoun_IsStudent_WhenKeyStageIsPost16() + { + var result = EmailSubstitutions.From(Window("16 to 19", keyStage: KeyStages.Post16)); + + Assert.Equal("Student", result.LearnerNoun); + } + + [Theory] + [InlineData(KeyStages.KS2)] + [InlineData(KeyStages.KS4)] + public void From_LearnerNoun_IsPupil_WhenKeyStageIsNotPost16(KeyStages keyStage) + { + var result = EmailSubstitutions.From(Window("KS4 June", keyStage: keyStage)); + + Assert.Equal("Pupil", result.LearnerNoun); + } + + // ── TurnaroundCommitment ───────────────────────────────────────────────── + + [Fact] + public void From_TurnaroundCommitment_PassesThroughConfiguredValueVerbatim() + { + var result = EmailSubstitutions.From(Window("KS4 June", turnaroundCommitment: "updated in the Autumn")); + + Assert.Equal("updated in the Autumn", result.TurnaroundCommitment); + } + + [Fact] + public void From_TurnaroundCommitment_IsEmpty_WhenNotConfigured() + { + var result = EmailSubstitutions.From(Window("KS4 June")); + + Assert.Equal(string.Empty, result.TurnaroundCommitment); + } +} diff --git a/tests/DfE.CheckPerformanceData.UnitTests/Notify/NotificationSenderTests.cs b/tests/DfE.CheckPerformanceData.UnitTests/Notify/NotificationSenderTests.cs index 1792abc69..842289948 100644 --- a/tests/DfE.CheckPerformanceData.UnitTests/Notify/NotificationSenderTests.cs +++ b/tests/DfE.CheckPerformanceData.UnitTests/Notify/NotificationSenderTests.cs @@ -24,7 +24,10 @@ private static EmailNotification Message( NotificationType type = NotificationType.SubmissionConfirmed, bool includeOrganisationUsers = true, string deadline = "5pm on Friday 26 June 2026", - string? linkUrl = null) => + string? linkUrl = null, + string ceName = "KS4 June", + string learnerNoun = "Pupil", + string turnaroundCommitment = "updated in the Autumn") => new() { Type = type, @@ -33,7 +36,10 @@ private static EmailNotification Message( LinkUrl = linkUrl, Ukprn = Ukprn, OriginatorEmail = OriginatorEmail, - IncludeOrganisationUsers = includeOrganisationUsers + IncludeOrganisationUsers = includeOrganisationUsers, + CeName = ceName, + LearnerNoun = learnerNoun, + TurnaroundCommitment = turnaroundCommitment }; [Fact] @@ -54,6 +60,7 @@ await _notifyService.Received(1).SendNotificationsAsync( Arg.Is>(r => r.Contains(OriginatorEmail) && r.Contains(orgUserEmail) && r.Count == 2), Arg.Any(), + Arg.Any(), Arg.Any()); } @@ -69,6 +76,7 @@ await _notifyService.Received(1).SendNotificationsAsync( string.Empty, Arg.Is>(r => r.Count == 1 && r.Contains(OriginatorEmail)), NotificationType.AmendmentWithdrawn, + Arg.Any(), Arg.Any()); } @@ -89,6 +97,7 @@ await _notifyService.Received(1).SendNotificationsAsync( Arg.Any(), Arg.Is>(r => r.Count == 1 && r.Contains(OriginatorEmail)), Arg.Any(), + Arg.Any(), Arg.Any()); } @@ -106,6 +115,30 @@ await _notifyService.Received(1).SendNotificationsAsync( "5pm on Friday 26 June 2026", Arg.Any>(), NotificationType.SubmissionConfirmed, + Arg.Is(s => + s.CeName == "KS4 June" && s.LearnerNoun == "Pupil" && + s.TurnaroundCommitment == "updated in the Autumn"), linkUrl); } + + [Fact] + public async Task SendAsync_PassesSubstitutionFieldsThroughToNotifyService() + { + _dfESignInApiClient.GetOrganisationUsersAsync(Ukprn) + .Returns(new OrganisationUsersResponseDto { Users = [] }); + + await _sut.SendAsync(Message( + ceName: "16 to 19", learnerNoun: "Student", turnaroundCommitment: "updated in the Spring")); + + await _notifyService.Received(1).SendNotificationsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Is(s => + s.CeName == "16 to 19" && s.LearnerNoun == "Student" && + s.TurnaroundCommitment == "updated in the Spring"), + Arg.Any(), + Arg.Any?>()); + } } diff --git a/tests/DfE.CheckPerformanceData.UnitTests/Notify/NotifyServiceResultsEnquiryTests.cs b/tests/DfE.CheckPerformanceData.UnitTests/Notify/NotifyServiceResultsEnquiryTests.cs index 31be1a967..c0c186326 100644 --- a/tests/DfE.CheckPerformanceData.UnitTests/Notify/NotifyServiceResultsEnquiryTests.cs +++ b/tests/DfE.CheckPerformanceData.UnitTests/Notify/NotifyServiceResultsEnquiryTests.cs @@ -16,6 +16,10 @@ public sealed class NotifyServiceResultsEnquiryTests { private const string EnquiryTemplateId = "aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb"; + // An enquiry carries no checking-exercise substitutions: ce name, learner noun and turnaround + // commitment are never sent for this notification type. + private static readonly EmailSubstitutions NoSubstitutions = new(string.Empty, string.Empty, string.Empty); + private readonly INotifyEmailClient _client = Substitute.For(); private readonly ILogger _logger = Substitute.For>(); @@ -42,7 +46,7 @@ public async Task A_results_enquiry_notification_uses_its_configured_template() // Without the switch case this throws ArgumentOutOfRangeException instead. await Build().SendNotificationsAsync( "CYPMD_16to19_RE_4F9C2A1", string.Empty, ["ada@school.test"], - NotificationType.ResultsEnquirySubmitted); + NotificationType.ResultsEnquirySubmitted, NoSubstitutions); await _client.Received(1).SendEmailAsync( "ada@school.test", EnquiryTemplateId, Arg.Any>()); @@ -54,7 +58,7 @@ public async Task The_reference_number_reaches_the_template() // The whole point of the email: the school needs the reference to quote back. await Build().SendNotificationsAsync( "CYPMD_16to19_RE_4F9C2A1", string.Empty, ["ada@school.test"], - NotificationType.ResultsEnquirySubmitted); + NotificationType.ResultsEnquirySubmitted, NoSubstitutions); await _client.Received(1).SendEmailAsync( Arg.Any(), Arg.Any(), @@ -68,7 +72,7 @@ public async Task Every_recipient_is_emailed() { await Build().SendNotificationsAsync( "CYPMD_16to19_RE_4F9C2A1", string.Empty, ["one@school.test", "two@school.test"], - NotificationType.ResultsEnquirySubmitted); + NotificationType.ResultsEnquirySubmitted, NoSubstitutions); await _client.Received(2).SendEmailAsync( Arg.Any(), EnquiryTemplateId, Arg.Any>()); @@ -85,7 +89,7 @@ public async Task An_unconfigured_template_logs_a_warning_and_sends_nothing(stri // would fail once per recipient. await Build(templateId).SendNotificationsAsync( "CYPMD_16to19_RE_4F9C2A1", string.Empty, ["ada@school.test"], - NotificationType.ResultsEnquirySubmitted); + NotificationType.ResultsEnquirySubmitted, NoSubstitutions); await _client.DidNotReceiveWithAnyArgs() .SendEmailAsync(default!, default!, default!); @@ -106,7 +110,7 @@ public async Task An_unconfigured_template_does_not_throw() await service.SendNotificationsAsync( "CYPMD_16to19_RE_4F9C2A1", string.Empty, ["ada@school.test"], - NotificationType.ResultsEnquirySubmitted); + NotificationType.ResultsEnquirySubmitted, NoSubstitutions); } [Fact] @@ -119,7 +123,7 @@ public async Task A_send_failure_is_isolated_per_recipient() await service.SendNotificationsAsync( "CYPMD_16to19_RE_4F9C2A1", string.Empty, ["bad@school.test", "good@school.test"], - NotificationType.ResultsEnquirySubmitted); + NotificationType.ResultsEnquirySubmitted, NoSubstitutions); await _client.Received(1).SendEmailAsync( "good@school.test", EnquiryTemplateId, Arg.Any>()); @@ -142,7 +146,7 @@ public async Task The_existing_notification_types_still_resolve_their_templates( { _client.ClearReceivedCalls(); - await service.SendNotificationsAsync("REF-1", "a deadline", ["ada@school.test"], type); + await service.SendNotificationsAsync("REF-1", "a deadline", ["ada@school.test"], type, NoSubstitutions); await _client.Received(1).SendEmailAsync( "ada@school.test", expected, Arg.Any>()); diff --git a/tests/DfE.CheckPerformanceData.UnitTests/Notify/NotifyServiceTests.cs b/tests/DfE.CheckPerformanceData.UnitTests/Notify/NotifyServiceTests.cs index f466761a5..55a7dd252 100644 --- a/tests/DfE.CheckPerformanceData.UnitTests/Notify/NotifyServiceTests.cs +++ b/tests/DfE.CheckPerformanceData.UnitTests/Notify/NotifyServiceTests.cs @@ -20,6 +20,8 @@ public sealed class NotifyServiceTests BulkSubmissionNotificationTemplateId = "bulk-template-id" }; private const string DeadlineText = "28 February 2025"; + private static readonly EmailSubstitutions Substitutions = + new("KS4 June", "Pupil", "updated in the Autumn"); private readonly ILogger _logger = Substitute.For>(); private readonly Infrastructure.Notify.NotifyService _sut; @@ -41,7 +43,8 @@ await _sut.SendNotificationsAsync( "REF001", DeadlineText, recipients, - NotificationType.SubmissionConfirmed); + NotificationType.SubmissionConfirmed, + Substitutions); await _client.Received(1).SendEmailAsync( recipients[0], @@ -63,7 +66,8 @@ await _sut.SendNotificationsAsync( "REF001", DeadlineText, recipients, - NotificationType.SubmissionConfirmed); + NotificationType.SubmissionConfirmed, + Substitutions); await _client.Received(1).SendEmailAsync( Arg.Any(), @@ -80,7 +84,8 @@ await _sut.SendNotificationsAsync( "REF001", DeadlineText, recipients, - NotificationType.DataCheckConfirmed); + NotificationType.DataCheckConfirmed, + Substitutions); await _client.Received(1).SendEmailAsync( Arg.Any(), @@ -99,6 +104,7 @@ await _sut.SendNotificationsAsync( DeadlineText, recipients, NotificationType.SubmissionConfirmed, + Substitutions, url); await _client.Received(1).SendEmailAsync( @@ -124,6 +130,7 @@ await sut.SendNotificationsAsync( deadline: "5pm on Friday 26 June 2026", recipientEmails: new[] { "a@x.gov.uk" }, notificationType: NotificationType.BulkSubmissionConfirmed, + substitutions: Substitutions, url: "https://link", referenceNumbers: new[] { "REF001", "REF002" }); @@ -147,6 +154,95 @@ await _client.Received(1).SendEmailAsync( Arg.Any>()); } + [Theory] + [InlineData(NotificationType.SubmissionConfirmed)] + [InlineData(NotificationType.BulkSubmissionConfirmed)] + [InlineData(NotificationType.DataCheckConfirmed)] + [InlineData(NotificationType.DataCheckWithdrawn)] + [InlineData(NotificationType.AmendmentWithdrawn)] + public async Task SendNotificationsAsync_IncludesCeNameKey_ForAllNotificationTypes(NotificationType type) + { + var recipients = new[] { "test@school.edu" }; + + await _sut.SendNotificationsAsync("REF001", DeadlineText, recipients, type, Substitutions); + + await _client.Received(1).SendEmailAsync( + Arg.Any(), + Arg.Any(), + Arg.Is>(p => + p.ContainsKey("ce name") && p["ce name"].ToString() == "KS4 June")); + } + + [Theory] + [InlineData(NotificationType.DataCheckConfirmed)] + [InlineData(NotificationType.DataCheckWithdrawn)] + [InlineData(NotificationType.AmendmentWithdrawn)] + public async Task SendNotificationsAsync_IncludesLearnerNounKey_ForDataCheckAndAmendmentWithdrawnTypes(NotificationType type) + { + var recipients = new[] { "test@school.edu" }; + + await _sut.SendNotificationsAsync("REF001", DeadlineText, recipients, type, Substitutions); + + await _client.Received(1).SendEmailAsync( + Arg.Any(), + Arg.Any(), + Arg.Is>(p => + p.ContainsKey("learner noun") && p["learner noun"].ToString() == "Pupil")); + } + + [Theory] + [InlineData(NotificationType.SubmissionConfirmed)] + [InlineData(NotificationType.BulkSubmissionConfirmed)] + public async Task SendNotificationsAsync_DoesNotIncludeLearnerNounKey_ForSubmissionTypes(NotificationType type) + { + var recipients = new[] { "test@school.edu" }; + + await _sut.SendNotificationsAsync("REF001", DeadlineText, recipients, type, Substitutions); + + await _client.Received(1).SendEmailAsync( + Arg.Any(), + Arg.Any(), + Arg.Is>(p => !p.ContainsKey("learner noun"))); + } + + [Theory] + [InlineData(NotificationType.SubmissionConfirmed)] + [InlineData(NotificationType.BulkSubmissionConfirmed)] + [InlineData(NotificationType.DataCheckConfirmed)] + [InlineData(NotificationType.DataCheckWithdrawn)] + [InlineData(NotificationType.AmendmentWithdrawn)] + public async Task SendNotificationsAsync_IncludesTurnaroundCommitmentKey_WhenNonEmpty(NotificationType type) + { + var recipients = new[] { "test@school.edu" }; + + await _sut.SendNotificationsAsync("REF001", DeadlineText, recipients, type, Substitutions); + + await _client.Received(1).SendEmailAsync( + Arg.Any(), + Arg.Any(), + Arg.Is>(p => + p.ContainsKey("turnaround commitment") && p["turnaround commitment"].ToString() == "updated in the Autumn")); + } + + [Theory] + [InlineData(NotificationType.SubmissionConfirmed)] + [InlineData(NotificationType.BulkSubmissionConfirmed)] + [InlineData(NotificationType.DataCheckConfirmed)] + [InlineData(NotificationType.DataCheckWithdrawn)] + [InlineData(NotificationType.AmendmentWithdrawn)] + public async Task SendNotificationsAsync_OmitsTurnaroundCommitmentKey_WhenEmpty(NotificationType type) + { + var recipients = new[] { "test@school.edu" }; + var emptySubstitutions = new EmailSubstitutions("KS4 June", "Pupil", ""); + + await _sut.SendNotificationsAsync("REF001", DeadlineText, recipients, type, emptySubstitutions); + + await _client.Received(1).SendEmailAsync( + Arg.Any(), + Arg.Any(), + Arg.Is>(p => !p.ContainsKey("turnaround commitment"))); + } + [Fact] public async Task SendNotificationsAsync_CatchesException_DoesNotRethrow() { @@ -155,7 +251,7 @@ public async Task SendNotificationsAsync_CatchesException_DoesNotRethrow() .Returns(x => throw new InvalidOperationException("Notify API failure")); var exception = await Record.ExceptionAsync(() => - _sut.SendNotificationsAsync("REF001", DeadlineText, recipients, NotificationType.SubmissionConfirmed)); + _sut.SendNotificationsAsync("REF001", DeadlineText, recipients, NotificationType.SubmissionConfirmed, Substitutions)); Assert.Null(exception); } @@ -167,7 +263,7 @@ public async Task SendNotificationsAsync_LogsErrorOnFailure() _client.SendEmailAsync(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(x => throw new InvalidOperationException("Notify API failure")); - await _sut.SendNotificationsAsync("REF001", DeadlineText, recipients, NotificationType.SubmissionConfirmed); + await _sut.SendNotificationsAsync("REF001", DeadlineText, recipients, NotificationType.SubmissionConfirmed, Substitutions); _logger.Received(1).Log( Arg.Is(l => l == LogLevel.Error), @@ -186,7 +282,7 @@ public async Task SendNotificationsAsync_ContinuesSendingAfterFailure() _client.SendEmailAsync(recipients[1], Arg.Any(), Arg.Any>()) .Returns(Task.CompletedTask); - await _sut.SendNotificationsAsync("REF001", DeadlineText, recipients, NotificationType.SubmissionConfirmed); + await _sut.SendNotificationsAsync("REF001", DeadlineText, recipients, NotificationType.SubmissionConfirmed, Substitutions); await _client.Received(1).SendEmailAsync(recipients[1], Arg.Any(), Arg.Any>()); } @@ -219,7 +315,7 @@ public async Task SendNotificationsAsync_RetriesOnTransientHttpException_WithPip return Task.CompletedTask; }); - await sut.SendNotificationsAsync("REF001", DeadlineText, recipients, NotificationType.SubmissionConfirmed); + await sut.SendNotificationsAsync("REF001", DeadlineText, recipients, NotificationType.SubmissionConfirmed, Substitutions); Assert.Equal(3, callCount); } @@ -236,7 +332,7 @@ public async Task SendNotificationsAsync_DoesNotRetryOnTransientFailure_WithoutP throw new HttpRequestException("Transient network error"); }); - await _sut.SendNotificationsAsync("REF001", DeadlineText, recipients, NotificationType.SubmissionConfirmed); + await _sut.SendNotificationsAsync("REF001", DeadlineText, recipients, NotificationType.SubmissionConfirmed, Substitutions); Assert.Equal(1, callCount); } diff --git a/tests/DfE.CheckPerformanceData.UnitTests/Notify/RequestNotificationServiceTests.cs b/tests/DfE.CheckPerformanceData.UnitTests/Notify/RequestNotificationServiceTests.cs index cd9b1fea1..2c4895515 100644 --- a/tests/DfE.CheckPerformanceData.UnitTests/Notify/RequestNotificationServiceTests.cs +++ b/tests/DfE.CheckPerformanceData.UnitTests/Notify/RequestNotificationServiceTests.cs @@ -22,6 +22,8 @@ public sealed class RequestNotificationServiceTests ApiKey = "x", BulkConsolidationThreshold = 5 }; + private static readonly EmailSubstitutions Substitutions = + new("KS4 June", "Student", "updated in the Autumn"); private readonly RequestNotificationService _sut; private static bool MatchWindowId(object o, Guid windowId) @@ -55,7 +57,7 @@ public RequestNotificationServiceTests() [Fact] public async Task NotifySubmissionConfirmedAsync_EnqueuesSubmissionMessageWithOrgUsersAndOriginator() { - await _sut.NotifySubmissionConfirmedAsync(WindowId, EndDate, ReferenceNumber); + await _sut.NotifySubmissionConfirmedAsync(WindowId, EndDate, ReferenceNumber, Substitutions); var msg = Captured(); Assert.NotNull(msg); @@ -66,10 +68,20 @@ public async Task NotifySubmissionConfirmedAsync_EnqueuesSubmissionMessageWithOr Assert.True(msg.IncludeOrganisationUsers); } + [Fact] + public async Task NotifySubmissionConfirmedAsync_PopulatesSubstitutionFields() + { + await _sut.NotifySubmissionConfirmedAsync(WindowId, EndDate, ReferenceNumber, Substitutions); + + Assert.Equal("KS4 June", Captured()!.CeName); + Assert.Equal("Student", Captured()!.LearnerNoun); + Assert.Equal("updated in the Autumn", Captured()!.TurnaroundCommitment); + } + [Fact] public async Task NotifySubmissionConfirmedAsync_FormatsDeadlineCorrectly() { - await _sut.NotifySubmissionConfirmedAsync(WindowId, EndDate, ReferenceNumber); + await _sut.NotifySubmissionConfirmedAsync(WindowId, EndDate, ReferenceNumber, Substitutions); Assert.Equal("5pm on Friday 26 June 2026", Captured()!.Deadline); } @@ -82,7 +94,7 @@ public async Task NotifySubmissionConfirmedAsync_GeneratesLinkOnRequestThreadAnd "WhatToChange", "Index", Arg.Is(o => MatchWindowId(o, WindowId)), "SubmissionNotification") .Returns(linkUrl); - await _sut.NotifySubmissionConfirmedAsync(WindowId, EndDate, ReferenceNumber); + await _sut.NotifySubmissionConfirmedAsync(WindowId, EndDate, ReferenceNumber, Substitutions); Assert.Equal(linkUrl, Captured()!.LinkUrl); } @@ -93,7 +105,7 @@ public async Task NotifySubmissionConfirmedAsync_GeneratesLinkOnRequestThreadAnd public async Task NotifySubmissionConfirmedAsync_HandlesDeadlineEdgeCases(int hour, int minute, string expected) { await _sut.NotifySubmissionConfirmedAsync( - WindowId, new DateTime(2026, 6, 26, hour, minute, 0), ReferenceNumber); + WindowId, new DateTime(2026, 6, 26, hour, minute, 0), ReferenceNumber, Substitutions); Assert.Equal(expected, Captured()!.Deadline); } @@ -103,7 +115,7 @@ await _sut.NotifySubmissionConfirmedAsync( [Fact] public async Task NotifyDataCheckConfirmedAsync_EnqueuesDataCheckMessageWithOrgUsersAndNoLink() { - await _sut.NotifyDataCheckConfirmedAsync(EndDate, ReferenceNumber); + await _sut.NotifyDataCheckConfirmedAsync(EndDate, ReferenceNumber, Substitutions); var msg = Captured(); Assert.NotNull(msg); @@ -114,12 +126,22 @@ public async Task NotifyDataCheckConfirmedAsync_EnqueuesDataCheckMessageWithOrgU Assert.True(msg.IncludeOrganisationUsers); } + [Fact] + public async Task NotifyDataCheckConfirmedAsync_PopulatesSubstitutionFields() + { + await _sut.NotifyDataCheckConfirmedAsync(EndDate, ReferenceNumber, Substitutions); + + Assert.Equal("KS4 June", Captured()!.CeName); + Assert.Equal("Student", Captured()!.LearnerNoun); + Assert.Equal("updated in the Autumn", Captured()!.TurnaroundCommitment); + } + // ── NotifyAmendmentWithdrawnAsync ───────────────────────────────────────── [Fact] public async Task NotifyAmendmentWithdrawnAsync_EnqueuesOriginatorOnlyMessage() { - await _sut.NotifyAmendmentWithdrawnAsync(ReferenceNumber, EndDate); + await _sut.NotifyAmendmentWithdrawnAsync(ReferenceNumber, EndDate, Substitutions); var msg = Captured(); Assert.NotNull(msg); @@ -130,12 +152,22 @@ public async Task NotifyAmendmentWithdrawnAsync_EnqueuesOriginatorOnlyMessage() Assert.False(msg.IncludeOrganisationUsers); } + [Fact] + public async Task NotifyAmendmentWithdrawnAsync_PopulatesSubstitutionFields() + { + await _sut.NotifyAmendmentWithdrawnAsync(ReferenceNumber, EndDate, Substitutions); + + Assert.Equal("KS4 June", Captured()!.CeName); + Assert.Equal("Student", Captured()!.LearnerNoun); + Assert.Equal("updated in the Autumn", Captured()!.TurnaroundCommitment); + } + // ── NotifyDataCheckWithdrawnAsync ───────────────────────────────────────── [Fact] public async Task NotifyDataCheckWithdrawnAsync_EnqueuesMessageWithOrgUsers() { - await _sut.NotifyDataCheckWithdrawnAsync(ReferenceNumber, EndDate); + await _sut.NotifyDataCheckWithdrawnAsync(ReferenceNumber, EndDate, Substitutions); var msg = Captured(); Assert.NotNull(msg); @@ -145,14 +177,24 @@ public async Task NotifyDataCheckWithdrawnAsync_EnqueuesMessageWithOrgUsers() Assert.True(msg.IncludeOrganisationUsers); } + [Fact] + public async Task NotifyDataCheckWithdrawnAsync_PopulatesSubstitutionFields() + { + await _sut.NotifyDataCheckWithdrawnAsync(ReferenceNumber, EndDate, Substitutions); + + Assert.Equal("KS4 June", Captured()!.CeName); + Assert.Equal("Student", Captured()!.LearnerNoun); + Assert.Equal("updated in the Autumn", Captured()!.TurnaroundCommitment); + } + // ── No external calls on the request thread ────────────────────────────── [Fact] public async Task Notify_DoesNotResolveLinkForNonSubmissionNotifications() { - await _sut.NotifyDataCheckConfirmedAsync(EndDate, ReferenceNumber); - await _sut.NotifyAmendmentWithdrawnAsync(ReferenceNumber, EndDate); - await _sut.NotifyDataCheckWithdrawnAsync(ReferenceNumber, EndDate); + await _sut.NotifyDataCheckConfirmedAsync(EndDate, ReferenceNumber, Substitutions); + await _sut.NotifyAmendmentWithdrawnAsync(ReferenceNumber, EndDate, Substitutions); + await _sut.NotifyDataCheckWithdrawnAsync(ReferenceNumber, EndDate, Substitutions); _emailLinkGenerator.DidNotReceive().GenerateLink( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); @@ -165,12 +207,18 @@ public async Task NotifyBulk_BelowThreshold_EnqueuesOneIndividualPerReference() { var refs = new[] { "R1", "R2", "R3", "R4" }; - await _sut.NotifyBulkSubmissionConfirmedAsync(WindowId, EndDate, refs); + await _sut.NotifyBulkSubmissionConfirmedAsync(WindowId, EndDate, refs, Substitutions); var all = AllCaptured(); Assert.Equal(4, all.Count); Assert.All(all, m => Assert.Equal(NotificationType.SubmissionConfirmed, m.Type)); Assert.Equal(new[] { "R1", "R2", "R3", "R4" }, all.Select(m => m.ReferenceNumber)); + Assert.All(all, m => + { + Assert.Equal("KS4 June", m.CeName); + Assert.Equal("Student", m.LearnerNoun); + Assert.Equal("updated in the Autumn", m.TurnaroundCommitment); + }); } [Fact] @@ -178,12 +226,15 @@ public async Task NotifyBulk_AtThreshold_EnqueuesSingleConsolidated() { var refs = new[] { "R1", "R2", "R3", "R4", "R5" }; - await _sut.NotifyBulkSubmissionConfirmedAsync(WindowId, EndDate, refs); + await _sut.NotifyBulkSubmissionConfirmedAsync(WindowId, EndDate, refs, Substitutions); var all = AllCaptured(); Assert.Single(all); Assert.Equal(NotificationType.BulkSubmissionConfirmed, all[0].Type); Assert.Equal(refs, all[0].ReferenceNumbers); Assert.True(all[0].IncludeOrganisationUsers); + Assert.Equal("KS4 June", all[0].CeName); + Assert.Equal("Student", all[0].LearnerNoun); + Assert.Equal("updated in the Autumn", all[0].TurnaroundCommitment); } }