mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-26 05:29:43 +00:00
3d920a8113
Every HTTP request body the web, the MCP or the agent sends is now a flat class in
AutopilotMonitor.Shared implementing IApiRequest — the request twin of IApiResponse.
WireTypeManifestBuilder exports the implementers as a third root, so the request DTOs
reach wire-types.generated.ts on both clients; for request types a nullable property
keeps `| null` (a PATCH may send an explicit null to clear an override).
Functions read a body only through RequestBody.ReadAsync<T>/ReadOptionalAsync<T>
(System.Text.Json, ApiJsonOptions.Read = wire options + case-insensitive; empty or
malformed body = 400 envelope, never a 500) whose generic constrains on IApiRequest.
ReadNewtonsoftAsync<T> stays for the Newtonsoft-bound cluster (tenant/admin config via
the patch service's PopulateObject, rule and pattern documents) as a frozen per-file
baseline. The tenant-plan PATCH keeps its presence-sensitive JsonDocument walk with the
keys derived from PatchTenantPlanRequest by nameof (PlanPatchKeyParityTests).
35 nested request classes, 7 JObject/JsonDocument hand-walks and the anonymous
DeserializeAnonymousType bodies moved to Shared; the two identical TicketRequests are
one DownloadTicketRequest; ServerAction is no longer the request (QueueSessionActionRequest,
the stored row is built server-side); create-from-template takes { "variables": … }
instead of a bare map. Fields the endpoints default server-side are nullable now
(CPE mapping, report attachments, feedback rating on dismissal, upn for applications).
TypedRequestGuardTests: no raw deserializer under Functions/ (named exemptions: agent
routes frozen by D-200, Azure Monitor webhook, upstream/stored-column parses), the
Newtonsoft branch only in its baseline, no request type in the Functions assembly, every
DTO flat and in Shared. RequestBodyTests cover both readers, the cap and the envelope.
QueryParamGuardTests baseline shrinks by 14 (the Content-Length TryParse lives in the reader).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
242 lines
11 KiB
C#
242 lines
11 KiB
C#
using System;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Threading.Tasks;
|
|
using AutopilotMonitor.Functions.Extensions;
|
|
using AutopilotMonitor.Functions.Helpers;
|
|
using AutopilotMonitor.Functions.Security;
|
|
using AutopilotMonitor.Functions.Services;
|
|
using AutopilotMonitor.Shared;
|
|
using AutopilotMonitor.Shared.DataAccess;
|
|
using AutopilotMonitor.Shared.Models;
|
|
using Microsoft.Azure.Functions.Worker;
|
|
using Microsoft.Azure.Functions.Worker.Http;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace AutopilotMonitor.Functions.Functions.Feedback
|
|
{
|
|
public class FeedbackFunction
|
|
{
|
|
private readonly ILogger<FeedbackFunction> _logger;
|
|
private readonly TenantConfigurationService _tenantConfigService;
|
|
private readonly AdminConfigurationService _adminConfigService;
|
|
private readonly TenantAdminsService _tenantAdminsService;
|
|
private readonly TelegramNotificationService _telegramNotificationService;
|
|
private readonly ISessionRepository _sessionRepo;
|
|
private readonly IFeedbackRepository _feedbackRepo;
|
|
|
|
public FeedbackFunction(
|
|
ILogger<FeedbackFunction> logger,
|
|
TenantConfigurationService tenantConfigService,
|
|
AdminConfigurationService adminConfigService,
|
|
TenantAdminsService tenantAdminsService,
|
|
TelegramNotificationService telegramNotificationService,
|
|
ISessionRepository sessionRepo,
|
|
IFeedbackRepository feedbackRepo)
|
|
{
|
|
_logger = logger;
|
|
_tenantConfigService = tenantConfigService;
|
|
_adminConfigService = adminConfigService;
|
|
_tenantAdminsService = tenantAdminsService;
|
|
_telegramNotificationService = telegramNotificationService;
|
|
_sessionRepo = sessionRepo;
|
|
_feedbackRepo = feedbackRepo;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks whether the current user is eligible to see the feedback bubble.
|
|
/// Evaluates: kill-switch, role, tenant age, session existence, cooldown.
|
|
/// </summary>
|
|
[Function("GetFeedbackStatus")]
|
|
public async Task<HttpResponseData> GetStatus(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "feedback/status")] HttpRequestData req,
|
|
FunctionContext context)
|
|
{
|
|
try
|
|
{
|
|
// 1. Kill-switch — gate first so a disabled feature doesn't fan out
|
|
// four extra Table reads on every poll from every active user.
|
|
var adminConfig = await _adminConfigService.GetConfigurationAsync();
|
|
if (!adminConfig.FeedbackEnabled)
|
|
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
|
|
|
|
// 2. User identity
|
|
string tenantId = TenantHelper.GetTenantId(req);
|
|
string upn = TenantHelper.GetUserIdentifier(req);
|
|
|
|
// 3-6. The remaining checks each hit a different Table partition and
|
|
// depend only on (tenantId, upn) — fan them out in parallel so
|
|
// the endpoint is bounded by the slowest read instead of summing
|
|
// four sequential round-trips on Flex Consumption.
|
|
var membershipTask = _tenantAdminsService.GetTableMembershipAsync(tenantId, upn);
|
|
var tenantConfigTask = _tenantConfigService.GetConfigurationAsync(tenantId);
|
|
var sessionsTask = _sessionRepo.GetSessionsPageAsync(tenantId, days: null, pageSize: 1, continuation: null);
|
|
var feedbackTask = _feedbackRepo.GetInAppFeedbackAsync(upn);
|
|
|
|
await Task.WhenAll(membershipTask, tenantConfigTask, sessionsTask, feedbackTask);
|
|
|
|
// Tenant config is needed both for the app-role opt-in flag (role check) and the
|
|
// tenant age check below.
|
|
var tenantConfig = tenantConfigTask.Result;
|
|
|
|
// 3. Role check — only Admin + Operator. Use the effective role so Entra-only
|
|
// members (app-role claim, no table row) are eligible too, consistent with
|
|
// auth/me and the protected APIs. A disabled table row stays ineligible.
|
|
var (tableState, tableRole) = membershipTask.Result;
|
|
var roleInfo = EntraAppRoleResolver.Resolve(
|
|
tableState, tableRole, context.GetUser()?.GetAppRoles(), tenantConfig.EntraAppRolesEnabled);
|
|
if (roleInfo == null || roleInfo.Role == Constants.TenantRoles.Viewer)
|
|
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
|
|
|
|
// 4. Tenant age check
|
|
if (tenantConfig.OnboardedAt == null ||
|
|
(DateTime.UtcNow - tenantConfig.OnboardedAt.Value).TotalDays < adminConfig.FeedbackMinTenantAgeDays)
|
|
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
|
|
|
|
// 5. Sessions check — at least 1 session exists.
|
|
var sessionPage = sessionsTask.Result;
|
|
if (sessionPage.Items.Count == 0)
|
|
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
|
|
|
|
// 6. Cooldown check
|
|
var feedbackEntry = feedbackTask.Result;
|
|
if (feedbackEntry?.InteractedAt != null)
|
|
{
|
|
// Cooldown = 0 means single wave only
|
|
if (adminConfig.FeedbackCooldownDays == 0)
|
|
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
|
|
|
|
var daysSinceInteraction = (DateTime.UtcNow - feedbackEntry.InteractedAt.Value).TotalDays;
|
|
if (daysSinceInteraction < adminConfig.FeedbackCooldownDays)
|
|
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
|
|
}
|
|
|
|
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = true });
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
var response = req.CreateResponse(HttpStatusCode.Unauthorized);
|
|
return response;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error checking feedback status");
|
|
// Fail closed — don't show bubble on errors
|
|
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Submits user feedback or records a dismissal.
|
|
/// On submit: stores feedback and sends Telegram notification.
|
|
/// On dismiss: stores dismissal so the bubble is not shown again (until cooldown).
|
|
/// </summary>
|
|
[Function("SubmitFeedback")]
|
|
public async Task<HttpResponseData> Submit(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "feedback")] HttpRequestData req)
|
|
{
|
|
try
|
|
{
|
|
string tenantId = TenantHelper.GetTenantId(req);
|
|
string upn = TenantHelper.GetUserIdentifier(req);
|
|
var principal = req.FunctionContext.GetUser();
|
|
string displayName = principal?.GetDisplayName() ?? upn;
|
|
|
|
// Request body size limit (1 MB)
|
|
var read = await req.ReadAsync<FeedbackRequest>(1_048_576);
|
|
if (read.Error != null) return read.Error;
|
|
var body = read.Value!;
|
|
|
|
// Validate rating
|
|
if (!body.Dismissed && body.Rating is not (>= 1 and <= 5))
|
|
{
|
|
return await req.BadRequestAsync("Rating must be between 1 and 5");
|
|
}
|
|
|
|
// Trim comment. Aligned with the offboarding-feedback endpoint at 4096 chars
|
|
// so users have room for a substantive comment without hitting a tight limit;
|
|
// Azure Tables caps a single property at 64 KB so this stays well within.
|
|
var comment = body.Comment?.Trim();
|
|
if (comment?.Length > 4096)
|
|
comment = comment.Substring(0, 4096);
|
|
|
|
// Upsert feedback record
|
|
await _feedbackRepo.SaveInAppFeedbackAsync(new FeedbackEntry
|
|
{
|
|
Upn = upn,
|
|
TenantId = tenantId,
|
|
DisplayName = displayName,
|
|
Rating = body.Dismissed ? null : body.Rating,
|
|
Comment = body.Dismissed ? null : comment,
|
|
Dismissed = body.Dismissed,
|
|
Submitted = !body.Dismissed,
|
|
InteractedAt = DateTime.UtcNow
|
|
});
|
|
|
|
// Telegram notification — only for actual submissions, fire-and-forget
|
|
if (!body.Dismissed && body.Rating is int rating && rating > 0)
|
|
{
|
|
_ = _telegramNotificationService.SendFeedbackAsync(tenantId, upn, displayName, rating, comment);
|
|
}
|
|
|
|
_logger.LogInformation("Feedback {Action} by {Upn} (tenant {TenantId})",
|
|
body.Dismissed ? "dismissed" : "submitted", upn, tenantId);
|
|
|
|
return await WriteJson(req, new SuccessOnlyResponse { Success = true });
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
return req.CreateResponse(HttpStatusCode.Unauthorized);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return await req.InternalServerErrorAsync(_logger, ex, "Feedback");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns all feedback entries for the Global Admin dashboard.
|
|
/// </summary>
|
|
[Function("GetAllFeedback")]
|
|
public async Task<HttpResponseData> GetAll(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "feedback/all")] HttpRequestData req)
|
|
{
|
|
try
|
|
{
|
|
var allEntries = await _feedbackRepo.GetAllAsync();
|
|
var entries = allEntries.Select(e => new FeedbackEntryWire
|
|
{
|
|
Type = e.Type,
|
|
Upn = e.Upn,
|
|
TenantId = e.TenantId,
|
|
DisplayName = e.DisplayName,
|
|
Rating = e.Rating,
|
|
Comment = e.Comment,
|
|
Dismissed = e.Dismissed,
|
|
Submitted = e.Submitted,
|
|
InteractedAt = e.InteractedAt?.ToString("o"),
|
|
HistoryRowKey = e.HistoryRowKey,
|
|
DomainName = e.DomainName,
|
|
}).ToList();
|
|
|
|
return await WriteJson(req, new FeedbackListResponse { Feedback = entries });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return await req.InternalServerErrorAsync(_logger, ex, "Feedback");
|
|
}
|
|
}
|
|
|
|
// Generic with an IApiResponse constraint (NOT an IApiResponse parameter): success
|
|
// bodies through this wrapper are typed by construction — an anonymous body no longer
|
|
// compiles — while the CONCRETE type still reaches the serializer (a declared interface
|
|
// type would serialize zero properties under System.Text.Json).
|
|
private static async Task<HttpResponseData> WriteJson<T>(HttpRequestData req, T data) where T : IApiResponse
|
|
{
|
|
var response = req.CreateResponse(HttpStatusCode.OK);
|
|
await response.WriteAsJsonAsync(data);
|
|
return response;
|
|
}
|
|
}
|
|
}
|