diff --git a/src/DfE.CheckPerformanceData.Application/Analytics/ISearchAnalyticsQueryService.cs b/src/DfE.CheckPerformanceData.Application/Analytics/ISearchAnalyticsQueryService.cs index ac1f9b209..0d03e7d56 100644 --- a/src/DfE.CheckPerformanceData.Application/Analytics/ISearchAnalyticsQueryService.cs +++ b/src/DfE.CheckPerformanceData.Application/Analytics/ISearchAnalyticsQueryService.cs @@ -318,4 +318,22 @@ Task GetSummaryDeltasAsync( DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default); + + // Pages that carry an on-page search widget, ranked by how much they were searched. + // Only instant-page rows have a host path, so the shape of the data does the filtering. + Task<(IReadOnlyList Rows, int TotalCount)> GetOnPageSearchPagesAsync( + DateTime fromUtc, + DateTime toUtc, + int page, + int pageSize, + CancellationToken cancellationToken = default); + + // What was searched for on one page. + Task<(IReadOnlyList Rows, int TotalCount)> GetOnPageSearchTermsAsync( + string hostPath, + DateTime fromUtc, + DateTime toUtc, + int page, + int pageSize, + CancellationToken cancellationToken = default); } diff --git a/src/DfE.CheckPerformanceData.Application/Analytics/ISearchSurfaceFilter.cs b/src/DfE.CheckPerformanceData.Application/Analytics/ISearchSurfaceFilter.cs new file mode 100644 index 000000000..3ff36c3a8 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/Analytics/ISearchSurfaceFilter.cs @@ -0,0 +1,20 @@ +namespace DfE.CheckPerformanceData.Application.Analytics; + +// Which search surfaces the dashboard is currently looking at. +// +// Ambient rather than a parameter on all thirty-odd read methods, following the same shape as +// ISearchDebugOptions: the value is a property of the request, every query wants it, and +// threading it through each signature would add a parameter that no caller ever varies within +// one request. +// +// The default is every surface. A reader that does not care must see everything rather than +// silently see only site searches. +public interface ISearchSurfaceFilter +{ + IReadOnlyList Surfaces { get; } +} + +public sealed class AllSearchSurfaces : ISearchSurfaceFilter +{ + public IReadOnlyList Surfaces { get; } = SearchSurfaces.All; +} diff --git a/src/DfE.CheckPerformanceData.Application/Analytics/InstantSearchEventMapper.cs b/src/DfE.CheckPerformanceData.Application/Analytics/InstantSearchEventMapper.cs new file mode 100644 index 000000000..ed76c7933 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/Analytics/InstantSearchEventMapper.cs @@ -0,0 +1,60 @@ +namespace DfE.CheckPerformanceData.Application.Analytics; + +// Transform from a browser-reported instant search to the same (SearchEventDto, result rows) +// pair the site-search mapper produces, so both surfaces land in one table and the dashboard +// reads them through one query layer. +// +// The counting rule is the only real decision here: a "section" hit increments ResultsSections +// rather than ResultsPages. A section is a heading on the page the person was already reading, +// not a document they could have found any other way — counting it as a page would inflate +// every per-page figure on the dashboard and make an on-page search look like site traffic. +public static class InstantSearchEventMapper +{ + public const string SectionKind = "section"; + public const string PageKind = "page"; + + public static (SearchEventDto Event, IReadOnlyList Results) From( + InstantSearchTelemetryEvent evt, string sessionId) + { + var results = new List(evt.Shown.Count); + var pages = 0; + var sections = 0; + + foreach (var hit in evt.Shown) + { + var kind = string.Equals(hit.Kind, SectionKind, StringComparison.Ordinal) + ? SectionKind + : PageKind; + + if (kind == SectionKind) sections++; + else pages++; + + results.Add(new SearchEventResultDto( + Position: hit.Position, + ResultKind: kind, + ResultKey: hit.Key, + // A typeahead has no ts_rank to report: the browser knows the order it showed + // but not the score behind it. Zero rather than a fabricated number, so a rank + // sort on the dashboard cannot mistake a suggestion for a scored search hit. + Rank: 0f)); + } + + var dto = new SearchEventDto( + OccurredAtUtc: evt.UtcTimestamp, + SessionId: sessionId, + QueryRaw: evt.QueryRaw, + QueryNormalised: evt.QueryNormalised, + Scope: evt.Scope, + ResultsPages: pages, + ResultsBlocks: 0, + LatencyMs: evt.LatencyMs, + Results: results, + ResultsSections: sections, + Surface: evt.Surface, + HostPath: evt.HostPath, + SelectedKey: evt.SelectedKey, + SelectedPosition: evt.SelectedPosition); + + return (dto, results); + } +} diff --git a/src/DfE.CheckPerformanceData.Application/Analytics/InstantSearchTelemetryEvent.cs b/src/DfE.CheckPerformanceData.Application/Analytics/InstantSearchTelemetryEvent.cs new file mode 100644 index 000000000..7840c167f --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/Analytics/InstantSearchTelemetryEvent.cs @@ -0,0 +1,35 @@ +namespace DfE.CheckPerformanceData.Application.Analytics; + +// What a typeahead reports once the person has settled on a query. +// +// Deliberately a different record from SearchTelemetryEvent. That one describes a search the +// server ran and carries per-field rank breakdowns and filter breadcrumbs it owns; this one +// describes what a person did in a menu, and the only authority on what was actually shown to +// them, and on whether they took any of it, is the browser. Keeping the two apart stops either +// from growing fields that are meaningless on the other. +public sealed record InstantSearchTelemetryEvent( + Guid SearchId, + DateTime UtcTimestamp, + string QueryRaw, + string QueryNormalised, + string? Scope, + // SearchSurfaces.Instant or SearchSurfaces.InstantPage. + string Surface, + // The page the widget sat on, for an on-page search; null otherwise. + string? HostPath, + // Time from the query being issued to the menu rendering, measured in the browser. + int LatencyMs, + IReadOnlyList Shown, + // Null when the menu was shown and nothing was taken from it. + string? SelectedKey, + int? SelectedPosition); + +// One row as it appeared in the menu. Position is one-indexed and is the order the person saw, +// which is the only ordering that means anything when asking why they picked the third one. +public sealed record InstantSearchShownHit( + int Position, + // "page" for a document, "section" for a heading on the current page. + string Kind, + // Canonical URL for a page, "#anchor" for a section. + string Key, + string Label); diff --git a/src/DfE.CheckPerformanceData.Application/Analytics/SearchAnalyticsSummaries.cs b/src/DfE.CheckPerformanceData.Application/Analytics/SearchAnalyticsSummaries.cs index 3df7b7a1f..a030b0e00 100644 --- a/src/DfE.CheckPerformanceData.Application/Analytics/SearchAnalyticsSummaries.cs +++ b/src/DfE.CheckPerformanceData.Application/Analytics/SearchAnalyticsSummaries.cs @@ -178,3 +178,22 @@ public sealed record ZeroResultJourney( IReadOnlyList Steps, bool EventuallyRecovered, bool SentFeedback); + +// One page that carries an on-page search widget, summarised over the window. +// +// SelectedCount is the number of searches where the person took one of the sections offered. +// The gap between Searches and SelectedCount is the interesting number: searches where the +// page was asked a question and the answer on offer was not taken. +public sealed record OnPageSearchPageRow( + string HostPath, + int Searches, + int UniqueSessions, + int ZeroResultCount, + int SelectedCount); + +// One term searched for on a single page. +public sealed record OnPageSearchTermRow( + string QueryNormalised, + int Searches, + int ZeroResultCount, + int SelectedCount); diff --git a/src/DfE.CheckPerformanceData.Application/Analytics/SearchEventDto.cs b/src/DfE.CheckPerformanceData.Application/Analytics/SearchEventDto.cs index 3db1d31cc..fae03aebf 100644 --- a/src/DfE.CheckPerformanceData.Application/Analytics/SearchEventDto.cs +++ b/src/DfE.CheckPerformanceData.Application/Analytics/SearchEventDto.cs @@ -15,6 +15,17 @@ public sealed record SearchEventDto( int ResultsBlocks, int LatencyMs, IReadOnlyList Results, + // Sections offered by an on-page instant search; zero for every other surface. + int ResultsSections = 0, + // Which surface produced the event. Defaulted so the existing site-search call sites + // need no change and cannot accidentally file themselves as something else. + string Surface = SearchSurfaces.Site, + // The page an on-page search ran on; null elsewhere. + string? HostPath = null, + // What was chosen from a suggestion menu, and where it sat. Null means nothing was + // taken — which for a typeahead is a result in its own right. + string? SelectedKey = null, + int? SelectedPosition = null, // Optional marker set by the sample-data seeder to true. Real events captured from // live user requests leave this at its default (false). The sink propagates the // flag onto both the parent SearchEvent row and its child SearchEventResult rows so diff --git a/src/DfE.CheckPerformanceData.Application/Analytics/SearchSurfaces.cs b/src/DfE.CheckPerformanceData.Application/Analytics/SearchSurfaces.cs new file mode 100644 index 000000000..0cb1d5d8d --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/Analytics/SearchSurfaces.cs @@ -0,0 +1,27 @@ +namespace DfE.CheckPerformanceData.Application.Analytics; + +// The search surfaces a recorded event can come from. String constants rather than an enum +// because the value is persisted as text, crosses an HTTP boundary from the browser, and is +// read back in raw SQL — one spelling in one place keeps those three honest. +public static class SearchSurfaces +{ + // A submitted search: /search, or a results widget on a content page. + public const string Site = "site"; + + // Typeahead over the whole site or a section of it. Choosing a suggestion opens a page. + public const string Instant = "instant"; + + // Typeahead over the sections of the page the widget sits on. Choosing a suggestion + // moves to a heading on that page rather than opening anything. + public const string InstantPage = "instant-page"; + + public static readonly IReadOnlyList All = [Site, Instant, InstantPage]; + + public static bool IsKnown(string? surface) => + surface is not null && All.Contains(surface, StringComparer.Ordinal); + + // True for the two typeahead surfaces — the ones whose rows carry a selection. + public static bool IsInstant(string? surface) => + string.Equals(surface, Instant, StringComparison.Ordinal) + || string.Equals(surface, InstantPage, StringComparison.Ordinal); +} diff --git a/src/DfE.CheckPerformanceData.Application/Analytics/SinkAndLogSearchTelemetry.cs b/src/DfE.CheckPerformanceData.Application/Analytics/SinkAndLogSearchTelemetry.cs index d6459c372..6b6f6af85 100644 --- a/src/DfE.CheckPerformanceData.Application/Analytics/SinkAndLogSearchTelemetry.cs +++ b/src/DfE.CheckPerformanceData.Application/Analytics/SinkAndLogSearchTelemetry.cs @@ -69,4 +69,23 @@ public void RecordSearch(SearchTelemetryEvent evt) // its summary log line. _inner.RecordSearch(evt); } + + public void RecordInstantSearch(InstantSearchTelemetryEvent evt) + { + var sessionId = _sessionProvider.GetSessionId(); + + if (!string.IsNullOrEmpty(sessionId)) + { + var (dto, _) = InstantSearchEventMapper.From(evt, sessionId); + if (!_writer.TryWrite(dto)) + { + _droppedCounter.Increment(); + _logger.LogWarning( + "Search analytics event dropped: channel full (SearchId={SearchId})", + evt.SearchId); + } + } + + _inner.RecordInstantSearch(evt); + } } diff --git a/src/DfE.CheckPerformanceData.Application/ContentPages/ContentNavBuilder.cs b/src/DfE.CheckPerformanceData.Application/ContentPages/ContentNavBuilder.cs index 8e8d20398..e9ae81509 100644 --- a/src/DfE.CheckPerformanceData.Application/ContentPages/ContentNavBuilder.cs +++ b/src/DfE.CheckPerformanceData.Application/ContentPages/ContentNavBuilder.cs @@ -1,32 +1,64 @@ namespace DfE.CheckPerformanceData.Application.ContentPages; -// Builds a page's left-hand nav by walking the content tree (depth-first, document order) for -// Heading widgets. H2 → top-level item; H3 → nested under the most recent H2 (an H3 before any H2 -// falls back to top-level). Other widgets and other heading levels (H1, H4–H6) do not contribute. +// Builds a page's contents nav by walking the content tree (depth-first, document order) for +// Heading widgets. +// +// Which heading levels appear is the author's choice — any combination of H1 to H6. The chosen +// levels form the hierarchy in the order they are chosen rather than by their absolute numbers, +// so an author who picks H2 and H4 because the page does not use H3 gets H4 nested directly +// under H2, and the H3s are ignored. That is the whole point of choosing: the nav should follow +// the structure of this page, not the structure the levels imply in the abstract. +// +// Levels fall back rather than being dropped: a heading whose parent level has not appeared yet +// attaches to the nearest chosen level above it that has, and one with nothing above it at all +// becomes top-level. Authors skip levels, and dropping those headings would leave sections of +// the page the nav cannot reach, which is the job it exists to do. public static class ContentNavBuilder { - public static IReadOnlyList Build(IReadOnlyList tree) + // What a page gets when nobody has chosen: the two levels a contents list is usually made of. + public static readonly IReadOnlyList DefaultLevels = [2, 3]; + + public static IReadOnlyList Build( + IReadOnlyList tree, + IReadOnlyCollection? levels = null) { + // Sorted and de-duplicated: the rank of a level is its position in this list, and that + // is what decides nesting. + var chosen = (levels ?? DefaultLevels) + .Where(level => level is >= 1 and <= 6) + .Distinct() + .OrderBy(level => level) + .ToList(); + + if (chosen.Count == 0) return []; + var top = new List(); - MutableItem? currentH2 = null; + + // The most recent item at each rank. A heading attaches to the nearest non-null entry + // above its own rank; everything below its rank is cleared, so a later sibling cannot + // collect the children of the branch that just ended. + var openAtRank = new MutableItem?[chosen.Count]; foreach (var heading in Walk(tree)) { var (level, text) = HeadingProps(heading); - if (text is null || heading.Anchor is null) continue; + if (text is null || heading.Anchor is null || level is null) continue; + + var rank = chosen.IndexOf(level.Value); + if (rank < 0) continue; var item = new MutableItem(text, $"#{heading.Anchor}"); - if (level == 2) - { - top.Add(item); - currentH2 = item; - } - else if (level == 3) - { - if (currentH2 is null) top.Add(item); - else currentH2.Children.Add(item); - } - // Other levels do not appear in the nav. + + MutableItem? parent = null; + for (var above = rank - 1; above >= 0 && parent is null; above--) + parent = openAtRank[above]; + + if (parent is null) top.Add(item); + else parent.Children.Add(item); + + openAtRank[rank] = item; + for (var below = rank + 1; below < openAtRank.Length; below++) + openAtRank[below] = null; } return top.Select(Freeze).ToList(); diff --git a/src/DfE.CheckPerformanceData.Application/ContentPages/NavLevelSelection.cs b/src/DfE.CheckPerformanceData.Application/ContentPages/NavLevelSelection.cs new file mode 100644 index 000000000..23b36d1c5 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/ContentPages/NavLevelSelection.cs @@ -0,0 +1,28 @@ +namespace DfE.CheckPerformanceData.Application.ContentPages; + +// Reads the page-nav widget's per-level tick boxes into the set of heading levels its contents +// list should show. +// +// A widget placed before the tick boxes existed carries none of these props. That is not the +// same as an author unticking everything: the first means "never asked", which is the default +// pair, and the second means "asked for none", which is an empty list. Telling them apart is +// why this checks for the props' presence rather than just reading each one as a boolean. +public static class NavLevelSelection +{ + public static IReadOnlyCollection For(WidgetNode widget) + { + var anyDeclared = false; + var levels = new List(); + + for (var level = 1; level <= 6; level++) + { + var key = $"h{level}"; + if (widget.Props?.ContainsKey(key) != true) continue; + + anyDeclared = true; + if (widget.GetBool(key) == true) levels.Add(level); + } + + return anyDeclared ? levels : ContentNavBuilder.DefaultLevels; + } +} diff --git a/src/DfE.CheckPerformanceData.Application/ContentPages/WidgetRegistry.cs b/src/DfE.CheckPerformanceData.Application/ContentPages/WidgetRegistry.cs index 1e6d5e963..c45fb6d17 100644 --- a/src/DfE.CheckPerformanceData.Application/ContentPages/WidgetRegistry.cs +++ b/src/DfE.CheckPerformanceData.Application/ContentPages/WidgetRegistry.cs @@ -15,9 +15,9 @@ public static class WidgetRegistry new("card", "Card", ContributesToNav: false, """{"title":"","body":"","href":""}"""), new("summarylist", "Summary list", ContributesToNav: false, """{"rows":[]}"""), new("published", "Published callout", ContributesToNav: false, """{"text":""}"""), - new("search", "Search", ContributesToNav: false, """{"label":"Search","placeholder":"","action":"/search","buttonText":"Search","scope":""}"""), + new("search", "Search", ContributesToNav: false, """{"label":"Search","placeholder":"","action":"/search","buttonText":"Search","scope":"","searchIn":"site","instant":"false","noResultsText":"No results found"}"""), new("results", "Search results", ContributesToNav: false, """{"scope":"","emptyText":"No results found."}"""), - new("pagenav", "Page navigation", ContributesToNav: false, """{"mode":"headings","childrenParentPath":"","showSearch":false,"searchPath":"","searchLabel":"Search"}""") + new("pagenav", "Page navigation", ContributesToNav: false, """{"mode":"headings","childrenParentPath":"","showSearch":false,"searchPath":"","searchLabel":"Search","h1":"false","h2":"true","h3":"true","h4":"false","h5":"false","h6":"false"}""") ]; private static readonly Dictionary ByType = diff --git a/src/DfE.CheckPerformanceData.Application/Search/ISearchTelemetry.cs b/src/DfE.CheckPerformanceData.Application/Search/ISearchTelemetry.cs index aedea1467..e21c64144 100644 --- a/src/DfE.CheckPerformanceData.Application/Search/ISearchTelemetry.cs +++ b/src/DfE.CheckPerformanceData.Application/Search/ISearchTelemetry.cs @@ -1,3 +1,5 @@ +using DfE.CheckPerformanceData.Application.Analytics; + namespace DfE.CheckPerformanceData.Application.Search; // Fire-and-forget emission surface for a completed search request. Callers assemble a @@ -9,4 +11,10 @@ namespace DfE.CheckPerformanceData.Application.Search; public interface ISearchTelemetry { void RecordSearch(SearchTelemetryEvent evt); + + // A typeahead reports once the person has settled on a query, not once per keystroke, and + // it reports from the browser — which is the only place that knows what was actually shown + // and whether any of it was taken. Separate from RecordSearch because the event shape is + // different and because the two must stay countable apart on the dashboard. + void RecordInstantSearch(InstantSearchTelemetryEvent evt); } diff --git a/src/DfE.CheckPerformanceData.Application/Search/ISiteSearchService.cs b/src/DfE.CheckPerformanceData.Application/Search/ISiteSearchService.cs index 90d7e8f58..b761dfeca 100644 --- a/src/DfE.CheckPerformanceData.Application/Search/ISiteSearchService.cs +++ b/src/DfE.CheckPerformanceData.Application/Search/ISiteSearchService.cs @@ -10,8 +10,22 @@ namespace DfE.CheckPerformanceData.Application.Search; public interface ISiteSearchService { Task SearchAsync(SiteSearchQuery query); + + // Typeahead for the instant-search widget: the same corpus, the same scope rules and the + // same silent filters as SearchAsync, reduced to the handful of rows a suggestion menu can + // show. Deliberately records no telemetry — see the implementation for why. + Task> SuggestAsync(SiteSearchSuggestQuery query); } +// A typeahead request. Limit is the number of rows the caller can display; the service clamps +// it so a hand-crafted request cannot ask for the whole corpus. +public sealed record SiteSearchSuggestQuery(string? Query, string? ScopePath = null, int Limit = 10); + +// One row in a suggestion menu. Label is what the visitor reads, Url where choosing it takes +// them — the menu navigates rather than filling a hidden field, which is why this carries a URL +// and not the id/label shape the journey-domain suggestion endpoints use. +public sealed record SiteSearchSuggestion(string Label, string Url); + // Positional record: the trailing three ints carry the paging window. // MaxPerType — per-corpus fetch window feeding the canonicaliser. Default 500 is the retired // MergedFetchCap — represents the ceiling from which the in-memory pager slices. diff --git a/src/DfE.CheckPerformanceData.Application/Search/LoggerSearchTelemetry.cs b/src/DfE.CheckPerformanceData.Application/Search/LoggerSearchTelemetry.cs index 757f75486..398e962c4 100644 --- a/src/DfE.CheckPerformanceData.Application/Search/LoggerSearchTelemetry.cs +++ b/src/DfE.CheckPerformanceData.Application/Search/LoggerSearchTelemetry.cs @@ -1,3 +1,4 @@ +using DfE.CheckPerformanceData.Application.Analytics; using Microsoft.Extensions.Logging; namespace DfE.CheckPerformanceData.Application.Search; @@ -89,4 +90,44 @@ public void RecordSearch(SearchTelemetryEvent evt) excl.Kind); } } + + // Same discrete-placeholder discipline as above: every browser-supplied string crosses as + // its own templated argument, never spliced into the format string. + public void RecordInstantSearch(InstantSearchTelemetryEvent evt) + { + if (evt.Shown.Count == 0) + { + counter.Increment(); + logger.LogWarning( + "Instant search returned zero results SearchId={SearchId} Surface={Surface} QueryRaw={QueryRaw} HostPath={HostPath}", + evt.SearchId, + evt.Surface, + evt.QueryRaw, + evt.HostPath ?? "(none)"); + } + + logger.LogInformation( + "Instant search settled SearchId={SearchId} Surface={Surface} QueryRaw={QueryRaw} HostPath={HostPath} Shown={Shown} SelectedKey={SelectedKey} SelectedPosition={SelectedPosition} LatencyMs={LatencyMs}ms", + evt.SearchId, + evt.Surface, + evt.QueryRaw, + evt.HostPath ?? "(none)", + evt.Shown.Count, + evt.SelectedKey ?? "(none)", + evt.SelectedPosition, + evt.LatencyMs); + + var breadcrumbLevel = debug.ShowSearchDebug ? LogLevel.Information : LogLevel.Debug; + foreach (var hit in evt.Shown) + { + logger.Log( + breadcrumbLevel, + "Instant search {SearchId} shown position={Position} kind={Kind} key={Key} label=\"{Label}\"", + evt.SearchId, + hit.Position, + hit.Kind, + hit.Key, + hit.Label); + } + } } diff --git a/src/DfE.CheckPerformanceData.Application/Search/SiteSearchService.cs b/src/DfE.CheckPerformanceData.Application/Search/SiteSearchService.cs index 71ef464be..88c4d2b2b 100644 --- a/src/DfE.CheckPerformanceData.Application/Search/SiteSearchService.cs +++ b/src/DfE.CheckPerformanceData.Application/Search/SiteSearchService.cs @@ -116,6 +116,56 @@ public async Task SearchAsync(SiteSearchQuery query) } } + // Per-corpus fetch window for a suggestion pass. Far below SearchAsync's 500 because a menu + // shows ten rows: canonicalisation only has to have enough candidates to fill them. + private const int SuggestFetchCap = 50; + private const int SuggestMaxLimit = 10; + + public async Task> SuggestAsync(SiteSearchSuggestQuery query) + { + var rawTerm = query.Query ?? string.Empty; + if (rawTerm.IndexOf('\0') >= 0) rawTerm = rawTerm.Replace("\0", string.Empty); + var term = rawTerm.Trim(); + var scope = string.IsNullOrWhiteSpace(query.ScopePath) ? null : query.ScopePath.Trim().Trim('/'); + var limit = Math.Clamp(query.Limit, 1, SuggestMaxLimit); + + try + { + // Same pass the real search runs, so scope handling, the silent filters and URL + // canonicalisation cannot drift between what is suggested and what /search returns. + var (result, _) = await SearchOnceAsync( + new SiteSearchQuery(term, scope, MaxPerType: SuggestFetchCap, Page: 1, PageSize: limit), + term, + scope, + 1, + limit); + + // The telemetry event is built and discarded on purpose. SearchAsync is the single + // place a SearchEvent row is written, and that is what makes "one row per search a + // person actually ran" true. A typeahead fires on every keystroke; recording here + // would bury the 90-day store in partial words and skew every volume, latency and + // zero-result chart on the analytics dashboard. Recording one event when a + // suggestion is chosen would be the useful signal, and is its own piece of work. + return result.Hits + .Select(h => new SiteSearchSuggestion(h.Title, h.Url)) + .ToArray(); + } + catch (Exception ex) when (ex is System.Data.Common.DbException + || (ex is RetryLimitExceededException && ex.InnerException is System.Data.Common.DbException)) + { + // A typeahead has no way to show an error state that would help anyone mid-word, and + // the page it sits on must not break. An empty menu is the honest degradation; the + // visitor can still submit the form, which surfaces the real unavailable screen. + logger.LogWarning( + ex, + "Search suggestions failed data store unavailable QueryRaw={QueryRaw} Scope={Scope}", + term, + scope ?? "(none)"); + + return []; + } + } + // Runs one canonicalised search pass and builds (but does not emit) the telemetry event. // The public SearchAsync decides when to call telemetry.RecordSearch so a DB-unavailable // branch produces zero events and a future hyphen-fallback re-invocation still emits diff --git a/src/DfE.CheckPerformanceData.Persistence/Analytics/DbSearchAnalyticsSink.cs b/src/DfE.CheckPerformanceData.Persistence/Analytics/DbSearchAnalyticsSink.cs index eff05ee87..3366a9bf0 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Analytics/DbSearchAnalyticsSink.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Analytics/DbSearchAnalyticsSink.cs @@ -61,7 +61,12 @@ public async Task RecordBatchAsync( Scope = dto.Scope, ResultsPages = dto.ResultsPages, ResultsBlocks = dto.ResultsBlocks, + ResultsSections = dto.ResultsSections, LatencyMs = dto.LatencyMs, + Surface = dto.Surface, + HostPath = dto.HostPath, + SelectedKey = dto.SelectedKey, + SelectedPosition = dto.SelectedPosition, IsSeeded = dto.IsSeeded, JobId = dto.JobId, }; diff --git a/src/DfE.CheckPerformanceData.Persistence/Analytics/SearchAnalyticsQueryService.cs b/src/DfE.CheckPerformanceData.Persistence/Analytics/SearchAnalyticsQueryService.cs index 70701905d..cc5565b3b 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Analytics/SearchAnalyticsQueryService.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Analytics/SearchAnalyticsQueryService.cs @@ -29,13 +29,27 @@ public sealed class SearchAnalyticsQueryService : ISearchAnalyticsQueryService // any window strictly greater than 48 hours reads day-granularity buckets. private static readonly TimeSpan HourBucketThreshold = TimeSpan.FromHours(48); + // Every dashboard read names the events table through this rather than directly, so a new + // query cannot quietly skip the surface filter. @surfaces is bound centrally in ReadAsync, + // and the (surface, occurred_at_utc) index carries the extra predicate. + private const string EventsSource = + "(SELECT * FROM search_events WHERE surface = ANY(@surfaces))"; + private readonly IPortalDbContext _dbContext; private readonly ISettingService _settings; - - public SearchAnalyticsQueryService(IPortalDbContext dbContext, ISettingService settings) + private readonly ISearchSurfaceFilter _surfaces; + + // The surface filter is optional so the many test call sites that predate it keep + // compiling, and so that "no filter supplied" means every surface rather than an + // accidental site-only view. + public SearchAnalyticsQueryService( + IPortalDbContext dbContext, + ISettingService settings, + ISearchSurfaceFilter? surfaceFilter = null) { _dbContext = dbContext; _settings = settings; + _surfaces = surfaceFilter ?? new AllSearchSurfaces(); } public async Task GetSummaryAsync( @@ -48,13 +62,13 @@ public async Task GetSummaryAsync( // pass; percentile_cont(0.95) WITHIN GROUP (ORDER BY latency_ms) is Postgres's // continuous percentile aggregate — interpolates between the two rows surrounding // the 95th percentile, so the tile stays smooth as the window slides. - const string sql = @" + const string sql = $@" SELECT COUNT(*)::int AS total_count, COUNT(DISTINCT session_id)::int AS unique_sessions, COUNT(*) FILTER (WHERE zero_results)::int AS zero_count, COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY latency_ms), 0) AS p95_latency -FROM search_events +FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to;"; var totalCount = 0; @@ -93,12 +107,12 @@ public async Task> GetTopQueriesAsync( int limit, CancellationToken cancellationToken = default) { - const string sql = @" + const string sql = $@" SELECT query_normalised, COUNT(*)::int AS c, SUM(CASE WHEN zero_results THEN 1 ELSE 0 END)::int AS z -FROM search_events +FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to AND query_normalised IS NOT NULL GROUP BY query_normalised ORDER BY c DESC, query_normalised ASC @@ -131,12 +145,12 @@ public async Task> GetTopZeroResultQueriesAsync( // returned nothing. ZeroResultCount equals Count for every row in this projection // by construction — the view uses both fields so the top-queries and top-zero // tables share one row shape. - const string sql = @" + const string sql = $@" SELECT query_normalised, COUNT(*)::int AS c, COUNT(*)::int AS z -FROM search_events +FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to AND query_normalised IS NOT NULL AND zero_results = true @@ -284,7 +298,7 @@ LEFT JOIN ( SELECT {groupedBucketExpr} AS bucket, {countExpr}::int AS value - FROM search_events + FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to{extraWhere} GROUP BY 1 ) e ON e.bucket = b.bucket @@ -337,7 +351,7 @@ LEFT JOIN ( percentile_cont(0.05) WITHIN GROUP (ORDER BY latency_ms) AS p5, percentile_cont(0.50) WITHIN GROUP (ORDER BY latency_ms) AS p50, percentile_cont(0.95) WITHIN GROUP (ORDER BY latency_ms) AS p95 - FROM search_events + FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to GROUP BY 1 ) e ON e.bucket = b.bucket @@ -411,7 +425,7 @@ LEFT JOIN ( {groupedBucketExpr} AS bucket, COUNT(*)::int AS searches, COUNT(DISTINCT session_id)::int AS unique_sessions - FROM search_events + FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to GROUP BY 1 ) e ON e.bucket = b.bucket @@ -518,7 +532,7 @@ LEFT JOIN ( EXTRACT(ISODOW FROM occurred_at_utc AT TIME ZONE 'UTC')::int AS weekday, EXTRACT(HOUR FROM occurred_at_utc AT TIME ZONE 'UTC')::int AS hour, {countExpr}::int AS value - FROM search_events + FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to{extraWhere} GROUP BY 1, 2 ) e ON e.weekday = w.weekday AND e.hour = hh.hour @@ -546,7 +560,7 @@ public async Task> GetLatencyPercentilesAggregatedB DateTime toUtc, CancellationToken cancellationToken = default) { - var sql = @" + var sql = $@" SELECT w.weekday::int AS weekday, hh.hour::int AS hour, @@ -562,7 +576,7 @@ LEFT JOIN ( percentile_cont(0.05) WITHIN GROUP (ORDER BY latency_ms) AS p5, percentile_cont(0.50) WITHIN GROUP (ORDER BY latency_ms) AS p50, percentile_cont(0.95) WITHIN GROUP (ORDER BY latency_ms) AS p95 - FROM search_events + FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to GROUP BY 1, 2 ) e ON e.weekday = w.weekday AND e.hour = hh.hour @@ -677,7 +691,7 @@ LEFT JOIN ( {groupedBucketExpr} AS bucket, COUNT(*)::int AS searches, COUNT(DISTINCT session_id)::int AS unique_sessions - FROM search_events + FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to GROUP BY 1 ) e ON e.bucket = b.bucket @@ -751,7 +765,7 @@ LEFT JOIN ( SELECT {groupedBucketExpr} AS bucket, {countExpr}::int AS value - FROM search_events + FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to{extraWhere} GROUP BY 1 ) e ON e.bucket = b.bucket @@ -827,7 +841,7 @@ LEFT JOIN ( percentile_cont(0.05) WITHIN GROUP (ORDER BY latency_ms) AS p5, percentile_cont(0.50) WITHIN GROUP (ORDER BY latency_ms) AS p50, percentile_cont(0.95) WITHIN GROUP (ORDER BY latency_ms) AS p95 - FROM search_events + FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to GROUP BY 1 ) e ON e.bucket = b.bucket @@ -913,7 +927,7 @@ await ReadAsync(pageSql, cancellationToken, command => var countSql = $@" SELECT COUNT(*) FROM ( SELECT query_normalised - FROM search_events + FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to AND query_normalised IS NOT NULL{extraFilter} GROUP BY query_normalised @@ -930,7 +944,7 @@ GROUP BY query_normalised query_normalised, COUNT(*)::int AS c, {zeroExpr} AS z -FROM search_events +FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to AND query_normalised IS NOT NULL{extraFilter} GROUP BY query_normalised @@ -997,23 +1011,23 @@ await ReadAsync(pageSql, cancellationToken, command => if (page < 1) page = 1; if (pageSize < 1) pageSize = 1; - const string countSql = @" + const string countSql = $@" SELECT COUNT(*) FROM ( SELECT r.result_key FROM search_event_results r - JOIN search_events e ON e.id = r.search_event_id + JOIN {EventsSource} e ON e.id = r.search_event_id WHERE e.occurred_at_utc >= @from AND e.occurred_at_utc < @to AND r.result_kind = @kind GROUP BY r.result_key ) p;"; - const string pageSql = @" + const string pageSql = $@" SELECT r.result_key, COUNT(*)::int AS impressions, COUNT(DISTINCT r.search_event_id)::int AS unique_queries FROM search_event_results r -JOIN search_events e ON e.id = r.search_event_id +JOIN {EventsSource} e ON e.id = r.search_event_id WHERE e.occurred_at_utc >= @from AND e.occurred_at_utc < @to AND r.result_kind = @kind GROUP BY r.result_key @@ -1093,9 +1107,9 @@ public async Task> GetRequestTimingsAsync( { if (samplingLimit < 1) samplingLimit = 1; - const string sql = @" + const string sql = $@" SELECT occurred_at_utc, latency_ms, session_id, query_raw, results_total -FROM search_events +FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to ORDER BY random() LIMIT @limit;"; @@ -1131,14 +1145,14 @@ await ReadAsync(sql, cancellationToken, command => if (page < 1) page = 1; if (pageSize < 1) pageSize = 1; - const string countSql = @" + const string countSql = $@" SELECT COUNT(*)::int -FROM search_events +FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to;"; - const string pageSql = @" + const string pageSql = $@" SELECT occurred_at_utc, latency_ms, session_id, query_raw, results_total -FROM search_events +FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to ORDER BY occurred_at_utc DESC, id DESC LIMIT @limit OFFSET @offset;"; @@ -1197,16 +1211,16 @@ await ReadAsync(pageSql, cancellationToken, command => // Postgres whose session default is Europe/London the same URL returns different // rows twice a year across the BST/GMT switch. AT TIME ZONE 'UTC' pins the field // extraction to UTC clock-face values regardless of the session's default. - const string countSql = @" + const string countSql = $@" SELECT COUNT(*)::int -FROM search_events +FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to AND EXTRACT(ISODOW FROM occurred_at_utc AT TIME ZONE 'UTC')::int = @weekday AND EXTRACT(HOUR FROM occurred_at_utc AT TIME ZONE 'UTC')::int = @hour;"; - const string pageSql = @" + const string pageSql = $@" SELECT occurred_at_utc, latency_ms, session_id, query_raw, results_total -FROM search_events +FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to AND EXTRACT(ISODOW FROM occurred_at_utc AT TIME ZONE 'UTC')::int = @weekday AND EXTRACT(HOUR FROM occurred_at_utc AT TIME ZONE 'UTC')::int = @hour @@ -1270,10 +1284,10 @@ public async Task GetZeroResultRecoveryStatsAsync( // recovered even though the last thing they did was fail — and the sibling // GetZeroResultOutcomeFunnelAsync (which does enforce the ordering) would report // a lower "refined" figure for the same window, so the two cards disagreed. - const string sql = @" + const string sql = $@" WITH first_zero AS ( SELECT session_id, MIN(occurred_at_utc) AS first_zero_utc - FROM search_events + FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to AND zero_results GROUP BY session_id @@ -1281,7 +1295,7 @@ GROUP BY session_id recovered AS ( SELECT DISTINCT fz.session_id FROM first_zero fz - JOIN search_events e ON e.session_id = fz.session_id + JOIN {EventsSource} e ON e.session_id = fz.session_id WHERE e.occurred_at_utc >= @from AND e.occurred_at_utc < @to AND e.occurred_at_utc > fz.first_zero_utc AND NOT e.zero_results @@ -1339,10 +1353,10 @@ await ReadAsync(sql, cancellationToken, command => if (page < 1) page = 1; if (pageSize < 1) pageSize = 1; - const string countSql = @" + const string countSql = $@" SELECT COUNT(*)::int FROM ( SELECT session_id - FROM search_events + FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to GROUP BY session_id HAVING BOOL_OR(zero_results) @@ -1351,10 +1365,10 @@ HAVING BOOL_OR(zero_results) // Rank sessions by their in-window chain length (event count) desc, id asc, take // the requested page, then fan out to every event for the ids on that page. One // ORDER BY at the outer SELECT gives per-session chronological chains. - const string pageSql = @" + const string pageSql = $@" WITH ranked AS ( SELECT session_id, COUNT(*) AS chain_length - FROM search_events + FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to GROUP BY session_id HAVING BOOL_OR(zero_results) @@ -1367,7 +1381,7 @@ SELECT session_id FROM ranked events AS ( SELECT e.session_id, e.occurred_at_utc, e.query_normalised, e.results_total, e.zero_results - FROM search_events e + FROM {EventsSource} e JOIN page_sessions p ON p.session_id = e.session_id WHERE e.occurred_at_utc >= @from AND e.occurred_at_utc < @to ), @@ -1379,7 +1393,7 @@ FROM search_messages m ), first_zero AS ( SELECT p.session_id, MIN(e.occurred_at_utc) AS first_zero_utc - FROM search_events e + FROM {EventsSource} e JOIN page_sessions p ON p.session_id = e.session_id WHERE e.occurred_at_utc >= @from AND e.occurred_at_utc < @to AND e.zero_results @@ -1388,7 +1402,7 @@ GROUP BY p.session_id recovered AS ( SELECT DISTINCT fz.session_id FROM first_zero fz - JOIN search_events e ON e.session_id = fz.session_id + JOIN {EventsSource} e ON e.session_id = fz.session_id WHERE e.occurred_at_utc >= @from AND e.occurred_at_utc < @to AND e.occurred_at_utc > fz.first_zero_utc AND NOT e.zero_results @@ -1464,7 +1478,7 @@ public async Task> GetSearchesByWeekdayAndHourA DateTime toUtc, CancellationToken cancellationToken = default) { - const string sql = @" + const string sql = $@" SELECT w.weekday::int AS weekday, h.hour::int AS hour, @@ -1476,7 +1490,7 @@ LEFT JOIN ( EXTRACT(ISODOW FROM occurred_at_utc AT TIME ZONE 'UTC')::int AS weekday, EXTRACT(HOUR FROM occurred_at_utc AT TIME ZONE 'UTC')::int AS hour, COUNT(*)::int AS c - FROM search_events + FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to GROUP BY 1, 2 ) e ON e.weekday = w.weekday AND e.hour = h.hour @@ -1508,7 +1522,7 @@ public async Task GetZeroResultOutcomeFunnelAsync( DateTime toUtc, CancellationToken cancellationToken = default) { - const string sql = @" + const string sql = $@" WITH first_zero AS ( -- DISTINCT ON rather than MIN(): the baseline must be the query that actually ran -- first, and MIN(query_normalised) is the alphabetically smallest zero-result query @@ -1520,7 +1534,7 @@ SELECT DISTINCT ON (session_id) session_id, occurred_at_utc AS first_zero_utc, query_normalised AS first_zero_query - FROM search_events + FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to AND zero_results = true ORDER BY session_id, occurred_at_utc, id @@ -1528,7 +1542,7 @@ FROM search_events refined AS ( SELECT DISTINCT fz.session_id FROM first_zero fz - JOIN search_events e ON e.session_id = fz.session_id + JOIN {EventsSource} e ON e.session_id = fz.session_id WHERE e.occurred_at_utc > fz.first_zero_utc AND e.occurred_at_utc < @to AND (e.query_normalised IS DISTINCT FROM fz.first_zero_query) @@ -1553,7 +1567,7 @@ FROM first_zero fz ), all_sessions AS ( SELECT COUNT(DISTINCT session_id)::int AS c - FROM search_events + FROM {EventsSource} AS search_events WHERE occurred_at_utc >= @from AND occurred_at_utc < @to ) SELECT @@ -1638,6 +1652,126 @@ public async Task GetSummaryDeltasAsync( // hands each row to the caller. Mirrors MetricsQueryService's helper — the two read // services share the layering pattern and a copy here keeps the sibling boundary // clean without introducing a shared helper class. + // ── Single-page search ─────────────────────────────────────────────────── + // + // Only an on-page search records a host path, so the IS NOT NULL predicate is what + // separates this section from the rest of the dashboard — no surface literal is hard-coded + // here, and a reader who has filtered the surfaces down sees that filter applied as well. + + public async Task<(IReadOnlyList Rows, int TotalCount)> GetOnPageSearchPagesAsync( + DateTime fromUtc, + DateTime toUtc, + int page, + int pageSize, + CancellationToken cancellationToken = default) + { + // One-indexed at the API boundary, matching every other paged drill-in here. + var safePage = Math.Max(1, page); + var safeSize = Math.Max(1, pageSize); + + var countSql = $@" +SELECT COUNT(DISTINCT host_path)::int +FROM {EventsSource} AS search_events +WHERE occurred_at_utc >= @from AND occurred_at_utc < @to + AND host_path IS NOT NULL;"; + + var total = 0; + await ReadAsync(countSql, cancellationToken, Bind, reader => total = reader.GetInt32(0)); + + var sql = $@" +SELECT + host_path, + COUNT(*)::int, + COUNT(DISTINCT session_id)::int, + COUNT(*) FILTER (WHERE zero_results)::int, + COUNT(*) FILTER (WHERE selected_key IS NOT NULL)::int +FROM {EventsSource} AS search_events +WHERE occurred_at_utc >= @from AND occurred_at_utc < @to + AND host_path IS NOT NULL +GROUP BY host_path +ORDER BY COUNT(*) DESC, host_path ASC +LIMIT @limit OFFSET @offset;"; + + var rows = new List(); + await ReadAsync(sql, cancellationToken, command => + { + Bind(command); + command.Parameters.Add(new NpgsqlParameter("limit", NpgsqlDbType.Integer) { Value = safeSize }); + command.Parameters.Add(new NpgsqlParameter("offset", NpgsqlDbType.Integer) { Value = (safePage - 1) * safeSize }); + }, reader => rows.Add(new OnPageSearchPageRow( + reader.GetString(0), + reader.GetInt32(1), + reader.GetInt32(2), + reader.GetInt32(3), + reader.GetInt32(4)))); + + return (rows, total); + + void Bind(NpgsqlCommand command) + { + command.Parameters.Add(new NpgsqlParameter("from", NpgsqlDbType.TimestampTz) { Value = fromUtc }); + command.Parameters.Add(new NpgsqlParameter("to", NpgsqlDbType.TimestampTz) { Value = toUtc }); + } + } + + public async Task<(IReadOnlyList Rows, int TotalCount)> GetOnPageSearchTermsAsync( + string hostPath, + DateTime fromUtc, + DateTime toUtc, + int page, + int pageSize, + CancellationToken cancellationToken = default) + { + // One-indexed at the API boundary, matching every other paged drill-in here. + var safePage = Math.Max(1, page); + var safeSize = Math.Max(1, pageSize); + + var countSql = $@" +SELECT COUNT(DISTINCT query_normalised)::int +FROM {EventsSource} AS search_events +WHERE occurred_at_utc >= @from AND occurred_at_utc < @to + AND host_path = @path + AND query_normalised IS NOT NULL;"; + + var total = 0; + await ReadAsync(countSql, cancellationToken, Bind, reader => total = reader.GetInt32(0)); + + var sql = $@" +SELECT + query_normalised, + COUNT(*)::int, + COUNT(*) FILTER (WHERE zero_results)::int, + COUNT(*) FILTER (WHERE selected_key IS NOT NULL)::int +FROM {EventsSource} AS search_events +WHERE occurred_at_utc >= @from AND occurred_at_utc < @to + AND host_path = @path + AND query_normalised IS NOT NULL +GROUP BY query_normalised +ORDER BY COUNT(*) DESC, query_normalised ASC +LIMIT @limit OFFSET @offset;"; + + var rows = new List(); + await ReadAsync(sql, cancellationToken, command => + { + Bind(command); + command.Parameters.Add(new NpgsqlParameter("limit", NpgsqlDbType.Integer) { Value = safeSize }); + command.Parameters.Add(new NpgsqlParameter("offset", NpgsqlDbType.Integer) { Value = (safePage - 1) * safeSize }); + }, reader => rows.Add(new OnPageSearchTermRow( + reader.GetString(0), + reader.GetInt32(1), + reader.GetInt32(2), + reader.GetInt32(3)))); + + return (rows, total); + + void Bind(NpgsqlCommand command) + { + command.Parameters.Add(new NpgsqlParameter("from", NpgsqlDbType.TimestampTz) { Value = fromUtc }); + command.Parameters.Add(new NpgsqlParameter("to", NpgsqlDbType.TimestampTz) { Value = toUtc }); + command.Parameters.Add(new NpgsqlParameter("path", NpgsqlDbType.Text) { Value = hostPath }); + } + } + private async Task ReadAsync( string sql, CancellationToken cancellationToken, @@ -1658,6 +1792,18 @@ private async Task ReadAsync( command.CommandText = sql; bindParameters(command); + // Bound here rather than at each call site: the parameter belongs to EventsSource, + // which every read composes, and a query that forgot it would silently widen to + // every surface. Only added when the SQL actually names it. + if (sql.Contains("@surfaces", StringComparison.Ordinal) + && !command.Parameters.Contains("surfaces")) + { + command.Parameters.Add(new NpgsqlParameter("surfaces", NpgsqlDbType.Array | NpgsqlDbType.Text) + { + Value = _surfaces.Surfaces.ToArray(), + }); + } + await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { diff --git a/src/DfE.CheckPerformanceData.Persistence/Configurations/SearchEventConfiguration.cs b/src/DfE.CheckPerformanceData.Persistence/Configurations/SearchEventConfiguration.cs index 904c9b72f..bbc15ea6a 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Configurations/SearchEventConfiguration.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Configurations/SearchEventConfiguration.cs @@ -1,4 +1,5 @@ using DfE.CheckPerformance.Persistence.Entities; +using DfE.CheckPerformanceData.Application.Analytics; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -37,20 +38,46 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.ResultsBlocks) .HasColumnName("results_blocks"); - // Postgres computes these on insert. The sink writer sets only ResultsPages + - // ResultsBlocks and Postgres derives the rest; the sink cannot drift out of - // sync with the derived values because it never assigns them. + // Sections offered by an on-page instant search. Defaulted so rows written before + // the column existed — all of them site searches, which have no sections — keep + // their totals unchanged. + builder.Property(x => x.ResultsSections) + .HasColumnName("results_sections") + .HasDefaultValue(0); + + // Postgres computes these on insert. The sink writer sets only the three raw counts + // and Postgres derives the rest; the sink cannot drift out of sync with the derived + // values because it never assigns them. Sections count toward both, so an on-page + // search that offered three headings is not filed as a zero-result search. builder.Property(x => x.ResultsTotal) .HasColumnName("results_total") - .HasComputedColumnSql("results_pages + results_blocks", stored: true); + .HasComputedColumnSql("results_pages + results_blocks + results_sections", stored: true); builder.Property(x => x.ZeroResults) .HasColumnName("zero_results") - .HasComputedColumnSql("(results_pages + results_blocks) = 0", stored: true); + .HasComputedColumnSql("(results_pages + results_blocks + results_sections) = 0", stored: true); builder.Property(x => x.LatencyMs) .HasColumnName("latency_ms"); + // Which surface produced the row. Defaulted to "site" so every row written before + // instant search existed classifies as what it actually was, with no backfill. + builder.Property(x => x.Surface) + .HasColumnName("surface") + .IsRequired() + .HasDefaultValue(SearchSurfaces.Site); + + // Set only for an on-page search — the page the widget sat on. + builder.Property(x => x.HostPath) + .HasColumnName("host_path"); + + // What was taken from the menu. Null means the menu was shown and nothing chosen. + builder.Property(x => x.SelectedKey) + .HasColumnName("selected_key"); + + builder.Property(x => x.SelectedPosition) + .HasColumnName("selected_position"); + // Marker for rows written by the sample-data seeder. Defaults to false so pre- // existing rows (from before the migration lands) remain classified as real // data. Delete-seeded on the admin surface filters on this column. @@ -102,6 +129,17 @@ public void Configure(EntityTypeBuilder builder) .HasDatabaseName("ix_search_events_occurred_at_query_normalised") .HasFilter("query_normalised IS NOT NULL"); + // Every dashboard read now filters by surface on top of the time window, so the + // composite leads on surface and lets the range predicate cut what is left. + builder.HasIndex(x => new { x.Surface, x.OccurredAtUtc }) + .HasDatabaseName("ix_search_events_surface_occurred_at"); + + // The single-page-search section groups by the page the widget sat on. Filtered to + // non-NULL so the overwhelming majority of rows (every site search) stay out of it. + builder.HasIndex(x => new { x.HostPath, x.OccurredAtUtc }) + .HasDatabaseName("ix_search_events_host_path_occurred_at") + .HasFilter("host_path IS NOT NULL"); + // Per-seed-run rollback lookup. Filtered to non-NULL rows so real user activity // (job_id IS NULL) does not bloat the index. Only seeder-written rows land here, // and they are dropped shortly after by the Cancel/rollback action anyway. diff --git a/src/DfE.CheckPerformanceData.Persistence/Entities/SearchEvent.cs b/src/DfE.CheckPerformanceData.Persistence/Entities/SearchEvent.cs index 5eaf1cc83..2733a0b8b 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Entities/SearchEvent.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Entities/SearchEvent.cs @@ -1,3 +1,5 @@ +using DfE.CheckPerformanceData.Application.Analytics; + namespace DfE.CheckPerformance.Persistence.Entities; // An append-only record of a search request against the site-search corpus. Keyed by an @@ -17,10 +19,36 @@ public sealed class SearchEvent public string? Scope { get; set; } public int ResultsPages { get; set; } public int ResultsBlocks { get; set; } + + // Sections of a single page offered by an on-page instant search. A third result kind + // rather than a reuse of ResultsPages, because a section is not a document: counting + // them as pages would inflate every per-page figure on the dashboard. Folded into the + // computed ResultsTotal/ZeroResults so an on-page search that showed three sections is + // not filed as a zero-result search. + public int ResultsSections { get; set; } + public int ResultsTotal { get; set; } public bool ZeroResults { get; set; } public int LatencyMs { get; set; } + // Which search surface produced the row: "site" for a submitted search at /search or a + // results widget, "instant" for a typeahead over the whole site or a section of it, and + // "instant-page" for a typeahead over the sections of the page the widget sits on. + // Existing rows migrate to "site", which is what they all were. + public string Surface { get; set; } = SearchSurfaces.Site; + + // The page the widget was sitting on, for an on-page search. Null for every other + // surface — a site search has no host page, it IS the search. + public string? HostPath { get; set; } + + // What the person chose from the menu, and where it sat in the list. Null means the + // menu was shown and nothing was taken from it: they either found their answer in the + // list without clicking, or none of it was any good and they typed something else. + // That second case is the signal an instant search cannot get any other way, so it is + // recorded as deliberately as a selection is. + public string? SelectedKey { get; set; } + public int? SelectedPosition { get; set; } + // True when the row was written by the sample-data seeder (dev-only Test-data admin // surface); false for every event captured from a real user request. Existing rows // default to false so the marker's write-once invariant survives the initial diff --git a/src/DfE.CheckPerformanceData.Persistence/Migrations/20260914164653_AddSearchEventSurface.Designer.cs b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260914164653_AddSearchEventSurface.Designer.cs new file mode 100644 index 000000000..9344384dd --- /dev/null +++ b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260914164653_AddSearchEventSurface.Designer.cs @@ -0,0 +1,1487 @@ +// +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("20260914164653_AddSearchEventSurface")] + partial class AddSearchEventSurface + { + /// + 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.ContentStagingSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BundleJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("bundle_json"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at_utc"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc") + .HasDatabaseName("ix_content_staging_sessions_expires_at_utc"); + + b.ToTable("content_staging_sessions", (string)null); + }); + + 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("HostPath") + .HasColumnType("text") + .HasColumnName("host_path"); + + 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("ResultsSections") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("results_sections"); + + b.Property("ResultsTotal") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("integer") + .HasColumnName("results_total") + .HasComputedColumnSql("results_pages + results_blocks + results_sections", true); + + b.Property("Scope") + .HasColumnType("text") + .HasColumnName("scope"); + + b.Property("SelectedKey") + .HasColumnType("text") + .HasColumnName("selected_key"); + + b.Property("SelectedPosition") + .HasColumnType("integer") + .HasColumnName("selected_position"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("session_id"); + + b.Property("Surface") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("site") + .HasColumnName("surface"); + + b.Property("ZeroResults") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("boolean") + .HasColumnName("zero_results") + .HasComputedColumnSql("(results_pages + results_blocks + results_sections) = 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("HostPath", "OccurredAtUtc") + .HasDatabaseName("ix_search_events_host_path_occurred_at") + .HasFilter("host_path IS NOT NULL"); + + 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("Surface", "OccurredAtUtc") + .HasDatabaseName("ix_search_events_surface_occurred_at"); + + 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("CheckingExerciseId") + .HasColumnType("uuid"); + + b.Property("CrmId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DecisionTrace") + .HasColumnType("text"); + + 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("CheckingExerciseId"); + + 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.CheckingExercise", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckingWindowId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExerciseType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Id"); + + b.HasIndex("CheckingWindowId", "ExerciseType") + .IsUnique(); + + b.ToTable("CheckingExercises", (string)null); + }); + + 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("NextOpportunity") + .HasColumnType("timestamp without time zone"); + + 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("CheckingExerciseId") + .HasColumnType("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("Required") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + 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.Property("SourceFile") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CheckingExerciseId", "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.CheckingExercise", null) + .WithMany() + .HasForeignKey("CheckingExerciseId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", null) + .WithMany() + .HasForeignKey("WindowId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", null) + .WithMany("CheckingExercises") + .HasForeignKey("CheckingWindowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("DfE.CheckPerformanceData.Persistence.Entities.ExerciseValidated", "Validated", b1 => + { + b1.Property("CheckingExerciseId") + .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("CheckingExerciseId"); + + b1.ToTable("CheckingExercises"); + + b1.WithOwner() + .HasForeignKey("CheckingExerciseId"); + }); + + b.Navigation("Validated"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindowDataset", b => + { + b.HasOne("DfE.CheckPerformanceData.Persistence.Entities.CheckingExercise", null) + .WithMany("Datasets") + .HasForeignKey("CheckingExerciseId") + .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.CheckingExercise", b => + { + b.Navigation("Datasets"); + }); + + modelBuilder.Entity("DfE.CheckPerformanceData.Persistence.Entities.CheckingWindow", b => + { + b.Navigation("CheckingExercises"); + }); + + 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/20260914164653_AddSearchEventSurface.cs b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260914164653_AddSearchEventSurface.cs new file mode 100644 index 000000000..34d157959 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Persistence/Migrations/20260914164653_AddSearchEventSurface.cs @@ -0,0 +1,141 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DfE.CheckPerformanceData.Persistence.Migrations +{ + /// + public partial class AddSearchEventSurface : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "host_path", + table: "search_events", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "results_sections", + table: "search_events", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "selected_key", + table: "search_events", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "selected_position", + table: "search_events", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "surface", + table: "search_events", + type: "text", + nullable: false, + defaultValue: "site"); + + migrationBuilder.AlterColumn( + name: "zero_results", + table: "search_events", + type: "boolean", + nullable: false, + computedColumnSql: "(results_pages + results_blocks + results_sections) = 0", + stored: true, + oldClrType: typeof(bool), + oldType: "boolean", + oldComputedColumnSql: "(results_pages + results_blocks) = 0", + oldStored: true); + + migrationBuilder.AlterColumn( + name: "results_total", + table: "search_events", + type: "integer", + nullable: false, + computedColumnSql: "results_pages + results_blocks + results_sections", + stored: true, + oldClrType: typeof(int), + oldType: "integer", + oldComputedColumnSql: "results_pages + results_blocks", + oldStored: true); + + migrationBuilder.CreateIndex( + name: "ix_search_events_host_path_occurred_at", + table: "search_events", + columns: new[] { "host_path", "occurred_at_utc" }, + filter: "host_path IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "ix_search_events_surface_occurred_at", + table: "search_events", + columns: new[] { "surface", "occurred_at_utc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // The generated columns are reverted FIRST: while their expression still names + // results_sections, Postgres will not let that column be dropped. EF emits the + // drops first by default, which makes the generated rollback fail at the first + // statement. + migrationBuilder.AlterColumn( + name: "zero_results", + table: "search_events", + type: "boolean", + nullable: false, + computedColumnSql: "(results_pages + results_blocks) = 0", + stored: true, + oldClrType: typeof(bool), + oldType: "boolean", + oldComputedColumnSql: "(results_pages + results_blocks + results_sections) = 0", + oldStored: true); + + migrationBuilder.AlterColumn( + name: "results_total", + table: "search_events", + type: "integer", + nullable: false, + computedColumnSql: "results_pages + results_blocks", + stored: true, + oldClrType: typeof(int), + oldType: "integer", + oldComputedColumnSql: "results_pages + results_blocks + results_sections", + oldStored: true); + + migrationBuilder.DropIndex( + name: "ix_search_events_host_path_occurred_at", + table: "search_events"); + + migrationBuilder.DropIndex( + name: "ix_search_events_surface_occurred_at", + table: "search_events"); + + migrationBuilder.DropColumn( + name: "host_path", + table: "search_events"); + + migrationBuilder.DropColumn( + name: "results_sections", + table: "search_events"); + + migrationBuilder.DropColumn( + name: "selected_key", + table: "search_events"); + + migrationBuilder.DropColumn( + name: "selected_position", + table: "search_events"); + + migrationBuilder.DropColumn( + name: "surface", + table: "search_events"); + } + } +} diff --git a/src/DfE.CheckPerformanceData.Persistence/Migrations/PortalDbContextModelSnapshot.cs b/src/DfE.CheckPerformanceData.Persistence/Migrations/PortalDbContextModelSnapshot.cs index 6684382af..2b2f05e88 100644 --- a/src/DfE.CheckPerformanceData.Persistence/Migrations/PortalDbContextModelSnapshot.cs +++ b/src/DfE.CheckPerformanceData.Persistence/Migrations/PortalDbContextModelSnapshot.cs @@ -175,6 +175,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("HostPath") + .HasColumnType("text") + .HasColumnName("host_path"); + b.Property("IsSeeded") .ValueGeneratedOnAdd() .HasColumnType("boolean") @@ -209,26 +213,47 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("results_pages"); + b.Property("ResultsSections") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("results_sections"); + b.Property("ResultsTotal") .ValueGeneratedOnAddOrUpdate() .HasColumnType("integer") .HasColumnName("results_total") - .HasComputedColumnSql("results_pages + results_blocks", true); + .HasComputedColumnSql("results_pages + results_blocks + results_sections", true); b.Property("Scope") .HasColumnType("text") .HasColumnName("scope"); + b.Property("SelectedKey") + .HasColumnType("text") + .HasColumnName("selected_key"); + + b.Property("SelectedPosition") + .HasColumnType("integer") + .HasColumnName("selected_position"); + b.Property("SessionId") .IsRequired() .HasColumnType("text") .HasColumnName("session_id"); + b.Property("Surface") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("site") + .HasColumnName("surface"); + b.Property("ZeroResults") .ValueGeneratedOnAddOrUpdate() .HasColumnType("boolean") .HasColumnName("zero_results") - .HasComputedColumnSql("(results_pages + results_blocks) = 0", true); + .HasComputedColumnSql("(results_pages + results_blocks + results_sections) = 0", true); b.HasKey("Id"); @@ -245,6 +270,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SessionId") .HasDatabaseName("ix_search_events_session_id"); + b.HasIndex("HostPath", "OccurredAtUtc") + .HasDatabaseName("ix_search_events_host_path_occurred_at") + .HasFilter("host_path IS NOT NULL"); + b.HasIndex("OccurredAtUtc", "QueryNormalised") .HasDatabaseName("ix_search_events_occurred_at_query_normalised") .HasFilter("query_normalised IS NOT NULL"); @@ -252,6 +281,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("OccurredAtUtc", "SessionId") .HasDatabaseName("ix_search_events_occurred_at_session_id"); + b.HasIndex("Surface", "OccurredAtUtc") + .HasDatabaseName("ix_search_events_surface_occurred_at"); + b.HasIndex("ZeroResults", "OccurredAtUtc") .HasDatabaseName("ix_search_events_zero_results_occurred_at") .HasFilter("zero_results = true"); diff --git a/src/DfE.CheckPerformanceData.Web/Analytics/QueryStringSearchSurfaceFilter.cs b/src/DfE.CheckPerformanceData.Web/Analytics/QueryStringSearchSurfaceFilter.cs new file mode 100644 index 000000000..a128f38b9 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Analytics/QueryStringSearchSurfaceFilter.cs @@ -0,0 +1,33 @@ +using DfE.CheckPerformanceData.Application.Analytics; +using Microsoft.AspNetCore.Http; + +namespace DfE.CheckPerformanceData.Web.Analytics; + +// Reads the dashboard's surface filter off the current request: ?surface=instant-page, or +// repeated for more than one. Anything absent, empty, or not a surface this app declares +// means every surface, so a mistyped value widens the view rather than silently emptying it. +// +// Scoped, and resolved per request — same shape and the same reason as +// CmsSettingsSearchDebugOptions: never register it as a Singleton, or the first request's +// filter would pin itself to the process for everyone. +public sealed class QueryStringSearchSurfaceFilter(IHttpContextAccessor accessor) : ISearchSurfaceFilter +{ + public const string QueryKey = "surface"; + + public IReadOnlyList Surfaces + { + get + { + var values = accessor.HttpContext?.Request.Query[QueryKey]; + if (values is null || values.Value.Count == 0) return SearchSurfaces.All; + + var chosen = values.Value + .Where(v => SearchSurfaces.IsKnown(v)) + .Select(v => v!) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + return chosen.Length == 0 ? SearchSurfaces.All : chosen; + } + } +} diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/InstantSearchAnalyticsController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/InstantSearchAnalyticsController.cs new file mode 100644 index 000000000..fb5c5eed0 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Controllers/InstantSearchAnalyticsController.cs @@ -0,0 +1,177 @@ +using System.Text.Json; +using DfE.CheckPerformanceData.Application.Analytics; +using DfE.CheckPerformanceData.Application.Search; +using DfE.CheckPerformanceData.Web.Session; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace DfE.CheckPerformanceData.Web.Controllers; + +// Where an instant search reports itself once the person has settled on a query. +// +// A typeahead has no server-side moment to record. The server sees a suggestion fetch per +// settled keystroke and nothing at all for an on-page search, and neither of those is the +// event worth keeping: what matters is the query someone ended on, what they were shown for +// it, and whether they took any of it. Only the browser knows that, so it reports it — once +// per settled query, never per keystroke. +// +// Everything in the payload is therefore untrusted input, and is treated as such: the surface +// must be one this app declares, the query is sliced to the same 100 characters /search uses, +// the result list is capped, an unrecognised result kind degrades to "page" rather than being +// written through, and a malformed report is refused rather than persisted half-formed. +// +// The session id is NOT taken from the payload. It comes from the server-side session, the +// same way the feedback form does, so a report cannot file itself against someone else's +// history. +// +// Load: the reports land on the same bounded channel as every other analytics event, which +// sheds and counts drops when full. A flood costs dropped rows and a warn line, not the app. +[AllowAnonymous] +public sealed class InstantSearchAnalyticsController(ISearchTelemetry telemetry) : Controller +{ + private const int MaxQueryLength = 100; + private const int MinQueryLength = 2; + private const int MaxShown = 10; + private const int MaxKeyLength = 512; + private const int MaxLabelLength = 256; + private const int MaxLatencyMs = 60_000; + + // Form-encoded rather than JSON because the browser sends this with sendBeacon, which + // survives the page being closed but cannot set headers — so the antiforgery token has to + // travel in the body, which means a form content type. The result list is one JSON field + // inside that form. + [HttpPost("/search/instant-analytics")] + [ValidateAntiForgeryToken] + public async Task Record([FromForm] InstantSearchReport report, CancellationToken ct) + { + if (!SearchSurfaces.IsInstant(report.Surface)) + { + return BadRequest(); + } + + var query = (report.Q ?? string.Empty).Trim(); + if (query.Length > MaxQueryLength) query = query[..MaxQueryLength]; + + // Below the minimum the widget never ran a search, so there is nothing to record. + // Not an error — the browser is allowed to report a query the person then deleted. + if (query.Length < MinQueryLength) + { + return NoContent(); + } + + if (!TryReadShown(report.Shown, out var shown)) + { + return BadRequest(); + } + + // A position outside the menu that was actually shown is nonsense, and a selection is + // only meaningful alongside its key — so an out-of-range one is dropped rather than + // stored as a number no chart can interpret. + var selectedPosition = report.SelectedPosition is { } p && p >= 1 && p <= shown.Count + ? p + : (int?)null; + + var selectedKey = Trim(report.SelectedKey, MaxKeyLength); + + await HttpContext.Session.LoadAsync(ct); + CpdSessionIdentity.Ensure(HttpContext.Session); + + telemetry.RecordInstantSearch(new InstantSearchTelemetryEvent( + SearchId: Guid.NewGuid(), + UtcTimestamp: DateTime.UtcNow, + QueryRaw: query, + QueryNormalised: SearchTermNormalizer.OrJoinWhitespace(query), + Scope: Trim(report.Scope, MaxKeyLength), + Surface: report.Surface!, + // A host page only means something for an on-page search. Ignoring it elsewhere + // keeps the single-page section of the dashboard from filling with site searches. + HostPath: report.Surface == SearchSurfaces.InstantPage + ? Trim(report.HostPath, MaxKeyLength) + : null, + LatencyMs: Math.Clamp(report.LatencyMs, 0, MaxLatencyMs), + Shown: shown, + SelectedKey: selectedKey, + SelectedPosition: selectedKey is null ? null : selectedPosition)); + + return NoContent(); + } + + private static bool TryReadShown(string? json, out IReadOnlyList shown) + { + shown = []; + if (string.IsNullOrWhiteSpace(json)) return true; + + List? parsed; + try + { + parsed = JsonSerializer.Deserialize>(json, JsonOptions); + } + catch (JsonException) + { + // A report we cannot read is refused outright. Writing the parent row without its + // results would look like a zero-result search, which is a different fact. + return false; + } + + if (parsed is null) return true; + + var hits = new List(Math.Min(parsed.Count, MaxShown)); + for (var i = 0; i < parsed.Count && hits.Count < MaxShown; i++) + { + var item = parsed[i]; + var key = Trim(item.Key, MaxKeyLength); + if (key is null) continue; + + // Anything but the one kind we recognise is filed as a page rather than written + // through — the column is read back into SQL and rendered on an admin page. + var kind = string.Equals(item.Kind, InstantSearchEventMapper.SectionKind, StringComparison.Ordinal) + ? InstantSearchEventMapper.SectionKind + : InstantSearchEventMapper.PageKind; + + hits.Add(new InstantSearchShownHit( + Position: hits.Count + 1, + Kind: kind, + Key: key, + Label: Trim(item.Label, MaxLabelLength) ?? string.Empty)); + } + + shown = hits; + return true; + } + + private static string? Trim(string? value, int max) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var trimmed = value.Trim(); + return trimmed.Length > max ? trimmed[..max] : trimmed; + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private sealed class ShownDto + { + public string? Kind { get; set; } + public string? Key { get; set; } + public string? Label { get; set; } + } +} + +// The form a browser posts. Bound as a model rather than loose parameters so the field names +// live in one place alongside the JavaScript that fills them. +public sealed class InstantSearchReport +{ + public string? Surface { get; set; } + public string? Q { get; set; } + public string? Scope { get; set; } + public string? HostPath { get; set; } + + // JSON array of {kind, key, label}, in the order the person saw them. + public string? Shown { get; set; } + + public string? SelectedKey { get; set; } + public int? SelectedPosition { get; set; } + public int LatencyMs { get; set; } +} diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/SearchAnalyticsController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/SearchAnalyticsController.cs index 037e3ffb8..c11ef7295 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/SearchAnalyticsController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/SearchAnalyticsController.cs @@ -309,6 +309,60 @@ public async Task HeatmapCell( }); } + // Single-page search. One action, two states: without a path it lists the pages that + // carry an on-page search widget; with one it lists what was searched for on that page. + // The gap between a page's searches and its selections is the number worth reading — it + // counts the times the page was asked something and had nothing useful to offer. + [HttpGet("OnPage")] + public async Task OnPage( + string? path, + string? range, + DateTime? from, + DateTime? to, + int page = 1, + CancellationToken ct = default) + { + ViewData["AdminActiveKey"] = AdminNavKeys.SearchAnalytics; + ViewData["Title"] = string.IsNullOrWhiteSpace(path) + ? "Single-page search" + : "Single-page search: " + path; + ViewData["AdminWide"] = true; + + var (fromUtc, toUtc, rangeKey) = ResolveWindowFromRequest(range, from, to); + var pageSize = await ResolvePageSizeAsync(); + if (page < 1) page = 1; + + var hostPath = string.IsNullOrWhiteSpace(path) ? null : path.Trim(); + + if (hostPath is null) + { + var (pages, pagesTotal) = await _query.GetOnPageSearchPagesAsync(fromUtc, toUtc, page, pageSize, ct); + return View("~/Views/Admin/Search/OnPage.cshtml", new OnPageSearchViewModel + { + Pages = pages, + TotalCount = pagesTotal, + Page = page, + PageSize = pageSize, + FromUtc = fromUtc, + ToUtc = toUtc, + RangeKey = rangeKey, + }); + } + + var (terms, termsTotal) = await _query.GetOnPageSearchTermsAsync(hostPath, fromUtc, toUtc, page, pageSize, ct); + return View("~/Views/Admin/Search/OnPage.cshtml", new OnPageSearchViewModel + { + HostPath = hostPath, + Terms = terms, + TotalCount = termsTotal, + Page = page, + PageSize = pageSize, + FromUtc = fromUtc, + ToUtc = toUtc, + RangeKey = rangeKey, + }); + } + [HttpGet("Queries")] public async Task Queries( string? range, diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/SearchSuggestionsController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/SearchSuggestionsController.cs new file mode 100644 index 000000000..9c5b0d1ea --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Controllers/SearchSuggestionsController.cs @@ -0,0 +1,40 @@ +using DfE.CheckPerformanceData.Application.Search; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace DfE.CheckPerformanceData.Web.Controllers; + +// Typeahead behind the content-page search widget's instant-search option. Anonymous, like +// /search itself, and reachable only as JSON. +// +// The payload is {label, url} rather than the {id, label} / {value, label} shapes the +// journey-domain suggestion endpoints use, because choosing a suggestion here navigates to a +// page instead of filling a hidden code field. +// +// Guards mirror /search: a term is trimmed then hard-sliced to the leading 100 characters, and +// anything shorter than the search service's own minimum never reaches the database at all — +// this runs on every keystroke, so the cheap rejection has to happen before the round trip. +[AllowAnonymous] +public sealed class SearchSuggestionsController(ISiteSearchService searchService) : Controller +{ + private const int MaxQueryLength = 100; + private const int MinQueryLength = 2; + private const int MaxSuggestions = 10; + + [HttpGet("/search/suggestions")] + public async Task Suggestions(string? q, string? scope) + { + var term = (q ?? string.Empty).Trim(); + if (term.Length > MaxQueryLength) term = term[..MaxQueryLength]; + + if (term.Length < MinQueryLength) + { + return Json(Array.Empty()); + } + + var suggestions = await searchService.SuggestAsync( + new SiteSearchSuggestQuery(term, scope, MaxSuggestions)); + + return Json(suggestions); + } +} diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/SearchAnalyticsDrillInViewModels.cs b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/SearchAnalyticsDrillInViewModels.cs index a0276fb05..b1bae6e3a 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/SearchAnalyticsDrillInViewModels.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/SearchAnalyticsDrillInViewModels.cs @@ -39,3 +39,21 @@ public sealed class SearchAnalyticsPagesDrillInViewModel public int TotalPages => PageSize <= 0 ? 0 : (int)Math.Ceiling(TotalCount / (double)PageSize); } + +// The single-page-search section. Two states behind one view model: with no HostPath it lists +// the pages that carry an on-page search widget; with one it lists the terms searched on that +// page. Sharing the model keeps the paging, window and filter chrome identical between them. +public sealed class OnPageSearchViewModel +{ + public string? HostPath { get; init; } + public IReadOnlyList Pages { get; init; } = []; + public IReadOnlyList Terms { get; init; } = []; + public required int TotalCount { get; init; } + public required int Page { get; init; } + public required int PageSize { get; init; } + public required DateTime FromUtc { get; init; } + public required DateTime ToUtc { get; init; } + public required string RangeKey { get; init; } + + public int TotalPages => PageSize <= 0 ? 0 : (int)Math.Ceiling(TotalCount / (double)PageSize); +} diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/TimeWindowFilterModel.cs b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/TimeWindowFilterModel.cs index e55424308..eabf7a4c9 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/TimeWindowFilterModel.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/ViewModels/TimeWindowFilterModel.cs @@ -20,4 +20,8 @@ public sealed class TimeWindowFilterModel // there and no aggregate value is submitted. public bool ShowAggregateToggle { get; init; } public bool AggregateOn { get; init; } + + // Bucket size only means something to a view that draws a time series. The single-page + // search tables do not, and a control that changes nothing is worse than no control. + public bool ShowBucketSize { get; init; } = true; } diff --git a/src/DfE.CheckPerformanceData.Web/Startup/SearchTelemetryExtensions.cs b/src/DfE.CheckPerformanceData.Web/Startup/SearchTelemetryExtensions.cs index e21041269..bad6a70cc 100644 --- a/src/DfE.CheckPerformanceData.Web/Startup/SearchTelemetryExtensions.cs +++ b/src/DfE.CheckPerformanceData.Web/Startup/SearchTelemetryExtensions.cs @@ -16,6 +16,13 @@ public static IServiceCollection AddCpdSearchTelemetry(this IServiceCollection s Application.Search.ISearchDebugOptions, Application.Search.CmsSettingsSearchDebugOptions>(); + // Which surfaces the analytics dashboard is reading. Scoped for the same reason as + // the debug options above: it is a property of the current request, and a Singleton + // would pin one reader's filter to every other reader. + services.AddScoped< + Application.Analytics.ISearchSurfaceFilter, + Analytics.QueryStringSearchSurfaceFilter>(); + // Emits a structured log per search request (Info summary, Debug/Info per-hit and // per-exclusion depending on the debug toggle, Warn zero-result). Registered as the // CONCRETE type so the composite decorator below can resolve it directly without a diff --git a/src/DfE.CheckPerformanceData.Web/Views/Admin/Search/Index.cshtml b/src/DfE.CheckPerformanceData.Web/Views/Admin/Search/Index.cshtml index 2fe0b0bdf..5dcfa6b8b 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/Admin/Search/Index.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/Admin/Search/Index.cshtml @@ -306,6 +306,26 @@ + @* Single-page search gets its own entry point rather than a card of figures: its rows + answer a different question from the rest of the dashboard (what one page was asked, + not what the site was asked), and reading them alongside site totals invites + comparing numbers that do not mean the same thing. *@ +
+
+

Single-page search

+
+
+

+ Searches made with a widget set to search the page it sits on. A visitor is + offered the sections of that page and jumps to one, so these never show up as + a page view or a results page anywhere else. +

+

+ View single-page search → +

+
+
+

Top zero-result queries

diff --git a/src/DfE.CheckPerformanceData.Web/Views/Admin/Search/OnPage.cshtml b/src/DfE.CheckPerformanceData.Web/Views/Admin/Search/OnPage.cshtml new file mode 100644 index 000000000..ffb9d5761 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Web/Views/Admin/Search/OnPage.cshtml @@ -0,0 +1,168 @@ +@using System.Globalization +@using DfE.CheckPerformanceData.Application.Analytics +@model DfE.CheckPerformanceData.Web.Controllers.ViewModels.OnPageSearchViewModel +@inject ISearchSurfaceFilter SurfaceFilter +@{ + var isDrillIn = !string.IsNullOrWhiteSpace(Model.HostPath); + ViewData["Title"] = isDrillIn ? "Single-page search: " + Model.HostPath : "Single-page search"; + + var rangeQs = SearchAnalyticsRangeQuery.Build(Model.RangeKey, Model.FromUtc, Model.ToUtc); + string PageLink(int p) => isDrillIn + ? $"/admin/Search/OnPage?path={Uri.EscapeDataString(Model.HostPath!)}&{rangeQs}&page={p}" + : $"/admin/Search/OnPage?{rangeQs}&page={p}"; + + const string tableId = "sa-onpage-table"; + var firstRow = Model.TotalCount == 0 ? 0 : ((Model.Page - 1) * Model.PageSize) + 1; + var lastRow = Math.Min(Model.Page * Model.PageSize, Model.TotalCount); + + // Searches where the person was offered sections and took none of them. The count worth + // reading on this page: it is the page being asked a question it could not answer. + string NotTaken(int searches, int selected) => + (searches - selected).ToString("N0", CultureInfo.CurrentCulture); +} + + + +@if (isDrillIn) +{ + Back to single-page search +} +else +{ + Back to search analytics +} + +

Single-page search

+ +@if (isDrillIn) +{ +

+ What visitors searched for on + @Model.HostPath + using the search widget on that page. +

+} +else +{ +

+ Pages carrying a search widget set to search the page itself. A search here never leaves + the page — the visitor is offered its sections and jumps to one — so these searches do + not appear as page views anywhere else. +

+} + +@await Html.PartialAsync("~/Views/Admin/Search/_TimeWindowFilterForm.cshtml", + new DfE.CheckPerformanceData.Web.Controllers.ViewModels.TimeWindowFilterModel + { + FormAction = isDrillIn + ? $"/admin/Search/OnPage?path={Uri.EscapeDataString(Model.HostPath!)}" + : "/admin/Search/OnPage", + RangeKey = Model.RangeKey, + FromUtc = Model.FromUtc, + ToUtc = Model.ToUtc, + BucketKey = "1d", + ShowBucketSize = false, + }) + +@* Every row on this page is an on-page search, so a surface filter that excludes them + empties the table. Saying "nothing was searched for" in that case would be a lie, and the + reader has no way to tell the difference. *@ +@if (!SurfaceFilter.Surfaces.Contains(SearchSurfaces.InstantPage)) +{ +
+ + + Warning + The search surface filter currently excludes on-page searches, so this section has + nothing to show. Tick Instant — this page only above to see it. + +
+} +else if (Model.TotalCount == 0) +{ +

+ @(isDrillIn + ? "Nothing was searched for on this page in this window." + : "No on-page searches recorded in this window.") +

+} +else if (SurfaceFilter.Surfaces.Contains(SearchSurfaces.InstantPage)) +{ +

+ Showing @firstRow to @lastRow of @Model.TotalCount. +

+ +
+ +
Instant filter, no server round-trip.
+ +
+ + + + + + + + @if (!isDrillIn) + { + + } + + + + + + + @if (isDrillIn) + { + @foreach (var row in Model.Terms) + { + + + + + + + + } + } + else + { + @foreach (var row in Model.Pages) + { + + + + + + + + + } + } + +
+ @(isDrillIn ? "Terms searched on this page" : "Pages with an on-page search widget") +
@(isDrillIn ? "Term" : "Page")SearchesSessionsNo matchJumpedNot taken
@row.QueryNormalised@row.Searches.ToString("N0", CultureInfo.CurrentCulture)@row.ZeroResultCount.ToString("N0", CultureInfo.CurrentCulture)@row.SelectedCount.ToString("N0", CultureInfo.CurrentCulture)@NotTaken(row.Searches, row.SelectedCount)
+ @row.HostPath + @row.Searches.ToString("N0", CultureInfo.CurrentCulture)@row.UniqueSessions.ToString("N0", CultureInfo.CurrentCulture)@row.ZeroResultCount.ToString("N0", CultureInfo.CurrentCulture)@row.SelectedCount.ToString("N0", CultureInfo.CurrentCulture)@NotTaken(row.Searches, row.SelectedCount)
+ + @await Component.InvokeAsync("Pager", new + { + currentPage = Model.Page, + totalPages = Model.TotalPages, + urlBuilder = (Func)PageLink, + }) +} + +@section Scripts { + +} diff --git a/src/DfE.CheckPerformanceData.Web/Views/Admin/Search/_TimeWindowFilterForm.cshtml b/src/DfE.CheckPerformanceData.Web/Views/Admin/Search/_TimeWindowFilterForm.cshtml index 4a43c9a2c..8fffbe20e 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/Admin/Search/_TimeWindowFilterForm.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/Admin/Search/_TimeWindowFilterForm.cshtml @@ -1,5 +1,7 @@ @using System.Globalization +@using DfE.CheckPerformanceData.Application.Analytics @model DfE.CheckPerformanceData.Web.Controllers.ViewModels.TimeWindowFilterModel +@inject ISearchSurfaceFilter SurfaceFilter @{ // Shared time-window + bucket-size filter form used by the landing dashboard and every // series drill-in. Same GDS controls; the form's action URL varies per caller (the @@ -59,6 +61,8 @@
+ @if (Model.ShowBucketSize) + {
Chart bucket size @@ -82,6 +86,7 @@
+ } @if (Model.ShowAggregateToggle) { @@ -101,6 +106,40 @@ } + @* Which search surfaces the figures cover. Read from the same ISearchSurfaceFilter the + query layer uses, so the boxes ticked here can never disagree with what was counted. + All ticked (or none) means everything, which is what an unfiltered dashboard shows. *@ +
+
+ Search surface +
+ Leave all ticked to count every search. Two of these are instant search — the + typeahead — and they are recorded separately because searching a whole section + of the site and searching the page you are reading are different acts. +
+
+ @foreach (var (value, label) in new[] + { + @* Named so the family is visible. The first labelling called these + "Instant search" and "Single-page search", which reads as two unrelated + features — so someone who used the instant search on a page ticked + "Instant search" and their own searches were filtered out. *@ + (SearchSurfaces.Site, "Submitted — someone pressed the button"), + (SearchSurfaces.Instant, "Instant — the whole site or a section"), + (SearchSurfaces.InstantPage, "Instant — this page only"), + }) + { + var id = "surface-" + value; +
+ + +
+ } +
+
+
+
Reset to last 7 days diff --git a/src/DfE.CheckPerformanceData.Web/Views/ContentPage/Edit.cshtml b/src/DfE.CheckPerformanceData.Web/Views/ContentPage/Edit.cshtml index c9dac7d4b..07d77affc 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/ContentPage/Edit.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/ContentPage/Edit.cshtml @@ -402,6 +402,7 @@ var wrapper = select.closest('.govuk-details__text'); if (!wrapper) return; var childrenPanel = wrapper.querySelector('[data-cpb-pagenav-children]'); + var headingsPanel = wrapper.querySelector('[data-cpb-pagenav-headings]'); var searchCheckbox = wrapper.querySelector('[data-cpb-pagenav-showsearch]'); var searchPanel = wrapper.querySelector('[data-cpb-pagenav-searchfields]'); if (!childrenPanel) return; @@ -409,6 +410,7 @@ function refresh() { var isChildren = select.value === 'children'; childrenPanel.hidden = !isChildren; + if (headingsPanel) headingsPanel.hidden = isChildren; if (searchPanel) searchPanel.hidden = !isChildren || !(searchCheckbox && searchCheckbox.checked); } select.addEventListener('change', refresh); @@ -418,6 +420,29 @@ })(); +@* Live toggling for the Search widget editor: the scope path only applies to a section of the + site, and the no-results copy only to instant search. *@ + + @* URL-change confirmation. When the segment field differs from the value the page loaded with, the outer form's submit is cancelled, the current title / subtitle / page name / segment are copied into the modal's hidden inputs, and the govuk-confirm-modal is opened. Title-only diff --git a/src/DfE.CheckPerformanceData.Web/Views/Page/Content.cshtml b/src/DfE.CheckPerformanceData.Web/Views/Page/Content.cshtml index 44f40ea14..a87abe6b6 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/Page/Content.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/Page/Content.cshtml @@ -10,6 +10,9 @@ // on ViewData here — the widget partial reads whichever key its mode dictates. ViewData["PageHeadingNav"] = Model.Nav ?? Array.Empty(); ViewData["PageChildrenNav"] = Model.ChildrenNav ?? Array.Empty(); + // The tree itself, so a PageNav widget can build a heading list for the levels its author + // ticked rather than being stuck with the one the controller happened to precompute. + ViewData["PageContentTree"] = content; // If the author has placed their own H1 widget inside the content tree, suppress the // template-emitted title + subtitle — otherwise we'd render two H1s and the title would diff --git a/src/DfE.CheckPerformanceData.Web/Views/Shared/ContentPages/Widgets/_PageNav.cshtml b/src/DfE.CheckPerformanceData.Web/Views/Shared/ContentPages/Widgets/_PageNav.cshtml index 655983e79..c9e558bed 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/Shared/ContentPages/Widgets/_PageNav.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/Shared/ContentPages/Widgets/_PageNav.cshtml @@ -5,8 +5,10 @@ @* Page-navigation widget. Two modes: - - "headings" (default) → renders the auto-generated heading nav for the current page - (H2 top-level, H3 nested). Pre-computed by PageController and stashed in ViewData. + - "headings" (default) → renders a contents list of the current page, covering whichever + heading levels the author ticked (H2 and H3 unless they said otherwise). Built here from + the page's own tree, because the levels are this widget's choice and two nav widgets on + one page may legitimately want different ones. - "children" → renders a list of direct child pages. Which parent's children are listed is set by the childrenParentPath prop (empty = current page). Optionally shows a @@ -50,7 +52,12 @@ } else { - items = ViewData["PageHeadingNav"] as IReadOnlyList ?? []; + var tree = ViewData["PageContentTree"] as IReadOnlyList; + items = tree is not null + ? ContentNavBuilder.Build(tree, NavLevelSelection.For(Model)) + // No tree in scope — the widget is being previewed in the editor, where the + // controller's precomputed list is the best available answer. + : ViewData["PageHeadingNav"] as IReadOnlyList ?? []; } var showSearch = mode == "children" && (Model.GetBool("showSearch") ?? false); diff --git a/src/DfE.CheckPerformanceData.Web/Views/Shared/ContentPages/Widgets/_Search.cshtml b/src/DfE.CheckPerformanceData.Web/Views/Shared/ContentPages/Widgets/_Search.cshtml index f4120e3af..9df292d15 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/Shared/ContentPages/Widgets/_Search.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/Shared/ContentPages/Widgets/_Search.cshtml @@ -6,10 +6,47 @@ var action = Model.GetString("action") ?? string.Empty; var buttonText = Model.GetString("buttonText") ?? "Search"; // Optional scope: narrows results to pages under a path prefix (matches /search's ?scope=). - var scope = (Model.GetString("scope") ?? string.Empty).Trim().Trim('/'); - var inputId = string.IsNullOrEmpty(Model.Anchor) ? "search" : $"search-{Model.Anchor}"; + var scopeProp = (Model.GetString("scope") ?? string.Empty).Trim().Trim('/'); + + // Where the widget searches: the whole site, one section of it, or the page it sits on. + var searchIn = (Model.GetString("searchIn") ?? string.Empty).Trim().ToLowerInvariant(); + // Widgets placed before this prop existed carry only a scope, so a scope they already set still + // means a section and an empty one still means the whole site. Nothing needs migrating. + if (searchIn.Length == 0) searchIn = scopeProp.Length > 0 ? "path" : "site"; + // An unrecognised value searches the whole site rather than rendering a box that does nothing. + if (searchIn != "path" && searchIn != "page") searchIn = "site"; + + // The scope that travels with the form. "This page" resolves to the page being viewed, which is + // what lets a no-JS submit still land on a search of just this page. + var currentPath = (ViewContext.HttpContext.Request.Path.Value ?? string.Empty).Trim('/'); + var scope = searchIn switch + { + "path" => scopeProp, + "page" => currentPath, + _ => string.Empty + }; + + // Instant search is an enhancement over the form below, never a replacement for it: the markup + // is identical either way, and the script only binds when the marker attribute is present. + var instant = Model.GetBool("instant") ?? false; + var noResultsText = Model.GetString("noResultsText") ?? "No results found"; + // Two search widgets on one page must not share an input id: the label's `for` keys off it, + // and so do the listbox and status ids accessible-autocomplete derives from it — duplicates + // would point a screen reader at the wrong menu. Counted per request; the first widget keeps + // the plain "search" id it has always had. + var widgetSeq = (int?)ViewContext.HttpContext.Items["cypmd-search-widget-seq"] ?? 0; + ViewContext.HttpContext.Items["cypmd-search-widget-seq"] = widgetSeq + 1; + var inputId = !string.IsNullOrEmpty(Model.Anchor) ? $"search-{Model.Anchor}" + : widgetSeq == 0 ? "search" + : $"search-{widgetSeq + 1}"; } -