Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSessionWatchService.cs
T
Oliver Kieselbach 0cd8baa85d Push receiver: history filter; the session-watch button hides when watching is impossible
- History page: one dropdown built from what the history holds — Platform (ops alerts, one
  entry per Category fact), Enrollment (one per tenant alert kind) and This device (the
  receiver's own messages) — with counts, a "12 of 48" readout and a clear button while a
  filter is active, hidden while there is only one kind to show. The choice is stored on the
  device (meta key historyFilter); a deep link to a hidden entry shows everything. A
  customer's device holds tenant pushes only, so it never sees a Platform group.
- SessionWatchResponse.Available: GET/DELETE sessions/{id}/watch say whether this person can
  watch at all (an enabled Push channel in one of their own scopes where they hold Admin or
  Operator); the portal's "Notify me when done" renders only then, instead of letting a tenant
  admin click into "enable a Push channel first", which only a Global Admin can do. An active
  watch stays available so it can be switched off.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 16:38:09 +02:00

188 lines
8.6 KiB
C#

using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services.Push;
/// <summary>Terminal-status hook for the ingest path: fires the pushes of everyone watching the session.</summary>
public interface IPushSessionWatchNotifier
{
Task NotifyAsync(string tenantId, string sessionId, bool success, string? deviceName, string? serialNumber);
}
/// <summary>
/// "Notify my devices when this session ends" (plan use case 3): one watch per (session, person),
/// consumed at the terminal status. The watch row lives in the SESSION's tenant (tenant isolation
/// of the session), the devices it fires on are the watcher's OWN: their home-tenant scope and,
/// for a Global Admin, the platform scope — so an operator can watch a customer's session from
/// their own paired phone. Admin and Operator only (F14), only in a scope with an enabled Push
/// channel (K4), and the fan-out runs through the same role and freshness filter as any alert.
/// Watches expire after 48 hours (the agent's emergency break) and die with the session's tenant.
/// </summary>
public class PushSessionWatchService : IPushSessionWatchNotifier
{
public static readonly TimeSpan WatchLifetime = TimeSpan.FromHours(48);
/// <summary>The terminal push waits for an offline phone longer than a system message: plan "Watch bis Session-Ende + 1 h".</summary>
public static readonly TimeSpan WatchPushTtl = TimeSpan.FromHours(1);
private const char ScopeSeparator = ';';
private readonly IPushDeviceRepository _repo;
private readonly PushEligibility _eligibility;
private readonly PushDeliveryService _delivery;
private readonly ILogger<PushSessionWatchService> _logger;
public PushSessionWatchService(
IPushDeviceRepository repo,
PushEligibility eligibility,
PushDeliveryService delivery,
ILogger<PushSessionWatchService> logger)
{
_repo = repo;
_eligibility = eligibility;
_delivery = delivery;
_logger = logger;
}
/// <summary>The scopes a caller's own devices can live in: the platform partition for a Global Admin, the home tenant for everyone.</summary>
internal static IReadOnlyList<NotificationScope> OwnScopes(PushCaller caller)
{
var scopes = new List<NotificationScope>(2);
if (caller.IsGlobalAdmin)
scopes.Add(NotificationScope.Platform);
if (!string.IsNullOrWhiteSpace(caller.HomeTenantId))
scopes.Add(NotificationScope.Tenant(caller.HomeTenantId));
return scopes;
}
public async Task<PushOpResult<SessionWatchResponse>> WatchAsync(string tenantId, string sessionId, PushCaller caller)
{
var anyChannel = false;
var anyEligible = false;
var usable = new List<string>();
foreach (var scope in OwnScopes(caller))
{
if (!await _eligibility.HasPushChannelAsync(scope).ConfigureAwait(false))
continue;
anyChannel = true;
if (!await _eligibility.IsEligibleAsync(scope, caller.Upn, caller.ObjectId, caller.HomeTenantId).ConfigureAwait(false))
continue;
anyEligible = true;
var hasDevice = (await _repo.GetDevicesAsync(scope.Key).ConfigureAwait(false))
.Any(d => d.Status == Constants.Push.DeviceStatus.Active
&& string.Equals(d.OwnerObjectId, caller.ObjectId, StringComparison.OrdinalIgnoreCase));
if (hasDevice)
usable.Add(scope.Key);
}
if (usable.Count == 0)
{
if (!anyChannel)
return PushOpResult<SessionWatchResponse>.Fail(PushOpError.ChannelRequired, "Enable a Push notification channel first.");
if (!anyEligible)
return PushOpResult<SessionWatchResponse>.Fail(PushOpError.NotEligible);
return PushOpResult<SessionWatchResponse>.Fail(PushOpError.Conflict, "Pair a device first — there is nothing to notify.");
}
var now = DateTime.UtcNow;
var watch = new PushSessionWatch
{
TenantId = tenantId.ToLowerInvariant(),
SessionId = sessionId,
OwnerObjectId = caller.ObjectId,
OwnerUpn = caller.Upn,
OwnerScopes = string.Join(ScopeSeparator, usable),
CreatedUtc = now,
ExpiresUtc = now + WatchLifetime,
};
await _repo.UpsertWatchAsync(watch).ConfigureAwait(false);
return PushOpResult<SessionWatchResponse>.Success(new SessionWatchResponse { Watching = true, ExpiresUtc = watch.ExpiresUtc, Available = true });
}
public async Task<SessionWatchResponse> UnwatchAsync(string tenantId, string sessionId, PushCaller caller)
{
await _repo.DeleteWatchAsync(tenantId, sessionId, caller.ObjectId).ConfigureAwait(false);
return new SessionWatchResponse { Watching = false, Available = await IsAvailableAsync(caller).ConfigureAwait(false) };
}
public async Task<SessionWatchResponse> GetAsync(string tenantId, string sessionId, PushCaller caller)
{
var watch = await _repo.GetWatchAsync(tenantId, sessionId, caller.ObjectId).ConfigureAwait(false);
var active = watch != null && watch.ExpiresUtc >= DateTime.UtcNow;
return new SessionWatchResponse
{
Watching = active,
ExpiresUtc = active ? watch!.ExpiresUtc : null,
Available = active || await IsAvailableAsync(caller).ConfigureAwait(false),
};
}
/// <summary>
/// Whether watching is possible for this person at all: an enabled Push channel in one of the
/// caller's own scopes where the caller is eligible. A device is not required here — the PUT
/// says "pair a device first" — so the portal can show the button as soon as the channel exists.
/// </summary>
private async Task<bool> IsAvailableAsync(PushCaller caller)
{
foreach (var scope in OwnScopes(caller))
{
if (!await _eligibility.HasPushChannelAsync(scope).ConfigureAwait(false))
continue;
if (await _eligibility.IsEligibleAsync(scope, caller.Upn, caller.ObjectId, caller.HomeTenantId).ConfigureAwait(false))
return true;
}
return false;
}
/// <summary>Fail-soft: a watch push must never cost the ingest path anything.</summary>
public async Task NotifyAsync(string tenantId, string sessionId, bool success, string? deviceName, string? serialNumber)
{
try
{
var watches = await _repo.GetWatchesForSessionAsync(tenantId, sessionId).ConfigureAwait(false);
if (watches.Count == 0)
return;
var now = DateTime.UtcNow;
var device = PushAlertProjector.Clean(deviceName, 48);
var serial = PushAlertProjector.MaskSerial(serialNumber);
var body = (device.Length > 0 ? device : "The device") + (serial.Length > 0 ? $" ({serial})" : string.Empty)
+ (success ? " finished enrollment." : " failed enrollment.");
foreach (var watch in watches)
{
if (watch.ExpiresUtc < now)
{
await _repo.DeleteWatchAsync(tenantId, sessionId, watch.OwnerObjectId).ConfigureAwait(false);
continue;
}
foreach (var scope in ScopesOf(watch, tenantId))
{
var payload = PushAlertProjector.SystemMessage("session_watch",
success ? "Enrollment finished" : "Enrollment failed",
body, success ? "success" : "error", scope, now, Constants.PortalSessionUrl(sessionId));
await _delivery.SendSystemToOwnerAsync(scope, watch.OwnerObjectId, payload, WatchPushTtl).ConfigureAwait(false);
}
await _repo.DeleteWatchAsync(tenantId, sessionId, watch.OwnerObjectId).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Session watch notification failed for session {SessionId}", sessionId);
}
}
private static IEnumerable<NotificationScope> ScopesOf(PushSessionWatch watch, string sessionTenantId)
{
var keys = (watch.OwnerScopes ?? string.Empty).Split(ScopeSeparator, StringSplitOptions.RemoveEmptyEntries);
if (keys.Length == 0)
keys = new[] { sessionTenantId };
foreach (var key in keys)
yield return NotificationScope.FromKey(key);
}
}