();
+
+ var viewModel = new DynamicCheckboxesViewModel
+ {
+ Label = label,
+ HintText = string.IsNullOrWhiteSpace(hintText) ? null : hintText,
+ ErrorMessage = errorMessage,
+ Required = required,
+ CssClass = string.IsNullOrWhiteSpace(cssClass) ? null : cssClass,
+ SelectedValues = selectedList,
+ Checkboxes = checkboxes.Select(cb => new DynamicCheckboxItemViewModel
+ {
+ Value = cb.Value,
+ Label = cb.Label,
+ HintText = cb.HintText,
+ Exclusive = cb.Exclusive,
+ Selected = selectedList.Contains(cb.Value),
+ }).ToList(),
+ };
+
+ this.ViewData["PropertyName"] = propertyName;
+ return this.View(viewModel);
+ }
+ }
+}
diff --git a/LearningHub.Nhs.WebUI/Views/Account/AccessRestricted.cshtml b/LearningHub.Nhs.WebUI/Views/Account/AccessRestricted.cshtml
new file mode 100644
index 000000000..ec8385d39
--- /dev/null
+++ b/LearningHub.Nhs.WebUI/Views/Account/AccessRestricted.cshtml
@@ -0,0 +1,32 @@
+@{
+ ViewData["Title"] = "You do not have the required permissions to access this part of the Learning Hub";
+ Layout = ViewData["Layout"].ToString();
+}
+
+@section styles{
+
+}
+
+
+
+
+
+
Access restricted
+
+
You do not have the required permissions to access this part of the Learning Hub yet.
+
+
+
+
+
+
+
+
If you think you should have access, please contact the support team.
+
+
+
+
+
+
+
+
diff --git a/LearningHub.Nhs.WebUI/Views/Policies/AcceptableUsePolicy.cshtml b/LearningHub.Nhs.WebUI/Views/Policies/AcceptableUsePolicy.cshtml
index 1b37e2979..3105eef83 100644
--- a/LearningHub.Nhs.WebUI/Views/Policies/AcceptableUsePolicy.cshtml
+++ b/LearningHub.Nhs.WebUI/Views/Policies/AcceptableUsePolicy.cshtml
@@ -68,7 +68,7 @@
4.5.2.2 which requires a subscription or payment to gain access to such content;
4.5.2.3 in which the user has a commercial interest;
4.5.2.4 which promotes a business name and/or logo;
- 4.5.2.5 which contains a link to an app via iOS or Google Play; or
+ 4.5.2.5 which contains a link to an app via iOS, Google Play or similar; or
4.5.2.6 which has as its purpose or effect the collection and sharing of personal data;
4.5.3 be irrelevant to the purpose or aims of the Platform or while addressing relevant subject matter, contain an irrelevant, unsuitable or inappropriate slant (for example relating to potentially controversial opinions or beliefs of any kind intended to influence others);
4.5.4 be defamatory of any person;
@@ -92,8 +92,10 @@
4.5.22 contain harmful material;
4.5.23 give the impression that the Contribution emanates from us, if this is not the case; or
4.5.24 disclose any third party’s confidential information, identity, personally identifiable information or personal data (including data concerning health).
- 4.6 You acknowledge and accept that, when using the Platform and accessing the Content, some Content that has been uploaded by third parties may be factually inaccurate, or the topics addressed by the Content may be offensive, indecent, or objectionable in nature. We are not responsible (legally or otherwise) for any claim you may have in relation to the Content.
- 4.7 When producing Content to upload to the Platform, we encourage you to implement NICE guideline recommendations and ensure that sources of evidence are valid (for example, by peer review).
+ 4.5.25 contain or request any material, the provision of which is not compliant with NHS England Information Governance guidance [https://www.england.nhs.uk/ig/ig-resources/].
+ 4.6 Posts must be made with consideration of the NHS People Promise[https://www.england.nhs.uk/our-nhs-people/online-version/lfaop/our-nhs-people-promise/].
+ 4.7 You acknowledge and accept that, when using the Platform and accessing the Content, some Content that has been uploaded by third parties may be factually inaccurate, or the topics addressed by the Content may be offensive,indecent, or objectionable in nature. We are not responsible (legally or otherwise) for any claim you may have in relation to the Content.
+ 4.8 When producing Content to upload to the Platform, we encourage you to implement NICE guideline recommendations and ensure that sources of evidence are valid (for example, by peer review).
5 Metadata
When making any Contribution, you must where prompted include a sufficient description of the Content so that other users can understand the description, source, and age of the Content. For example, if Content has been quality assured, then the relevant information should be posted in the appropriate field. All metadata fields on the Platform must be completed appropriately before initiating upload. Including the correct information is important in order to help other users locate the Content (otherwise the Content may not appear in search results for others to select).
6 Updates
@@ -121,13 +123,13 @@
10.4 legal proceedings against you for reimbursement of all costs on an indemnity basis (including, but not limited to, reasonable administrative and legal costs) resulting from the breach, and/or further legal action against you;
10.5 disclosure of such information to law enforcement authorities as we reasonably feel is necessary or as required by law; and/or
10.6 any other action we reasonably deem appropriate.
- Moderation of Contributions
+ 11 Moderation of Contributions
11.1 Course Manager means a person authorised by NHS England who is responsible for creating and managing courses and learning resources on the Learning Hub, including the moderation of learner's Contributions within social learning environments of an online course, such as discussion forums.
11.2 Course Managers shall be responsible for monitoring local forum activity and ensuring compliance of all Contributions with the Learning Hub’s Acceptable Use Policy. Inappropriate Contributions shall be addressed promptly and escalated where necessary to the Learning Hub, who will review the user’s access to the Platform and the relevant forum.
11.3 Course Managers shall also take responsibility for the following:
- 11.3.1 Clear Expectations: Set clear local forum rules within their course at the outset, compliantly with this Acceptable Use Policy, which includes expected behaviour, response times and moderation practices.
- 11.3.2 Inclusive Practice: Encourage participation from all learners/users and foster a respectful, inclusive environment that values diverse perspectives. This should involve reminding local forum users of the appropriate measures of this Acceptable Use Policy and potentially removing Contributions that infringe the rules of this Acceptable Use Policy including under paragraph 4.5 above.
- 11.3.3 Data Protection and Safety: Ensure that no sensitive or personal data i shared, requested or stored in local forums, including by promptly removing any inclusions or requests for such material and notifying NHS England promptly of any potential breaches by contacting NHS England’s Data Protection Officer team via england.dpo@nhs.net; and ensure that risks are actively managed and the local forums are maintained as a safe space for discussion.
+ 11.3.1 Clear Expectations: Set clear local forum rules within their course at the outset, compliantly with this Acceptable Use Policy, which includes expected behaviour, response times and moderation practices.
+ 11.3.2 Inclusive Practice: Encourage participation from all learners/users and foster a respectful, inclusive environment that values diverse perspectives. This should involve reminding local forum users of the appropriate measures of this Acceptable Use Policy and potentially removing Contributions that infringe the rules of this Acceptable Use Policy including under paragraph 4.5 above.
+ 11.3.3 Data Protection and Safety: Ensure that no sensitive or personal data is shared, requested or stored in local forums, including by promptly removing any inclusions or requests for such material and notifying NHS England promptly of any potential breaches by contacting NHS England’s Data Protection Officer team via england.dpo@nhs.net ; and ensure that risks are actively managed and the local forums are maintained as a safe space for discussion.
11.4 All users must comply with this Acceptable Use Policy. Breaches may result in removal of Contributions and/or withdrawal or suspension of user access without notice.
diff --git a/LearningHub.Nhs.WebUI/Views/Reports/CourseProgressReport.cshtml b/LearningHub.Nhs.WebUI/Views/Reports/CourseProgressReport.cshtml
new file mode 100644
index 000000000..ea0660794
--- /dev/null
+++ b/LearningHub.Nhs.WebUI/Views/Reports/CourseProgressReport.cshtml
@@ -0,0 +1,166 @@
+@using LearningHub.Nhs.Models.Enums
+@using LearningHub.Nhs.Models.Enums.Report
+@using LearningHub.Nhs.WebUI.Helpers
+@using LearningHub.Nhs.WebUI.Models
+@using LearningHub.Nhs.WebUI.Models.Learning
+@model LearningHub.Nhs.WebUI.Models.Report.CourseCompletionViewModel
+
+@{
+ ViewData["Title"] = "Course progress report";
+ var returnUrl = $"{Context.Request.Path}{Context.Request.QueryString}";
+
+
+ var pagingModel = Model.ReportPaging;
+ int currentPage = pagingModel.CurrentPage;
+ int pageSize = pagingModel.PageSize;
+ int totalRows = pagingModel.TotalItems;
+
+ int startRow = (currentPage * pageSize) + 1;
+ int endRow = Math.Min(startRow + pageSize - 1, totalRows);
+ var distinctCourses = this.ViewData["matchedCourseNames"] as List;
+
+}
+
+@section styles {
+
+}
+
+
+
+
+
+
+
+
+
+
+
Course progress report
+
+
+
+ Course@(distinctCourses.Count() > 1 ? "s" : "")
+
+
+ @if (distinctCourses.Count() > 1)
+ {
+
+ @foreach (var entry in distinctCourses)
+ {
+ @entry
+ }
+
+ }
+ else
+ {
+ @distinctCourses.FirstOrDefault()
+ }
+
+
+
+
+
+
+ Change Course
+
+
+
+
+
+
+
+ Reporting period
+
+
+ @{
+ if (Model.TimePeriod == "Custom" && Model.StartDate.HasValue && Model.EndDate.HasValue)
+ {
+ @Model.StartDate.Value.ToString("dd MMMM yyyy") to @Model.EndDate.Value.ToString("dd MMMM yyyy")
+ }
+ else
+ {
+ @Model.TimePeriod days
+ }
+ }
+
+
+
+
+
+ Change Reporting period
+
+
+
+
+
+
+
+
+
+
+ @if (Model.TotalCount > 0)
+ {
+
Displaying @startRow to @endRow of @Model.TotalCount filtered row@(Model.TotalCount > 1 ? "s" : "")
+
+
+ @if (Model.ReportHistoryModel != null && Model.ReportHistoryModel.DownloadRequest == null)
+ {
+
+ Request to download this report in a spreadsheet (.xls) format. You will be notified
+ when the report is ready.
+
+
+ }
+ else if (Model.ReportHistoryModel != null && Model.ReportHistoryModel.DownloadRequest == true && Model.ReportHistoryModel.ReportStatusId == ((int)Status.Pending))
+ {
+
+
Information:
+
We’re getting your report ready
+
+ You will be notified once it’s ready to download and you will be able to access it in the
+ Reports section .
+
+
+ }
+ }
+ else
+ {
+
Displaying no results
+ }
+
+ @if (Model.CourseCompletionRecords.Any())
+ {
+ @await Html.PartialAsync("_ReportTable", Model)
+ }
+ else
+ {
+
+
+
+
+
+ No information is available
+
+
+ Please adjust your filters
+
+
+
+
+
+ }
+
+
+
+
+ @await Html.PartialAsync("_ReportPaging", Model)
+
+
+
+
diff --git a/LearningHub.Nhs.WebUI/Views/Reports/CreateReportCourseSelection.cshtml b/LearningHub.Nhs.WebUI/Views/Reports/CreateReportCourseSelection.cshtml
new file mode 100644
index 000000000..71ad7a7ff
--- /dev/null
+++ b/LearningHub.Nhs.WebUI/Views/Reports/CreateReportCourseSelection.cshtml
@@ -0,0 +1,79 @@
+@using LearningHub.Nhs.Models.Enums
+@using LearningHub.Nhs.WebUI.Helpers
+@using LearningHub.Nhs.WebUI.Models
+@using LearningHub.Nhs.WebUI.Models.Learning
+@using NHSUKViewComponents.Web.ViewModels
+@model LearningHub.Nhs.WebUI.Models.Report.ReportCreationCourseSelection;
+@{
+ ViewData["Title"] = "Select Course";
+ var returnUrl = $"{Context.Request.Path}{Context.Request.QueryString}";
+ var errorHasOccurred = !ViewData.ModelState.IsValid;
+}
+
+@section styles {
+
+}
+
+
+
+
+
+
+
+ @if (string.IsNullOrWhiteSpace(ViewBag.ReturnUrl))
+ {
+
+ }
+ else
+ {
+
+ }
+
+ Create a course progress report
+
+ @if (errorHasOccurred)
+ {
+
+ }
+
+ Select course(s)
+
+
+
+
+
+
+
+
+
+
diff --git a/LearningHub.Nhs.WebUI/Views/Reports/CreateReportDateSelection.cshtml b/LearningHub.Nhs.WebUI/Views/Reports/CreateReportDateSelection.cshtml
new file mode 100644
index 000000000..04c840a73
--- /dev/null
+++ b/LearningHub.Nhs.WebUI/Views/Reports/CreateReportDateSelection.cshtml
@@ -0,0 +1,164 @@
+@using LearningHub.Nhs.Models.Enums
+@using LearningHub.Nhs.WebUI.Helpers
+@using LearningHub.Nhs.WebUI.Models
+@using LearningHub.Nhs.WebUI.Models.Learning
+@using NHSUKViewComponents.Web.ViewModels
+@model LearningHub.Nhs.WebUI.Models.Report.ReportCreationDateSelection;
+@{
+ ViewData["Title"] = "Select Date";
+ var returnUrl = $"{Context.Request.Path}{Context.Request.QueryString}";
+ var errorHasOccurred = !ViewData.ModelState.IsValid;
+ var customId = $"TimePeriod-{Model.PopulateDateRange().Count()}";
+ var hintTextLines = new List { $"For example, {Model.StartDay} {Model.StartMonth} {Model.StartYear}" };
+ var hintTextLine = new List { $" " };
+}
+
+@section styles {
+
+}
+
+
+
+
+
+
+
+
+
+ @if (string.IsNullOrWhiteSpace(ViewBag.ReturnUrl))
+ {
+
+ }
+ else
+ {
+
+ }
+
+ Create a course progress report
+
+ @if (errorHasOccurred)
+ {
+
+ }
+
+ Reporting period
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LearningHub.Nhs.WebUI/Views/Reports/Index.cshtml b/LearningHub.Nhs.WebUI/Views/Reports/Index.cshtml
new file mode 100644
index 000000000..5f03ecbe6
--- /dev/null
+++ b/LearningHub.Nhs.WebUI/Views/Reports/Index.cshtml
@@ -0,0 +1,215 @@
+@using LearningHub.Nhs.Models.Enums
+@using LearningHub.Nhs.Models.Enums.Report
+@using LearningHub.Nhs.WebUI.Helpers
+@using LearningHub.Nhs.WebUI.Models
+@using LearningHub.Nhs.WebUI.Models.Learning
+@model LearningHub.Nhs.WebUI.Models.Report.ReportHistoryViewModel
+;
+@{
+ ViewData["Title"] = "Reports";
+ var returnUrl = $"{Context.Request.Path}{Context.Request.QueryString}";
+ var allCourses = this.ViewData["AllCourses"] as List>;
+}
+
+@section styles {
+
+}
+
+@section NavBreadcrumbs {
+
+
+
+
+
+
+
+
+
+
Reports
+
View and manage your reports
+
+
+
+
+
+
+}
+
+
+
+
+
+
+
+
+ This page lists all reports you can access or have created. Use the Create a course progress report button to generate a new report.
+
+
Create a course progress report
+
+
+
+
Previously run reports
+
+ @if (Model.ReportHistoryModels.Any())
+ {
+
+ Reports are stored for 30 days from the date they’re generated. If you need to keep a copy, make sure you download it before it expires.
+
+
+ @foreach (var entry in Model.ReportHistoryModels)
+ {
+ var matchedCourseNames = string.Empty;
+ var matchedCourseNamesDetails = new List
();
+
+ if (string.IsNullOrWhiteSpace(entry.CourseFilter))
+ {
+ matchedCourseNames = "All courses";
+ matchedCourseNamesDetails = new List { "All courses" };
+ }
+ else
+ {
+ var matched = allCourses
+ .Where(c => entry.CourseFilter.Contains(c.Key))
+ .Select(c => c.Value)
+ .ToList();
+
+ matchedCourseNamesDetails = matched;
+
+ if (matched.Count == 1)
+ {
+ matchedCourseNames = matched[0];
+ }
+ else
+ {
+ matchedCourseNames = $"{matched[0]} and {matched.Count - 1} other{((matched.Count - 1) > 1 ? "s" : "")}";
+ }
+ matchedCourseNames = UtilityHelper.ConvertToSentenceCase(matchedCourseNames);
+ }
+
+
+ string datePeriod = entry.PeriodDays > 0 ? $"{entry.PeriodDays} days" : $"{entry.StartDate.GetValueOrDefault().ToString("dd MMM yyyy")} to {entry.EndDate.GetValueOrDefault().ToString("dd MMM yyyy")}";
+ bool downloadCheck = entry.DownloadRequest != null && (bool)entry.DownloadRequest;
+ string expiryDate = entry.LastRun.AddDays(30).ToString("dd MMM yyyy");
+
+
+
+
+ Course progress for @matchedCourseNames
+
+ @if (downloadCheck)
+ {
+
+ @if (entry.ReportStatusId == ((int)Status.Ready) && DateTime.Now.Date < entry.LastRun.AddDays(30).Date)
+ {
+
+
+ Expires on @expiryDate
+
+
+ Ready to download
+ }
+ else if (entry.ReportStatusId == ((int)Status.Pending))
+ {
+ Getting it ready
+ }
+ else
+ {
+ Expired
+ }
+
+ }
+
+
+
+
+
Date requested:
+ @entry.FirstRun.Date.ToString("dd MMM yyyy")
+
+
+
Date period:
+ @datePeriod
+
+
+
Type:
+ Course progress
+
+
+
Reporting on:
+
+
+ @if (matchedCourseNamesDetails.Count > 1)
+ {
+
+ @foreach (var item in matchedCourseNamesDetails)
+ {
+ @item
+ }
+
+ }
+ else
+ {
+ @matchedCourseNamesDetails.FirstOrDefault()
+ }
+
+
+
+
+
+
+
+
+
+ View report
+
+
+
+ @if (downloadCheck)
+ {
+ @if (entry.ReportStatusId == ((int)Status.Ready) && DateTime.Now.Date < entry.LastRun.AddDays(30).Date)
+ {
+
+ Download report
+
+ }
+ }
+
+
+
+
+ }
+
+ }
+ else
+ {
+
+
+
+
+
+ No reports available yet
+
+
+ You haven’t run any reports yet, so this section is currently empty.
+ Once you generate a report, it will appear here for you to view or download.
+
+
+
+
+
+
+ }
+
+
+
+
+ @await Html.PartialAsync("_ReportHistoryPaging", Model)
+
+
+
+
diff --git a/LearningHub.Nhs.WebUI/Views/Reports/_ReportHistoryPaging.cshtml b/LearningHub.Nhs.WebUI/Views/Reports/_ReportHistoryPaging.cshtml
new file mode 100644
index 000000000..2e4d43245
--- /dev/null
+++ b/LearningHub.Nhs.WebUI/Views/Reports/_ReportHistoryPaging.cshtml
@@ -0,0 +1,76 @@
+@using System.Web;
+@using LearningHub.Nhs.WebUI.Models.Learning
+@using LearningHub.Nhs.WebUI.Models.Search;
+@model LearningHub.Nhs.WebUI.Models.Report.ReportHistoryViewModel
+
+
+@{
+ var pagingModel = Model.ReportPaging;
+ var showPaging = pagingModel.CurrentPage >= 0 && pagingModel.CurrentPage <= pagingModel.TotalPages - 1;
+ var previousMessage = $"{pagingModel.CurrentPage} of {pagingModel.TotalPages}";
+ int CurrentPageNumber = pagingModel.CurrentPage + 1;
+ var nextMessage = string.Empty;
+ if (CurrentPageNumber <= pagingModel.TotalPages)
+ {
+ nextMessage = $"{CurrentPageNumber + 1} of {pagingModel.TotalPages}";
+ }
+ else
+ {
+ previousMessage = $"{CurrentPageNumber - 1} of {pagingModel.TotalPages}";
+ nextMessage = $"{CurrentPageNumber} of {pagingModel.TotalPages}";
+ }
+
+ var routeData = new Dictionary();
+ routeData["CurrentPageIndex"] = pagingModel.CurrentPage.ToString();
+ var nextRouteData = new Dictionary(routeData);
+ var previousRouteData = new Dictionary(routeData);
+ nextRouteData["ReportFormActionType"] = ReportFormActionTypeEnum.NextPageChange.ToString();
+ previousRouteData["ReportFormActionType"] = ReportFormActionTypeEnum.PreviousPageChange.ToString();
+}
+
+@if (pagingModel.TotalPages > 1)
+{
+
+}
\ No newline at end of file
diff --git a/LearningHub.Nhs.WebUI/Views/Reports/_ReportPaging.cshtml b/LearningHub.Nhs.WebUI/Views/Reports/_ReportPaging.cshtml
new file mode 100644
index 000000000..37af21817
--- /dev/null
+++ b/LearningHub.Nhs.WebUI/Views/Reports/_ReportPaging.cshtml
@@ -0,0 +1,76 @@
+@using System.Web;
+@using LearningHub.Nhs.WebUI.Models.Learning
+@using LearningHub.Nhs.WebUI.Models.Search;
+@model LearningHub.Nhs.WebUI.Models.Report.CourseCompletionViewModel
+
+
+@{
+ var pagingModel = Model.ReportPaging;
+ var showPaging = pagingModel.CurrentPage >= 0 && pagingModel.CurrentPage <= pagingModel.TotalPages - 1;
+ var previousMessage = $"{pagingModel.CurrentPage} of {pagingModel.TotalPages}";
+ int CurrentPageNumber = pagingModel.CurrentPage + 1;
+ var nextMessage = string.Empty;
+ if (CurrentPageNumber <= pagingModel.TotalPages)
+ {
+ nextMessage = $"{CurrentPageNumber + 1} of {pagingModel.TotalPages}";
+ }
+ else
+ {
+ previousMessage = $"{CurrentPageNumber - 1} of {pagingModel.TotalPages}";
+ nextMessage = $"{CurrentPageNumber} of {pagingModel.TotalPages}";
+ }
+
+ var routeData = new Dictionary();
+ routeData["CurrentPageIndex"] = pagingModel.CurrentPage.ToString();
+ var nextRouteData = new Dictionary(routeData);
+ var previousRouteData = new Dictionary(routeData);
+ nextRouteData["ReportFormActionType"] = ReportFormActionTypeEnum.NextPageChange.ToString();
+ previousRouteData["ReportFormActionType"] = ReportFormActionTypeEnum.PreviousPageChange.ToString();
+}
+
+@if (pagingModel.TotalPages > 1)
+{
+
+}
\ No newline at end of file
diff --git a/LearningHub.Nhs.WebUI/Views/Reports/_ReportTable.cshtml b/LearningHub.Nhs.WebUI/Views/Reports/_ReportTable.cshtml
new file mode 100644
index 000000000..733d9a72b
--- /dev/null
+++ b/LearningHub.Nhs.WebUI/Views/Reports/_ReportTable.cshtml
@@ -0,0 +1,153 @@
+@model LearningHub.Nhs.WebUI.Models.Report.CourseCompletionViewModel
+
+@{
+ var returnUrl = $"{Context.Request.Path}{Context.Request.QueryString}";
+}
+
+
+@if (Model.TotalCount > 0)
+{
+
+
+
+
+
+ Username
+
+
+ First name
+
+
+ Last name
+
+
+ Email address
+
+
+ Medical council no
+
+
+ Medical council name
+
+
+ Role
+
+
+ Grade
+
+
+ Location
+
+
+ Programme name
+
+
+ Course learning path name
+
+
+ Status
+
+
+
+
+ @foreach (var entry in Model.CourseCompletionRecords)
+ {
+
+
+ Username
+
+ @entry.UserName
+
+
+
+ First name
+ @entry.FirstName
+
+
+
+ Last name
+ @entry.LastName
+
+
+
+ Email address
+ @entry.Email
+
+
+
+ Medical council no
+ @entry.MedicalCouncilNo
+
+
+
+ Medical council name
+ @entry.MedicalCouncilName
+
+
+
+ Role
+ @entry.Role
+
+
+
+ Grade
+ @entry.Grade
+
+
+
+ Location
+ @entry.Location
+
+
+
+ Programme name
+ @entry.Programme
+
+
+
+ Course learning path name
+ @entry.Course
+
+
+
+ Status
+ @ViewActivityHelper.GetReportStatusDisplayText(entry.CourseStatus)
+
+
+
+
+
+ }
+
+
+
+
+}
diff --git a/LearningHub.Nhs.WebUI/Views/Search/Index.cshtml b/LearningHub.Nhs.WebUI/Views/Search/Index.cshtml
index 4b8e0e6e3..9783090ba 100644
--- a/LearningHub.Nhs.WebUI/Views/Search/Index.cshtml
+++ b/LearningHub.Nhs.WebUI/Views/Search/Index.cshtml
@@ -1,19 +1,22 @@
-@model LearningHub.Nhs.WebUI.Models.Search.SearchResultViewModel
+@using Microsoft.FeatureManagement
+@inject IFeatureManager FeatureManager
+@model LearningHub.Nhs.WebUI.Models.Search.SearchResultViewModel
@{
ViewData["Title"] = "Search";
+ var azureSearchEnabled = await FeatureManager.IsEnabledAsync("AzureSearch");
}
-@section styles{
+@section styles {
}
-
+
@if (Model.DidYouMeanEnabled)
{
- No results were found for @(Model.SearchString) please change your search term or explore the suggestions below
+ No results were found for @(Model.SearchString) please change your search term or explore the suggestions below
}
else
@@ -22,14 +25,14 @@
Search results @(!string.IsNullOrEmpty(Model.SearchString) ? "for " + Model.SearchString : string.Empty)
}
-
+
@await Html.PartialAsync("_SearchBar", @Model.SearchString)
- @if (Model.CatalogueSearchResult?.TotalHits > 0)
+ @if (Model.CatalogueSearchResult?.TotalHits > 0 && !azureSearchEnabled)
{
@if (Model.DidYouMeanEnabled)
{
@@ -52,18 +55,32 @@
Showing results for
@(Model.SuggestedResource)
}
+
+ @* @if (azureSearchEnabled) // [BY] Use this when we create quick filters for Azure Search
+ {
+ @await Html.PartialAsync("_SearchFilter", Model)
+ } *@
+
@await Html.PartialAsync("_ResourceFilter", Model)
+
- @await Html.PartialAsync("_ResourceSearchResult", Model)
+ @if (azureSearchEnabled)
+ {
+ @await Html.PartialAsync("_SearchResult", Model)
+ }
+ else
+ {
+ @await Html.PartialAsync("_ResourceSearchResult", Model)
+ }
@await Html.PartialAsync("_ResourcePagination", Model)
}
-
+
@if ((Model.CatalogueSearchResult?.TotalHits ?? 0) == 0 && (Model.ResourceSearchResult?.TotalHits ?? 0) == 0)
{
diff --git a/LearningHub.Nhs.WebUI/Views/Search/_AutoSuggest.cshtml b/LearningHub.Nhs.WebUI/Views/Search/_AutoSuggest.cshtml
new file mode 100644
index 000000000..3b5582254
--- /dev/null
+++ b/LearningHub.Nhs.WebUI/Views/Search/_AutoSuggest.cshtml
@@ -0,0 +1,81 @@
+@model LearningHub.Nhs.Models.Search.AutoSuggestionModel;
+@inject LearningHub.Nhs.WebUI.Interfaces.IMoodleApiService moodleApiService;
+
+@using LearningHub.Nhs.Models.Search.SearchClick;
+@using Microsoft.AspNetCore.WebUtilities
+@using System.Web
+@{
+ var counter = 0;
+ var counter_res = 0;
+ string GetUrl(string term, string searchType, AutoSuggestionClickPayloadModel payload, string reference, int? resourceReferenceId)
+ {
+
+ var url = string.Empty;
+ if (!string.IsNullOrEmpty(reference) && searchType == "catalogue")
+ {
+ url = "/Catalogue/" + reference;
+ }
+ else if ((resourceReferenceId > 0) && searchType == "resource")
+ {
+ url = "/Resource/" + resourceReferenceId;
+ }
+ else if ((resourceReferenceId > 0) && searchType == "course")
+ {
+ if (resourceReferenceId != null)
+ return moodleApiService.GetCourseUrl(resourceReferenceId.Value);
+ }
+ else if (!string.IsNullOrEmpty(term) && searchType == "Concepts")
+ {
+ url = "/Search/results?term=" + term;
+ }
+ else
+ {
+ url = "/Search/results?term=" + term;
+ }
+
+ return $@"/search/record-autosuggestion-click?term={term}&url={url}&clickTargetUrl={payload.ClickTargetUrl}&itemIndex={payload?.HitNumber}&&totalNumberOfHits={payload?.SearchSignal?.Stats?.TotalHits}
+&containerId={payload?.ContainerId}&name={payload?.DocumentFields.Name}&query={payload?.SearchSignal?.Query}&userQuery={HttpUtility.UrlEncode(payload?.SearchSignal?.UserQuery)}
+&searchId={payload?.SearchSignal?.SearchId}&timeOfSearch={payload?.SearchSignal?.TimeOfSearch}&title={payload?.DocumentFields?.Title}";
+ }
+}
+@if (Model != null)
+{
+ @if (Model.ConceptDocument != null)
+ {
+ @foreach (var item in Model.ConceptDocument.ConceptDocumentList)
+ {
+ counter++;
+
+
+
+
+
+ @item.Concept
+
+
+ }
+ }
+ @if (Model.ResourceCollectionDocument != null)
+ {
+ @foreach (var item in Model.ResourceCollectionDocument.DocumentList)
+ {
+ counter_res++;
+
+
+ @item.Title
+
+ Type: @*
+ @(item.ResourceType == "resource" ? "Learning resource" : item.ResourceType) *@
+
+ @(item.ResourceType == "resource"
+ ? "Learning resource"
+ : !string.IsNullOrEmpty(item.ResourceType)
+ ? char.ToUpper(item.ResourceType[0]) + item.ResourceType.Substring(1)
+ : string.Empty)
+
+
+
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/LearningHub.Nhs.WebUI/Views/Search/_ResourceFilter.cshtml b/LearningHub.Nhs.WebUI/Views/Search/_ResourceFilter.cshtml
index 429eae962..51fc9223e 100644
--- a/LearningHub.Nhs.WebUI/Views/Search/_ResourceFilter.cshtml
+++ b/LearningHub.Nhs.WebUI/Views/Search/_ResourceFilter.cshtml
@@ -4,12 +4,11 @@
@{
var resourceResult = Model.ResourceSearchResult;
var filtersApplied = resourceResult.SortItemSelected.Value != string.Empty
- || resourceResult.SearchFilters.Any(f => f.Selected) || resourceResult.SearchResourceAccessLevelFilters.Any(f => f.Selected)
- || resourceResult.SearchProviderFilters.Any(f =>f.Selected);
+ || resourceResult.SearchFilters.Any(f => f.Selected) || resourceResult.SearchResourceAccessLevelFilters.Any(f => f.Selected);
var queryParams = QueryHelpers.ParseQuery(Context.Request.QueryString.ToString().ToLower());
queryParams["actiontype"] = "sort-filter";
- var pageUrl = Model.CatalogueId > 0 ? "/catalogue/" + Model.CatalogueUrl +"/search" : "/search/results";
+ var pageUrl = Model.CatalogueId > 0 ? "/catalogue/" + Model.CatalogueUrl + "/search" : "/search/results";
var actionUrl = QueryHelpers.AddQueryString(pageUrl, queryParams);
var pageFragment = "#search-filters";
@@ -25,17 +24,10 @@
.Select(f => $"
{char.ToUpper(f.DisplayName[0])}{f.DisplayName[1..]} ");
if (resourceAccessLevelFilters.Any())
- {
+ {
summary += $" and Filtered by Audience access level {string.Join(" ", resourceAccessLevelFilters)}";
}
-
-
- var providerfilters = resourceResult.SearchProviderFilters.Where(f => f.Selected).Select(f => $"
{f.DisplayName} ");
- if (providerfilters.Any())
- {
- summary += $" and Filtered by Provider {string.Join(" ", providerfilters)}";
- }
if (filters.Any())
{
@@ -46,7 +38,7 @@
}
- @($"{resourceResult.TotalHits} resource{(resourceResult.TotalHits == 1 ? string.Empty : "s")}")
+ @($"Showing {resourceResult.TotalHits} result{(resourceResult.TotalHits == 1 ? string.Empty : "s")}")
@if (resourceResult.TotalHits > 0)
@@ -128,7 +120,7 @@
+ value="@filter.Value" checked="@filter.Selected" class="@(filter.Count > 0 ? "" : "disabled")">
Show @filter.DisplayName (@filter.Count)
@@ -140,35 +132,6 @@
}
- @if (resourceResult.SearchProviderFilters.Count > 0)
- {
-
-
-
-
-
- Filter by provider:
-
-
-
- @foreach (var filter in resourceResult.SearchProviderFilters)
- {
-
-
-
-
-
- @filter.DisplayName (@filter.Count)
-
-
-
- }
-
-
-
- }
-
diff --git a/LearningHub.Nhs.WebUI/Views/Search/_ResourceSearchResult.cshtml b/LearningHub.Nhs.WebUI/Views/Search/_ResourceSearchResult.cshtml
index 50c8df8f4..91a16273d 100644
--- a/LearningHub.Nhs.WebUI/Views/Search/_ResourceSearchResult.cshtml
+++ b/LearningHub.Nhs.WebUI/Views/Search/_ResourceSearchResult.cshtml
@@ -15,11 +15,11 @@
var pagingModel = Model.ResourceResultPaging;
var index = pagingModel.CurrentPage * pagingModel.PageSize;
var searchString = HttpUtility.UrlEncode(Model.SearchString);
+ string groupId = HttpUtility.UrlEncode(Model.GroupId.ToString());
string GetUrl(int resourceReferenceId, int itemIndex, int nodePathId, SearchClickPayloadModel payload)
{
- var searchSignal = payload?.SearchSignal;
- string groupId = HttpUtility.UrlEncode(Model.GroupId.ToString());
+ var searchSignal = payload?.SearchSignal;
string searchSignalQueryEncoded = HttpUtility.UrlEncode(HttpUtility.UrlDecode(searchSignal?.Query));
return $@"/search/record-resource-click?url=/Resource/{resourceReferenceId}&nodePathId={nodePathId}&itemIndex={payload?.HitNumber}
@@ -28,7 +28,7 @@
&query={searchSignalQueryEncoded}&title={payload?.DocumentFields?.Title}";
}
- string GetMoodleCourseUrl(string courseIdWithPrefix)
+ string GetMoodleCourseUrl(string courseIdWithPrefix, int resourceReferenceId, int itemIndex, int nodePathId, SearchClickPayloadModel payload)
{
const string prefix = "M";
@@ -46,7 +46,15 @@
if (int.TryParse(courseIdPart, out int courseId))
{
- return moodleApiService.GetCourseUrl(courseId);
+ var searchSignal = payload?.SearchSignal;
+
+ string searchSignalQueryEncoded = HttpUtility.UrlEncode(HttpUtility.UrlDecode(searchSignal?.Query));
+ string url = moodleApiService.GetCourseUrl(courseId);
+
+ return $@"/search/record-course-click?url={url}&nodePathId={nodePathId}&itemIndex={payload?.HitNumber}
+ &pageIndex={pagingModel.CurrentPage}&totalNumberOfHits={payload?.SearchSignal?.Stats?.TotalHits}&searchText={searchString}&resourceReferenceId={resourceReferenceId}
+ &groupId={groupId}&searchId={searchSignal?.SearchId}&timeOfSearch={searchSignal?.TimeOfSearch}&userQuery={HttpUtility.UrlEncode(searchSignal.UserQuery)}
+ &query={searchSignalQueryEncoded}&title={payload?.DocumentFields?.Title}";
}
else
{
@@ -66,7 +74,7 @@
@if (item.ResourceType == "moodle")
{
- @item.Title
+ @item.Title
}
else
{
diff --git a/LearningHub.Nhs.WebUI/Views/Search/_SearchBar.cshtml b/LearningHub.Nhs.WebUI/Views/Search/_SearchBar.cshtml
index da4a475e9..7c1a5705d 100644
--- a/LearningHub.Nhs.WebUI/Views/Search/_SearchBar.cshtml
+++ b/LearningHub.Nhs.WebUI/Views/Search/_SearchBar.cshtml
@@ -19,6 +19,7 @@
var searchInput = document.getElementById("sub-search-field");
var suggestionsList = document.getElementById("sub-search-field_listbox");
var minLengthAutoComplete = 3;
+ let currentIndex = -1;
function fetchSuggestions(term) {
var xhr = new XMLHttpRequest();
@@ -66,12 +67,50 @@
}
}
});
+
+ searchInput.addEventListener("focus", function () {
+ currentIndex = -1;
+ });
+ searchInput.addEventListener("keydown", handleArrowKeys);
+ suggestionsList.addEventListener("keydown", handleArrowKeys);
}
}
+ function handleArrowKeys(e) {
+ const items = suggestionsList.querySelectorAll('.autosuggestion-option > a');
+ if (!items.length) return;
+
+ switch (e.key) {
+ case "ArrowDown":
+ e.preventDefault();
+ currentIndex = (currentIndex + 1) % items.length;
+ items[currentIndex].focus();
+ break;
+
+ case "ArrowUp":
+ e.preventDefault();
+ currentIndex = (currentIndex - 1 + items.length) % items.length;
+ items[currentIndex].focus();
+ break;
+
+ case "Tab":
+ if (currentIndex >= items.length - 1) {
+ closeAllLists();
+ currentIndex = -1;
+ }
+ else {
+ e.preventDefault();
+ currentIndex++;
+ items[currentIndex].focus();
+ }
+ break;
+ }
+ }
+
function closeAllLists() {
suggestionsList.innerHTML = '';
suggestionsList.style.display = "none";
+ currentIndex = -1;
}
autocomplete(searchInput, minLengthAutoComplete);
diff --git a/LearningHub.Nhs.WebUI/Views/Search/_SearchCatalogueResult.cshtml b/LearningHub.Nhs.WebUI/Views/Search/_SearchCatalogueResult.cshtml
new file mode 100644
index 000000000..86a7784c1
--- /dev/null
+++ b/LearningHub.Nhs.WebUI/Views/Search/_SearchCatalogueResult.cshtml
@@ -0,0 +1,45 @@
+@using System.Web;
+@using LearningHub.Nhs.WebUI.Extensions
+@using LearningHub.Nhs.Models.Search.SearchClick;
+
+@model (LearningHub.Nhs.Models.Search.Document document, string groupId, int currentPage)
+
+@{
+ var item = Model.document;
+ var searchString = string.Empty;// HttpUtility.UrlEncode(Model.se);
+ var suggestedSearchString = string.Empty; /* Model.DidYouMeanEnabled ? HttpUtility.UrlEncode(Model.SuggestedCatalogue) : HttpUtility.UrlEncode (Model.SearchString);*/
+
+ string GetUrl(string catalogueUrl, int? nodePathId, int itemIndex, int catalogueId, SearchClickPayloadModel payload)
+ {
+
+ var searchSignal = payload?.SearchSignal;
+ var pagingModel = Model.currentPage;
+ string encodedCatalogueUrl = HttpUtility.UrlEncode("/Catalogue/" + catalogueUrl);
+ string groupId = Model.groupId;// HttpUtility.UrlEncode(Model.GroupId.ToString());
+ string searchSignalQueryEncoded = HttpUtility.UrlEncode(HttpUtility.UrlDecode(searchSignal?.Query));
+
+ var url = $@"/search/record-catalogue-click?url={encodedCatalogueUrl}&nodePathId={nodePathId}&itemIndex={payload?.HitNumber}
+&pageIndex={pagingModel}&totalNumberOfHits={payload?.SearchSignal?.Stats?.TotalHits}&searchText={suggestedSearchString}&catalogueId={catalogueId}
+&GroupId={groupId}&searchId={searchSignal?.SearchId}&timeOfSearch={searchSignal?.TimeOfSearch}&userQuery={HttpUtility.UrlEncode(searchSignal?.UserQuery)}
+&query={searchSignalQueryEncoded}&name={payload?.DocumentFields?.Name}";
+ return url;
+ }
+}
+
+
+
+
+
+
Catalogue
+
+
+
+
+ @item.Description
+
+
+
+
\ No newline at end of file
diff --git a/LearningHub.Nhs.WebUI/Views/Search/_SearchFilter.cshtml b/LearningHub.Nhs.WebUI/Views/Search/_SearchFilter.cshtml
new file mode 100644
index 000000000..03a1518a9
--- /dev/null
+++ b/LearningHub.Nhs.WebUI/Views/Search/_SearchFilter.cshtml
@@ -0,0 +1,227 @@
+@using Microsoft.AspNetCore.WebUtilities
+@model LearningHub.Nhs.WebUI.Models.Search.SearchResultViewModel
+
+@{
+ var resourceResult = Model.ResourceSearchResult;
+ var filtersApplied = resourceResult.SortItemSelected.Value != string.Empty
+ || resourceResult.SearchFilters.Any(f => f.Selected) || resourceResult.SearchResourceAccessLevelFilters.Any(f => f.Selected);
+
+ var queryParams = QueryHelpers.ParseQuery(Context.Request.QueryString.ToString().ToLower());
+ queryParams["actiontype"] = "sort-filter";
+ var pageUrl = Model.CatalogueId > 0 ? "/catalogue/" + Model.CatalogueUrl + "/search" : "/search/results";
+ var actionUrl = QueryHelpers.AddQueryString(pageUrl, queryParams);
+ var pageFragment = "#search-filters";
+
+ string FilterSummary()
+ {
+ string summary = $"Sorted by {resourceResult.SortItemSelected.Name} ";
+ var filters = resourceResult.SearchFilters
+ .Where(f => f.Selected)
+ .Select(f => $"{f.DisplayName} ");
+
+ var resourceAccessLevelFilters = resourceResult.SearchResourceAccessLevelFilters
+ .Where(f => f.Selected)
+ .Select(f => $"{char.ToUpper(f.DisplayName[0])}{f.DisplayName[1..]} ");
+
+ if (resourceAccessLevelFilters.Any())
+ {
+ summary += $" and Filtered by Audience access level {string.Join(" ", resourceAccessLevelFilters)}";
+ }
+
+ if (filters.Any())
+ {
+ summary += $" and Filtered by Type {string.Join(" ", filters)}";
+ }
+ return summary;
+ }
+}
+
+
+ @($"Showing {resourceResult.TotalHits} result{(resourceResult.TotalHits == 1 ? string.Empty : "s")}")
+
+
+@if (resourceResult.TotalHits > 0)
+{
+
+}
\ No newline at end of file
diff --git a/LearningHub.Nhs.WebUI/Views/Search/_SearchResult.cshtml b/LearningHub.Nhs.WebUI/Views/Search/_SearchResult.cshtml
new file mode 100644
index 000000000..e251ebca8
--- /dev/null
+++ b/LearningHub.Nhs.WebUI/Views/Search/_SearchResult.cshtml
@@ -0,0 +1,155 @@
+@model LearningHub.Nhs.WebUI.Models.Search.SearchResultViewModel
+@inject LearningHub.Nhs.WebUI.Interfaces.IMoodleApiService moodleApiService;
+
+@using System.Linq;
+@using System.Web;
+@using LearningHub.Nhs.WebUI.Helpers;
+@using LearningHub.Nhs.Models.Search;
+@using LearningHub.Nhs.Models.Search.SearchFeedback;
+@using LearningHub.Nhs.Models.Enums;
+@using LearningHub.Nhs.WebUI.Models.Search;
+@using LearningHub.Nhs.Models.Search.SearchClick;
+
+@{
+ var resourceResult = Model.ResourceSearchResult;
+ var pagingModel = Model.ResourceResultPaging;
+ var index = pagingModel.CurrentPage * pagingModel.PageSize;
+ var searchString = HttpUtility.UrlEncode(Model.SearchString);
+ string groupId = HttpUtility.UrlEncode(Model.GroupId.ToString());
+
+ string GetUrl(int resourceReferenceId, int itemIndex, int nodePathId, SearchClickPayloadModel payload)
+ {
+ var searchSignal = payload?.SearchSignal;
+
+ string searchSignalQueryEncoded = HttpUtility.UrlEncode(HttpUtility.UrlDecode(searchSignal?.Query));
+
+ return $@"/search/record-resource-click?url=/Resource/{resourceReferenceId}&nodePathId={nodePathId}&itemIndex={payload?.HitNumber}
+&pageIndex={pagingModel.CurrentPage}&totalNumberOfHits={payload?.SearchSignal?.Stats?.TotalHits}&searchText={searchString}&resourceReferenceId={resourceReferenceId}
+&groupId={groupId}&searchId={searchSignal?.SearchId}&timeOfSearch={searchSignal?.TimeOfSearch}&userQuery={HttpUtility.UrlEncode(searchSignal.UserQuery)}
+&query={searchSignalQueryEncoded}&title={payload?.DocumentFields?.Title}";
+ }
+
+ string GetMoodleCourseUrl(string courseIdWithPrefix, int resourceReferenceId,int itemIndex, int nodePathId, SearchClickPayloadModel payload)
+ {
+
+ if (int.TryParse(courseIdWithPrefix, out int courseId))
+ {
+ var searchSignal = payload?.SearchSignal;
+
+ string searchSignalQueryEncoded = HttpUtility.UrlEncode(HttpUtility.UrlDecode(searchSignal?.Query));
+ string url = moodleApiService.GetCourseUrl(courseId);
+
+ return $@"/search/record-course-click?url={url}&nodePathId={nodePathId}&itemIndex={payload?.HitNumber}
+ &pageIndex={pagingModel.CurrentPage}&totalNumberOfHits={payload?.SearchSignal?.Stats?.TotalHits}&searchText={searchString}&resourceReferenceId={resourceReferenceId}
+ &groupId={groupId}&searchId={searchSignal?.SearchId}&timeOfSearch={searchSignal?.TimeOfSearch}&userQuery={HttpUtility.UrlEncode(searchSignal.UserQuery)}
+ &query={searchSignalQueryEncoded}&title={payload?.DocumentFields?.Title}";
+
+ // return moodleApiService.GetCourseUrl(courseId);
+ }
+ else
+ {
+ return string.Empty;
+ }
+ }
+
+ bool showCatalogueFieldsInResources = ViewBag.ShowCatalogueFieldsInResources == null || ViewBag.ShowCatalogueFieldsInResources == true;
+ bool resourceAccessLevelFilterSelected = resourceResult.SearchResourceAccessLevelFilters.Any(f => f.Selected);
+}
+
+@foreach (var item in resourceResult.DocumentModel)
+{
+
+ @if (item.ResourceType == "catalogue")
+ {
+ @await Html.PartialAsync("_SearchCatalogueResult", (item, groupId, pagingModel.CurrentPage))
+ }
+ else
+ {
+ var provider = item.Providers?.FirstOrDefault();
+
+
+
+ @if (item.ResourceType == "moodle")
+ {
+ @item.Title
+ }
+ else
+ {
+ @item.Title
+ }
+
+
+ @if (provider != null)
+ {
+
+
+
+
@ProviderHelper.GetProviderString(provider.Name)
+
+
+ }
+ @if (item.CatalogueRestrictedAccess && !Model.HideRestrictedBadge && showCatalogueFieldsInResources)
+ {
+
+ @((item.CatalogueHasAccess || this.User.IsInRole("Administrator")) ? "Access Granted" : "Access restricted")
+
+ }
+
+ @if (!resourceAccessLevelFilterSelected)
+ {
+
+
+ Audience access level:
+ @ResourceAccessLevelHelper.GetResourceAccessLevelText((ResourceAccessibilityEnum)item.ResourceAccessLevel)
+
+
+ }
+
+ @if (!string.IsNullOrEmpty(item.ResourceType))
+ {
+
+
+ Type:
+ @UtilityHelper.GetPrettifiedResourceTypeName(UtilityHelper.ToEnum(item.ResourceType), 0)
+
+
+ @if (item.ResourceType != "moodle")
+ {
+ @await Html.PartialAsync("../Shared/_StarRating.cshtml", item.Rating)
+ }
+
+
+ }
+
+ @item.Description
+
+
+
+ @if (!string.IsNullOrWhiteSpace(item.CatalogueBadgeUrl) && showCatalogueFieldsInResources)
+ {
+
+ }
+
+ @if (!string.IsNullOrEmpty(item.CatalogueName) && !this.Model.CatalogueId.HasValue && showCatalogueFieldsInResources)
+ {
+
+ }
+
+
+ @UtilityHelper.GetAttribution(item.Authors)
+
+ @if (!string.IsNullOrWhiteSpace(item.AuthoredDate))
+ {
+ string formattedAuthoredDate = @UtilityHelper.GetFormattedAuthoredDate(item.AuthoredDate);
+ @* Render helper text plus formatted date *@
+ @UtilityHelper.GetInOn(formattedAuthoredDate)
+ @: @formattedAuthoredDate
+ }
+
+
+
+ index++;
+ }
+}
\ No newline at end of file
diff --git a/LearningHub.Nhs.WebUI/Views/Shared/Components/DynamicCheckboxes/Default.cshtml b/LearningHub.Nhs.WebUI/Views/Shared/Components/DynamicCheckboxes/Default.cshtml
new file mode 100644
index 000000000..a8512bcf9
--- /dev/null
+++ b/LearningHub.Nhs.WebUI/Views/Shared/Components/DynamicCheckboxes/Default.cshtml
@@ -0,0 +1,49 @@
+@using LearningHub.Nhs.WebUI.Models.DynamicCheckbox
+@model DynamicCheckboxesViewModel
+@{
+ var propertyName = ViewData["PropertyName"]?.ToString() ?? "SelectedValues";
+ var exclusiveGroup = $"{propertyName}-list";
+}
+
+
diff --git a/LearningHub.Nhs.WebUI/Views/Shared/Components/NavigationItems/Default.cshtml b/LearningHub.Nhs.WebUI/Views/Shared/Components/NavigationItems/Default.cshtml
index 43881e477..d98f78679 100644
--- a/LearningHub.Nhs.WebUI/Views/Shared/Components/NavigationItems/Default.cshtml
+++ b/LearningHub.Nhs.WebUI/Views/Shared/Components/NavigationItems/Default.cshtml
@@ -50,7 +50,15 @@
}
+ @if (Model.ShowReports)
+ {
+
+ }
@if (Model.ShowMyBookmarks)
{
}
+
@if (Context.Request.Path.Value != "/Home/Error" && !SystemOffline())
{
@if (Model.ShowHelp)
diff --git a/LearningHub.Nhs.WebUI/Views/Shared/Components/NavigationItems/Searchbar.cshtml b/LearningHub.Nhs.WebUI/Views/Shared/Components/NavigationItems/Searchbar.cshtml
index f3a69f8e6..b572b63f4 100644
--- a/LearningHub.Nhs.WebUI/Views/Shared/Components/NavigationItems/Searchbar.cshtml
+++ b/LearningHub.Nhs.WebUI/Views/Shared/Components/NavigationItems/Searchbar.cshtml
@@ -43,6 +43,7 @@
var searchInput = document.getElementById("search-field");
var suggestionsList = document.getElementById("search-field_listbox");
var minLengthAutoComplete = 3;
+ let currentIndex = -1;
function fetchSuggestions(term) {
var xhr = new XMLHttpRequest();
@@ -90,12 +91,50 @@
}
}
});
+
+ searchInput.addEventListener("focus", function () {
+ currentIndex = -1;
+ });
+ searchInput.addEventListener("keydown", handleArrowKeys);
+ suggestionsList.addEventListener("keydown", handleArrowKeys);
}
}
+ function handleArrowKeys(e) {
+ const items = suggestionsList.querySelectorAll('.autosuggestion-option > a');
+ if (!items.length) return;
+
+ switch (e.key) {
+ case "ArrowDown":
+ e.preventDefault();
+ currentIndex = (currentIndex + 1) % items.length;
+ items[currentIndex].focus();
+ break;
+
+ case "ArrowUp":
+ e.preventDefault();
+ currentIndex = (currentIndex - 1 + items.length) % items.length;
+ items[currentIndex].focus();
+ break;
+
+ case "Tab":
+ if (currentIndex >= items.length - 1) {
+ closeAllLists();
+ currentIndex = -1;
+ }
+ else {
+ e.preventDefault();
+ currentIndex++;
+ items[currentIndex].focus();
+ }
+ break;
+ }
+ }
+
function closeAllLists() {
suggestionsList.innerHTML = '';
suggestionsList.style.display = "none";
+ currentIndex = -1;
}
autocomplete(searchInput, minLengthAutoComplete);
diff --git a/LearningHub.Nhs.WebUI/appsettings.json b/LearningHub.Nhs.WebUI/appsettings.json
index c1875cc2b..ab5887246 100644
--- a/LearningHub.Nhs.WebUI/appsettings.json
+++ b/LearningHub.Nhs.WebUI/appsettings.json
@@ -115,10 +115,11 @@
"MKPlayerLicence": "",
"MediaKindStorageConnectionString": ""
},
+ "StatMandId": 0,
"EnableTempDebugging": "false",
"LimitScormToAdmin": "false"
- },
+ },
"LearningHubAuthServiceConfig": {
"Authority": "",
"ClientId": "",
@@ -164,11 +165,13 @@
"ClientId": "",
"ClientIdentityKey": ""
},
- "FeatureManagement": {
- "ContributeAudioVideoResource": true,
- "DisplayAudioVideoResource": true,
- "EnableMoodle": false
- },
+ "FeatureManagement": {
+ "ContributeAudioVideoResource": true,
+ "DisplayAudioVideoResource": true,
+ "EnableMoodle": false,
+ "AzureSearch": false,
+ "InPlatformReport": false
+ },
"IpRateLimiting": {
"EnableEndpointRateLimiting": true,
"StackBlockedRequests": false,
diff --git a/LearningHub.Nhs.WebUI/package-lock.json b/LearningHub.Nhs.WebUI/package-lock.json
index 17125bf85..1b09c4094 100644
--- a/LearningHub.Nhs.WebUI/package-lock.json
+++ b/LearningHub.Nhs.WebUI/package-lock.json
@@ -5,6 +5,7 @@
"requires": true,
"packages": {
"": {
+ "name": "learninghubnhswebui",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
diff --git a/LearningHub.Nhs.WebUI/package.json b/LearningHub.Nhs.WebUI/package.json
index 1759001f8..19f91556a 100644
--- a/LearningHub.Nhs.WebUI/package.json
+++ b/LearningHub.Nhs.WebUI/package.json
@@ -47,7 +47,7 @@
"vue-carousel-3d": "^1.0.1",
"vue-clamp": "0.4.1",
"vue-click-outside": "1.1.0",
- "vue-ctk-date-time-picker": "^2.5.0",
+ "vue-ctk-date-time-picker": "2.5.0",
"vue-router": "^3.6.5",
"vue-simple-progress": "^1.1.1",
"vue-typeahead": "^2.3.2",
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/AzureSearchConfig.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/AzureSearchConfig.cs
new file mode 100644
index 000000000..15f0921ee
--- /dev/null
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/AzureSearchConfig.cs
@@ -0,0 +1,102 @@
+using System.Collections.Generic;
+
+namespace LearningHub.Nhs.OpenApi.Models.Configuration
+{
+ ///
+ /// The Azure AI Search configuration settings.
+ ///
+ public class AzureSearchConfig
+ {
+ ///
+ /// Gets or sets the Azure Search service endpoint URL.
+ ///
+ public string ServiceEndpoint { get; set; } = null!;
+
+ ///
+ /// Gets or sets the Azure Search admin API key.
+ ///
+ public string AdminApiKey { get; set; } = null!;
+
+ ///
+ /// Gets or sets the Azure Search query API key.
+ ///
+ public string QueryApiKey { get; set; } = null!;
+
+ ///
+ /// Gets or sets the index name.
+ ///
+ public string IndexName { get; set; } = null!;
+
+ ///
+ /// Gets or sets the default item limit for search results.
+ ///
+ public int DefaultItemLimitForSearch { get; set; } = 10;
+
+ ///
+ /// Gets or sets the description length limit.
+ ///
+ public int DescriptionLengthLimit { get; set; } = 3000;
+
+ ///
+ /// Gets or sets the maximum description length.
+ ///
+ public int MaximumDescriptionLength { get; set; } = 150;
+
+ ///
+ /// Gets or sets the suggester name for auto-complete and suggestions.
+ ///
+ public string SuggesterName { get; set; } = null!;
+
+ ///
+ /// Gets or sets the suggester size for auto-complete and suggestions.
+ ///
+ public int ConceptsSuggesterSize { get; set; } = 5;
+
+ ///
+ /// Gets or sets the resource collection size (catalogue, course and resources) for auto-complete and suggestions.
+ ///
+ public int ResourceCollectionSuggesterSize { get; set; } = 5;
+
+ ///
+ /// Gets or sets the search query type (semantic, full, or simple).
+ ///
+ public string SearchQueryType { get; set; } = "Semantic";
+
+ ///
+ /// Gets or sets the semantic result buffer size for post-processing sorts.
+ /// When sorting is applied to semantic search results, this many results are retrieved
+ /// before applying the sort and pagination. Default is 50.
+ ///
+ public int SemanticResultBufferSize { get; set; } = 55;
+
+ ///
+ /// Gets or sets the scoring profile name used for boosting search results.
+ /// Default is "boostExactTitle".
+ ///
+ public string ScoringProfile { get; set; } = "boostExactTitle";
+
+ ///
+ /// Gets or sets the semantic configuration name for semantic search.
+ /// Default is "default".
+ ///
+ public string SemanticConfigurationName { get; set; } = "default";
+
+ ///
+ /// Gets or sets the facet fields to include in search results.
+ /// Default is ["resource_type", "resource_collection", "provider_ids"].
+ ///
+ public List
FacetFields { get; set; } = new List { "resource_type", "resource_collection", "provider_ids", "resource_access_level" };
+
+ ///
+ /// Gets or sets the field name for the deleted filter.
+ /// Default is "is_deleted".
+ ///
+ public string DeletedFilterField { get; set; } = "is_deleted";
+
+ ///
+ /// Gets or sets the value for the deleted filter.
+ /// Default is "false".
+ ///
+ public string DeletedFilterValue { get; set; } = "false";
+ }
+}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/DatabricksConfig.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/DatabricksConfig.cs
new file mode 100644
index 000000000..3483a2d60
--- /dev/null
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/DatabricksConfig.cs
@@ -0,0 +1,60 @@
+namespace LearningHub.Nhs.OpenApi.Models.Configuration
+{
+ ///
+ /// DatabricksConfig
+ ///
+ public class DatabricksConfig
+ {
+ ///
+ /// Gets or sets the ResourceId for the databricks instance.
+ ///
+ public string ResourceId { get; set; } = null!;
+
+ ///
+ /// Gets or sets the base url for the databricks instance.
+ ///
+ public string InstanceUrl { get; set; } = null!;
+
+ ///
+ /// Gets or sets the warehouse id for databricks.
+ ///
+ public string WarehouseId { get; set; } = null!;
+
+ ///
+ /// Gets or sets the job id for databricks.
+ ///
+ public string JobId { get; set; } = null!;
+
+ ///
+ /// Gets or sets the tenant Id of the service pricncipl.
+ ///
+ public string TenantId { get; set; } = null!;
+
+ ///
+ /// Gets or sets the client Id of the service pricncipl.
+ ///
+ public string ClientId { get; set; } = null!;
+
+ ///
+ /// Gets or sets the client scret of the service pricncipl.
+ ///
+ public string ClientSecret { get; set; } = null!;
+
+ ///
+ /// Gets or sets the endpoint to check user permission.
+ ///
+ public string UserPermissionEndpoint { get; set; } = null!;
+
+
+ ///
+ /// Gets or sets the endpoint for course completion record.
+ ///
+ public string CourseCompletionEndpoint { get; set; } = null!;
+
+ ///
+ /// Gets or sets the token.
+ ///
+ public string Token { get; set; } = null!;
+
+ }
+}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/FeatureFlagsConfig.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/FeatureFlagsConfig.cs
new file mode 100644
index 000000000..4c3f3b8b4
--- /dev/null
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/FeatureFlagsConfig.cs
@@ -0,0 +1,14 @@
+namespace LearningHub.Nhs.OpenApi.Models.Configuration
+{
+ ///
+ /// Defines the .
+ ///
+ public class FeatureFlagsConfig
+ {
+
+ ///
+ /// The InPlatformReport.
+ ///
+ public string InPlatformReport { get; set; } = null!;
+ }
+}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/LearningHubConfig.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/LearningHubConfig.cs
index 81b91aa65..9815f4828 100644
--- a/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/LearningHubConfig.cs
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/LearningHubConfig.cs
@@ -67,6 +67,11 @@ public class LearningHubConfig
///
public string ContentManagementQueueName { get; set; } = null!;
+ ///
+ /// Gets or sets .
+ ///
+ public string DatabricksProcessingQueueName { get; set; } = null!;
+
///
/// Gets or sets .
///
@@ -142,6 +147,15 @@ public class LearningHubConfig
///
public string BrowseCataloguesUrl { get; set; } = null!;
+ ///
+ /// Gets or sets .
+ ///
+ public string ReportUrl { get; set; } = null!;
+
+ ///
+ /// Gets or sets the StatMandId.
+ ///
+ public int StatMandId { get; set; }
///
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/NotificationSetting.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/NotificationSetting.cs
index 7b9b7b5ba..a3ea9a1bf 100644
--- a/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/NotificationSetting.cs
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/NotificationSetting.cs
@@ -55,5 +55,15 @@ public class NotificationSetting
/// Gets or sets the ResourceContributeAccess.
///
public string ResourceContributeAccess { get; set; } = null!;
+
+ ///
+ /// Gets or sets the report title notification content.
+ ///
+ public string ReportTitle { get; set; } = null!;
+
+ ///
+ /// Gets or sets the report notification content.
+ ///
+ public string Report { get; set; } = null!;
}
}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Models/LearningHub.Nhs.OpenApi.Models.csproj b/OpenAPI/LearningHub.Nhs.OpenApi.Models/LearningHub.Nhs.OpenApi.Models.csproj
index cecf1b22a..829bba059 100644
--- a/OpenAPI/LearningHub.Nhs.OpenApi.Models/LearningHub.Nhs.OpenApi.Models.csproj
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Models/LearningHub.Nhs.OpenApi.Models.csproj
@@ -1,4 +1,4 @@
-
+
net8.0
@@ -16,7 +16,8 @@
-
+
+
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Models/ServiceModels/AzureSearch/CacheableFacetResult.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Models/ServiceModels/AzureSearch/CacheableFacetResult.cs
new file mode 100644
index 000000000..d10ab70cb
--- /dev/null
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Models/ServiceModels/AzureSearch/CacheableFacetResult.cs
@@ -0,0 +1,19 @@
+namespace LearningHub.Nhs.OpenApi.Models.ServiceModels.AzureSearch
+{
+ ///
+ /// A cacheable representation of Azure Search FacetResult.
+ /// This DTO is used to cache facet data without serialization issues with Azure SDK types.
+ ///
+ public class CacheableFacetResult
+ {
+ ///
+ /// Gets or sets the facet value.
+ ///
+ public object? Value { get; set; }
+
+ ///
+ /// Gets or sets the count of documents matching this facet value.
+ ///
+ public long Count { get; set; }
+ }
+}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Models/ServiceModels/AzureSearch/SearchDocument.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Models/ServiceModels/AzureSearch/SearchDocument.cs
new file mode 100644
index 000000000..37b1b52d2
--- /dev/null
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Models/ServiceModels/AzureSearch/SearchDocument.cs
@@ -0,0 +1,175 @@
+namespace LearningHub.Nhs.OpenApi.Models.ServiceModels.AzureSearch
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Text.Json.Serialization;
+ using System.Text.RegularExpressions;
+ using Azure.Search.Documents.Indexes;
+
+ ///
+ /// Represents a search document for Azure AI Search integration.
+ ///
+ public class SearchDocument
+ {
+ private string _description = string.Empty;
+
+ ///
+ /// Gets or sets the unique identifier.
+ ///
+ [JsonPropertyName("id")]
+ public string PrefixedId { get; set; } = string.Empty;
+
+ ///
+ /// Gets the numeric ID extracted from the PrefixedId.
+ ///
+ [JsonIgnore]
+ public string Id
+ {
+ get
+ {
+ if (string.IsNullOrWhiteSpace(PrefixedId))
+ return "0";
+
+ var parts = PrefixedId.Split('-');
+ if (parts.Length != 2)
+ return "0";
+
+ return int.TryParse(parts[1], out int id) ? id.ToString() : "0";
+ }
+ }
+
+ ///
+ /// Gets or sets the title.
+ ///
+ [JsonPropertyName("title")]
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the normalised title.
+ ///
+ [JsonPropertyName("normalised_title")]
+ public string NormalisedTitle { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the description.
+ ///
+ [JsonPropertyName("description")]
+ public string Description
+ {
+ get => _description;
+ set => _description = StripParagraphTags(value);
+ }
+
+ ///
+ /// Gets or sets the resource type.
+ ///
+ [JsonPropertyName("resource_collection")]
+ public string ResourceCollection { get; set; } = string.Empty;
+
+ ///
+ /// gets or sets the catalogue identifier.
+ ///
+ [JsonPropertyName("catalogue_id")]
+ public string CatalogueId { get; set; } = string.Empty;
+
+ [JsonPropertyName("resource_reference_id")]
+ public string ResourceReferenceId { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the manual tag JSON.
+ ///
+ [JsonPropertyName("manual_tag")]
+ public string ManualTagJson { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the manual tags.
+ ///
+ [JsonPropertyName("manualTags")]
+ public List ManualTags { get; set; } = new List();
+
+ ///
+ /// Gets or sets the content type.
+ ///
+ [JsonPropertyName("resource_type")]
+ public string? ResourceType { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the resource access level.
+ ///
+ [JsonPropertyName("resource_access_level")]
+ public string? ResourceAccessLevel { get; set; }
+
+ ///
+ /// Gets or sets the date authored.
+ ///
+ [JsonPropertyName("date_authored")]
+ public DateTime? DateAuthored { get; set; }
+
+ ///
+ /// Gets or sets the rating.
+ ///
+ [JsonPropertyName("rating")]
+ public double? Rating { get; set; }
+
+ ///
+ /// Gets or sets the provider IDs.
+ ///
+ [JsonPropertyName("provider_ids")]
+ public string ProviderIds { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets a value indicating whether this is statutory mandatory.
+ ///
+ [JsonPropertyName("statutory_mandatory")]
+ public bool? StatutoryMandatory { get; set; }
+
+ ///
+ /// Gets or sets the author.
+ ///
+ [JsonPropertyName("author")]
+ public string Author { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the url.
+ ///
+ [JsonPropertyName("url")]
+ public string Url { get; set; } = string.Empty;
+
+ //
+ /// Gets or sets the url.
+ ///
+ [JsonPropertyName("is_deleted")]
+ public bool IsDeleted { get; set; }
+
+ ///
+ /// Strips paragraph tags from input string.
+ ///
+ /// The input string.
+ /// The cleaned string.
+ private static string StripParagraphTags(string input)
+ {
+ if (string.IsNullOrWhiteSpace(input))
+ return string.Empty;
+
+ return Regex.Replace(input, @"<\/?p[^>]*>", string.Empty, RegexOptions.IgnoreCase);
+ }
+
+ ///
+ /// Parses the ManualTagJson into ManualTags list.
+ ///
+ public void ParseManualTags()
+ {
+ if (!string.IsNullOrEmpty(ManualTagJson))
+ {
+ try
+ {
+ ManualTags = System.Text.Json.JsonSerializer.Deserialize>(ManualTagJson) ?? new List();
+ }
+ catch
+ {
+ ManualTags = new List();
+ }
+ }
+ }
+ }
+}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Models/ViewModels/DatabricksNotification.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Models/ViewModels/DatabricksNotification.cs
new file mode 100644
index 000000000..5ce62c6eb
--- /dev/null
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Models/ViewModels/DatabricksNotification.cs
@@ -0,0 +1,40 @@
+using Newtonsoft.Json;
+
+namespace LearningHub.Nhs.OpenApi.Models.ViewModels
+{
+ ///
+ /// DatabricksNotification
+ ///
+ public class DatabricksNotification
+ {
+ ///
+ /// Gets or sets .
+ ///
+ [JsonProperty("event_type")]
+ public string EventType { get; set; }
+
+ ///
+ /// Gets or sets .
+ ///
+ [JsonProperty("run")]
+ public RunInfo Run { get; set; }
+
+ ///
+ /// RunInfo
+ ///
+ public class RunInfo
+ {
+ ///
+ /// Gets or sets .
+ ///
+ [JsonProperty("run_id")]
+ public long RunId { get; set; }
+
+ ///
+ /// Gets or sets .
+ ///
+ [JsonProperty("parent_run_id")]
+ public long ParentRunId { get; set; }
+ }
+ }
+}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Models/ViewModels/NavigationModel.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Models/ViewModels/NavigationModel.cs
index 912d99cae..c7ccc7862 100644
--- a/OpenAPI/LearningHub.Nhs.OpenApi.Models/ViewModels/NavigationModel.cs
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Models/ViewModels/NavigationModel.cs
@@ -75,6 +75,11 @@ public class NavigationModel
///
public bool ShowBrowseCatalogues { get; set; }
+ ///
+ /// Gets or sets a value indicating whether to show reports.
+ ///
+ public bool ShowReports { get; set; }
+
///
/// Gets or sets a value indicating whether to show home.
///
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories.Interface/LearningHub.Nhs.OpenApi.Repositories.Interface.csproj b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories.Interface/LearningHub.Nhs.OpenApi.Repositories.Interface.csproj
index a52fd6bf1..cbac27a64 100644
--- a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories.Interface/LearningHub.Nhs.OpenApi.Repositories.Interface.csproj
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories.Interface/LearningHub.Nhs.OpenApi.Repositories.Interface.csproj
@@ -17,7 +17,7 @@
-
+
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories.Interface/Repositories/IReportHistoryRepository.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories.Interface/Repositories/IReportHistoryRepository.cs
new file mode 100644
index 000000000..b2aabcb95
--- /dev/null
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories.Interface/Repositories/IReportHistoryRepository.cs
@@ -0,0 +1,28 @@
+namespace LearningHub.Nhs.OpenApi.Repositories.Interface.Repositories
+{
+ using System.Collections.Generic;
+ using System.Linq;
+ using System.Threading.Tasks;
+ using LearningHub.Nhs.Models.Entities;
+ using LearningHub.Nhs.Models.Entities.DatabricksReport;
+
+ ///
+ /// The ProviderRepository interface.
+ ///
+ public interface IReportHistoryRepository : IGenericRepository
+ {
+ ///
+ /// The get by id async.
+ ///
+ /// The id.
+ /// The .
+ Task GetByIdAsync(int id);
+
+ ///
+ /// The get by user id async.
+ ///
+ /// The userId.
+ /// The .
+ IQueryable GetByUserIdAsync(int userId);
+ }
+}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/EntityFramework/LearningHubDbContext.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/EntityFramework/LearningHubDbContext.cs
index 1b8fe8783..80ec18a5c 100644
--- a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/EntityFramework/LearningHubDbContext.cs
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/EntityFramework/LearningHubDbContext.cs
@@ -6,6 +6,7 @@ namespace LearningHub.Nhs.OpenApi.Repositories.EntityFramework
using LearningHub.Nhs.Models.Entities;
using LearningHub.Nhs.Models.Entities.Activity;
using LearningHub.Nhs.Models.Entities.Content;
+ using LearningHub.Nhs.Models.Entities.DatabricksReport;
using LearningHub.Nhs.Models.Entities.External;
using LearningHub.Nhs.Models.Entities.Hierarchy;
using LearningHub.Nhs.Models.Entities.Messaging;
@@ -750,6 +751,11 @@ public LearningHubDbContextOptions Options
///
public virtual DbSet UserProvider { get; set; }
+ ///
+ /// Gets or sets Report History.
+ ///
+ public virtual DbSet ReportHistory { get; set; }
+
///
/// Gets or sets Resource Version Provider.
///
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/EntityFramework/ServiceMappings.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/EntityFramework/ServiceMappings.cs
index a571d177a..197f95dcb 100644
--- a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/EntityFramework/ServiceMappings.cs
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/EntityFramework/ServiceMappings.cs
@@ -184,6 +184,7 @@ public static void AddLearningHubMappings(this IServiceCollection services, ICon
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
// External
services.AddSingleton();
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/LearningHub.Nhs.OpenApi.Repositories.csproj b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/LearningHub.Nhs.OpenApi.Repositories.csproj
index 46722121a..6a727dc60 100644
--- a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/LearningHub.Nhs.OpenApi.Repositories.csproj
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/LearningHub.Nhs.OpenApi.Repositories.csproj
@@ -24,7 +24,7 @@
-
+
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/Map/ReportHistoryMap.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/Map/ReportHistoryMap.cs
new file mode 100644
index 000000000..3e398744c
--- /dev/null
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/Map/ReportHistoryMap.cs
@@ -0,0 +1,23 @@
+using LearningHub.Nhs.Models.Entities.DatabricksReport;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace LearningHub.Nhs.OpenApi.Repositories.Map
+{
+ ///
+ /// The ReportHistory Map.
+ ///
+ public class ReportHistoryMap : BaseEntityMap
+ {
+ ///
+ /// The internal map.
+ ///
+ ///
+ /// The model builder.
+ ///
+ protected override void InternalMap(EntityTypeBuilder modelBuilder)
+ {
+ modelBuilder.ToTable("ReportHistory", "Reports");
+ }
+ }
+}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/Repositories/ReportHistoryRepository.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/Repositories/ReportHistoryRepository.cs
new file mode 100644
index 000000000..32bfd0458
--- /dev/null
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/Repositories/ReportHistoryRepository.cs
@@ -0,0 +1,57 @@
+namespace LearningHub.Nhs.OpenApi.Repositories.Repositories
+{
+ using System.Collections.Generic;
+ using System.Data;
+ using System.Linq;
+ using System.Threading.Tasks;
+ using LearningHub.Nhs.Models.Entities;
+ using LearningHub.Nhs.Models.Entities.DatabricksReport;
+ using LearningHub.Nhs.Models.Entities.Hierarchy;
+ using LearningHub.Nhs.Models.Entities.Resource;
+ using LearningHub.Nhs.OpenApi.Repositories.EntityFramework;
+ using LearningHub.Nhs.OpenApi.Repositories.Interface.Repositories;
+ using Microsoft.EntityFrameworkCore;
+
+ ///
+ /// The provider repository.
+ ///
+ public class ReportHistoryRepository : GenericRepository, IReportHistoryRepository
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The db context.
+ /// The Timezone offset manager.
+ public ReportHistoryRepository(LearningHubDbContext dbContext, ITimezoneOffsetManager tzOffsetManager)
+ : base(dbContext, tzOffsetManager)
+ {
+ }
+
+ ///
+ public async Task GetByIdAsync(int id)
+ {
+ return await DbContext.ReportHistory.AsNoTracking().FirstOrDefaultAsync(n => n.Id == id && !n.Deleted);
+ }
+
+ ///
+ public IQueryable GetByUserIdAsync(int userId)
+ {
+ return DbContext.ReportHistory.AsNoTracking().Where(n => n.CreateUserId == userId && !n.Deleted);
+ }
+
+ ///
+ /// The get by user id async.
+ ///
+ /// The user id.
+ /// The .
+ public IQueryable GetProvidersByUserIdAsync(int userId)
+ {
+ return DbContext.Set()
+ .Include(up => up.Provider)
+ .Where(up => up.UserId == userId && !up.Deleted).AsNoTracking()
+ .Select(up => up.Provider);
+ }
+
+
+ }
+}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/Startup.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/Startup.cs
index 767d35d19..37d56292d 100644
--- a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/Startup.cs
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/Startup.cs
@@ -56,6 +56,7 @@ private static void AddRepositoryImplementations(this IServiceCollection service
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddTransient();
services.AddTransient();
services.AddTransient();
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/HttpClients/IDatabricksApiHttpClient.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/HttpClients/IDatabricksApiHttpClient.cs
new file mode 100644
index 000000000..f4226cd8f
--- /dev/null
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/HttpClients/IDatabricksApiHttpClient.cs
@@ -0,0 +1,26 @@
+namespace LearningHub.Nhs.OpenApi.Services.Interface.HttpClients
+{
+ using System;
+ using System.Net.Http;
+ using System.Threading.Tasks;
+
+ ///
+ /// The Bookmark Http Client interface.
+ ///
+ public interface IDatabricksApiHttpClient : IDisposable
+ {
+ ///
+ /// GETs data from Databricks API.
+ ///
+ /// The URL to make a get call to.
+ /// Optional authorization header.
+ /// A representing the result of the asynchronous operation.
+ Task GetData(string requestUrl, string? authHeader);
+
+ ///
+ /// The Get Client method.
+ ///
+ /// The .
+ HttpClient GetClient();
+ }
+}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/LearningHub.Nhs.OpenApi.Services.Interface.csproj b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/LearningHub.Nhs.OpenApi.Services.Interface.csproj
index f6e92a8ce..175ac7fb3 100644
--- a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/LearningHub.Nhs.OpenApi.Services.Interface.csproj
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/LearningHub.Nhs.OpenApi.Services.Interface.csproj
@@ -17,7 +17,7 @@
-
+
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/IDatabricksService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/IDatabricksService.cs
new file mode 100644
index 000000000..10e0f358c
--- /dev/null
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/IDatabricksService.cs
@@ -0,0 +1,77 @@
+using LearningHub.Nhs.Models.Common;
+using LearningHub.Nhs.Models.Databricks;
+using LearningHub.Nhs.OpenApi.Models.ViewModels;
+using System.Threading.Tasks;
+
+namespace LearningHub.Nhs.OpenApi.Services.Interface.Services
+{
+ ///
+ /// IDatabricks service
+ ///
+ public interface IDatabricksService
+ {
+ ///
+ /// IsUserReporter.
+ ///
+ /// The userId.
+ /// A representing the result of the asynchronous operation.
+ Task IsUserReporter(int userId);
+
+ ///
+ /// CourseCompletionReport.
+ ///
+ /// The userId.
+ /// The model.
+ /// A representing the result of the asynchronous operation.
+ Task CourseCompletionReport(int userId, DatabricksRequestModel model);
+
+ ///
+ /// CourseCompletionReport.
+ ///
+ /// The userId.
+ /// The page.
+ /// The pageSize.
+ /// A representing the result of the asynchronous operation.
+ Task> GetPagedReportHistory(int userId, int page, int pageSize);
+
+ ///
+ /// GetPagedReportHistoryById.
+ ///
+ /// The userId.
+ /// The reportHistoryId.
+ /// A representing the result of the asynchronous operation.
+ Task GetPagedReportHistoryById(int userId, int reportHistoryId);
+
+ ///
+ /// QueueReportDownload
+ ///
+ ///
+ ///
+ ///
+ Task QueueReportDownload(int userId, int reportHistoryId);
+
+ ///
+ /// DownloadReport
+ ///
+ ///
+ ///
+ ///
+ Task DownloadReport(int userId, int reportHistoryId);
+
+ ///
+ /// DatabricksJobUpdate.
+ ///
+ ///
+ ///
+ ///
+ Task DatabricksJobUpdate(int userId, DatabricksNotification databricksNotification);
+
+ ///
+ /// DatabricksJobUpdate.
+ ///
+ /// userId.
+ /// databricksUpdateRequest.
+ ///
+ Task UpdateDatabricksReport(int userId, DatabricksUpdateRequest databricksUpdateRequest);
+ }
+}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/INotificationService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/INotificationService.cs
index 0a0af21eb..217a77228 100644
--- a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/INotificationService.cs
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/INotificationService.cs
@@ -69,5 +69,14 @@ public interface INotificationService
/// Error message.
/// The .
Task CreatePublishFailedNotificationAsync(int userId, string resourceTitle, string errorMessage = "");
+
+ ///
+ /// Creates report processed notification.
+ ///
+ /// The current user id.
+ /// Report Name.
+ /// Report Content.
+ /// The .
+ Task CreateReportNotificationAsync(int userId, string reportName, string reportContent);
}
}
\ No newline at end of file
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/ISearchService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/ISearchService.cs
index 194264b15..2a238edf5 100644
--- a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/ISearchService.cs
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/ISearchService.cs
@@ -1,5 +1,6 @@
namespace LearningHub.Nhs.OpenApi.Services.Interface.Services
{
+ using System.Threading;
using System.Threading.Tasks;
using LearningHub.Nhs.Models.Search;
using LearningHub.Nhs.Models.Search.SearchClick;
@@ -17,8 +18,9 @@ public interface ISearchService
///
/// The catalog search request model.
/// The user id.
+ /// Cancellation token.
/// The .
- Task GetSearchResultAsync(SearchRequestModel searchRequestModel, int userId);
+ Task GetSearchResultAsync(SearchRequestModel searchRequestModel, int userId, CancellationToken cancellationToken = default);
///
/// The Get Catalogue Search Result Async method.
@@ -29,10 +31,11 @@ public interface ISearchService
///
/// The user id.
///
+ /// Cancellation token.
///
/// The .
///
- Task GetCatalogueSearchResultAsync(CatalogueSearchRequestModel catalogSearchRequestModel, int userId);
+ Task GetCatalogueSearchResultAsync(CatalogueSearchRequestModel catalogSearchRequestModel, int userId, CancellationToken cancellationToken = default);
///
/// The create resource search action async.
@@ -152,8 +155,9 @@ public interface ISearchService
/// The Get Auto suggestion Results Async method.
///
/// The term.
+ /// Cancellation token.
/// The .
- Task GetAutoSuggestionResultsAsync(string term);
+ Task GetAutoSuggestionResultsAsync(string term, CancellationToken cancellationToken = default);
///
/// The Send AutoSuggestion Event Async.
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/Messaging/IEmailSenderService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/Messaging/IEmailSenderService.cs
index 794a55ae4..3e4a3f47f 100644
--- a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/Messaging/IEmailSenderService.cs
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/Messaging/IEmailSenderService.cs
@@ -54,5 +54,13 @@ public interface IEmailSenderService
/// The isUserRoleUpgrade.
/// The task.
Task SendEmailVerifiedEmail(int userId, SendEmailModel model, bool isUserRoleUpgrade);
+
+ ///
+ /// Sends report generation completion email to user.
+ ///
+ /// The userId sending the email.
+ /// The model.
+ /// The task.
+ Task SendReportProcessedEmail(int userId, SendEmailModel model);
}
}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/Messaging/IEmailTemplateService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/Messaging/IEmailTemplateService.cs
index 7e75ae57b..9f676fcda 100644
--- a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/Messaging/IEmailTemplateService.cs
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/Messaging/IEmailTemplateService.cs
@@ -56,5 +56,13 @@ public interface IEmailTemplateService
/// The isUserRoleUpgrade.
/// The subject and body.
EmailDetails GetEmailVerificationEmail(SendEmailModel emailModel, bool isUserRoleUpgrade);
+
+
+ ///
+ /// The GetCatalogueAccessRequestFailure.
+ ///
+ /// The email model.
+ /// The subject and body.
+ EmailDetails GetReportProcessed(SendEmailModel emailModel);
}
}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/AzureSearchClientFactory.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/AzureSearchClientFactory.cs
new file mode 100644
index 000000000..ad0faee6c
--- /dev/null
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/AzureSearchClientFactory.cs
@@ -0,0 +1,47 @@
+namespace LearningHub.Nhs.OpenApi.Services.Helpers
+{
+ using System;
+ using Azure;
+ using Azure.Search.Documents;
+ using LearningHub.Nhs.OpenApi.Models.Configuration;
+
+ ///
+ /// Factory for creating Azure Search clients.
+ ///
+ public static class AzureSearchClientFactory
+ {
+ ///
+ /// Creates a SearchClient for querying the Azure Search index.
+ ///
+ /// The Azure Search configuration.
+ /// A configured SearchClient instance.
+ public static SearchClient CreateQueryClient(AzureSearchConfig config)
+ {
+ if (config == null)
+ throw new ArgumentNullException(nameof(config));
+
+ var credential = new AzureKeyCredential(config.QueryApiKey);
+ return new SearchClient(
+ new Uri(config.ServiceEndpoint),
+ config.IndexName,
+ credential);
+ }
+
+ ///
+ /// Creates a SearchClient with admin credentials for indexing operations.
+ ///
+ /// The Azure Search configuration.
+ /// A configured SearchClient instance with admin credentials.
+ public static SearchClient CreateAdminClient(AzureSearchConfig config)
+ {
+ if (config == null)
+ throw new ArgumentNullException(nameof(config));
+
+ var adminCredential = new AzureKeyCredential(config.AdminApiKey);
+ return new SearchClient(
+ new Uri(config.ServiceEndpoint),
+ config.IndexName,
+ adminCredential);
+ }
+ }
+}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/AzureSearchFacetHelper.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/AzureSearchFacetHelper.cs
new file mode 100644
index 000000000..066c6aae3
--- /dev/null
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/AzureSearchFacetHelper.cs
@@ -0,0 +1,172 @@
+namespace LearningHub.Nhs.OpenApi.Services.Helpers.Search
+{
+ using Azure.Search.Documents.Models;
+ using LearningHub.Nhs.Models.Search;
+ using LearningHub.Nhs.OpenApi.Models.ServiceModels.AzureSearch;
+ using System;
+ using System.Collections.Generic;
+ using System.Linq;
+ using System.Reflection;
+
+ ///
+ /// Helper class for Azure Search facet operations including conversion and caching.
+ ///
+ public static class AzureSearchFacetHelper
+ {
+ private static readonly ConstructorInfo FacetResultCtor =
+ typeof(FacetResult)
+ .GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic)
+ .FirstOrDefault(c =>
+ {
+ var parameters = c.GetParameters();
+ return parameters.Length == 2 &&
+ parameters[0].ParameterType == typeof(long?) &&
+ typeof(IReadOnlyDictionary).IsAssignableFrom(parameters[1].ParameterType);
+ });
+
+ ///
+ /// Converts Azure Search FacetResult dictionary to cacheable DTO.
+ ///
+ /// The facets from Azure Search.
+ /// A cacheable dictionary of facets.
+ public static IDictionary> ConvertToCacheable(
+ IDictionary>? facets)
+ {
+ if (facets == null)
+ return new Dictionary>();
+
+ return facets.ToDictionary(
+ kvp => kvp.Key,
+ kvp => (IList)kvp.Value
+ .Select(f => new CacheableFacetResult
+ {
+ Value = f.Value,
+ Count = f.Count ?? 0
+ })
+ .ToList());
+ }
+
+ ///
+ /// Converts cacheable DTO back to Azure Search FacetResult dictionary.
+ ///
+ /// The cached facets.
+ /// A dictionary of FacetResult.
+ public static IDictionary> ConvertFromCacheable(
+ IDictionary>? cacheableFacets)
+ {
+ var result = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+
+ if (cacheableFacets == null || FacetResultCtor == null)
+ return result;
+
+ foreach (var kvp in cacheableFacets)
+ {
+ var facetResults = new List();
+
+ foreach (var cf in kvp.Value)
+ {
+ // Build the additionalProperties dictionary just like Azure returns in JSON
+ var additionalProps = new Dictionary
+ {
+ ["value"] = cf.Value ?? string.Empty
+ };
+
+ // Use the internal constructor via reflection
+ var facet = (FacetResult)FacetResultCtor.Invoke(new object?[] { cf.Count, additionalProps });
+
+ facetResults.Add(facet);
+ }
+
+ result[kvp.Key] = facetResults;
+ }
+
+ return result;
+ }
+
+
+ ///
+ /// Merges filtered and unfiltered facets to maintain visibility of all filter options.
+ ///
+ /// Facets from the filtered search.
+ /// Facets from the unfiltered search.
+ /// The currently applied filters.
+ /// An array of merged facets.
+ public static Facet[] MergeFacets(
+ IDictionary> filteredFacets,
+ IDictionary> unfilteredFacets,
+ Dictionary> appliedFilters)
+ {
+ if (unfilteredFacets == null || !unfilteredFacets.Any())
+ {
+ return Array.Empty();
+ }
+
+ var facets = new Facet[unfilteredFacets.Count];
+ var index = 0;
+
+ foreach (var facetGroup in unfilteredFacets)
+ {
+ var facetKey = facetGroup.Key;
+ var hasAppliedFilter = appliedFilters?.ContainsKey(facetKey) == true;
+ var appliedValues = hasAppliedFilter ? appliedFilters[facetKey] : new List();
+
+ // Get filtered facet values if available
+ var filteredFacetValues = filteredFacets?.ContainsKey(facetKey) == true
+ ? filteredFacets[facetKey].ToDictionary(f => f.Value?.ToString()?.ToLower() ?? "", f => (int)f.Count)
+ : new Dictionary();
+
+ var filters = facetGroup.Value.Select(f =>
+ {
+ var displayName = f.Value?.ToString()?.ToLower() ?? "";
+ var isSelected = appliedValues.Any(av => av.Equals(f.Value?.ToString(), StringComparison.OrdinalIgnoreCase));
+
+ // Default to the unfiltered count. This is used when:
+ // - no filtered search has been performed, OR
+ // - the filter value is selected (keep count so the option remains visible for deselection), OR
+ // - this facet group itself has an applied filter (multi-select pattern: a group's own
+ // counts are not narrowed down by its own selections so the user can still pick other values).
+ // Only update counts using the filtered results when a filter from a DIFFERENT group
+ // is driving the narrowing of this group.
+ var count = (int)f.Count;
+ if (!isSelected && filteredFacets != null && !hasAppliedFilter)
+ {
+ count = filteredFacetValues.TryGetValue(displayName, out var filteredCount) ? filteredCount : 0;
+ }
+
+ return new Filter
+ {
+ DisplayName = displayName,
+ Count = count,
+ Selected = isSelected
+ };
+ }).ToList();
+
+ // Ensure all selected filter values remain visible even if absent from the unfiltered
+ // baseline (e.g. a resource selected under a resource-level filter that yields no results).
+ foreach (var selectedValue in appliedValues)
+ {
+ var alreadyPresent = filters.Any(f =>
+ f.DisplayName.Equals(selectedValue, StringComparison.OrdinalIgnoreCase));
+
+ if (!alreadyPresent)
+ {
+ filters.Add(new Filter
+ {
+ DisplayName = selectedValue.ToLower(),
+ Count = 0,
+ Selected = true,
+ });
+ }
+ }
+
+ facets[index++] = new Facet
+ {
+ Id = facetKey,
+ Filters = filters.ToArray()
+ };
+ }
+
+ return facets;
+ }
+ }
+}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/LuceneQueryBuilder.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/LuceneQueryBuilder.cs
new file mode 100644
index 000000000..529cbbb18
--- /dev/null
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/LuceneQueryBuilder.cs
@@ -0,0 +1,44 @@
+namespace LearningHub.Nhs.OpenApi.Services.Helpers.Search
+{
+ using System;
+ using System.Linq;
+ using System.Text.RegularExpressions;
+
+ ///
+ /// Helper class for building Lucene queries for Azure Search.
+ ///
+ public static class LuceneQueryBuilder
+ {
+ ///
+ /// Builds a Lucene query from the search text.
+ ///
+ /// The search text.
+ /// The Lucene query string.
+ public static string BuildLuceneQuery(string? searchText)
+ {
+ if (string.IsNullOrWhiteSpace(searchText))
+ return "*";
+
+ var tokens = searchText
+ .Split(' ', StringSplitOptions.RemoveEmptyEntries)
+ .Select(EscapeLuceneSpecialCharacters)
+ .Where(t => !string.IsNullOrWhiteSpace(t));
+
+ return string.Join(" AND ", tokens);
+ }
+
+ ///
+ /// Escapes Lucene special characters in the input string.
+ ///
+ /// The input string.
+ /// The escaped string.
+ public static string EscapeLuceneSpecialCharacters(string? input)
+ {
+ if (string.IsNullOrEmpty(input))
+ return input ?? string.Empty;
+
+ var pattern = @"([+\-!(){}[\]^\""?~*:\\/])";
+ return Regex.Replace(input, pattern, "\\$1");
+ }
+ }
+}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/SearchFilterBuilder.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/SearchFilterBuilder.cs
new file mode 100644
index 000000000..7c16bc461
--- /dev/null
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/SearchFilterBuilder.cs
@@ -0,0 +1,196 @@
+namespace LearningHub.Nhs.OpenApi.Services.Helpers.Search
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Linq;
+ using System.Web;
+
+ ///
+ /// Helper class for building Azure Search filter expressions.
+ ///
+ public static class SearchFilterBuilder
+ {
+
+ public static Dictionary> CombineAndNormaliseFilters(string requestTypeFilterText, string? providerFilterText, string? resourceAccessLevelFilterText)
+ {
+ var filters = new Dictionary>
+ {
+ // { "resource_collection", new List { "Resource" } }
+ };
+
+ // Parse and merge additional filters from query string
+ var requestTypeFilters = ParseQueryStringFilters(requestTypeFilterText);
+ var providerFilters = ParseQueryStringFilters(providerFilterText);
+ var resourceAccessLevelFilters = ParseQueryStringFilters(resourceAccessLevelFilterText);
+
+ // Merge filters from both sources
+ MergeFilterDictionary(filters, requestTypeFilters);
+ MergeFilterDictionary(filters, providerFilters);
+ MergeFilterDictionary(filters, resourceAccessLevelFilters);
+
+ //NormaliseFilters(filters);
+
+ return filters;
+ }
+
+ private static void MergeFilterDictionary(Dictionary> target, Dictionary> source)
+ {
+ foreach (var kvp in source)
+ {
+ if (!target.ContainsKey(kvp.Key))
+ target[kvp.Key] = new List();
+
+ target[kvp.Key].AddRange(kvp.Value);
+ }
+ }
+
+ ///
+ /// Builds a filter expression from a dictionary of filters.
+ ///
+ /// The filters to apply.
+ /// The filter expression string.
+ ///
+ /// Build an OData filter that supports multi-select values.
+ /// Pass a dictionary where key = field name, value = list of selected values.
+ /// If `collectionFields` contains a field name, that field will be treated as a collection and use any(...).
+ ///
+ public static string BuildFilterExpression(
+ Dictionary>? filters,
+ ISet? collectionFields = null)
+ {
+ if (filters == null || !filters.Any())
+ return string.Empty;
+
+ collectionFields ??= new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ // Handle spacing, NBSP, escaping quotes
+ string Normalize(string v)
+ {
+ if (v == null) return string.Empty;
+
+ // Replace NBSP
+ v = v.Replace('\u00A0', ' ').Trim();
+
+ // Collapse multiple spaces
+ v = System.Text.RegularExpressions.Regex.Replace(v, @"\s+", " ");
+
+ // Escape single quotes for OData
+ v = v.Replace("'", "''");
+
+ return v;
+ }
+
+ var expressions = new List();
+
+ foreach (var kvp in filters)
+ {
+ var field = kvp.Key;
+ var values = kvp.Value?.Where(v => !string.IsNullOrWhiteSpace(v)).ToList();
+
+ if (values == null || values.Count == 0)
+ continue;
+
+ // Normalize all values
+ var normalizedValues = values.Select(Normalize).Distinct().ToList();
+
+ // Single value → use eq
+ if (normalizedValues.Count == 1)
+ {
+ var v = normalizedValues[0];
+
+ if (collectionFields.Contains(field))
+ {
+ expressions.Add($"{field}/any(t: t eq '{v}')");
+ }
+ else if (v.Contains("false"))
+ {
+ expressions.Add($"{field} eq {v}");
+ }
+ else
+ {
+ expressions.Add($"{field} eq '{v}'");
+ }
+
+ continue;
+ }
+
+ // Multiple values → use OR conditions (ALWAYS works)
+ if (collectionFields.Contains(field))
+ {
+ // collection field (array) → OR any(...) conditions
+ var ors = normalizedValues
+ .Select(v => $"{field}/any(t: t eq '{v}')");
+
+ expressions.Add("(" + string.Join(" or ", ors) + ")");
+ }
+ else
+ {
+ // single string field → OR eq conditions
+ var ors = normalizedValues
+ .Select(v => $"{field} eq '{v}'");
+
+ expressions.Add("(" + string.Join(" or ", ors) + ")");
+ }
+ }
+
+ return expressions.Count > 0
+ ? string.Join(" and ", expressions)
+ : string.Empty;
+ }
+
+
+
+ ///
+ /// Parses filter parameters from a query string.
+ ///
+ /// The query string to parse.
+ /// A dictionary of filter names and their values.
+ public static Dictionary> ParseQueryStringFilters(string? queryString)
+ {
+ var filters = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+
+ if (string.IsNullOrWhiteSpace(queryString))
+ return filters;
+
+ // Remove '?' if present
+ if (queryString.StartsWith("?"))
+ queryString = queryString.Substring(1);
+
+ // Parse using HttpUtility
+ var parsed = HttpUtility.ParseQueryString(queryString);
+
+ foreach (string? key in parsed.AllKeys)
+ {
+ if (key == null)
+ continue; // skip null keys
+
+ var values = parsed.GetValues(key);
+ if (values != null)
+ {
+ // Add to dictionary (support multiple values per key)
+ if (!filters.ContainsKey(key))
+ filters[key] = new List();
+
+ filters[key].AddRange(values);
+ }
+ }
+
+ return filters;
+ }
+
+ ///
+ /// Normalizes resource type and resource collection filter values by capitalizing the first letter.
+ ///
+ /// The filters dictionary to normalize.
+ public static void NormaliseFilters(Dictionary>? filters)
+ {
+ if (filters == null || !filters.ContainsKey("resource_type"))
+ return;
+
+ filters["resource_type"] = filters["resource_type"]
+ .Where(v => !string.IsNullOrEmpty(v))
+ .Select(v => char.ToUpper(v[0]) + v.Substring(1))
+ .ToList();
+ }
+ }
+}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/SearchOptionsBuilder.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/SearchOptionsBuilder.cs
new file mode 100644
index 000000000..40686cd46
--- /dev/null
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/SearchOptionsBuilder.cs
@@ -0,0 +1,246 @@
+namespace LearningHub.Nhs.OpenApi.Services.Helpers.Search
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Linq;
+ using Azure.Search.Documents;
+ using Azure.Search.Documents.Models;
+
+ ///
+ /// Helper class for building Azure Search options.
+ ///
+ public static class SearchOptionsBuilder
+ {
+ ///
+ /// Determines if a sort direction is descending.
+ ///
+ /// The sort direction string.
+ /// True if descending, false otherwise.
+ public static bool IsDescendingSort(string? sortDirection)
+ {
+ return sortDirection != null &&
+ (sortDirection.Equals("desc", StringComparison.OrdinalIgnoreCase) ||
+ sortDirection.Equals("descending", StringComparison.OrdinalIgnoreCase));
+ }
+
+ ///
+ /// Maps a UI sort column name to the corresponding Azure Search field name.
+ ///
+ /// The UI sort column name.
+ /// The Azure Search field name, or null if no mapping exists.
+ public static string? MapSortColumnToSearchField(string? uiSortColumn)
+ {
+ if (string.IsNullOrWhiteSpace(uiSortColumn))
+ return null;
+
+ return uiSortColumn.Trim().ToLowerInvariant() switch
+ {
+ "avgrating" => "rating",
+ "rating" => "rating",
+ "authored_date" => "date_authored",
+ "authoreddate" => "date_authored",
+ "authoredDate" => "date_authored",
+ "title" => "normalised_title",
+ "atoz" => "normalised_title",
+ "alphabetical" => "normalised_title",
+ "ztoa" => "normalised_title",
+ _ => null
+ };
+ }
+
+ ///
+ /// Maps a UI sort column name to the corresponding Document property name for in-memory sorting.
+ ///
+ /// The UI sort column name.
+ /// The Document property name.
+ public static string? MapSortColumnToDocumentProperty(string? uiSortColumn)
+ {
+ if (string.IsNullOrWhiteSpace(uiSortColumn))
+ return null;
+
+ return uiSortColumn.Trim().ToLowerInvariant() switch
+ {
+ "avgrating" => "rating",
+ "rating" => "rating",
+ "authored_date" => "authored_date",
+ "authoreddate" => "authored_date",
+ "authoredDate" => "authored_date",
+ "title" => "title",
+ "atoz" => "title",
+ "alphabetical" => "title",
+ "ztoa" => "title",
+ _ => "title" // Default to title
+ };
+ }
+
+ ///
+ /// Applies post-processing sort to a list of documents.
+ /// Used when semantic search is active with non-relevance sorting.
+ ///
+ /// The documents to sort.
+ /// The column to sort by (title, rating, authored_date).
+ /// The sort direction (ascending/descending).
+ /// The sorted list of documents.
+ public static List ApplyPostProcessingSort(
+ List documents,
+ string? sortColumn,
+ string? sortDirection)
+ {
+ if (documents == null || documents.Count == 0)
+ {
+ return documents;
+ }
+
+ bool isDescending = IsDescendingSort(sortDirection);
+ string? mappedColumn = MapSortColumnToDocumentProperty(sortColumn);
+
+ IOrderedEnumerable sortedDocuments = mappedColumn?.ToLowerInvariant() switch
+ {
+ "title" => isDescending
+ ? documents.OrderByDescending(d => d.Title, StringComparer.OrdinalIgnoreCase)
+ : documents.OrderBy(d => d.Title, StringComparer.OrdinalIgnoreCase),
+ "rating" => isDescending
+ ? documents.OrderByDescending(d => d.Rating)
+ : documents.OrderBy(d => d.Rating),
+ "authored_date" => isDescending
+ ? documents.OrderByDescending(d =>
+ DateTime.TryParse(d.AuthoredDate, out var dt) ? dt : DateTime.MinValue)
+ : documents.OrderBy(d =>
+ DateTime.TryParse(d.AuthoredDate, out var dt) ? dt : DateTime.MinValue),
+ _ => isDescending
+ ? documents.OrderByDescending(d => d.Title, StringComparer.OrdinalIgnoreCase)
+ : documents.OrderBy(d => d.Title, StringComparer.OrdinalIgnoreCase)
+ };
+
+ return sortedDocuments.ToList();
+ }
+
+ ///
+ /// Builds search options for Azure Search queries.
+ ///
+ /// The type of search query.
+ /// The number of results to skip.
+ /// The number of results to return.
+ /// The filters to apply.
+ /// The sort to apply.
+ /// Whether to include facets.
+ /// The Azure Search configuration.
+ /// The configured search options.
+ public static SearchOptions BuildSearchOptions(
+ SearchQueryType searchQueryType,
+ int offset,
+ int pageSize,
+ Dictionary>? filters,
+ Dictionary? sortBy,
+ bool includeFacets,
+ Models.Configuration.AzureSearchConfig config)
+ {
+ var searchOptions = new SearchOptions
+ {
+ Skip = offset,
+ Size = pageSize,
+ IncludeTotalCount = true,
+ ScoringProfile = config.ScoringProfile
+ };
+
+ string sortByFinal = GetSortOption(sortBy);
+
+ // Configure query type
+ if (searchQueryType == SearchQueryType.Semantic)
+ {
+ searchOptions.QueryType = SearchQueryType.Semantic;
+ searchOptions.SemanticSearch = new SemanticSearchOptions
+ {
+ SemanticConfigurationName = config.SemanticConfigurationName
+ };
+ }
+ else if (searchQueryType == SearchQueryType.Simple)
+ {
+ searchOptions.QueryType = SearchQueryType.Simple;
+ searchOptions.SearchMode = SearchMode.Any;
+ searchOptions.OrderBy.Add(sortByFinal);
+ }
+ else
+ {
+ searchOptions.QueryType = SearchQueryType.Full;
+ searchOptions.OrderBy.Add(sortByFinal);
+ }
+
+ // Add facets
+ if (includeFacets && config.FacetFields != null)
+ {
+ foreach (var facet in config.FacetFields)
+ {
+ var facetValue = facet.Contains("count:")
+ ? facet: $"{facet},count:20";
+
+ searchOptions.Facets.Add(facetValue);
+ }
+ }
+
+ // Add deleted filter
+ Dictionary> deleteFilter = new Dictionary>
+ {
+ { config.DeletedFilterField, new List { config.DeletedFilterValue } }
+ };
+ filters = filters == null ? deleteFilter : filters.Concat(deleteFilter).ToDictionary(k => k.Key, v => v.Value);
+
+ // Apply filters
+ if (filters?.Any() == true)
+ {
+ searchOptions.Filter = SearchFilterBuilder.BuildFilterExpression(filters);
+ }
+
+ return searchOptions;
+ }
+
+ private static string GetSortOption(Dictionary? sortBy)
+ {
+ // If null or empty → Azure Search will default to relevance
+ if (sortBy == null || sortBy.Count == 0)
+ return string.Empty;
+
+ // Extract key/value (only first pair used)
+ string? uiSortKey = sortBy.Keys.FirstOrDefault();
+ string? directionInput = sortBy.Values.FirstOrDefault();
+
+ // Handle empty key → no sorting
+ if (string.IsNullOrWhiteSpace(uiSortKey))
+ return string.Empty;
+
+ // Determine direction using shared helper
+ string sortDirection = IsDescendingSort(directionInput) ? "desc" : "asc";
+
+ // Map UI values to search fields using shared helper
+ string? sortColumn = MapSortColumnToSearchField(uiSortKey);
+
+ // No valid mapping → fall back to relevance
+ if (string.IsNullOrWhiteSpace(sortColumn))
+ return string.Empty;
+
+ return $"{sortColumn} {sortDirection}";
+ }
+
+ ///
+ /// Parses the search query type from configuration string.
+ /// Parsing is case-insensitive. If the value is null, empty, or invalid, defaults to Semantic.
+ ///
+ /// The search query type string (semantic, full, or simple).
+ /// The parsed SearchQueryType enum value.
+ public static SearchQueryType ParseSearchQueryType(string searchQueryTypeString)
+ {
+ if (string.IsNullOrWhiteSpace(searchQueryTypeString))
+ {
+ return SearchQueryType.Semantic;
+ }
+
+ if (Enum.TryParse(searchQueryTypeString, ignoreCase: true, out var queryType) &&
+ (queryType == SearchQueryType.Semantic || queryType == SearchQueryType.Full || queryType == SearchQueryType.Simple))
+ {
+ return queryType;
+ }
+
+ return SearchQueryType.Semantic;
+ }
+ }
+}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/TextCasingHelper.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/TextCasingHelper.cs
new file mode 100644
index 000000000..415211a32
--- /dev/null
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/TextCasingHelper.cs
@@ -0,0 +1,24 @@
+namespace LearningHub.Nhs.OpenApi.Services.Helpers
+{
+ ///
+ /// TextCasingHelper.
+ ///
+ public class TextCasingHelper
+ {
+ ///
+ /// Returns sentence case of input string.
+ ///
+ /// input.
+ /// A sentence case string corresponding to the input string.
+ public static string ConvertToSentenceCase(string input)
+ {
+ if (string.IsNullOrEmpty(input))
+ {
+ return input;
+ }
+
+ input = input.ToLower();
+ return char.ToUpper(input[0]) + input.Substring(1);
+ }
+ }
+}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/HttpClients/DatabricksApiHttpClient.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/HttpClients/DatabricksApiHttpClient.cs
new file mode 100644
index 000000000..b5452240a
--- /dev/null
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/HttpClients/DatabricksApiHttpClient.cs
@@ -0,0 +1,63 @@
+namespace LearningHub.Nhs.OpenApi.Services.HttpClients
+{
+ using System;
+ using System.Net.Http;
+ using System.Net.Http.Headers;
+ using System.Threading.Tasks;
+ using IdentityModel.Client;
+ using LearningHub.Nhs.OpenApi.Models.Configuration;
+ using LearningHub.Nhs.OpenApi.Services.Interface.HttpClients;
+ using Microsoft.Extensions.Options;
+ using Microsoft.IdentityModel.Protocols.OpenIdConnect;
+
+ ///
+ /// Http client for Databricks.
+ ///
+ public class DatabricksApiHttpClient : IDatabricksApiHttpClient
+ {
+ private readonly HttpClient httpClient;
+ private readonly IOptions databricksConfig;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Configuration details for the databricks.
+ public DatabricksApiHttpClient(IOptions databricksConfig)
+ {
+ this.databricksConfig = databricksConfig;
+ this.httpClient = new HttpClient { BaseAddress = new Uri(databricksConfig.Value.InstanceUrl) };
+ this.httpClient.DefaultRequestHeaders.Accept.Clear();
+ this.httpClient.DefaultRequestHeaders.Accept.Add(
+ new MediaTypeWithQualityHeaderValue("application/json"));
+ }
+
+ ///
+ public void Dispose()
+ {
+ this.httpClient.Dispose();
+ }
+
+ ///
+ /// The Get Client method.
+ ///
+ /// The .
+ public HttpClient GetClient()
+ {
+ string accessToken = this.databricksConfig.Value.Token;
+ this.httpClient.SetBearerToken(accessToken);
+ return this.httpClient;
+ }
+
+ ///
+ public async Task GetData(string requestUrl, string? authHeader)
+ {
+ if (!string.IsNullOrEmpty(authHeader))
+ {
+ this.httpClient.SetBearerToken(authHeader);
+ }
+
+ var message = await this.httpClient.GetAsync(requestUrl).ConfigureAwait(false);
+ return message;
+ }
+ }
+}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/LearningHub.Nhs.OpenApi.Services.csproj b/OpenAPI/LearningHub.Nhs.OpenApi.Services/LearningHub.Nhs.OpenApi.Services.csproj
index e2e064738..36c6d8f5f 100644
--- a/OpenAPI/LearningHub.Nhs.OpenApi.Services/LearningHub.Nhs.OpenApi.Services.csproj
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/LearningHub.Nhs.OpenApi.Services.csproj
@@ -23,6 +23,7 @@
+
@@ -30,7 +31,7 @@
-
+
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/AzureSearch/AzureSearchService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/AzureSearch/AzureSearchService.cs
new file mode 100644
index 000000000..25a98541e
--- /dev/null
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/AzureSearch/AzureSearchService.cs
@@ -0,0 +1,947 @@
+namespace LearningHub.Nhs.OpenApi.Services.Services.AzureSearch
+{
+ using AutoMapper;
+ using Azure.Search.Documents;
+ using Azure.Search.Documents.Models;
+ using LearningHub.Nhs.Models.Entities.Activity;
+ using LearningHub.Nhs.Models.Entities.Resource;
+ using LearningHub.Nhs.Models.Entities.Resource.Blocks;
+ using LearningHub.Nhs.Models.Enums;
+ using LearningHub.Nhs.Models.Search;
+ using LearningHub.Nhs.Models.Search.SearchClick;
+ using LearningHub.Nhs.Models.Validation;
+ using LearningHub.Nhs.Models.ViewModels.Helpers;
+ using LearningHub.Nhs.OpenApi.Models.Configuration;
+ using LearningHub.Nhs.OpenApi.Models.ServiceModels.AzureSearch;
+ using LearningHub.Nhs.OpenApi.Models.ServiceModels.Resource;
+ using LearningHub.Nhs.OpenApi.Models.ViewModels;
+ using LearningHub.Nhs.OpenApi.Repositories.Interface.Repositories;
+ using LearningHub.Nhs.OpenApi.Services.Helpers;
+ using LearningHub.Nhs.OpenApi.Services.Helpers.Search;
+ using LearningHub.Nhs.OpenApi.Services.Interface.Services;
+ using Microsoft.Extensions.Logging;
+ using Microsoft.Extensions.Options;
+ using Newtonsoft.Json;
+ using System;
+ using System.Collections.Generic;
+ using System.Globalization;
+ using System.Linq;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using static Microsoft.EntityFrameworkCore.DbLoggerCategory;
+ using Event = LearningHub.Nhs.Models.Entities.Analytics.Event;
+
+
+ ///
+ /// The Azure AI Search service implementation.
+ /// Provides search functionality with facet caching and parallel query execution.
+ ///
+ public class AzureSearchService : ISearchService
+ {
+ ///
+ /// Default cache expiration time in minutes for facet results.
+ ///
+ private const int DefaultFacetCacheExpirationMinutes = 5;
+ private readonly IEventService eventService;
+ private readonly ILearningHubService learningHubService;
+ private readonly IResourceRepository resourceRepository;
+ private readonly ICachingService cachingService;
+ private readonly SearchClient searchClient;
+ private readonly ILogger logger;
+ private readonly AzureSearchConfig azureSearchConfig;
+ private readonly IMapper mapper;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The learning hub service.
+ /// The event service.
+ /// The Azure Search configuration.
+ /// The resource repository.
+ /// The caching service.
+ /// The logger.
+ /// The mapper.
+ public AzureSearchService(
+ ILearningHubService learningHubService,
+ IEventService eventService,
+ IOptions azureSearchConfig,
+ IResourceRepository resourceRepository,
+ ICachingService cachingService,
+ ILogger logger,
+ IMapper mapper)
+ {
+ this.learningHubService = learningHubService;
+ this.eventService = eventService;
+ this.resourceRepository = resourceRepository;
+ this.cachingService = cachingService;
+ this.logger = logger;
+ this.mapper = mapper;
+ this.azureSearchConfig = azureSearchConfig.Value;
+
+ this.searchClient = AzureSearchClientFactory.CreateQueryClient(this.azureSearchConfig);
+ }
+
+ ///
+ public async Task GetSearchResultAsync(SearchRequestModel searchRequestModel, int userId, CancellationToken cancellationToken = default)
+ {
+ var viewmodel = new SearchResultModel();
+
+ try
+ {
+ var searchQueryType = SearchOptionsBuilder.ParseSearchQueryType(this.azureSearchConfig.SearchQueryType); ;
+ var pageSize = searchRequestModel.PageSize;
+ var offset = searchRequestModel.PageIndex * pageSize;
+
+ // Build query string
+ var query = searchQueryType == SearchQueryType.Full
+ ? LuceneQueryBuilder.BuildLuceneQuery(searchRequestModel.SearchText)
+ : searchRequestModel.SearchText;
+
+ Dictionary sortBy = new Dictionary()
+ {
+ { searchRequestModel.SortColumn, searchRequestModel.SortDirection }
+ };
+
+ // Determine if we need to apply post-processing sort
+ bool needsPostProcessingSort = searchQueryType == SearchQueryType.Semantic &&
+ !string.IsNullOrEmpty(searchRequestModel.SortColumn) &&
+ searchRequestModel.SortColumn != "relevance";
+
+ // For semantic search with sorting, retrieve buffer size results for post-processing
+ int queryPageSize = pageSize;
+ int queryOffset = offset;
+ int semanticResultBufferSize = this.azureSearchConfig.SemanticResultBufferSize;
+
+ if (needsPostProcessingSort)
+ {
+ // Retrieve semantic buffer size results from the starta nd add offset for post-processing sort and pagination
+ queryPageSize = semanticResultBufferSize + offset;
+ queryOffset = 0;
+ }
+
+ // Normalize resource_type filter values
+ var filters = SearchFilterBuilder.CombineAndNormaliseFilters(searchRequestModel.FilterText, searchRequestModel.ProviderFilterText, searchRequestModel.ResourceAccessLevelFilterText);
+
+ if (searchRequestModel.CatalogueId.HasValue)
+ {
+ Dictionary> catalogueIdFilter = new Dictionary> { { "catalogue_id", new List { searchRequestModel.CatalogueId.ToString() } } };
+ filters = filters == null ? catalogueIdFilter : filters.Concat(catalogueIdFilter).ToDictionary(k => k.Key, v => v.Value);
+ }
+
+ var searchOptions = SearchOptionsBuilder.BuildSearchOptions(searchQueryType, queryOffset, queryPageSize, filters, sortBy, true, this.azureSearchConfig);
+ SearchResults filteredResponse = await this.searchClient.SearchAsync(query, searchOptions, cancellationToken);
+ var count = Convert.ToInt32(filteredResponse.TotalCount);
+
+ // Map documents
+ var documents = filteredResponse.GetResults()
+ .Select(result =>
+ {
+ var doc = result.Document;
+ doc.ParseManualTags();
+
+ return new Document
+ {
+ Id = doc.Id,
+ Title = doc.Title,
+ Description = doc.Description,
+ ResourceType = MapToResourceType(doc.ResourceType),
+ ProviderIds = doc.ProviderIds?.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(),
+ CatalogueIds =
+ doc.ResourceType == "catalogue"
+ ? new List { Convert.ToInt32(doc.Id) }
+ : (
+ doc.CatalogueId?
+ .Split(',', StringSplitOptions.RemoveEmptyEntries)
+ .Select(id => int.TryParse(id, out var val) ? val : 0)
+ .ToList()
+ ?? new List()
+ ),
+ Rating = Convert.ToDecimal(doc.Rating),
+ ResourceAccessLevel = Convert.ToInt32(doc.ResourceAccessLevel),
+ Author = doc.Author,
+ Authors = doc.Author?.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(a => a.Trim()).ToList(),
+ AuthoredDate = doc.DateAuthored?.ToString(),
+ ResourceReferenceId = int.TryParse(doc.ResourceReferenceId, out var id) ? id : 0,
+ Click = BuildSearchClickModel(doc.Id, doc.Title, searchRequestModel.PageIndex, searchRequestModel.SearchId, filters, query, count)
+ };
+ })
+ .ToList();
+
+ // Apply post-processing sort if needed
+ if (needsPostProcessingSort)
+ {
+ documents = SearchOptionsBuilder.ApplyPostProcessingSort(documents, searchRequestModel.SortColumn, searchRequestModel.SortDirection);
+
+ // Apply pagination after sorting
+ documents = documents.Skip(offset).Take(pageSize).ToList();
+ }
+
+ viewmodel.DocumentList = new Documentlist
+ {
+ Documents = documents.ToArray()
+ };
+
+ var unfilteredFacets = await GetUnfilteredFacetsAsync(
+ searchRequestModel.SearchText,
+ filteredResponse.Facets,
+ searchRequestModel.ResourceAccessLevelFilterText,
+ cancellationToken);
+
+ // Merge facets from filtered and unfiltered results
+ viewmodel.Facets = AzureSearchFacetHelper.MergeFacets(filteredResponse.Facets, unfilteredFacets, filters);
+
+
+ viewmodel.Stats = new Stats
+ {
+ TotalHits = count
+ };
+ searchRequestModel.TotalNumberOfHits = viewmodel.Stats.TotalHits;
+
+ return viewmodel;
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogError(ex, "Azure Search query failed for search text: {SearchText}", searchRequestModel.SearchText);
+ throw;
+ }
+ }
+
+ ///
+ public async Task GetCatalogueSearchResultAsync(CatalogueSearchRequestModel catalogSearchRequestModel, int userId, CancellationToken cancellationToken = default)
+ {
+ var viewmodel = new SearchCatalogueResultModel();
+
+ try
+ {
+ var offset = catalogSearchRequestModel.PageIndex * catalogSearchRequestModel.PageSize;
+
+ // Build filters for catalogue search
+ var filters = new Dictionary>
+ {
+ { "resource_collection", new List { "Catalogue" } }
+ };
+
+ var searchOptions = new SearchOptions
+ {
+ Skip = offset,
+ Size = catalogSearchRequestModel.PageSize,
+ IncludeTotalCount = true,
+ Filter = SearchFilterBuilder.BuildFilterExpression(filters)
+ };
+
+ SearchResults response = await this.searchClient.SearchAsync(
+ catalogSearchRequestModel.SearchText,
+ searchOptions,
+ cancellationToken);
+ var count = Convert.ToInt32(response.TotalCount);
+
+ var documentList = new CatalogueDocumentList
+ {
+ Documents = response.GetResults()
+ .Select(result =>
+ {
+ var doc = result.Document;
+ doc.ParseManualTags();
+
+ return new CatalogueDocument
+ {
+ Id = doc.Id,
+ Name = doc.Title,
+ Description = doc.Description,
+ Click = BuildSearchClickModel(doc.Id, doc.Title, catalogSearchRequestModel.PageIndex, catalogSearchRequestModel.SearchId, filters, catalogSearchRequestModel.SearchText, count)
+ };
+ })
+ .ToArray()
+ };
+
+ viewmodel.DocumentList = documentList;
+ viewmodel.Stats = new Stats
+ {
+ TotalHits = count
+ };
+ catalogSearchRequestModel.TotalNumberOfHits = viewmodel.Stats.TotalHits;
+
+ var remainingItems = catalogSearchRequestModel.TotalNumberOfHits - offset;
+ var resultsPerPage = remainingItems >= catalogSearchRequestModel.PageSize ? catalogSearchRequestModel.PageSize : remainingItems;
+ var validationResult = await this.CreateCatalogueSearchTerm(catalogSearchRequestModel, resultsPerPage, userId);
+
+ viewmodel.SearchId = validationResult.CreatedId ?? 0;
+
+ return viewmodel;
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogError(ex, "Azure Search catalogue query failed for search text: {SearchText}", catalogSearchRequestModel.SearchText);
+ throw;
+ }
+ }
+
+ ///
+ public async Task CreateResourceSearchActionAsync(SearchActionResourceModel searchActionResourceModel, int userId)
+ {
+ var jsonobj = new
+ {
+ searchActionResourceModel.SearchText,
+ searchActionResourceModel.NodePathId,
+ searchActionResourceModel.ItemIndex,
+ searchActionResourceModel.NumberOfHits,
+ searchActionResourceModel.TotalNumberOfHits,
+ searchActionResourceModel.ResourceReferenceId,
+ };
+
+ var json = JsonConvert.SerializeObject(jsonobj);
+
+ var eventEntity = new Event
+ {
+ EventTypeEnum = EventTypeEnum.SearchLaunchResource,
+ JsonData = json,
+ UserId = userId,
+ GroupId = searchActionResourceModel.GroupId,
+ };
+
+ return await this.eventService.CreateAsync(userId, eventEntity);
+ }
+
+ ///
+ public async Task CreateCatalogueSearchActionAsync(SearchActionCatalogueModel searchActionCatalogueModel, int userId)
+ {
+ var jsonobj = new
+ {
+ searchActionCatalogueModel.SearchText,
+ searchActionCatalogueModel.NodePathId,
+ searchActionCatalogueModel.ItemIndex,
+ searchActionCatalogueModel.NumberOfHits,
+ searchActionCatalogueModel.TotalNumberOfHits,
+ searchActionCatalogueModel.CatalogueId,
+ };
+
+ var json = JsonConvert.SerializeObject(jsonobj);
+
+ var eventEntity = new Event
+ {
+ EventTypeEnum = EventTypeEnum.SearchLaunchCatalogue,
+ JsonData = json,
+ UserId = userId,
+ GroupId = searchActionCatalogueModel.GroupId,
+ };
+
+ return await this.eventService.CreateAsync(userId, eventEntity);
+ }
+
+ ///
+ public async Task CreateCatalogueResourceSearchActionAsync(SearchActionResourceModel searchActionResourceModel, int userId)
+ {
+ var jsonobj = new
+ {
+ searchActionResourceModel.SearchText,
+ searchActionResourceModel.NodePathId,
+ searchActionResourceModel.ItemIndex,
+ searchActionResourceModel.ResourceReferenceId,
+ searchActionResourceModel.NumberOfHits,
+ searchActionResourceModel.TotalNumberOfHits,
+ };
+
+ var json = JsonConvert.SerializeObject(jsonobj);
+
+ var eventEntity = new Event
+ {
+ EventTypeEnum = EventTypeEnum.LaunchCatalogueResource,
+ JsonData = json,
+ UserId = userId,
+ GroupId = searchActionResourceModel.GroupId,
+ };
+
+ return await this.eventService.CreateAsync(userId, eventEntity);
+ }
+
+ ///
+ public async Task SubmitFeedbackAsync(SearchFeedBackModel searchFeedbackModel, int userId)
+ {
+ var jsonobj = new
+ {
+ searchFeedbackModel.SearchText,
+ searchFeedbackModel.Feedback,
+ searchFeedbackModel.TotalNumberOfHits,
+ };
+
+ var json = JsonConvert.SerializeObject(jsonobj);
+
+ var eventEntity = new Event
+ {
+ EventTypeEnum = EventTypeEnum.SearchSubmitFeedback,
+ JsonData = json,
+ UserId = userId,
+ GroupId = searchFeedbackModel.GroupId,
+ };
+
+ return await this.eventService.CreateAsync(userId, eventEntity);
+ }
+
+ ///
+ public async Task CreateSearchTermEvent(SearchRequestModel searchRequestModel, int userId)
+ {
+ var pageSize = searchRequestModel.PageSize;
+ var offset = searchRequestModel.PageIndex * pageSize;
+
+ var remainingItems = searchRequestModel.TotalNumberOfHits - offset;
+ var resultsPerPage = remainingItems >= pageSize ? pageSize : remainingItems;
+
+ var searchEventModel = this.mapper.Map(searchRequestModel);
+ searchEventModel.ItemsViewed = resultsPerPage;
+ var json = JsonConvert.SerializeObject(searchEventModel);
+
+ var eventEntity = new Event
+ {
+ EventTypeEnum = searchRequestModel.EventTypeEnum,
+ JsonData = json,
+ UserId = userId,
+ GroupId = searchRequestModel.GroupId,
+ };
+
+ return await this.eventService.CreateAsync(userId, eventEntity);
+ }
+
+ ///
+ public async Task CreateCatalogueSearchTermEvent(CatalogueSearchRequestModel catalogueSearchRequestModel, int userId)
+ {
+ var offset = catalogueSearchRequestModel.PageIndex * catalogueSearchRequestModel.PageSize;
+
+ var remainingItems = catalogueSearchRequestModel.TotalNumberOfHits - offset;
+ var resultsPerPage = remainingItems >= catalogueSearchRequestModel.PageSize ? catalogueSearchRequestModel.PageSize : remainingItems;
+
+ var searchCatalogueEventModel = this.mapper.Map(catalogueSearchRequestModel);
+ searchCatalogueEventModel.ItemsViewed = resultsPerPage;
+ var json = JsonConvert.SerializeObject(searchCatalogueEventModel);
+ var eventEntity = new Event
+ {
+ EventTypeEnum = catalogueSearchRequestModel.EventTypeEnum,
+ JsonData = json,
+ UserId = userId,
+ GroupId = catalogueSearchRequestModel.GroupId,
+ };
+
+ return await this.eventService.CreateAsync(userId, eventEntity);
+ }
+
+ ///
+ public async Task SendResourceSearchEventClickAsync(SearchActionResourceModel searchActionResourceModel)
+ {
+ // Azure Search doesn't need click tracking like Findwise
+ // Log the event but return true
+ this.logger.LogInformation($"Search click event logged for resource {searchActionResourceModel.ResourceReferenceId}");
+ return await Task.FromResult(true);
+ }
+
+ ///
+ public async Task CreateCatalogueSearchTerm(CatalogueSearchRequestModel catalogueSearchRequestModel, int resultsPerPage, int userId)
+ {
+ var searchCatalogueEventModel = this.mapper.Map(catalogueSearchRequestModel);
+ searchCatalogueEventModel.ItemsViewed = resultsPerPage;
+ var json = JsonConvert.SerializeObject(searchCatalogueEventModel);
+ var eventEntity = new Event
+ {
+ EventTypeEnum = catalogueSearchRequestModel.EventTypeEnum,
+ JsonData = json,
+ UserId = userId,
+ GroupId = catalogueSearchRequestModel.GroupId,
+ };
+
+ return await this.eventService.CreateAsync(userId, eventEntity);
+ }
+
+ ///
+ public async Task Search(ResourceSearchRequest query, int? currentUserId)
+ {
+ try
+ {
+ var searchOptions = new SearchOptions
+ {
+ Skip = query.Offset,
+ Size = query.Limit,
+ IncludeTotalCount = true,
+ };
+
+ SearchResults response = await this.searchClient.SearchAsync(
+ query.SearchText,
+ searchOptions);
+
+ var documentsFound = response.GetResults().Select(r => r.Document).ToList();
+ var findwiseResourceIds = documentsFound.Select(d => int.Parse(d.Id)).ToList();
+
+ if (!findwiseResourceIds.Any())
+ {
+ return new ResourceSearchResultModel(
+ new List(),
+ LearningHub.Nhs.OpenApi.Models.ServiceModels.Findwise.FindwiseRequestStatus.Success,
+ 0);
+ }
+
+ var resourceMetadataViewModels = await this.GetResourceMetadataViewModels(findwiseResourceIds, currentUserId);
+
+ var totalHits = (int)(response.TotalCount ?? 0);
+
+ return new ResourceSearchResultModel(
+ resourceMetadataViewModels,
+ LearningHub.Nhs.OpenApi.Models.ServiceModels.Findwise.FindwiseRequestStatus.Success,
+ totalHits);
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogError(ex, "Azure Search query failed");
+ return ResourceSearchResultModel.FailedWithStatus(
+ LearningHub.Nhs.OpenApi.Models.ServiceModels.Findwise.FindwiseRequestStatus.Timeout);
+ }
+ }
+
+ ///
+ public async Task RemoveResourceFromSearchAsync(int resourceId)
+ {
+ try
+ {
+ // We are not currently implementing delete in Azure Search, it is handled via data source indexer
+ // var adminClient = AzureSearchClientFactory.CreateAdminClient(this.azureSearchConfig);
+
+ // await adminClient.DeleteDocumentsAsync("id", new[] { resourceId.ToString() });
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogError(ex, $"Failed to remove resource {resourceId} from Azure Search");
+ throw new Exception($"Removal of resource from search failed: {resourceId} : {ex.Message}");
+ }
+ }
+
+ ///
+ public async Task SendResourceForSearchAsync(SearchResourceRequestModel searchResourceRequestModel, int userId, int? iterations)
+ {
+ try
+ {
+ // We are not currently implementing in Azure Search, it is handled via data source indexer
+
+ // var adminClient = AzureSearchClientFactory.CreateAdminClient(this.azureSearchConfig);
+ // var document = new Models.ServiceModels.AzureSearch.SearchDocument{};
+ // await adminClient.IndexDocumentsAsync(IndexDocumentsBatch.Upload(new[] { document }));
+ return true;
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogError(ex, $"Failed to add resource {searchResourceRequestModel.Id} to Azure Search");
+ throw new Exception($"Posting of resource to search failed: {searchResourceRequestModel.Id} : {ex.Message}");
+ }
+ }
+
+ ///
+ public async Task SendCatalogueSearchEventAsync(SearchActionCatalogueModel searchActionCatalogueModel)
+ {
+ // We are not currently implementing in Azure Search, it is handled via data source indexer
+ return await Task.FromResult(true);
+ }
+
+ ///
+ public async Task GetAllCatalogueSearchResultsAsync(AllCatalogueSearchRequestModel catalogSearchRequestModel)
+ {
+ var viewmodel = new SearchAllCatalogueResultModel();
+ CancellationToken cancellationToken = default;
+ try
+ {
+ var offset = catalogSearchRequestModel.PageIndex * catalogSearchRequestModel.PageSize;
+ var filters = new Dictionary>
+ {
+ { "resource_collection", new List { "catalogue" } }
+ };
+
+ var searchOptions = new SearchOptions
+ {
+ Skip = offset,
+ Size = catalogSearchRequestModel.PageSize,
+ IncludeTotalCount = true,
+ Filter = SearchFilterBuilder.BuildFilterExpression(filters)
+ };
+
+ SearchResults response = await this.searchClient.SearchAsync(
+ catalogSearchRequestModel.SearchText, searchOptions, cancellationToken);
+ var count = Convert.ToInt32(response.TotalCount);
+
+ var documentList = new CatalogueDocumentList
+ {
+ Documents = response.GetResults()
+ .Select(result =>
+ {
+ var doc = result.Document;
+ doc.ParseManualTags();
+
+ return new CatalogueDocument
+ {
+ Id = doc.Id,
+ Name = doc.Title,
+ Description = doc.Description,
+ Click = BuildSearchClickModel(doc.Id, doc.Title, catalogSearchRequestModel.PageIndex, catalogSearchRequestModel.SearchId, filters, catalogSearchRequestModel.SearchText, count)
+ };
+ })
+ .ToArray()
+ };
+
+ viewmodel.DocumentList = documentList;
+ viewmodel.Stats = new Stats
+ {
+ TotalHits = count
+ };
+
+ catalogSearchRequestModel.TotalNumberOfHits = viewmodel.Stats.TotalHits;
+
+ return viewmodel;
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogError(ex, "Azure Search all catalogue query failed");
+ throw;
+ }
+ }
+
+ ///
+ public async Task GetAutoSuggestionResultsAsync(string term, CancellationToken cancellationToken = default)
+ {
+ var viewmodel = new AutoSuggestionModel();
+
+ try
+ {
+ var searchOptions = new SearchOptions
+ {
+ Size = 10,
+ };
+
+ var response = await this.searchClient.SearchAsync(
+ term,
+ searchOptions,
+ cancellationToken);
+
+ var suggestOptions = new SuggestOptions
+ {
+ Size = 50,
+ UseFuzzyMatching = true
+ };
+ suggestOptions.SearchFields.Add("title");
+ suggestOptions.SearchFields.Add("description");
+ suggestOptions.SearchFields.Add("manual_tag");
+ suggestOptions.Select.Add("id");
+ suggestOptions.Select.Add("title");
+ suggestOptions.Select.Add("description");
+ suggestOptions.Select.Add("manual_tag");
+ suggestOptions.Select.Add("resource_type");
+ suggestOptions.Select.Add("resource_collection");
+ suggestOptions.Select.Add("url");
+ suggestOptions.Select.Add("resource_reference_id");
+ suggestOptions.Select.Add("is_deleted");
+
+ var autoOptions = new AutocompleteOptions
+ {
+ Mode = AutocompleteMode.OneTermWithContext,
+ Size = this.azureSearchConfig.ConceptsSuggesterSize,
+ Filter = "is_deleted eq false"
+ };
+
+ var searchText = LuceneQueryBuilder.EscapeLuceneSpecialCharacters(term);
+ var suggesterName = this.azureSearchConfig.SuggesterName;
+
+ // Fire both requests in parallel for performance
+ var suggestTask = this.searchClient.SuggestAsync(searchText, suggesterName, suggestOptions, cancellationToken);
+ var autoTask = this.searchClient.AutocompleteAsync(searchText, suggesterName, autoOptions, cancellationToken);
+
+ await Task.WhenAll(suggestTask, autoTask);
+
+ var suggestResponse = await suggestTask;
+ var autoResponse = await autoTask;
+
+ var suggestResults = suggestResponse.Value.Results
+ .Where(r => !string.IsNullOrEmpty(r.Document?.Title) &&
+ r.Document?.IsDeleted == false)
+ .Select(r => new
+ {
+ Id = r.Document.Id,
+ Text = r.Document.Title.Trim(),
+ URL = r.Document.Url,
+ ResourceReferenceId = (r.Document.ResourceCollection == "resource") ? r.Document.ResourceReferenceId : r.Document.Id,
+ Type = r.Document.ResourceCollection ?? "Suggestion"
+ });
+
+ var autoResults = autoResponse.Value.Results
+ .Where(r => !string.IsNullOrWhiteSpace(r.Text))
+ .Select((r, index) => new
+ {
+ Id = "A" + (index + 1),
+ Text = r.Text.Trim(),
+ URL = string.Empty,
+ ResourceReferenceId = (string?)null,
+ Type = "AutoComplete"
+ });
+
+ var combined = suggestResults
+ .Concat(autoResults)
+ .GroupBy(r => r.Text, StringComparer.OrdinalIgnoreCase)
+ .Select(g => g.First())
+ .ToList();
+
+ viewmodel.Stats = new Stats
+ {
+ TotalHits = combined.Count
+ };
+
+ var autoSuggestion = new AutoSuggestionResourceCollection
+ {
+ TotalHits = suggestResults.Count(),
+ DocumentList = suggestResults.Select(item => new AutoSuggestionResourceCollectionDocument
+ {
+ Id = item.Id,
+ ResourceType = item.Type,
+ URL = item.URL,
+ ResourceReferenceId = item.ResourceReferenceId != null && int.TryParse(item.ResourceReferenceId, out var refId) ? refId : 0,
+ Title = item.Text,
+ Click = BuildAutoSuggestClickModel(item.Id, item.Text, 0, 0, term, suggestResults.Count())
+ })
+ .Take(this.azureSearchConfig.ResourceCollectionSuggesterSize)
+ .ToList()
+ };
+
+ // We couldn't pass null value so just etting to empty collection with 0 hits, as we are using one resouce colection in auto-suggest
+ var autoSuggestionCatalogue = new AutoSuggestionCatalogue
+ {
+ TotalHits = suggestResults.Count(),
+ CatalogueDocumentList = suggestResults
+ .Where(a => a.Type == "catalogue")
+ .Select(item => new AutoSuggestionCatalogueDocument
+ {
+ Id = item.Id,
+ Name = item.Text,
+ Click = BuildAutoSuggestClickModel(item.Id, item.Text, 0, 0, term, suggestResults.Count())
+ })
+ .Take(0)
+ .ToList()
+ };
+
+ var autoSuggestionConcept = new AutoSuggestionConcept
+ {
+ TotalHits = autoResults.Count(),
+ ConceptDocumentList = autoResults
+ .Select(item => new AutoSuggestionConceptDocument
+ {
+ Id = item.Id,
+ Concept = item.Text,
+ Title = item.Text,
+ Click = BuildAutoSuggestClickModel(item.Id, item.Text, 0, 0, term, autoResults.Count())
+ })
+ .ToList()
+ };
+
+ viewmodel.ResourceCollectionDocument = autoSuggestion;
+ viewmodel.CatalogueDocument = autoSuggestionCatalogue; // We coundt pass null value so just etting to empty collection with 0 hits, as we are using one resouce colection in auto-suggest
+ viewmodel.ConceptDocument = autoSuggestionConcept;
+
+ return viewmodel;
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogError(ex, "Azure Search auto-suggestion query failed for term: {Term}", term);
+ throw;
+ }
+ }
+
+ ///
+ public async Task SendAutoSuggestionEventAsync(AutoSuggestionClickPayloadModel clickPayloadModel)
+ {
+ // We are not currently implementing in Azure Search, it is handled via data source indexer
+ return await Task.FromResult(true);
+ }
+
+ private async Task> GetResourceMetadataViewModels(
+ List findwiseResourceIds, int? currentUserId)
+ {
+ List resourceActivities = new List();
+ List resourceMetadataViewModels = new List();
+
+ if (!findwiseResourceIds.Any())
+ {
+ return new List();
+ }
+
+ var resourcesFound = await this.resourceRepository.GetResourcesFromIds(findwiseResourceIds);
+
+ if (currentUserId.HasValue)
+ {
+ List resourceIds = resourcesFound.Select(x => x.Id).ToList();
+ List userIds = new List { currentUserId.Value };
+
+ resourceActivities = (await this.resourceRepository.GetResourceActivityPerResourceMajorVersion(resourceIds, userIds))?.ToList() ?? new List();
+ }
+
+ resourceMetadataViewModels = resourcesFound.Select(resource => this.MapToViewModel(resource, resourceActivities.Where(x => x.ResourceId == resource.Id).ToList()))
+ .OrderBySequence(findwiseResourceIds)
+ .ToList();
+
+ var unmatchedResources = findwiseResourceIds
+ .Except(resourceMetadataViewModels.Select(r => r.ResourceId)).ToList();
+
+ if (unmatchedResources.Any())
+ {
+ var unmatchedResourcesIdsString = string.Join(", ", unmatchedResources);
+ this.logger.LogWarning(
+ "Azure Search returned documents that were not found in the database with IDs: " +
+ unmatchedResourcesIdsString);
+ }
+
+ return resourceMetadataViewModels;
+ }
+
+
+ private static SearchClickModel BuildSearchClickModel(string targetUrl, string title, int hitNumber, long searchId, Dictionary> filters, string query, int count)
+ {
+ return new SearchClickModel
+ {
+ Payload = new SearchClickPayloadModel
+ {
+ ClickTargetUrl = targetUrl,
+ DocumentFields = new SearchClickDocumentModel() { Name = title, Title = title },
+ HitNumber = hitNumber,
+ SearchSignal = new SearchClickSignalModel()
+ {
+ SearchId = Convert.ToString(searchId),
+ Query = query + "&" + filters,
+ UserQuery = query,
+ TimeOfSearch = DateTimeOffset.Now.ToUnixTimeMilliseconds(),
+ Stats = new SearchClickStatsModel() { TotalHits = count }
+ },
+ }
+ };
+ }
+
+ private static AutoSuggestionClickModel BuildAutoSuggestClickModel(string targetUrl, string title, int hitNumber, long searchId, string query, int count)
+ {
+ return new AutoSuggestionClickModel
+ {
+ Payload = new AutoSuggestionClickPayloadModel
+ {
+ ClickTargetUrl = targetUrl,
+ DocumentFields = new SearchClickDocumentModel() { Name = title, Title = title },
+ HitNumber = hitNumber,
+ SearchSignal = new SearchClickSignalModel()
+ {
+ SearchId = Convert.ToString(searchId),
+ Query = query,
+ UserQuery = query,
+ TimeOfSearch = DateTimeOffset.Now.ToUnixTimeMilliseconds(),
+ Stats = new SearchClickStatsModel() { TotalHits = count }
+ },
+ }
+ };
+ }
+
+ private string MapToResourceType(string resourceType)
+ {
+ if (string.IsNullOrWhiteSpace(resourceType))
+ return ResourceTypeEnum.Undefined.ToString();
+
+ string cleanedResourceType = resourceType
+ .Trim()
+ .ToLower()
+ .Replace(" ", "")
+ .Replace("_", "")
+ .Replace("-", "");
+
+ if (cleanedResourceType.StartsWith("scorm"))
+ cleanedResourceType = ResourceTypeEnum.Scorm.ToString();
+ else if (cleanedResourceType.StartsWith("web"))
+ cleanedResourceType = ResourceTypeEnum.WebLink.ToString();
+ else if (cleanedResourceType.Contains("file"))
+ cleanedResourceType = ResourceTypeEnum.GenericFile.ToString();
+
+ return cleanedResourceType;
+ }
+
+ ///
+ /// Gets unfiltered facets for a search term, using caching.
+ ///
+ /// The search text.
+ /// The facet results.
+ /// The resource access level filter, if any, used to further differentiate cache entries.
+ /// Cancellation token.
+ /// The unfiltered facet results.
+ private async Task>> GetUnfilteredFacetsAsync(
+ string searchText,
+ IDictionary> facets,
+ string? resourceAccessLevel,
+ CancellationToken cancellationToken)
+ {
+ var normalizedSearch = searchText?.ToLowerInvariant() ?? "*";
+ var accessLevelKey = resourceAccessLevel?.ToString() ?? "null";
+ var cacheKey = $"Facets_{normalizedSearch}";
+ if (!string.IsNullOrWhiteSpace(accessLevelKey))
+ cacheKey += $"_{accessLevelKey.Replace("=", "_")}";
+
+ var cacheResponse = await this.cachingService.GetAsync>>(cacheKey);
+
+ if (cacheResponse.ResponseEnum == CacheReadResponseEnum.Found && string.IsNullOrEmpty(accessLevelKey))
+ {
+ // Convert cached DTO back to FacetResult dictionary
+ return AzureSearchFacetHelper.ConvertFromCacheable(cacheResponse.Item);
+ }
+
+ if (facets != null)
+ {
+ // Convert to cacheable DTO before caching
+ var cacheableFacets = AzureSearchFacetHelper.ConvertToCacheable(facets);
+ await this.cachingService.SetAsync(cacheKey, cacheableFacets, DefaultFacetCacheExpirationMinutes, slidingExpiration: true);
+ }
+
+ return facets ?? new Dictionary>();
+ }
+
+ private ResourceMetadataViewModel MapToViewModel(Resource resource, List resourceActivities)
+ {
+ var hasCurrentResourceVersion = resource.CurrentResourceVersion != null;
+ var hasRating = resource.CurrentResourceVersion?.ResourceVersionRatingSummary != null;
+
+ List majorVersionIdActivityStatusDescription = new List();
+
+ if (resourceActivities != null && resourceActivities.Count != 0)
+ {
+ majorVersionIdActivityStatusDescription = ActivityStatusHelper.GetMajorVersionIdActivityStatusDescriptionLSPerResource(resource, resourceActivities)
+ .ToList();
+ }
+
+ if (!hasCurrentResourceVersion)
+ {
+ this.logger.LogInformation(
+ $"Resource with id {resource.Id} is missing a current resource version");
+ }
+
+ if (!hasRating)
+ {
+ this.logger.LogInformation(
+ $"Resource with id {resource.Id} is missing a ResourceVersionRatingSummary");
+ }
+
+ var resourceTypeNameOrEmpty = resource.GetResourceTypeNameOrEmpty();
+ if (resourceTypeNameOrEmpty == string.Empty)
+ {
+ this.logger.LogError($"Resource has unrecognised type: {resource.ResourceTypeEnum}");
+ }
+
+ return new ResourceMetadataViewModel(
+ resource.Id,
+ resource.CurrentResourceVersion?.Title ?? ResourceHelpers.NoResourceVersionText,
+ resource.CurrentResourceVersion?.Description ?? string.Empty,
+ resource.ResourceReference.Select(this.GetResourceReferenceViewModel).ToList(),
+ resourceTypeNameOrEmpty,
+ resource.CurrentResourceVersion?.MajorVersion ?? 0,
+ resource.CurrentResourceVersion?.ResourceVersionRatingSummary?.AverageRating ?? 0.0m,
+ majorVersionIdActivityStatusDescription);
+ }
+
+ private ResourceReferenceViewModel GetResourceReferenceViewModel(
+ ResourceReference resourceReference)
+ {
+ return new ResourceReferenceViewModel(
+ resourceReference.OriginalResourceReferenceId,
+ resourceReference.GetCatalogue(),
+ this.learningHubService.GetResourceLaunchUrl(resourceReference.OriginalResourceReferenceId));
+ }
+ }
+}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/BaseService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/BaseService.cs
index 612ae06ca..ac1eb38ba 100644
--- a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/BaseService.cs
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/BaseService.cs
@@ -16,20 +16,20 @@ public abstract class BaseService
///
private readonly ILogger logger;
- ///
- /// The Find Wise HTTP Client.
- ///
- private IFindwiseClient findwiseClient;
+ /////
+ ///// The Find Wise HTTP Client.
+ /////
+ //private IFindwiseClient findwiseClient;
///
/// Initializes a new instance of the class.
/// The base service.
///
- /// The Find Wise http client.
+ ///// The Find Wise http client.
/// The logger.
- protected BaseService(IFindwiseClient findwiseClient, ILogger logger)
+ protected BaseService(ILogger logger)
{
- this.findwiseClient = findwiseClient;
+ //this.findwiseClient = findwiseClient;
this.logger = logger;
}
@@ -44,9 +44,9 @@ protected ILogger Logger
///
/// Gets the Find Wise HTTP Client.
///
- protected IFindwiseClient FindwiseClient
- {
- get { return this.findwiseClient; }
- }
+ //protected IFindwiseClient FindwiseClient
+ //{
+ // get { return this.findwiseClient; }
+ //}
}
}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/DatabricksService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/DatabricksService.cs
new file mode 100644
index 000000000..e7037e78a
--- /dev/null
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/DatabricksService.cs
@@ -0,0 +1,517 @@
+using LearningHub.Nhs.OpenApi.Models.Configuration;
+using LearningHub.Nhs.OpenApi.Services.HttpClients;
+using LearningHub.Nhs.OpenApi.Services.Interface.Services;
+using Microsoft.Extensions.Options;
+using Newtonsoft.Json;
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Threading.Tasks;
+using LearningHub.Nhs.Models.Databricks;
+using System.Linq;
+using LearningHub.Nhs.OpenApi.Repositories.Interface.Repositories;
+using LearningHub.Nhs.Models.Entities.DatabricksReport;
+using AutoMapper;
+using LearningHub.Nhs.Models.Common;
+using Microsoft.EntityFrameworkCore;
+using LearningHub.Nhs.OpenApi.Models.ViewModels;
+using LearningHub.Nhs.Models.Enums;
+using System.Text.Json;
+using LearningHub.Nhs.Models.Entities;
+using LearningHub.Nhs.OpenApi.Services.Interface.Services.Messaging;
+using LearningHub.Nhs.Models.Email.Models;
+using LearningHub.Nhs.Models.Email;
+using LearningHub.Nhs.OpenApi.Services.Helpers;
+using System.Configuration;
+
+namespace LearningHub.Nhs.OpenApi.Services.Services
+{
+ ///
+ /// DatabricksService
+ ///
+ public class DatabricksService : IDatabricksService
+ {
+ private const string CacheKey = "DatabricksReporter";
+ private readonly IOptions databricksConfig;
+ private readonly IOptions learningHubConfig;
+ private readonly IReportHistoryRepository reportHistoryRepository;
+ private readonly IQueueCommunicatorService queueCommunicatorService;
+ private readonly ICachingService cachingService;
+ private readonly INotificationService notificationService;
+ private readonly IEmailSenderService emailSenderService;
+ private readonly IUserNotificationService userNotificationService;
+ private readonly IMoodleApiService moodleApiService;
+ private readonly IUserProfileService userProfileService;
+ private readonly IMapper mapper;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// databricksConfig.
+ /// learningHubConfig.
+ /// reportHistoryRepository.
+ /// mapper.
+ /// queueCommunicatorService.
+ /// cachingService.
+ /// notificationService.
+ /// userNotificationService.
+ /// moodleApiService.
+ /// emailSenderService.
+ /// userProfileService.
+ public DatabricksService(IOptions databricksConfig,IOptions learningHubConfig, IReportHistoryRepository reportHistoryRepository, IMapper mapper, IQueueCommunicatorService queueCommunicatorService, ICachingService cachingService, INotificationService notificationService, IUserNotificationService userNotificationService, IMoodleApiService moodleApiService, IEmailSenderService emailSenderService, IUserProfileService userProfileService)
+ {
+ this.databricksConfig = databricksConfig;
+ this.learningHubConfig = learningHubConfig;
+ this.reportHistoryRepository = reportHistoryRepository;
+ this.mapper = mapper;
+ this.queueCommunicatorService = queueCommunicatorService;
+ this.cachingService = cachingService;
+ this.notificationService = notificationService;
+ this.userNotificationService = userNotificationService;
+ this.moodleApiService = moodleApiService;
+ this.emailSenderService = emailSenderService;
+ this.userProfileService = userProfileService;
+ }
+
+ ///
+ public async Task IsUserReporter(int userId)
+ {
+ bool isReporter = false;
+ string cacheKey = $"{userId}:{CacheKey}";
+ try
+ {
+ var userReportPermission = await this.cachingService.GetAsync(cacheKey);
+ if (userReportPermission.ResponseEnum == CacheReadResponseEnum.Found)
+ {
+ return userReportPermission.Item;
+ }
+
+
+ DatabricksApiHttpClient databricksInstance = new DatabricksApiHttpClient(this.databricksConfig);
+
+ var sqlText = $"CALL {this.databricksConfig.Value.UserPermissionEndpoint}({userId});";
+ const string requestUrl = "/api/2.0/sql/statements";
+
+ var requestPayload = new
+ {
+ warehouse_id = this.databricksConfig.Value.WarehouseId,
+ statement = sqlText,
+ wait_timeout = "30s",
+ on_wait_timeout = "CANCEL"
+ };
+
+ var jsonBody = JsonConvert.SerializeObject(requestPayload);
+ using var content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
+
+ var response = await databricksInstance.GetClient().PostAsync(requestUrl, content);
+
+ var databricksResponse = await databricksInstance.GetClient().PostAsync(requestUrl, content);
+ if (databricksResponse.StatusCode is not HttpStatusCode.OK)
+ {
+ //log failure
+ return false;
+ }
+ var responseResult = await databricksResponse.Content.ReadAsStringAsync();
+
+ responseResult = responseResult.Trim();
+ var root = JsonDocument.Parse(responseResult).RootElement;
+ string data = root.GetProperty("result").GetProperty("data_array")[0][0].GetString();
+ isReporter = data == "1";
+
+ await this.cachingService.SetAsync(cacheKey, isReporter);
+ return isReporter;
+
+ }
+ catch
+ {
+ await this.cachingService.SetAsync(cacheKey, isReporter);
+ return isReporter;
+ }
+ }
+
+ ///
+ public async Task CourseCompletionReport(int userId, DatabricksRequestModel model)
+ {
+ newEntry:
+ if (model.ReportHistoryId == 0 && model.Take > 1)
+ {
+
+ bool timePeriodCheck = int.TryParse(model.TimePeriod, out int timePeriod);
+ var reportHistory = new ReportHistory { CourseFilter = string.Join(",", model.Courses) ,StartDate = model.StartDate,EndDate =model.EndDate, PeriodDays = timePeriodCheck ? timePeriod : 0 ,
+ FirstRun = DateTimeOffset.Now, LastRun = DateTimeOffset.Now, ReportStatusId = 2};
+ model.ReportHistoryId = await AddReportHistory(userId, reportHistory);
+ }
+ else if(model.ReportHistoryId > 0 && model.Take > 1)
+ {
+ //get the existing values and compare
+ var reportChecker = await GetPagedReportHistoryById(userId, model.ReportHistoryId);
+ if (reportChecker != null)
+ {
+ if(reportChecker.CourseFilter == "all") { reportChecker.CourseFilter = string.Empty; }
+ if(reportChecker.CourseFilter != string.Join(",", model.Courses) || reportChecker.StartDate.GetValueOrDefault().Date != model.StartDate.GetValueOrDefault().Date || reportChecker.EndDate.GetValueOrDefault().Date != model.EndDate.GetValueOrDefault().Date)
+ {
+ model.ReportHistoryId = 0;
+ goto newEntry;
+ }
+ }
+ await UpdateReportLastRunTime(userId, model.ReportHistoryId);
+ }
+
+ DatabricksApiHttpClient databricksInstance = new DatabricksApiHttpClient(this.databricksConfig);
+
+ const string requestUrl = "/api/2.0/sql/statements";
+
+ var sql = $@"CALL {this.databricksConfig.Value.CourseCompletionEndpoint}(:par_adminId, :par_completionFlag, :par_locationId, :par_catalogueId, :par_learnerId, :par_courseId, :par_PageSize, :par_PageNumber, :par_Date_from, :par_Date_to);";
+
+
+ var parameters = new List>
+ {
+ new("par_adminId", userId),
+ new("par_completionFlag", -1),
+ new("par_locationId", -1),
+ new("par_catalogueId", -1),
+ new("par_learnerId", -1),
+ new("par_courseId", model.Courses.Count < 1 ? string.Empty : string.Join(",", model.Courses)),
+ new("par_PageSize", model.Take),
+ new("par_PageNumber", model.Skip),
+ new("par_Date_from", model.StartDate.HasValue ? model.StartDate.Value.ToString("yyyy-MM-dd"): string.Empty),
+ new("par_Date_to", model.EndDate.HasValue ? model.EndDate.Value.ToString("yyyy-MM-dd"): string.Empty),
+ };
+
+ var formattedParams = parameters.Select(p => new { name = p.Key, value = p.Value });
+
+ var body = new
+ {
+ warehouse_id = this.databricksConfig.Value.WarehouseId,
+ statement = sql,
+ parameters = formattedParams,
+ wait_timeout = "30s",
+ on_wait_timeout = "CANCEL"
+ };
+
+ var json = JsonConvert.SerializeObject(body);
+ using var content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var response = await databricksInstance.GetClient().PostAsync(requestUrl, content);
+
+ var databricksResponse = await databricksInstance.GetClient().PostAsync(requestUrl, content);
+ if (databricksResponse.StatusCode is not HttpStatusCode.OK)
+ {
+ //log failure
+ return new DatabricksDetailedViewModel { ReportHistoryId = model.ReportHistoryId };
+ }
+ var responseResult = await databricksResponse.Content.ReadAsStringAsync();
+
+ var result = JsonConvert.DeserializeObject(responseResult);
+ if (result != null && result.Result.DataArray != null)
+ {
+ var records = MapDataArrayToCourseCompletionRecords(result.Result.DataArray);
+ return new DatabricksDetailedViewModel { CourseCompletionRecords = records, ReportHistoryId = model.ReportHistoryId };
+
+ }
+
+ return new DatabricksDetailedViewModel { CourseCompletionRecords= new List(), ReportHistoryId = model.ReportHistoryId };
+ }
+
+ ///
+ public async Task> GetPagedReportHistory(int userId,int page, int pageSize)
+ {
+ var result = new PagedResultSet();
+ var query = this.reportHistoryRepository.GetByUserIdAsync(userId);
+
+ // Execute async count
+ result.TotalItemCount = await query.CountAsync();
+ try
+ {
+ // Execute async paging
+ var pagedItems = await query
+ .OrderByDescending(x => x.LastRun)
+ .Skip((page - 1) * pageSize)
+ .Take(pageSize)
+ .ToListAsync();
+
+ result.Items = mapper.Map>(pagedItems);
+ }
+ catch(Exception e)
+ {
+
+ }
+
+ return result;
+ }
+
+ ///
+ public async Task GetPagedReportHistoryById(int userId, int reportHistoryId)
+ {
+ var result = new ReportHistoryModel();
+
+ var reportHistory = await this.reportHistoryRepository.GetByIdAsync(reportHistoryId);
+ if(reportHistory != null)
+ {
+ if(reportHistory.CreateUserId != userId)
+ {
+ throw new Exception("Invalid Id");
+ }
+ }
+ result = mapper.Map(reportHistory);
+ if(result != null && string.IsNullOrWhiteSpace(result.CourseFilter))
+ {
+ result.CourseFilter = "all";
+ }
+
+ return result;
+
+ }
+
+ ///
+ public async Task QueueReportDownload(int userId, int reportHistoryId)
+ {
+ var result = new ReportHistoryModel();
+
+ var reportHistory = await this.reportHistoryRepository.GetByIdAsync(reportHistoryId);
+ if (reportHistory != null && reportHistory.DownloadRequest == null)
+ {
+ if (reportHistory.CreateUserId != userId)
+ {
+ throw new Exception("Invalid Id");
+ }
+ reportHistory.DownloadRequest = true;
+ reportHistory.DownloadRequested = DateTimeOffset.Now;
+ }
+ else
+ {
+ throw new Exception("Invalid Id");
+ }
+ //call the job
+ DatabricksApiHttpClient databricksInstance = new DatabricksApiHttpClient(this.databricksConfig);
+
+ const string requestUrl = "/api/2.1/jobs/run-now";
+
+ var body = new
+ {
+ job_id = this.databricksConfig.Value.JobId,
+ notebook_params = new
+ {
+ par_adminId = userId,
+ par_completionFlag = -1,
+ par_locationId = -1,
+ par_catalogueId = -1,
+ par_learnerId = -1,
+ par_courseId = reportHistory.CourseFilter,
+ par_PageSize = 0,
+ par_PageNumber = 0,
+ par_Date_from = reportHistory.StartDate.GetValueOrDefault().ToString("yyyy-MM-dd"),
+ par_Date_to = reportHistory.EndDate.GetValueOrDefault().ToString("yyyy-MM-dd"),
+ par_reportId = reportHistoryId
+ }
+ };
+
+ var json = JsonConvert.SerializeObject(body);
+ using var content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var databricksResponse = await databricksInstance.GetClient().PostAsync(requestUrl, content);
+ if (databricksResponse.StatusCode is not HttpStatusCode.OK)
+ {
+ reportHistory.ProcessingMessage = databricksResponse.ReasonPhrase;
+ reportHistory.ReportStatusId = (int)Nhs.Models.Enums.Report.Status.Failed;
+ await reportHistoryRepository.UpdateAsync(userId, reportHistory);
+ return false;
+ }
+ var responseResult = await databricksResponse.Content.ReadAsStringAsync();
+ var responseData = JsonConvert.DeserializeObject(responseResult);
+ if (responseData != null)
+ {
+ reportHistory.ReportStatusId = (int)Nhs.Models.Enums.Report.Status.Pending;
+ reportHistory.ParentJobRunId = (long)responseData.run_id;
+ await reportHistoryRepository.UpdateAsync(userId, reportHistory);
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ public async Task DownloadReport(int userId, int reportHistoryId)
+ {
+ var response = new ReportHistoryModel();
+
+ var reportHistory = await this.reportHistoryRepository.GetByIdAsync(reportHistoryId);
+ if (reportHistory != null)
+ {
+ if (reportHistory.CreateUserId != userId)
+ {
+ throw new Exception("Invalid Id");
+ }
+ reportHistory.DownloadedDate = DateTimeOffset.Now;
+ await reportHistoryRepository.UpdateAsync(userId, reportHistory);
+ response = mapper.Map(reportHistory);
+ }
+ else
+ {
+ throw new Exception("Invalid Id");
+ }
+
+ return response;
+ }
+
+ ///
+ /// DatabricksJobUpdate.
+ ///
+ ///
+ ///
+ ///
+ public async Task DatabricksJobUpdate(int userId, DatabricksNotification databricksNotification)
+ {
+ var reportHistory = await this.reportHistoryRepository.GetAll().FirstOrDefaultAsync(x=>x.ParentJobRunId == databricksNotification.Run.ParentRunId);
+ if (reportHistory == null) { return; }
+ reportHistory.JobRunId = databricksNotification.Run.RunId;
+ if (!databricksNotification.EventType.Contains("success"))
+ {
+ reportHistory.ReportStatusId = (int)Nhs.Models.Enums.Report.Status.Failed;
+ reportHistoryRepository.Update(userId, reportHistory);
+ return;
+ }
+ reportHistoryRepository.Update(userId, reportHistory);
+
+ await this.queueCommunicatorService.SendAsync(this.learningHubConfig.Value.DatabricksProcessingQueueName, databricksNotification.Run.RunId);
+ return;
+ }
+
+
+ ///
+ /// DatabricksJobUpdate.
+ ///
+ /// userId.
+ /// databricksUpdateRequest.
+ ///
+ public async Task UpdateDatabricksReport(int userId, DatabricksUpdateRequest databricksUpdateRequest)
+ {
+ var reportHistory = await this.reportHistoryRepository.GetAll().FirstOrDefaultAsync(x => x.JobRunId == databricksUpdateRequest.RunId);
+ if (reportHistory == null) { return; }
+ if(string.IsNullOrWhiteSpace(databricksUpdateRequest.ProcessingMessage))
+ {
+ reportHistory.DownloadReady = DateTimeOffset.Now;
+ reportHistory.FilePath = databricksUpdateRequest.FilePath;
+ reportHistory.ReportStatusId = (int)Nhs.Models.Enums.Report.Status.Ready;
+ //send notification
+ string firstCourse = string.Empty;
+
+ var courses = await moodleApiService.GetCoursesByCategoryIdAsync(learningHubConfig.Value.StatMandId);
+
+ if (string.IsNullOrWhiteSpace(reportHistory.CourseFilter))
+ {
+ firstCourse = "All courses";
+ }
+ else
+ {
+ var matched = courses.Courses
+ .Where(c => reportHistory.CourseFilter.Contains(c.Id.ToString()))
+ .Select(c => c.Displayname)
+ .ToList();
+
+ if (matched.Count == 1)
+ {
+ firstCourse = matched[0].ToLower();
+ }
+ else
+ {
+ firstCourse = $"{matched[0].ToLower()} and {matched.Count - 1} other{((matched.Count - 1) > 1 ? "s" : "")}";
+
+ }
+ }
+
+ firstCourse = TextCasingHelper.ConvertToSentenceCase(firstCourse);
+
+ try
+ {
+ var notificationId = await this.notificationService.CreateReportNotificationAsync(userId, "Course progress", firstCourse);
+
+ if (notificationId > 0)
+ {
+ await this.userNotificationService.CreateAsync(userId, new UserNotification { UserId = reportHistory.CreateUserId, NotificationId = notificationId });
+ }
+ var user = await this.userProfileService.GetByIdAsync(reportHistory.CreateUserId);
+ var emailModel = new SendEmailModel(
+ new ReportSucessEmailModel
+ {
+ UserFirstName = user.FirstName,
+ ReportName = "Course progress",
+ ReportTitle = firstCourse,
+ ReportUrl = $"{this.learningHubConfig.Value.BaseUrl.TrimEnd('/')}/{this.learningHubConfig.Value.ReportUrl.TrimStart('/')}"
+ });
+ emailModel.EmailAddress = user.EmailAddress;
+
+ await this.emailSenderService.SendReportProcessedEmail(userId, emailModel);
+ }
+ catch { }
+
+ }
+ else
+ {
+ reportHistory.ProcessingMessage = databricksUpdateRequest.ProcessingMessage;
+ reportHistory.ReportStatusId = (int)Nhs.Models.Enums.Report.Status.Failed;
+ }
+
+ reportHistoryRepository.Update(userId, reportHistory);
+ return;
+ }
+
+
+ private async Task AddReportHistory(int userId,ReportHistory model)
+ {
+ return await reportHistoryRepository.CreateAsync(userId, model);
+ }
+
+ private async Task UpdateReportLastRunTime(int userId, int reportHistoryId)
+ {
+ var entry = await reportHistoryRepository.GetByIdAsync(reportHistoryId);
+ entry.LastRun = DateTime.Now;
+ await reportHistoryRepository.UpdateAsync(userId, entry);
+ }
+
+ ///
+ /// MapDataArrayToCourseCompletionRecords.
+ ///
+ ///
+ ///
+ public static List MapDataArrayToCourseCompletionRecords(List> dataArray)
+ {
+ var records = new List();
+
+ foreach (var row in dataArray)
+ {
+ if (row == null || row.Count < 19) continue;
+
+ var record = new DatabricksDetailedItemViewModel
+ {
+ UserName = row[0]?.ToString(),
+ FirstName = row[1]?.ToString(),
+ LastName = row[2]?.ToString(),
+ Email = row[3]?.ToString(),
+ Programme = row[4]?.ToString(),
+ Course = row[5]?.ToString(),
+ CourseStatus = row[6]?.ToString(),
+ Location = row[7]?.ToString(),
+ Role = row[8]?.ToString(),
+ Grade = row[9]?.ToString(),
+ MedicalCouncilNo = row[10]?.ToString(),
+ MedicalCouncilName = row[11]?.ToString(),
+ LastAccess = row[12]?.ToString(),
+ CourseCompletionDate = row[13]?.ToString(),
+ ReferenceType = row[14]?.ToString(),
+ ReferenceValue = row[15]?.ToString(),
+ PermissionType = row[16]?.ToString(),
+ MinValidDate = row[17]?.ToString(),
+ TotalRows = row[18] != null && int.TryParse(row[18].ToString(), out int totalRows) ? totalRows : 0
+ };
+
+ records.Add(record);
+ }
+
+ return records;
+ }
+
+ }
+
+}
diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/DatabricksServiceNoImplementation.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/DatabricksServiceNoImplementation.cs
new file mode 100644
index 000000000..72d373339
--- /dev/null
+++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/DatabricksServiceNoImplementation.cs
@@ -0,0 +1,84 @@
+using LearningHub.Nhs.Models.Common;
+using LearningHub.Nhs.Models.Databricks;
+using LearningHub.Nhs.OpenApi.Models.ViewModels;
+using LearningHub.Nhs.OpenApi.Services.Interface.Services;
+using System.Collections.Generic;
+
+using System.Threading.Tasks;
+
+namespace LearningHub.Nhs.OpenApi.Services.Services
+{
+ ///
+ /// DatabricksServiceNoImplementation
+ ///
+ public class DatabricksServiceNoImplementation : IDatabricksService
+ {
+ ///
+ public Task IsUserReporter(int userId)
+ {
+ // Feature disabled → always return false
+ return Task.FromResult(false);
+ }
+
+ ///