Add risk center and editable submission flows

This commit is contained in:
AzuTear
2026-06-17 12:01:57 +02:00
parent 670259a983
commit 92dd6f7432
12 changed files with 661 additions and 20 deletions
+26 -1
View File
@@ -6,10 +6,33 @@ public sealed record AdminActivityDto(string Label, string Age);
public sealed record AdminTopCategoryDto(string Category, int Votes);
public sealed record AdminRiskFlagDto(
int Id,
string Source,
string Type,
string Severity,
string Status,
string Summary,
string? TwitchUserId,
string CreatedFromIp,
DateTimeOffset CreatedAt,
string MetadataJson);
public sealed record AdminAuditEntryDto(
int Id,
string AdminTwitchUserId,
string ActionType,
string EntityType,
string EntityId,
string Summary,
DateTimeOffset CreatedAt);
public sealed record AdminDashboardResponse(
IEnumerable<AdminMetricDto> Metrics,
IEnumerable<AdminActivityDto> Activities,
IEnumerable<AdminTopCategoryDto> TopCategories);
IEnumerable<AdminTopCategoryDto> TopCategories,
IEnumerable<AdminRiskFlagDto> RiskFlags,
IEnumerable<AdminAuditEntryDto> AuditEntries);
public sealed record AdminSeasonListItemDto(
int Id,
@@ -76,3 +99,5 @@ public sealed record ApproveNominationRequest(
string? DisplayName,
string? ChannelSlug,
string? Platform);
public sealed record ResolveRiskFlagRequest(string Status);
+26
View File
@@ -13,6 +13,8 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
public DbSet<VoteBallot> VoteBallots => Set<VoteBallot>();
public DbSet<VoteEntry> VoteEntries => Set<VoteEntry>();
public DbSet<UserSession> UserSessions => Set<UserSession>();
public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>();
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -62,6 +64,30 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.TwitchUserId).HasMaxLength(120);
entity.Property(item => item.DisplayName).HasMaxLength(120);
entity.Property(item => item.Role).HasMaxLength(40);
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
entity.Property(item => item.UserAgent).HasMaxLength(400);
});
modelBuilder.Entity<RiskFlag>(entity =>
{
entity.Property(item => item.TwitchUserId).HasMaxLength(120);
entity.Property(item => item.Source).HasMaxLength(80);
entity.Property(item => item.Type).HasMaxLength(80);
entity.Property(item => item.Severity).HasMaxLength(20);
entity.Property(item => item.Status).HasMaxLength(20);
entity.Property(item => item.Summary).HasMaxLength(240);
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
entity.Property(item => item.UserAgent).HasMaxLength(400);
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
});
modelBuilder.Entity<AdminAuditEntry>(entity =>
{
entity.Property(item => item.AdminTwitchUserId).HasMaxLength(120);
entity.Property(item => item.ActionType).HasMaxLength(80);
entity.Property(item => item.EntityType).HasMaxLength(80);
entity.Property(item => item.EntityId).HasMaxLength(120);
entity.Property(item => item.Summary).HasMaxLength(240);
});
SeedData.Apply(modelBuilder);
@@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
public static class OperationalTablesBootstrapper
{
public static Task EnsureAsync(AwardsDbContext db) =>
db.Database.ExecuteSqlRawAsync(
"""
ALTER TABLE "UserSessions"
ADD COLUMN IF NOT EXISTS "CreatedFromIp" character varying(80) NOT NULL DEFAULT '';
ALTER TABLE "UserSessions"
ADD COLUMN IF NOT EXISTS "UserAgent" character varying(400) NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS "RiskFlags" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NULL,
"TwitchUserId" character varying(120) NULL,
"Source" character varying(80) NOT NULL,
"Type" character varying(80) NOT NULL,
"Severity" character varying(20) NOT NULL,
"Status" character varying(20) NOT NULL,
"Summary" character varying(240) NOT NULL,
"CreatedFromIp" character varying(80) NOT NULL,
"UserAgent" character varying(400) NOT NULL,
"MetadataJson" text NOT NULL,
"ReviewedByTwitchId" character varying(120) NULL,
"CreatedAt" timestamp with time zone NOT NULL,
"ReviewedAt" timestamp with time zone NULL
);
CREATE INDEX IF NOT EXISTS "IX_RiskFlags_Status_CreatedAt"
ON "RiskFlags" ("Status", "CreatedAt" DESC);
CREATE INDEX IF NOT EXISTS "IX_RiskFlags_SeasonId"
ON "RiskFlags" ("SeasonId");
CREATE TABLE IF NOT EXISTS "AdminAuditEntries" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"AdminTwitchUserId" character varying(120) NOT NULL,
"ActionType" character varying(80) NOT NULL,
"EntityType" character varying(80) NOT NULL,
"EntityId" character varying(120) NOT NULL,
"Summary" character varying(240) NOT NULL,
"MetadataJson" text NOT NULL,
"CreatedAt" timestamp with time zone NOT NULL
);
CREATE INDEX IF NOT EXISTS "IX_AdminAuditEntries_CreatedAt"
ON "AdminAuditEntries" ("CreatedAt" DESC);
""");
}
+13
View File
@@ -0,0 +1,13 @@
namespace Backend.Domain;
public sealed class AdminAuditEntry
{
public int Id { get; set; }
public string AdminTwitchUserId { get; set; } = string.Empty;
public string ActionType { get; set; } = string.Empty;
public string EntityType { get; set; } = string.Empty;
public string EntityId { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
public string MetadataJson { get; set; } = "{}";
public DateTimeOffset CreatedAt { get; set; }
}
+20
View File
@@ -0,0 +1,20 @@
namespace Backend.Domain;
public sealed class RiskFlag
{
public int Id { get; set; }
public int? SeasonId { get; set; }
public Season? Season { get; set; }
public string? TwitchUserId { get; set; }
public string Source { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public string Severity { get; set; } = "medium";
public string Status { get; set; } = "open";
public string Summary { get; set; } = string.Empty;
public string CreatedFromIp { get; set; } = string.Empty;
public string UserAgent { get; set; } = string.Empty;
public string MetadataJson { get; set; } = "{}";
public string? ReviewedByTwitchId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? ReviewedAt { get; set; }
}
+2
View File
@@ -7,6 +7,8 @@ public sealed class UserSession
public string TwitchUserId { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string Role { get; set; } = "viewer";
public string CreatedFromIp { get; set; } = string.Empty;
public string UserAgent { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset LastSeenAt { get; set; }
public bool IsActive { get; set; }
+362 -19
View File
@@ -2,6 +2,7 @@ using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration["VTSA_POSTGRES"]
@@ -58,6 +59,79 @@ static async Task<UserSession?> ResolveSessionAsync(HttpContext context, AwardsD
return session;
}
static string ReadClientIp(HttpContext context) =>
context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
static string ReadUserAgent(HttpContext context)
{
var value = context.Request.Headers.UserAgent.ToString().Trim();
return value.Length > 400 ? value[..400] : value;
}
static void AddAuditEntry(
AwardsDbContext db,
string adminTwitchUserId,
string actionType,
string entityType,
string entityId,
string summary,
object? metadata = null)
{
db.AdminAuditEntries.Add(new AdminAuditEntry
{
AdminTwitchUserId = adminTwitchUserId,
ActionType = actionType,
EntityType = entityType,
EntityId = entityId,
Summary = summary,
MetadataJson = JsonSerializer.Serialize(metadata ?? new { }),
CreatedAt = DateTimeOffset.UtcNow,
});
}
static async Task AddRiskFlagIfMissingAsync(
AwardsDbContext db,
int? seasonId,
string? twitchUserId,
string source,
string type,
string severity,
string summary,
string createdFromIp,
string userAgent,
object? metadata = null)
{
var threshold = DateTimeOffset.UtcNow.AddHours(-6);
var exists = await db.RiskFlags.AnyAsync(item =>
item.Status == "open"
&& item.Source == source
&& item.Type == type
&& item.TwitchUserId == twitchUserId
&& item.CreatedFromIp == createdFromIp
&& item.SeasonId == seasonId
&& item.CreatedAt >= threshold);
if (exists)
{
return;
}
db.RiskFlags.Add(new RiskFlag
{
SeasonId = seasonId,
TwitchUserId = twitchUserId,
Source = source,
Type = type,
Severity = severity,
Status = "open",
Summary = summary,
CreatedFromIp = createdFromIp,
UserAgent = userAgent,
MetadataJson = JsonSerializer.Serialize(metadata ?? new { }),
CreatedAt = DateTimeOffset.UtcNow,
});
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
@@ -86,15 +160,18 @@ using (var scope = app.Services.CreateScope())
try
{
await SessionBootstrapper.EnsureAsync(db);
await OperationalTablesBootstrapper.EnsureAsync(db);
}
catch
{
// If the session table bootstrap fails, the rest of the API can still start.
// If the operational table bootstrap fails, the rest of the API can still start.
}
}
app.MapPost("/api/auth/dev-login", async (LoginRequest request, AwardsDbContext db) =>
app.MapPost("/api/auth/dev-login", async (HttpContext context, LoginRequest request, AwardsDbContext db) =>
{
var createdFromIp = ReadClientIp(context);
var userAgent = ReadUserAgent(context);
var session = new UserSession
{
Id = Guid.NewGuid(),
@@ -102,11 +179,32 @@ app.MapPost("/api/auth/dev-login", async (LoginRequest request, AwardsDbContext
TwitchUserId = request.TwitchUserId.Trim(),
DisplayName = request.DisplayName.Trim(),
Role = string.Equals(request.Role, "admin", StringComparison.OrdinalIgnoreCase) ? "admin" : "viewer",
CreatedFromIp = createdFromIp,
UserAgent = userAgent,
CreatedAt = DateTimeOffset.UtcNow,
LastSeenAt = DateTimeOffset.UtcNow,
IsActive = true,
};
var recentSessionsFromIp = await db.UserSessions.CountAsync(item =>
item.CreatedFromIp == createdFromIp
&& item.CreatedAt >= DateTimeOffset.UtcNow.AddMinutes(-15));
if (recentSessionsFromIp >= 3)
{
await AddRiskFlagIfMissingAsync(
db,
null,
session.TwitchUserId,
"login",
"rapid_login_ip",
"medium",
"Mehrere neue Sessions wurden in kurzer Zeit von derselben IP erzeugt.",
createdFromIp,
userAgent,
new { recentSessionsFromIp });
}
db.UserSessions.Add(session);
await db.SaveChangesAsync();
@@ -338,6 +436,25 @@ app.MapPost("/api/public/nominations", async (HttpContext context, CreateNominat
return Results.BadRequest(new { message = "A logged in user is required to submit nominations." });
}
var createdFromIp = ReadClientIp(context);
var userAgent = ReadUserAgent(context);
var existingNominationCount = await db.Nominations.CountAsync(item =>
item.SeasonId == category.SeasonId
&& item.CategoryId == category.Id
&& item.SubmittedByTwitchId == submitterId);
var previousNominations = await db.Nominations
.Where(item =>
item.SeasonId == category.SeasonId
&& item.CategoryId == category.Id
&& item.SubmittedByTwitchId == submitterId)
.ToArrayAsync();
if (previousNominations.Length > 0)
{
db.Nominations.RemoveRange(previousNominations);
}
var records = distinctNominees.Select(name => new Nomination
{
SeasonId = category.SeasonId,
@@ -348,9 +465,44 @@ app.MapPost("/api/public/nominations", async (HttpContext context, CreateNominat
});
await db.Nominations.AddRangeAsync(records);
var recentNominationVolume = await db.Nominations.CountAsync(item =>
item.SubmittedByTwitchId == submitterId
&& item.CreatedAt >= DateTimeOffset.UtcNow.AddMinutes(-10));
if (existingNominationCount > 0)
{
await AddRiskFlagIfMissingAsync(
db,
category.SeasonId,
submitterId,
"nomination",
"resubmitted_nomination",
"low",
"Ein User hat seine Nominierung in derselben Kategorie erneut eingereicht.",
createdFromIp,
userAgent,
new { categoryId = category.Id, existingNominationCount });
}
if (recentNominationVolume >= 10)
{
await AddRiskFlagIfMissingAsync(
db,
category.SeasonId,
submitterId,
"nomination",
"rapid_nomination_burst",
"high",
"Ungewoehnlich viele Nominierungsaktionen in kurzer Zeit erkannt.",
createdFromIp,
userAgent,
new { recentNominationVolume });
}
await db.SaveChangesAsync();
return Results.Ok(new { saved = distinctNominees.Length, category = category.Name });
return Results.Ok(new { saved = distinctNominees.Length, category = category.Name, replacedPrevious = previousNominations.Length > 0 });
})
.WithName("CreateNomination")
.WithOpenApi();
@@ -379,24 +531,92 @@ app.MapPost("/api/public/votes", async (HttpContext context, CreateVoteRequest r
return Results.BadRequest(new { message = "A logged in user is required to submit votes." });
}
var ballot = new VoteBallot
{
SeasonId = request.SeasonId,
SubmittedByTwitchId = submitterId,
SubmittedAt = DateTimeOffset.UtcNow,
Status = "submitted",
};
var createdFromIp = ReadClientIp(context);
var userAgent = ReadUserAgent(context);
var candidateIds = request.Entries.Select(item => item.CandidateId).Distinct().ToArray();
var validCandidates = await db.Candidates
.AsNoTracking()
.Where(item => item.SeasonId == request.SeasonId && candidateIds.Contains(item.Id))
.Select(item => new { item.Id, item.CategoryId })
.ToArrayAsync();
if (validCandidates.Length != candidateIds.Length)
{
return Results.BadRequest(new { message = "One or more selected candidates do not belong to this season." });
}
var candidateCategoryMap = validCandidates.ToDictionary(item => item.Id, item => item.CategoryId);
if (request.Entries.Any(item => candidateCategoryMap[item.CandidateId] != item.CategoryId))
{
return Results.BadRequest(new { message = "A selected candidate does not match the submitted category." });
}
var ballot = await db.VoteBallots
.Include(item => item.Entries)
.FirstOrDefaultAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId);
var isResubmission = ballot is not null;
if (ballot is null)
{
ballot = new VoteBallot
{
SeasonId = request.SeasonId,
SubmittedByTwitchId = submitterId,
};
await db.VoteBallots.AddAsync(ballot);
}
else
{
db.VoteEntries.RemoveRange(ballot.Entries);
ballot.Entries.Clear();
}
ballot.SubmittedAt = DateTimeOffset.UtcNow;
ballot.Status = "submitted";
ballot.Entries = request.Entries.Select(entry => new VoteEntry
{
CategoryId = entry.CategoryId,
CandidateId = entry.CandidateId,
}).ToList();
await db.VoteBallots.AddAsync(ballot);
var recentVoteSubmissions = await db.VoteBallots.CountAsync(item =>
item.SubmittedByTwitchId == submitterId
&& item.SubmittedAt >= DateTimeOffset.UtcNow.AddMinutes(-10));
if (isResubmission)
{
await AddRiskFlagIfMissingAsync(
db,
request.SeasonId,
submitterId,
"vote",
"resubmitted_ballot",
"low",
"Ein User hat sein Ballot erneut gespeichert oder aktualisiert.",
createdFromIp,
userAgent,
new { entryCount = request.Entries.Length });
}
if (recentVoteSubmissions >= 3)
{
await AddRiskFlagIfMissingAsync(
db,
request.SeasonId,
submitterId,
"vote",
"rapid_vote_updates",
"high",
"Mehrere Voting-Aenderungen wurden in kurzer Zeit erkannt.",
createdFromIp,
userAgent,
new { recentVoteSubmissions });
}
await db.SaveChangesAsync();
return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count });
return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count, updated = isResubmission });
})
.WithName("CreateVote")
.WithOpenApi();
@@ -433,6 +653,43 @@ app.MapGet("/api/admin/dashboard", async (HttpContext context, AwardsDbContext d
.Take(5)
.ToArray();
var riskFlags = await db.RiskFlags
.AsNoTracking()
.Where(item => item.Status == "open")
.OrderByDescending(item => item.CreatedAt)
.Take(8)
.Select(item => new AdminRiskFlagDto(
item.Id,
item.Source,
item.Type,
item.Severity,
item.Status,
item.Summary,
item.TwitchUserId,
item.CreatedFromIp,
item.CreatedAt,
item.MetadataJson))
.ToArrayAsync();
var auditEntries = await db.AdminAuditEntries
.AsNoTracking()
.OrderByDescending(item => item.CreatedAt)
.Take(8)
.Select(item => new AdminAuditEntryDto(
item.Id,
item.AdminTwitchUserId,
item.ActionType,
item.EntityType,
item.EntityId,
item.Summary,
item.CreatedAt))
.ToArrayAsync();
var activityItems = auditEntries
.Take(3)
.Select(item => new AdminActivityDto(item.Summary, $"{Math.Max(1, (int)Math.Round((DateTimeOffset.UtcNow - item.CreatedAt).TotalMinutes))} Min."))
.ToArray();
var response = new AdminDashboardResponse(
new[]
{
@@ -441,13 +698,10 @@ app.MapGet("/api/admin/dashboard", async (HttpContext context, AwardsDbContext d
new AdminMetricDto("Kategorien", categoryCount, "aktiv im aktuellen Jahr"),
new AdminMetricDto("Reviews offen", reviewCount, "Freitext und Dubletten"),
},
new[]
{
new AdminActivityDto("Neue Nominierung in Best New VTuber", "vor 2 Min."),
new AdminActivityDto("Clip-Dublette erkannt in Clip des Jahres", "vor 7 Min."),
new AdminActivityDto("Alias-Merge fuer Hoshimi Miyu reviewt", "vor 18 Min."),
},
topCategories);
activityItems,
topCategories,
riskFlags,
auditEntries);
return Results.Ok(response);
})
@@ -594,6 +848,14 @@ app.MapPut("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int
}
season.IsCurrent = request.IsCurrent;
AddAuditEntry(
db,
session.TwitchUserId,
"season.update",
"season",
season.Id.ToString(),
$"Season {season.Year} wurde aktualisiert.",
new { request.CurrentPhase, request.IsCurrent });
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, seasonId = season.Id });
@@ -627,6 +889,14 @@ app.MapPost("/api/admin/seasons/{seasonId:int}/categories", async (HttpContext c
};
db.Categories.Add(category);
AddAuditEntry(
db,
session.TwitchUserId,
"category.create",
"category",
request.Slug.Trim(),
$"Kategorie {request.Name.Trim()} wurde angelegt.",
new { seasonId, request.GroupName, request.SortOrder });
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, categoryId = category.Id });
@@ -655,6 +925,14 @@ app.MapPut("/api/admin/categories/{categoryId:int}", async (HttpContext context,
category.SortOrder = request.SortOrder;
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
AddAuditEntry(
db,
session.TwitchUserId,
"category.update",
"category",
category.Id.ToString(),
$"Kategorie {request.Name.Trim()} wurde aktualisiert.",
new { request.GroupName, request.SortOrder, request.MaxNomineesPerUser });
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, categoryId = category.Id });
@@ -686,6 +964,14 @@ app.MapPost("/api/admin/seasons/{seasonId:int}/candidates", async (HttpContext c
};
db.Candidates.Add(candidate);
AddAuditEntry(
db,
session.TwitchUserId,
"candidate.create",
"candidate",
request.DisplayName.Trim(),
$"Kandidat {request.DisplayName.Trim()} wurde angelegt.",
new { seasonId, request.CategoryId, request.Platform });
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, candidateId = candidate.Id });
@@ -712,6 +998,14 @@ app.MapPut("/api/admin/candidates/{candidateId:int}", async (HttpContext context
candidate.ChannelSlug = request.ChannelSlug.Trim();
candidate.Platform = request.Platform.Trim();
AddAuditEntry(
db,
session.TwitchUserId,
"candidate.update",
"candidate",
candidate.Id.ToString(),
$"Kandidat {request.DisplayName.Trim()} wurde aktualisiert.",
new { request.CategoryId, request.Platform });
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, candidateId = candidate.Id });
@@ -783,6 +1077,14 @@ app.MapPost("/api/admin/nominations/{nominationId:int}/approve", async (HttpCont
nomination.CandidateId = candidate.Id;
nomination.CandidateText = null;
AddAuditEntry(
db,
session.TwitchUserId,
"nomination.approve",
"nomination",
nomination.Id.ToString(),
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen.",
new { candidateId = candidate.Id, created = existingCandidate is null });
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, nominationId = nomination.Id, candidateId = candidate.Id, created = existingCandidate is null });
@@ -806,6 +1108,13 @@ app.MapPost("/api/admin/nominations/{nominationId:int}/reject", async (HttpConte
nomination.CandidateText = null;
nomination.CandidateId = null;
AddAuditEntry(
db,
session.TwitchUserId,
"nomination.reject",
"nomination",
nomination.Id.ToString(),
$"Nominierung {nomination.Id} wurde verworfen.");
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true });
@@ -813,4 +1122,38 @@ app.MapPost("/api/admin/nominations/{nominationId:int}/reject", async (HttpConte
.WithName("RejectAdminNomination")
.WithOpenApi();
app.MapPost("/api/admin/risk-flags/{riskFlagId:int}/resolve", async (HttpContext context, int riskFlagId, ResolveRiskFlagRequest request, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var riskFlag = await db.RiskFlags.FirstOrDefaultAsync(item => item.Id == riskFlagId);
if (riskFlag is null)
{
return Results.NotFound();
}
riskFlag.Status = string.IsNullOrWhiteSpace(request.Status) ? "resolved" : request.Status.Trim().ToLowerInvariant();
riskFlag.ReviewedAt = DateTimeOffset.UtcNow;
riskFlag.ReviewedByTwitchId = session.TwitchUserId;
AddAuditEntry(
db,
session.TwitchUserId,
"risk.resolve",
"risk-flag",
riskFlag.Id.ToString(),
$"Risk Flag {riskFlag.Id} wurde als {riskFlag.Status} markiert.",
new { riskFlag.Type, riskFlag.Source });
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, riskFlagId = riskFlag.Id, status = riskFlag.Status });
})
.WithName("ResolveRiskFlag")
.WithOpenApi();
app.Run();