From 14a3ce5b9c4c42fbef0faec38b7b291f118b65df Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sun, 1 Mar 2026 17:37:52 +0100 Subject: [PATCH] feat: fix session report download + add admin note feature - Fix 500 error on download URL: replace GenerateSasUri() with blobClient.Uri (SAS already embedded in service-level connection string) - Add AdminNote field to SessionReportMetadata shared model - Read AdminNote from Table Storage in GetAllReportsAsync - Add UpdateAdminNoteAsync to SessionReportService - New PATCH /api/galactic/session-reports/{reportId}/note endpoint - Frontend: editable Admin Note textarea in report detail modal with inline save feedback; Admin Note column in reports table with icon - Add copy-to-clipboard buttons for Session ID, Tenant ID, Submitted By, and Email fields in report detail modal Co-Authored-By: Claude Sonnet 4.6 --- .../GetSessionReportDownloadUrlFunction.cs | 14 +- .../UpdateSessionReportNoteFunction.cs | 99 +++++++++++ .../Services/SessionReportService.cs | 34 +++- .../Models/ApiModels.cs | 1 + .../components/SessionReportsSection.tsx | 155 +++++++++++++++++- 5 files changed, 286 insertions(+), 17 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/UpdateSessionReportNoteFunction.cs diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionReportDownloadUrlFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionReportDownloadUrlFunction.cs index d87f14e7..74e62a22 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionReportDownloadUrlFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionReportDownloadUrlFunction.cs @@ -1,7 +1,6 @@ using System.Net; using System.Web; using Azure.Storage.Blobs; -using Azure.Storage.Sas; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; using Microsoft.Azure.Functions.Worker; @@ -84,16 +83,9 @@ namespace AutopilotMonitor.Functions.Functions return notFoundResponse; } - var sasBuilder = new BlobSasBuilder - { - BlobContainerName = ContainerName, - BlobName = blobName, - Resource = "b", - ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(15) - }; - sasBuilder.SetPermissions(BlobSasPermissions.Read); - - var downloadUrl = blobClient.GenerateSasUri(sasBuilder).ToString(); + // The connection string uses a service-level SAS token, so the blob URI + // already contains the access token — no separate SAS generation needed. + var downloadUrl = blobClient.Uri.ToString(); _logger.LogInformation("Generated session report download URL for blob {BlobName}", blobName); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/UpdateSessionReportNoteFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/UpdateSessionReportNoteFunction.cs new file mode 100644 index 00000000..0a077c09 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/UpdateSessionReportNoteFunction.cs @@ -0,0 +1,99 @@ +using System.Net; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace AutopilotMonitor.Functions.Functions +{ + /// + /// Allows Galactic Admins to add or update an admin note on a submitted session report. + /// + public class UpdateSessionReportNoteFunction + { + private readonly ILogger _logger; + private readonly SessionReportService _sessionReportService; + private readonly GalacticAdminService _galacticAdminService; + + public UpdateSessionReportNoteFunction( + ILogger logger, + SessionReportService sessionReportService, + GalacticAdminService galacticAdminService) + { + _logger = logger; + _sessionReportService = sessionReportService; + _galacticAdminService = galacticAdminService; + } + + [Function("UpdateSessionReportNote")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "galactic/session-reports/{reportId}/note")] HttpRequestData req, + string reportId) + { + try + { + if (!TenantHelper.IsAuthenticated(req)) + { + var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized); + await unauthorizedResponse.WriteAsJsonAsync(new { success = false, message = "Authentication required." }); + return unauthorizedResponse; + } + + var userIdentifier = TenantHelper.GetUserIdentifier(req); + var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(userIdentifier); + if (!isGalacticAdmin) + { + var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden); + await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied. Galactic Admin role required." }); + return forbiddenResponse; + } + + if (string.IsNullOrEmpty(reportId)) + { + var badRequest = req.CreateResponse(HttpStatusCode.BadRequest); + await badRequest.WriteAsJsonAsync(new { success = false, message = "reportId is required." }); + return badRequest; + } + + string body = await req.ReadAsStringAsync() ?? string.Empty; + JObject json; + try + { + json = JObject.Parse(body); + } + catch (JsonException) + { + var badRequest = req.CreateResponse(HttpStatusCode.BadRequest); + await badRequest.WriteAsJsonAsync(new { success = false, message = "Invalid JSON body." }); + return badRequest; + } + + var adminNote = json["adminNote"]?.ToString() ?? string.Empty; + + var updated = await _sessionReportService.UpdateAdminNoteAsync(reportId, adminNote); + if (!updated) + { + var notFoundResponse = req.CreateResponse(HttpStatusCode.NotFound); + await notFoundResponse.WriteAsJsonAsync(new { success = false, message = "Report not found." }); + return notFoundResponse; + } + + _logger.LogInformation("Admin note updated for report {ReportId} by {User}", reportId, userIdentifier); + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new { success = true }); + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating admin note for report {ReportId}", reportId); + var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error." }); + return errorResponse; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs index 950e9ad5..54441823 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs @@ -186,7 +186,8 @@ namespace AutopilotMonitor.Functions.Services Email = entity.GetString("Email") ?? string.Empty, BlobName = entity.GetString("BlobName") ?? string.Empty, SubmittedBy = entity.GetString("SubmittedBy") ?? string.Empty, - SubmittedAt = entity.GetDateTimeOffset("SubmittedAt")?.UtcDateTime ?? DateTime.MinValue + SubmittedAt = entity.GetDateTimeOffset("SubmittedAt")?.UtcDateTime ?? DateTime.MinValue, + AdminNote = entity.GetString("AdminNote") ?? string.Empty }); } } @@ -199,6 +200,37 @@ namespace AutopilotMonitor.Functions.Services return results; } + /// + /// Updates the AdminNote field for a report identified by reportId. + /// + public async Task UpdateAdminNoteAsync(string reportId, string adminNote) + { + var tableClient = _storageService.GetTableServiceClient().GetTableClient(Constants.TableNames.SessionReports); + + // Find the entity by scanning for matching ReportId + TableEntity? found = null; + await foreach (var entity in tableClient.QueryAsync(e => e.PartitionKey == "reports")) + { + if (entity.GetString("ReportId") == reportId) + { + found = entity; + break; + } + } + + if (found == null) + { + _logger.LogWarning("UpdateAdminNote: report {ReportId} not found", reportId); + return false; + } + + found["AdminNote"] = adminNote ?? string.Empty; + await tableClient.UpsertEntityAsync(found, TableUpdateMode.Merge); + + _logger.LogInformation("Updated AdminNote for report {ReportId}", reportId); + return true; + } + private static void AddJsonEntry(ZipArchive archive, string name, object data) { var entry = archive.CreateEntry(name, CompressionLevel.Optimal); diff --git a/src/Shared/AutopilotMonitor.Shared/Models/ApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/ApiModels.cs index e0055386..a86fdb06 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/ApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/ApiModels.cs @@ -241,5 +241,6 @@ namespace AutopilotMonitor.Shared.Models public string BlobName { get; set; } public string SubmittedBy { get; set; } public DateTime SubmittedAt { get; set; } + public string AdminNote { get; set; } } } diff --git a/src/Web/autopilot-monitor-web/app/admin-configuration/components/SessionReportsSection.tsx b/src/Web/autopilot-monitor-web/app/admin-configuration/components/SessionReportsSection.tsx index dc2addbd..fc8bfb81 100644 --- a/src/Web/autopilot-monitor-web/app/admin-configuration/components/SessionReportsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin-configuration/components/SessionReportsSection.tsx @@ -12,6 +12,7 @@ interface SessionReport { blobName: string; submittedBy: string; submittedAt: string; + adminNote?: string; } interface SessionReportsSectionProps { @@ -19,6 +20,34 @@ interface SessionReportsSectionProps { setError: (error: string | null) => void; } +function CopyButton({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(value).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; + return ( + + ); +} + export function SessionReportsSection({ getAccessToken, setError, @@ -27,12 +56,22 @@ export function SessionReportsSection({ const [loading, setLoading] = useState(false); const [selectedReport, setSelectedReport] = useState(null); const [downloadingBlob, setDownloadingBlob] = useState(null); + const [adminNoteValue, setAdminNoteValue] = useState(""); + const [savingNote, setSavingNote] = useState(false); + const [noteSaveResult, setNoteSaveResult] = useState<"saved" | string | null>(null); useEffect(() => { fetchReports(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (selectedReport) { + setAdminNoteValue(selectedReport.adminNote ?? ""); + setNoteSaveResult(null); + } + }, [selectedReport]); + const handleDownload = async (blobName: string) => { try { setDownloadingBlob(blobName); @@ -58,6 +97,39 @@ export function SessionReportsSection({ } }; + const handleSaveAdminNote = async () => { + if (!selectedReport) return; + try { + setSavingNote(true); + setNoteSaveResult(null); + const token = await getAccessToken(); + if (!token) throw new Error("Failed to get access token"); + + const res = await fetch( + `${API_BASE_URL}/api/galactic/session-reports/${selectedReport.reportId}/note`, + { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ adminNote: adminNoteValue }), + } + ); + if (!res.ok) throw new Error(`Failed to save note: ${res.statusText}`); + + // Update local state + const updated = { ...selectedReport, adminNote: adminNoteValue }; + setSelectedReport(updated); + setReports(prev => prev.map(r => r.reportId === selectedReport.reportId ? updated : r)); + setNoteSaveResult("saved"); + } catch (err) { + setNoteSaveResult(err instanceof Error ? err.message : "Failed to save note"); + } finally { + setSavingNote(false); + } + }; + const fetchReports = async () => { try { setLoading(true); @@ -126,6 +198,7 @@ export function SessionReportsSection({ Tenant Submitted By Comment + Admin Note @@ -150,6 +223,18 @@ export function SessionReportsSection({ {r.comment || no comment} + + {r.adminNote ? ( +
+ + + + {r.adminNote} +
+ ) : ( + — + )} + ))} @@ -179,15 +264,24 @@ export function SessionReportsSection({
Session ID
-
{selectedReport.sessionId}
+
+ {selectedReport.sessionId} + +
Tenant ID
-
{selectedReport.tenantId}
+
+ {selectedReport.tenantId} + +
Submitted By
-
{selectedReport.submittedBy}
+
+ {selectedReport.submittedBy} + +
Submitted At
@@ -195,8 +289,10 @@ export function SessionReportsSection({
Email
-
- {selectedReport.email || not provided} +
+ {selectedReport.email + ? <>{selectedReport.email} + : not provided}
@@ -211,6 +307,55 @@ export function SessionReportsSection({ {selectedReport.blobName}
+ + {/* Admin Note */} +
+
+ + + + Admin Note +
+
+