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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-03-01 17:37:52 +01:00
parent 3f7261f200
commit 14a3ce5b9c
5 changed files with 286 additions and 17 deletions
@@ -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);
@@ -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
{
/// <summary>
/// Allows Galactic Admins to add or update an admin note on a submitted session report.
/// </summary>
public class UpdateSessionReportNoteFunction
{
private readonly ILogger<UpdateSessionReportNoteFunction> _logger;
private readonly SessionReportService _sessionReportService;
private readonly GalacticAdminService _galacticAdminService;
public UpdateSessionReportNoteFunction(
ILogger<UpdateSessionReportNoteFunction> logger,
SessionReportService sessionReportService,
GalacticAdminService galacticAdminService)
{
_logger = logger;
_sessionReportService = sessionReportService;
_galacticAdminService = galacticAdminService;
}
[Function("UpdateSessionReportNote")]
public async Task<HttpResponseData> 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;
}
}
}
}
@@ -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;
}
/// <summary>
/// Updates the AdminNote field for a report identified by reportId.
/// </summary>
public async Task<bool> 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<TableEntity>(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);
@@ -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; }
}
}
@@ -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 (
<button
onClick={handleCopy}
title="Copy to clipboard"
className="ml-1.5 p-0.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
{copied ? (
<svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
);
}
export function SessionReportsSection({
getAccessToken,
setError,
@@ -27,12 +56,22 @@ export function SessionReportsSection({
const [loading, setLoading] = useState(false);
const [selectedReport, setSelectedReport] = useState<SessionReport | null>(null);
const [downloadingBlob, setDownloadingBlob] = useState<string | null>(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({
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Tenant</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Submitted By</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Comment</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Admin Note</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
@@ -150,6 +223,18 @@ export function SessionReportsSection({
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 truncate max-w-xs">
{r.comment || <span className="text-gray-400 italic">no comment</span>}
</td>
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
{r.adminNote ? (
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4 text-indigo-500 dark:text-indigo-400 shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 000-1.41l-2.34-2.34a1 1 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
</svg>
<span className="truncate max-w-[140px]">{r.adminNote}</span>
</div>
) : (
<span className="text-gray-300 dark:text-gray-600">—</span>
)}
</td>
</tr>
))}
</tbody>
@@ -179,15 +264,24 @@ export function SessionReportsSection({
<dl className="space-y-3 text-sm">
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">Session ID</dt>
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.sessionId}</dd>
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5 flex items-center">
{selectedReport.sessionId}
<CopyButton value={selectedReport.sessionId} />
</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">Tenant ID</dt>
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.tenantId}</dd>
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5 flex items-center">
{selectedReport.tenantId}
<CopyButton value={selectedReport.tenantId} />
</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">Submitted By</dt>
<dd className="text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.submittedBy}</dd>
<dd className="text-gray-900 dark:text-gray-100 mt-0.5 flex items-center">
{selectedReport.submittedBy}
<CopyButton value={selectedReport.submittedBy} />
</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">Submitted At</dt>
@@ -195,8 +289,10 @@ export function SessionReportsSection({
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">Email</dt>
<dd className="text-gray-900 dark:text-gray-100 mt-0.5">
{selectedReport.email || <span className="text-gray-400 italic">not provided</span>}
<dd className="text-gray-900 dark:text-gray-100 mt-0.5 flex items-center">
{selectedReport.email
? <><span>{selectedReport.email}</span><CopyButton value={selectedReport.email} /></>
: <span className="text-gray-400 italic">not provided</span>}
</dd>
</div>
<div>
@@ -211,6 +307,55 @@ export function SessionReportsSection({
{selectedReport.blobName}
</dd>
</div>
{/* Admin Note */}
<div className="pt-1 border-t border-gray-100 dark:border-gray-700">
<dt className="font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1.5">
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 000-1.41l-2.34-2.34a1 1 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
</svg>
Admin Note
</dt>
<dd className="mt-1.5">
<textarea
value={adminNoteValue}
onChange={e => { setAdminNoteValue(e.target.value); setNoteSaveResult(null); }}
rows={3}
placeholder="Add an internal note about this report..."
className="w-full text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none placeholder:text-gray-400"
/>
<div className="flex items-center justify-between mt-1.5">
<div className="text-xs">
{noteSaveResult === "saved" && (
<span className="text-green-600 dark:text-green-400 flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Saved
</span>
)}
{noteSaveResult && noteSaveResult !== "saved" && (
<span className="text-red-600 dark:text-red-400">{noteSaveResult}</span>
)}
</div>
<button
onClick={handleSaveAdminNote}
disabled={savingNote}
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-xs font-medium"
>
{savingNote ? (
<>
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Saving...
</>
) : "Save Note"}
</button>
</div>
</dd>
</div>
</dl>
<div className="mt-6 flex items-center justify-between">