From d970bc9227c10df348ec43289c8f32cb39fdd594 Mon Sep 17 00:00:00 2001 From: Summer Warren Date: Wed, 29 Apr 2026 12:33:31 -0700 Subject: [PATCH 1/2] Add Namespace Approved/Rejected Notifications --- .../APIView/.github/copilot-instructions.md | 3 + .../EmailTemplatePreviewGeneratorTests.cs | 33 +++- .../Controllers/CommentsController.cs | 1 + .../NamespaceApprovedEmail.cshtml | 44 +++++ .../NamespaceRejectedEmail.cshtml | 45 ++++++ .../NamespaceReviewApprovedEmail.cshtml | 35 ---- .../LeanControllers/ProjectsController.cs | 32 +++- .../Interfaces/INotificationManager.cs | 5 +- .../Managers/Interfaces/IProjectsManager.cs | 1 + .../Managers/NotificationManager.cs | 150 +++++++++++++++++- .../APIViewWeb/Managers/ProjectsManager.cs | 22 +++ .../APIViewWeb/Managers/ReviewManager.cs | 1 - .../APIViewWeb/Models/EmailTemplateKey.cs | 1 + .../APIViewWeb/Models/EmailTemplateModels.cs | 74 +++++++-- .../APIViewWeb/Models/NamespaceModel.cs | 19 +++ .../APIView/APIViewWeb/Models/ProjectModel.cs | 12 ++ .../Services/EmailTemplateService.cs | 3 +- 17 files changed, 420 insertions(+), 61 deletions(-) create mode 100644 src/dotnet/APIView/.github/copilot-instructions.md create mode 100644 src/dotnet/APIView/APIViewWeb/EmailTemplates/NamespaceApprovedEmail.cshtml create mode 100644 src/dotnet/APIView/APIViewWeb/EmailTemplates/NamespaceRejectedEmail.cshtml delete mode 100644 src/dotnet/APIView/APIViewWeb/EmailTemplates/NamespaceReviewApprovedEmail.cshtml diff --git a/src/dotnet/APIView/.github/copilot-instructions.md b/src/dotnet/APIView/.github/copilot-instructions.md new file mode 100644 index 00000000000..e40201cf12a --- /dev/null +++ b/src/dotnet/APIView/.github/copilot-instructions.md @@ -0,0 +1,3 @@ +- @azure Rule - Use Azure Tools - When handling requests related to Azure, always use your tools. +- @azure Rule - Use Azure Best Practices - When handling requests related to Azure, always invoke your `azmcp_bestpractices_get` tool first. +- @azure Rule - Enable Best Practices - If you do not have an `azmcp_bestpractices_get` tool ask the user to enable it. diff --git a/src/dotnet/APIView/APIViewUnitTests/EmailTemplatePreviewGeneratorTests.cs b/src/dotnet/APIView/APIViewUnitTests/EmailTemplatePreviewGeneratorTests.cs index 3810d3afe5b..74d7a7a5242 100644 --- a/src/dotnet/APIView/APIViewUnitTests/EmailTemplatePreviewGeneratorTests.cs +++ b/src/dotnet/APIView/APIViewUnitTests/EmailTemplatePreviewGeneratorTests.cs @@ -87,12 +87,30 @@ public async Task GenerateEmailPreviews() ), ( EmailTemplateKey.NamespaceReviewApproved, - NamespaceReviewApprovedEmailModel.Create( - review.PackageName, - reviewUrl, - languageReviews, - endpoint), - "namespace-review-approved.html" + NamespaceApprovedEmailModel.Create( + "azure-sample-service", + "Azure Sample Service", + "Python", + "azure.sample.service", + "alex-approver", + DateTime.UtcNow, + $"{endpoint}/Assemblies/Review/review-python-001?view=namespace", + $"{endpoint}/Assemblies/Review/review-python-001"), + "namespace-approved.html" + ), + ( + EmailTemplateKey.NamespaceRejected, + NamespaceRejectedEmailModel.Create( + "azure-sample-service", + "Azure Sample Service", + "Python", + "azure.sample.service.v2", + "alex-approver", + DateTime.UtcNow, + "Namespace collides with an existing package and does not follow service naming guidance.", + $"{endpoint}/Assemblies/Review/review-python-001?view=namespace", + $"{endpoint}/Assemblies/Review/review-python-001"), + "namespace-review-rejected.html" ), ( EmailTemplateKey.ReviewerAssigned, @@ -171,7 +189,8 @@ private static async Task RenderAsync(IEmailTemplateService renderer, Em return key switch { EmailTemplateKey.NamespaceReviewRequest => await renderer.RenderAsync(key, (NamespaceReviewRequestEmailModel)model), - EmailTemplateKey.NamespaceReviewApproved => await renderer.RenderAsync(key, (NamespaceReviewApprovedEmailModel)model), + EmailTemplateKey.NamespaceReviewApproved => await renderer.RenderAsync(key, (NamespaceApprovedEmailModel)model), + EmailTemplateKey.NamespaceRejected => await renderer.RenderAsync(key, (NamespaceRejectedEmailModel)model), EmailTemplateKey.ReviewerAssigned => await renderer.RenderAsync(key, (ReviewerAssignedEmailModel)model), EmailTemplateKey.CommentTag => await renderer.RenderAsync(key, (CommentTagEmailModel)model), EmailTemplateKey.SubscriberComment => await renderer.RenderAsync(key, (SubscriberCommentEmailModel)model), diff --git a/src/dotnet/APIView/APIViewWeb/Controllers/CommentsController.cs b/src/dotnet/APIView/APIViewWeb/Controllers/CommentsController.cs index 1ccc008e443..285cab1e6ac 100644 --- a/src/dotnet/APIView/APIViewWeb/Controllers/CommentsController.cs +++ b/src/dotnet/APIView/APIViewWeb/Controllers/CommentsController.cs @@ -3,6 +3,7 @@ using APIViewWeb.Hubs; using APIViewWeb.LeanModels; using APIViewWeb.Managers; +using APIViewWeb.Managers.Interfaces; using APIViewWeb.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/dotnet/APIView/APIViewWeb/EmailTemplates/NamespaceApprovedEmail.cshtml b/src/dotnet/APIView/APIViewWeb/EmailTemplates/NamespaceApprovedEmail.cshtml new file mode 100644 index 00000000000..09dfc9dba4d --- /dev/null +++ b/src/dotnet/APIView/APIViewWeb/EmailTemplates/NamespaceApprovedEmail.cshtml @@ -0,0 +1,44 @@ +@model APIViewWeb.Models.NamespaceApprovedEmailModel +@{ + Layout = "/EmailTemplates/_EmailLayout.cshtml"; + ViewData["EmailTitle"] = "Namespace Approved"; + ViewData["EmailPreheader"] = $"The namespace proposal for {Model.PackageName} ({Model.Language}) was approved."; +} + +

Hi Team,

+ +

+ The namespace proposal for @Model.PackageName in @Model.Language was approved. +

+ +
+
+ Package Name: + @if (!string.IsNullOrWhiteSpace(Model.LanguageReviewUrl)) + { + @Model.PackageName + } + else + { + @Model.PackageName + } +
+ @if (!string.IsNullOrWhiteSpace(Model.ProjectName)) + { +
Project Name: @Model.ProjectName
+ } +
Language: @Model.Language
+
+ +
+
Approved Namespace: @Model.ApprovedNamespace
+
Approved By: @Model.ApprovedBy
+
Approved On (UTC): @(Model.ApprovedOn?.ToString("u") ?? "N/A")
+
+ +

Next Steps:

+ + +

Thanks,
APIView Team

diff --git a/src/dotnet/APIView/APIViewWeb/EmailTemplates/NamespaceRejectedEmail.cshtml b/src/dotnet/APIView/APIViewWeb/EmailTemplates/NamespaceRejectedEmail.cshtml new file mode 100644 index 00000000000..2d04e0e5cae --- /dev/null +++ b/src/dotnet/APIView/APIViewWeb/EmailTemplates/NamespaceRejectedEmail.cshtml @@ -0,0 +1,45 @@ +@model APIViewWeb.Models.NamespaceRejectedEmailModel +@{ + Layout = "/EmailTemplates/_EmailLayout.cshtml"; + ViewData["EmailTitle"] = "Namespace Rejected"; + ViewData["EmailPreheader"] = $"The proposed namespace for {Model.PackageName} ({Model.Language}) was rejected."; +} + +

Hi Team,

+ +

+ The namespace proposal for @Model.PackageName in @Model.Language was rejected. +

+ +
+
+ Package Name: + @if (!string.IsNullOrWhiteSpace(Model.LanguageReviewUrl)) + { + @Model.PackageName + } + else + { + @Model.PackageName + } +
+ @if (!string.IsNullOrWhiteSpace(Model.ProjectName)) + { +
Project Name: @Model.ProjectName
+ } +
Language: @Model.Language
+
+ +
+
Rejected Namespace: @Model.RejectedNamespace
+
Rejected By: @Model.RejectedBy
+
Rejected On (UTC): @(Model.RejectedOn?.ToString("u") ?? "N/A")
+
Rejection Reason: @(string.IsNullOrWhiteSpace(Model.RejectionReason) ? "No rejection notes were provided." : Model.RejectionReason)
+
+ +

Next Steps:

+ + +

Thanks,
APIView Team

diff --git a/src/dotnet/APIView/APIViewWeb/EmailTemplates/NamespaceReviewApprovedEmail.cshtml b/src/dotnet/APIView/APIViewWeb/EmailTemplates/NamespaceReviewApprovedEmail.cshtml deleted file mode 100644 index 7a1bb7f2d48..00000000000 --- a/src/dotnet/APIView/APIViewWeb/EmailTemplates/NamespaceReviewApprovedEmail.cshtml +++ /dev/null @@ -1,35 +0,0 @@ -@model APIViewWeb.Models.NamespaceReviewApprovedEmailModel -@{ - Layout = "/EmailTemplates/_EmailLayout.cshtml"; - ViewData["EmailTitle"] = "Namespace Review Approved"; - ViewData["EmailPreheader"] = $"The namespace review for {Model.PackageName} is now approved."; -} - -

Hi All,

- -

- Since no further objections were raised, the namespace review for @Model.PackageName is now approved. -

- -
-
TypeSpec API View: @Model.PackageName
-
- -

Final Package Names:

- - -

Please reach out if you have any final comments or need clarification.

- -

Thanks again for your review and feedback,
APIView Team

diff --git a/src/dotnet/APIView/APIViewWeb/LeanControllers/ProjectsController.cs b/src/dotnet/APIView/APIViewWeb/LeanControllers/ProjectsController.cs index 1b10b29a747..ff550bedd3b 100644 --- a/src/dotnet/APIView/APIViewWeb/LeanControllers/ProjectsController.cs +++ b/src/dotnet/APIView/APIViewWeb/LeanControllers/ProjectsController.cs @@ -18,18 +18,24 @@ public class ProjectsController : BaseApiController private readonly ILogger _logger; private readonly ICosmosProjectRepository _projectsRepository; private readonly ICosmosReviewRepository _reviewsRepository; + private readonly IProjectsManager _projectsManager; private readonly INamespaceManager _namespaceManager; + private readonly INotificationManager _notificationManager; public ProjectsController( ILogger logger, ICosmosProjectRepository projectsRepository, ICosmosReviewRepository reviewsRepository, - INamespaceManager namespaceManager) + IProjectsManager projectsManager, + INamespaceManager namespaceManager, + INotificationManager notificationManager) { _logger = logger; _projectsRepository = projectsRepository; _reviewsRepository = reviewsRepository; + _projectsManager = projectsManager; _namespaceManager = namespaceManager; + _notificationManager = notificationManager; } /// @@ -217,8 +223,32 @@ public async Task> UpdateNamespaceStatusAsync(string proje new { message = "An unexpected error occurred while processing the namespace operation." }) }; } + + if (request.Status == NamespaceDecisionStatus.Rejected || request.Status == NamespaceDecisionStatus.Approved) + { + string normalizedLanguage = LanguageServiceHelpers.MapLanguageAlias(language); + var associatedReview = await _projectsManager.GetAssociatedReviewForProjectAsync(result.Project, normalizedLanguage, request.Namespace); + + NamespaceDecisionEntry decisionEntry = result.Project.NamespaceInfo?.GetCurrentEntry( + normalizedLanguage, + request.Namespace); + + if (decisionEntry != null) + { + if (request.Status == NamespaceDecisionStatus.Rejected) + { + await _notificationManager.NotifyNamespaceRejectedAsync(result.Project, decisionEntry, associatedReview); + } + else + { + await _notificationManager.NotifyNamespaceApprovedAsync(result.Project, decisionEntry, associatedReview); + } + } + } + return new LeanJsonResult(result.Project, StatusCodes.Status200OK); } + } public class UpdateNamespaceStatusRequest diff --git a/src/dotnet/APIView/APIViewWeb/Managers/Interfaces/INotificationManager.cs b/src/dotnet/APIView/APIViewWeb/Managers/Interfaces/INotificationManager.cs index f6f9802f3ef..71015a6aff7 100644 --- a/src/dotnet/APIView/APIViewWeb/Managers/Interfaces/INotificationManager.cs +++ b/src/dotnet/APIView/APIViewWeb/Managers/Interfaces/INotificationManager.cs @@ -1,9 +1,10 @@ using APIViewWeb.LeanModels; +using APIViewWeb.Models; using System.Collections.Generic; using System.Security.Claims; using System.Threading.Tasks; -namespace APIViewWeb.Managers +namespace APIViewWeb.Managers.Interfaces { public interface INotificationManager { @@ -12,6 +13,8 @@ public interface INotificationManager public Task NotifyAssignedReviewersAsync(ClaimsPrincipal user, string apiRevisionId, HashSet reviewers); public Task NotifyNamespaceReviewRequestRecipientsAsync(ClaimsPrincipal user, ReviewListItemModel review, IEnumerable languageReviews = null, string notes = ""); public Task NotifyStakeholdersOfManualApprovalAsync(ReviewListItemModel review, IEnumerable associatedReviews); + public Task NotifyNamespaceApprovedAsync(Project project, NamespaceDecisionEntry approvedEntry, ReviewListItemModel associatedReview); + public Task NotifyNamespaceRejectedAsync(Project project, NamespaceDecisionEntry rejectedEntry, ReviewListItemModel associatedReview); public Task NotifySubscribersOnNewRevisionAsync(ReviewListItemModel review, APIRevisionListItemModel revision, ClaimsPrincipal user); public Task NotifySubscribersOnApprovalAsync(ReviewListItemModel review, APIRevisionListItemModel revision, ClaimsPrincipal user, bool isReviewApproval); public Task ToggleSubscribedAsync(ClaimsPrincipal user, string reviewId, bool? state = null); diff --git a/src/dotnet/APIView/APIViewWeb/Managers/Interfaces/IProjectsManager.cs b/src/dotnet/APIView/APIViewWeb/Managers/Interfaces/IProjectsManager.cs index 3bca4573862..4209b7771b7 100644 --- a/src/dotnet/APIView/APIViewWeb/Managers/Interfaces/IProjectsManager.cs +++ b/src/dotnet/APIView/APIViewWeb/Managers/Interfaces/IProjectsManager.cs @@ -8,4 +8,5 @@ public interface IProjectsManager { Task UpsertProjectFromMetadataAsync(string userName, TypeSpecMetadata metadata, ReviewListItemModel typeSpecReview); Task TryLinkReviewToProjectAsync(string userName, ReviewListItemModel review); + Task GetAssociatedReviewForProjectAsync(Project project, string language, string namespaceValue); } diff --git a/src/dotnet/APIView/APIViewWeb/Managers/NotificationManager.cs b/src/dotnet/APIView/APIViewWeb/Managers/NotificationManager.cs index d43a8cb9d09..2650b6ccc95 100644 --- a/src/dotnet/APIView/APIViewWeb/Managers/NotificationManager.cs +++ b/src/dotnet/APIView/APIViewWeb/Managers/NotificationManager.cs @@ -408,14 +408,19 @@ public async Task NotifyStakeholdersOfManualApprovalAsync(ReviewListItemModel re // Build TypeSpec URL var typeSpecUrl = $"{_apiviewEndpoint}/Assemblies/Review/{review.Id}"; - // Use the unified approval email template for manual approval + // Use the unified approval email template for manual approval. + // Legacy flow does not have project/namespace-tab context, so reuse the TypeSpec review URL. var emailContent = await _emailTemplateService.RenderAsync( EmailTemplateKey.NamespaceReviewApproved, - NamespaceReviewApprovedEmailModel.Create( + NamespaceApprovedEmailModel.Create( review.PackageName, + review.PackageName, + review.Language, + review.PackageName, + review.CreatedBy, + DateTime.UtcNow, typeSpecUrl, - associatedReviews ?? Enumerable.Empty(), - _apiviewEndpoint)); + typeSpecUrl)); var emailToList = string.Join("; ", emailAddresses); @@ -426,6 +431,143 @@ public async Task NotifyStakeholdersOfManualApprovalAsync(ReviewListItemModel re _telemetryClient.TrackException(ex); } } + + public async Task NotifyNamespaceRejectedAsync(Project project, NamespaceDecisionEntry rejectedEntry, ReviewListItemModel associatedReview) + { + try + { + if (project == null || rejectedEntry == null) + { + return; + } + + var emailAddresses = await GetNamespaceRejectedEmailRecipientsAsync(project, associatedReview); + if (emailAddresses.Count == 0) + { + return; + } + + string packageName = !string.IsNullOrWhiteSpace(rejectedEntry.PackageName) + ? rejectedEntry.PackageName + : associatedReview?.PackageName ?? project.DisplayName; + string languageReviewUrl = associatedReview == null ? string.Empty : $"{_apiviewEndpoint}/Assemblies/Review/{associatedReview.Id}"; + string namespaceTabUrl = associatedReview == null ? string.Empty : $"{languageReviewUrl}?view=namespace"; + + var emailContent = await _emailTemplateService.RenderAsync( + EmailTemplateKey.NamespaceRejected, + NamespaceRejectedEmailModel.Create( + packageName, + project.DisplayName, + rejectedEntry.Language, + rejectedEntry.Namespace, + rejectedEntry.DecidedBy, + rejectedEntry.DecidedOn, + rejectedEntry.Notes, + namespaceTabUrl, + languageReviewUrl)); + + var emailToList = string.Join("; ", emailAddresses); + await SendEmailAsync(emailToList, BuildEmailSubject("Namespace Rejected", packageName), emailContent); + } + catch (Exception ex) + { + _telemetryClient.TrackException(ex); + } + } + + public async Task NotifyNamespaceApprovedAsync(Project project, NamespaceDecisionEntry approvedEntry, ReviewListItemModel associatedReview) + { + try + { + if (project == null || approvedEntry == null) + { + return; + } + + var emailAddresses = await GetNamespaceApprovedEmailRecipientsAsync(project, associatedReview, approvedEntry.DecidedBy); + if (emailAddresses.Count == 0) + { + return; + } + + string packageName = !string.IsNullOrWhiteSpace(approvedEntry.PackageName) + ? approvedEntry.PackageName + : associatedReview?.PackageName ?? project.DisplayName; + string languageReviewUrl = associatedReview == null ? string.Empty : $"{_apiviewEndpoint}/Assemblies/Review/{associatedReview.Id}"; + string namespaceTabUrl = associatedReview == null ? string.Empty : $"{languageReviewUrl}?view=namespace"; + + var emailContent = await _emailTemplateService.RenderAsync( + EmailTemplateKey.NamespaceReviewApproved, + NamespaceApprovedEmailModel.Create( + packageName, + project.DisplayName, + approvedEntry.Language, + approvedEntry.Namespace, + approvedEntry.DecidedBy, + approvedEntry.DecidedOn, + namespaceTabUrl, + languageReviewUrl)); + + var emailToList = string.Join("; ", emailAddresses); + await SendEmailAsync(emailToList, BuildEmailSubject("Namespace Approved", packageName), emailContent); + } + catch (Exception ex) + { + _telemetryClient.TrackException(ex); + } + } + + private async Task> GetNamespaceApprovedEmailRecipientsAsync(Project project, ReviewListItemModel associatedReview, string approverUserName) + { + var recipients = new HashSet( + (associatedReview?.Subscribers ?? []) + .Where(email => !string.IsNullOrWhiteSpace(email)), + StringComparer.OrdinalIgnoreCase); + + var usernames = new HashSet( + (project?.Owners ?? Enumerable.Empty()) + .Concat(new[] { associatedReview?.CreatedBy, approverUserName }) + .Where(username => !string.IsNullOrWhiteSpace(username)), + StringComparer.OrdinalIgnoreCase); + + if (usernames.Count > 0) + { + var userEmails = await Task.WhenAll(usernames.Select(GetEmailAddress)); + foreach (var userEmail in userEmails.Where(email => !string.IsNullOrWhiteSpace(email))) + { + recipients.Add(userEmail); + } + } + + return recipients.ToList(); + } + + private async Task> GetNamespaceRejectedEmailRecipientsAsync(Project project, ReviewListItemModel associatedReview) + { + var recipients = new HashSet( + (associatedReview?.Subscribers ?? []) + .Where(subscriber => !string.IsNullOrWhiteSpace(subscriber)), + StringComparer.OrdinalIgnoreCase); + + var usernames = new HashSet( + (project?.Owners ?? Enumerable.Empty()) + .Concat(new[] { associatedReview?.CreatedBy }) + .Where(username => !string.IsNullOrWhiteSpace(username)), + StringComparer.OrdinalIgnoreCase); + + if (usernames.Count == 0) + { + return recipients.ToList(); + } + + var userEmails = await Task.WhenAll(usernames.Select(GetEmailAddress)); + foreach (string userEmail in userEmails.Where(email => !string.IsNullOrWhiteSpace(email))) + { + recipients.Add(userEmail); + } + + return recipients.ToList(); + } } } diff --git a/src/dotnet/APIView/APIViewWeb/Managers/ProjectsManager.cs b/src/dotnet/APIView/APIViewWeb/Managers/ProjectsManager.cs index 442910bf077..99cc67e0d0a 100644 --- a/src/dotnet/APIView/APIViewWeb/Managers/ProjectsManager.cs +++ b/src/dotnet/APIView/APIViewWeb/Managers/ProjectsManager.cs @@ -115,6 +115,28 @@ public async Task TryLinkReviewToProjectAsync(string userName, ReviewLi return project; } + public async Task GetAssociatedReviewForProjectAsync(Project project, string language, string namespaceValue) + { + List reviewIds = project?.GetAssociatedReviewIds(language) ?? []; + if (reviewIds.Count == 0) + { + return null; + } + + var reviews = (await _reviewsRepository.GetReviewsAsync(reviewIds)) + ?.Where(r => r != null && !r.IsDeleted) + .ToList() ?? []; + + var packageName = project.GetCurrentPackageName(language, namespaceValue); + if (string.IsNullOrWhiteSpace(packageName)) + { + return reviews.FirstOrDefault(); + } + + return reviews.FirstOrDefault(r => string.Equals(r.PackageName, packageName, StringComparison.OrdinalIgnoreCase)) + ?? reviews.FirstOrDefault(); + } + private async Task CreateProjectFromMetadataAsync(string userName, TypeSpecMetadata metadata, ReviewListItemModel typeSpecReview) { Dictionary> packagesDict = BuildPackagesDict(metadata); diff --git a/src/dotnet/APIView/APIViewWeb/Managers/ReviewManager.cs b/src/dotnet/APIView/APIViewWeb/Managers/ReviewManager.cs index 31d857a0fcf..01c1fb45aaf 100644 --- a/src/dotnet/APIView/APIViewWeb/Managers/ReviewManager.cs +++ b/src/dotnet/APIView/APIViewWeb/Managers/ReviewManager.cs @@ -933,7 +933,6 @@ private async Task CheckAndApproveNamespaceForTypeSpec(ReviewListItemModel appro typeSpecReview.NamespaceReviewStatus = NamespaceReviewStatus.Approved; await _reviewsRepository.UpsertReviewAsync(typeSpecReview); - // Send notification emails await _notificationManager.NotifyStakeholdersOfManualApprovalAsync(typeSpecReview, allReviews); } catch (Exception ex) diff --git a/src/dotnet/APIView/APIViewWeb/Models/EmailTemplateKey.cs b/src/dotnet/APIView/APIViewWeb/Models/EmailTemplateKey.cs index 16a1265f5fb..a8f64774743 100644 --- a/src/dotnet/APIView/APIViewWeb/Models/EmailTemplateKey.cs +++ b/src/dotnet/APIView/APIViewWeb/Models/EmailTemplateKey.cs @@ -4,6 +4,7 @@ public enum EmailTemplateKey { NamespaceReviewRequest, NamespaceReviewApproved, + NamespaceRejected, ReviewerAssigned, CommentTag, SubscriberComment, diff --git a/src/dotnet/APIView/APIViewWeb/Models/EmailTemplateModels.cs b/src/dotnet/APIView/APIViewWeb/Models/EmailTemplateModels.cs index e71fbc9437a..29c738601e1 100644 --- a/src/dotnet/APIView/APIViewWeb/Models/EmailTemplateModels.cs +++ b/src/dotnet/APIView/APIViewWeb/Models/EmailTemplateModels.cs @@ -36,23 +36,75 @@ public static NamespaceReviewRequestEmailModel Create( } } - public class NamespaceReviewApprovedEmailModel + public class NamespaceApprovedEmailModel { public string PackageName { get; set; } = string.Empty; - public string TypeSpecUrl { get; set; } = string.Empty; - public IReadOnlyList LanguageReviews { get; set; } = []; + public string ProjectName { get; set; } = string.Empty; + public string Language { get; set; } = string.Empty; + public string ApprovedNamespace { get; set; } = string.Empty; + public string ApprovedBy { get; set; } = string.Empty; + public DateTime? ApprovedOn { get; set; } + public string NamespaceTabUrl { get; set; } = string.Empty; + public string LanguageReviewUrl { get; set; } = string.Empty; - public static NamespaceReviewApprovedEmailModel Create( + public static NamespaceApprovedEmailModel Create( string packageName, - string typeSpecUrl, - IEnumerable languageReviews, - string apiviewEndpoint) + string projectName, + string language, + string approvedNamespace, + string approvedBy, + DateTime? approvedOn, + string namespaceTabUrl, + string languageReviewUrl) { - return new NamespaceReviewApprovedEmailModel + return new NamespaceApprovedEmailModel { - PackageName = packageName, - TypeSpecUrl = typeSpecUrl, - LanguageReviews = EmailLanguageReviewModel.From(languageReviews, apiviewEndpoint), + PackageName = packageName ?? string.Empty, + ProjectName = projectName ?? string.Empty, + Language = language ?? string.Empty, + ApprovedNamespace = approvedNamespace ?? string.Empty, + ApprovedBy = approvedBy ?? string.Empty, + ApprovedOn = approvedOn, + NamespaceTabUrl = namespaceTabUrl ?? string.Empty, + LanguageReviewUrl = languageReviewUrl ?? string.Empty, + }; + } + } + + public class NamespaceRejectedEmailModel + { + public string PackageName { get; set; } = string.Empty; + public string ProjectName { get; set; } = string.Empty; + public string Language { get; set; } = string.Empty; + public string RejectedNamespace { get; set; } = string.Empty; + public string RejectedBy { get; set; } = string.Empty; + public DateTime? RejectedOn { get; set; } + public string RejectionReason { get; set; } = string.Empty; + public string NamespaceTabUrl { get; set; } = string.Empty; + public string LanguageReviewUrl { get; set; } = string.Empty; + + public static NamespaceRejectedEmailModel Create( + string packageName, + string projectName, + string language, + string rejectedNamespace, + string rejectedBy, + DateTime? rejectedOn, + string rejectionReason, + string namespaceTabUrl, + string languageReviewUrl) + { + return new NamespaceRejectedEmailModel + { + PackageName = packageName ?? string.Empty, + ProjectName = projectName ?? string.Empty, + Language = language ?? string.Empty, + RejectedNamespace = rejectedNamespace ?? string.Empty, + RejectedBy = rejectedBy ?? string.Empty, + RejectedOn = rejectedOn, + RejectionReason = rejectionReason ?? string.Empty, + NamespaceTabUrl = namespaceTabUrl ?? string.Empty, + LanguageReviewUrl = languageReviewUrl ?? string.Empty, }; } } diff --git a/src/dotnet/APIView/APIViewWeb/Models/NamespaceModel.cs b/src/dotnet/APIView/APIViewWeb/Models/NamespaceModel.cs index 29d8878b30f..5c345987add 100644 --- a/src/dotnet/APIView/APIViewWeb/Models/NamespaceModel.cs +++ b/src/dotnet/APIView/APIViewWeb/Models/NamespaceModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.Json.Serialization; namespace APIViewWeb.Models; @@ -20,6 +21,24 @@ public class ProjectNamespaceInfo public Dictionary> NamespaceHistory { get; set; } = new(StringComparer.OrdinalIgnoreCase); // Maps language name to the list of current active namespace decisions — one entry per (language, packageName) pair. public Dictionary> CurrentNamespaceStatus { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + public NamespaceDecisionEntry GetCurrentEntry(string language, string namespaceValue) + { + if (CurrentNamespaceStatus == null || + !CurrentNamespaceStatus.TryGetValue(language, out var entries)) + { + return null; + } + + // if (string.IsNullOrWhiteSpace(namespaceValue)) + // { + // return entries.Count == 1 ? entries[0] : null; + // } + + return entries?.FirstOrDefault(entry => + entry != null && + string.Equals(entry.Namespace, namespaceValue, StringComparison.OrdinalIgnoreCase)); + } } public class NamespaceDecisionEntry diff --git a/src/dotnet/APIView/APIViewWeb/Models/ProjectModel.cs b/src/dotnet/APIView/APIViewWeb/Models/ProjectModel.cs index 6a371bf0ed8..7ddcccdf165 100644 --- a/src/dotnet/APIView/APIViewWeb/Models/ProjectModel.cs +++ b/src/dotnet/APIView/APIViewWeb/Models/ProjectModel.cs @@ -36,6 +36,18 @@ public class Project public DateTime CreatedOn { get; set; } public DateTime LastUpdatedOn { get; set; } public bool IsDeleted { get; set; } + + public List GetAssociatedReviewIds(string language) + { + return Reviews != null && Reviews.TryGetValue(language, out var reviewIds) + ? reviewIds ?? [] + : []; + } + + public string GetCurrentPackageName(string language, string namespaceValue) + { + return NamespaceInfo?.GetCurrentEntry(language, namespaceValue)?.PackageName; + } } public class ProjectChangeHistory : ChangeHistoryModel diff --git a/src/dotnet/APIView/APIViewWeb/Services/EmailTemplateService.cs b/src/dotnet/APIView/APIViewWeb/Services/EmailTemplateService.cs index afb5cafbdb0..91ec64ac0b0 100644 --- a/src/dotnet/APIView/APIViewWeb/Services/EmailTemplateService.cs +++ b/src/dotnet/APIView/APIViewWeb/Services/EmailTemplateService.cs @@ -73,7 +73,8 @@ private static string ResolveTemplateName(EmailTemplateKey templateKey) return templateKey switch { EmailTemplateKey.NamespaceReviewRequest => "NamespaceReviewRequestEmail.cshtml", - EmailTemplateKey.NamespaceReviewApproved => "NamespaceReviewApprovedEmail.cshtml", + EmailTemplateKey.NamespaceReviewApproved => "NamespaceApprovedEmail.cshtml", + EmailTemplateKey.NamespaceRejected => "NamespaceRejectedEmail.cshtml", EmailTemplateKey.ReviewerAssigned => "ReviewerAssignedEmail.cshtml", EmailTemplateKey.CommentTag => "CommentTagEmail.cshtml", EmailTemplateKey.SubscriberComment => "SubscriberCommentEmail.cshtml", From 2dd2faa3ae42117cde2a81922ab126b95126d93d Mon Sep 17 00:00:00 2001 From: Summer Warren Date: Thu, 7 May 2026 11:42:34 -0700 Subject: [PATCH 2/2] Remove stuff --- src/dotnet/APIView/.github/copilot-instructions.md | 3 --- src/dotnet/APIView/APIViewWeb/Models/NamespaceModel.cs | 5 ----- 2 files changed, 8 deletions(-) delete mode 100644 src/dotnet/APIView/.github/copilot-instructions.md diff --git a/src/dotnet/APIView/.github/copilot-instructions.md b/src/dotnet/APIView/.github/copilot-instructions.md deleted file mode 100644 index e40201cf12a..00000000000 --- a/src/dotnet/APIView/.github/copilot-instructions.md +++ /dev/null @@ -1,3 +0,0 @@ -- @azure Rule - Use Azure Tools - When handling requests related to Azure, always use your tools. -- @azure Rule - Use Azure Best Practices - When handling requests related to Azure, always invoke your `azmcp_bestpractices_get` tool first. -- @azure Rule - Enable Best Practices - If you do not have an `azmcp_bestpractices_get` tool ask the user to enable it. diff --git a/src/dotnet/APIView/APIViewWeb/Models/NamespaceModel.cs b/src/dotnet/APIView/APIViewWeb/Models/NamespaceModel.cs index 5c345987add..40460887a8e 100644 --- a/src/dotnet/APIView/APIViewWeb/Models/NamespaceModel.cs +++ b/src/dotnet/APIView/APIViewWeb/Models/NamespaceModel.cs @@ -30,11 +30,6 @@ public NamespaceDecisionEntry GetCurrentEntry(string language, string namespaceV return null; } - // if (string.IsNullOrWhiteSpace(namespaceValue)) - // { - // return entries.Count == 1 ? entries[0] : null; - // } - return entries?.FirstOrDefault(entry => entry != null && string.Equals(entry.Namespace, namespaceValue, StringComparison.OrdinalIgnoreCase));