consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -0,0 +1,574 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.JobEngine.Core.Domain;
|
||||
using StellaOps.JobEngine.Infrastructure.Repositories;
|
||||
using StellaOps.JobEngine.WebService.Contracts;
|
||||
using StellaOps.JobEngine.WebService.Services;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
namespace StellaOps.JobEngine.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoints for ledger operations.
|
||||
/// </summary>
|
||||
public static class LedgerEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps ledger endpoints to the route builder.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapLedgerEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/jobengine/ledger")
|
||||
.WithTags("Orchestrator Ledger")
|
||||
.RequireAuthorization(JobEnginePolicies.Read)
|
||||
.RequireTenant();
|
||||
|
||||
// Ledger entry operations
|
||||
group.MapGet(string.Empty, ListLedgerEntries)
|
||||
.WithName("Orchestrator_ListLedgerEntries")
|
||||
.WithDescription(_t("orchestrator.ledger.list_description"));
|
||||
|
||||
group.MapGet("{ledgerId:guid}", GetLedgerEntry)
|
||||
.WithName("Orchestrator_GetLedgerEntry")
|
||||
.WithDescription(_t("orchestrator.ledger.get_description"));
|
||||
|
||||
group.MapGet("run/{runId:guid}", GetByRunId)
|
||||
.WithName("Orchestrator_GetLedgerByRunId")
|
||||
.WithDescription(_t("orchestrator.ledger.get_by_run_description"));
|
||||
|
||||
group.MapGet("source/{sourceId:guid}", GetBySource)
|
||||
.WithName("Orchestrator_GetLedgerBySource")
|
||||
.WithDescription(_t("orchestrator.ledger.get_by_source_description"));
|
||||
|
||||
group.MapGet("latest", GetLatestEntry)
|
||||
.WithName("Orchestrator_GetLatestLedgerEntry")
|
||||
.WithDescription(_t("orchestrator.ledger.get_latest_description"));
|
||||
|
||||
group.MapGet("sequence/{startSeq:long}/{endSeq:long}", GetBySequenceRange)
|
||||
.WithName("Orchestrator_GetLedgerBySequence")
|
||||
.WithDescription(_t("orchestrator.ledger.get_by_sequence_description"));
|
||||
|
||||
// Summary and verification
|
||||
group.MapGet("summary", GetLedgerSummary)
|
||||
.WithName("Orchestrator_GetLedgerSummary")
|
||||
.WithDescription(_t("orchestrator.ledger.summary_description"));
|
||||
|
||||
group.MapGet("verify", VerifyLedgerChain)
|
||||
.WithName("Orchestrator_VerifyLedgerChain")
|
||||
.WithDescription(_t("orchestrator.ledger.verify_chain_description"));
|
||||
|
||||
// Export operations
|
||||
group.MapGet("exports", ListExports)
|
||||
.WithName("Orchestrator_ListLedgerExports")
|
||||
.WithDescription(_t("orchestrator.ledger.list_exports_description"));
|
||||
|
||||
group.MapGet("exports/{exportId:guid}", GetExport)
|
||||
.WithName("Orchestrator_GetLedgerExport")
|
||||
.WithDescription(_t("orchestrator.ledger.get_export_description"));
|
||||
|
||||
group.MapPost("exports", CreateExport)
|
||||
.WithName("Orchestrator_CreateLedgerExport")
|
||||
.WithDescription(_t("orchestrator.ledger.create_export_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.ExportOperator);
|
||||
|
||||
// Manifest operations
|
||||
group.MapGet("manifests", ListManifests)
|
||||
.WithName("Orchestrator_ListManifests")
|
||||
.WithDescription(_t("orchestrator.ledger.list_manifests_description"));
|
||||
|
||||
group.MapGet("manifests/{manifestId:guid}", GetManifest)
|
||||
.WithName("Orchestrator_GetManifest")
|
||||
.WithDescription(_t("orchestrator.ledger.get_manifest_description"));
|
||||
|
||||
group.MapGet("manifests/subject/{subjectId:guid}", GetManifestBySubject)
|
||||
.WithName("Orchestrator_GetManifestBySubject")
|
||||
.WithDescription(_t("orchestrator.ledger.get_manifest_by_subject_description"));
|
||||
|
||||
group.MapGet("manifests/{manifestId:guid}/verify", VerifyManifest)
|
||||
.WithName("Orchestrator_VerifyManifest")
|
||||
.WithDescription(_t("orchestrator.ledger.verify_manifest_description"));
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListLedgerEntries(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerRepository repository,
|
||||
[FromQuery] string? runType = null,
|
||||
[FromQuery] Guid? sourceId = null,
|
||||
[FromQuery] string? finalStatus = null,
|
||||
[FromQuery] DateTimeOffset? startTime = null,
|
||||
[FromQuery] DateTimeOffset? endTime = null,
|
||||
[FromQuery] int? limit = null,
|
||||
[FromQuery] string? cursor = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var effectiveLimit = EndpointHelpers.GetLimit(limit);
|
||||
var offset = EndpointHelpers.ParseCursorOffset(cursor);
|
||||
|
||||
RunStatus? parsedStatus = null;
|
||||
if (!string.IsNullOrEmpty(finalStatus) && Enum.TryParse<RunStatus>(finalStatus, true, out var rs))
|
||||
{
|
||||
parsedStatus = rs;
|
||||
}
|
||||
|
||||
var entries = await repository.ListAsync(
|
||||
tenantId,
|
||||
runType,
|
||||
sourceId,
|
||||
parsedStatus,
|
||||
startTime,
|
||||
endTime,
|
||||
effectiveLimit,
|
||||
offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var responses = entries.Select(LedgerEntryResponse.FromDomain).ToList();
|
||||
var nextCursor = EndpointHelpers.CreateNextCursor(offset, effectiveLimit, responses.Count);
|
||||
|
||||
return Results.Ok(new LedgerEntryListResponse(responses, nextCursor));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetLedgerEntry(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid ledgerId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var entry = await repository.GetByIdAsync(tenantId, ledgerId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(LedgerEntryResponse.FromDomain(entry));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetByRunId(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid runId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var entry = await repository.GetByRunIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(LedgerEntryResponse.FromDomain(entry));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetBySource(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid sourceId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerRepository repository,
|
||||
[FromQuery] int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var effectiveLimit = EndpointHelpers.GetLimit(limit);
|
||||
|
||||
var entries = await repository.GetBySourceAsync(
|
||||
tenantId,
|
||||
sourceId,
|
||||
effectiveLimit,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var responses = entries.Select(LedgerEntryResponse.FromDomain).ToList();
|
||||
return Results.Ok(new LedgerEntryListResponse(responses, null));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetLatestEntry(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var entry = await repository.GetLatestAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(LedgerEntryResponse.FromDomain(entry));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetBySequenceRange(
|
||||
HttpContext context,
|
||||
[FromRoute] long startSeq,
|
||||
[FromRoute] long endSeq,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
if (startSeq < 1 || endSeq < startSeq)
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("orchestrator.ledger.error.invalid_sequence_range") });
|
||||
}
|
||||
|
||||
var entries = await repository.GetBySequenceRangeAsync(
|
||||
tenantId,
|
||||
startSeq,
|
||||
endSeq,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var responses = entries.Select(LedgerEntryResponse.FromDomain).ToList();
|
||||
return Results.Ok(new LedgerEntryListResponse(responses, null));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetLedgerSummary(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerRepository repository,
|
||||
[FromQuery] DateTimeOffset? since = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var summary = await repository.GetSummaryAsync(tenantId, since, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(LedgerSummaryResponse.FromDomain(summary));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> VerifyLedgerChain(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerRepository repository,
|
||||
[FromQuery] long? startSeq = null,
|
||||
[FromQuery] long? endSeq = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var result = await repository.VerifyChainAsync(tenantId, startSeq, endSeq, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Infrastructure.JobEngineMetrics.LedgerChainVerified(tenantId, result.IsValid);
|
||||
|
||||
return Results.Ok(ChainVerificationResponse.FromDomain(result));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListExports(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerExportRepository repository,
|
||||
[FromQuery] string? status = null,
|
||||
[FromQuery] int? limit = null,
|
||||
[FromQuery] string? cursor = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var effectiveLimit = EndpointHelpers.GetLimit(limit);
|
||||
var offset = EndpointHelpers.ParseCursorOffset(cursor);
|
||||
|
||||
LedgerExportStatus? parsedStatus = null;
|
||||
if (!string.IsNullOrEmpty(status) && Enum.TryParse<LedgerExportStatus>(status, true, out var es))
|
||||
{
|
||||
parsedStatus = es;
|
||||
}
|
||||
|
||||
var exports = await repository.ListAsync(
|
||||
tenantId,
|
||||
parsedStatus,
|
||||
effectiveLimit,
|
||||
offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var responses = exports.Select(LedgerExportResponse.FromDomain).ToList();
|
||||
var nextCursor = EndpointHelpers.CreateNextCursor(offset, effectiveLimit, responses.Count);
|
||||
|
||||
return Results.Ok(new LedgerExportListResponse(responses, nextCursor));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetExport(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid exportId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerExportRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var export = await repository.GetByIdAsync(tenantId, exportId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (export is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(LedgerExportResponse.FromDomain(export));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateExport(
|
||||
HttpContext context,
|
||||
[FromBody] CreateLedgerExportRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerExportRepository repository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actorId = context.User?.Identity?.Name ?? "system";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
// Validate format
|
||||
var validFormats = new[] { "json", "ndjson", "csv" };
|
||||
if (!validFormats.Contains(request.Format?.ToLowerInvariant()))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("orchestrator.ledger.error.invalid_format", string.Join(", ", validFormats)) });
|
||||
}
|
||||
|
||||
// Validate time range
|
||||
if (request.StartTime.HasValue && request.EndTime.HasValue && request.StartTime > request.EndTime)
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("orchestrator.ledger.error.start_before_end") });
|
||||
}
|
||||
|
||||
var export = LedgerExport.CreateRequest(
|
||||
tenantId: tenantId,
|
||||
format: request.Format!,
|
||||
requestedBy: actorId,
|
||||
requestedAt: now,
|
||||
startTime: request.StartTime,
|
||||
endTime: request.EndTime,
|
||||
runTypeFilter: request.RunTypeFilter,
|
||||
sourceIdFilter: request.SourceIdFilter);
|
||||
|
||||
await repository.CreateAsync(export, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created($"/api/v1/jobengine/ledger/exports/{export.ExportId}",
|
||||
LedgerExportResponse.FromDomain(export));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListManifests(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IManifestRepository repository,
|
||||
[FromQuery] string? provenanceType = null,
|
||||
[FromQuery] int? limit = null,
|
||||
[FromQuery] string? cursor = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var effectiveLimit = EndpointHelpers.GetLimit(limit);
|
||||
var offset = EndpointHelpers.ParseCursorOffset(cursor);
|
||||
|
||||
ProvenanceType? parsedType = null;
|
||||
if (!string.IsNullOrEmpty(provenanceType) && Enum.TryParse<ProvenanceType>(provenanceType, true, out var pt))
|
||||
{
|
||||
parsedType = pt;
|
||||
}
|
||||
|
||||
var manifests = await repository.ListAsync(
|
||||
tenantId,
|
||||
parsedType,
|
||||
effectiveLimit,
|
||||
offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var responses = manifests.Select(ManifestResponse.FromDomain).ToList();
|
||||
var nextCursor = EndpointHelpers.CreateNextCursor(offset, effectiveLimit, responses.Count);
|
||||
|
||||
return Results.Ok(new ManifestListResponse(responses, nextCursor));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetManifest(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid manifestId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IManifestRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var manifest = await repository.GetByIdAsync(tenantId, manifestId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(ManifestDetailResponse.FromDomain(manifest));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetManifestBySubject(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid subjectId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IManifestRepository repository,
|
||||
[FromQuery] string? provenanceType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
ProvenanceType parsedType = ProvenanceType.Run;
|
||||
if (!string.IsNullOrEmpty(provenanceType) && Enum.TryParse<ProvenanceType>(provenanceType, true, out var pt))
|
||||
{
|
||||
parsedType = pt;
|
||||
}
|
||||
|
||||
var manifest = await repository.GetBySubjectAsync(tenantId, parsedType, subjectId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(ManifestDetailResponse.FromDomain(manifest));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> VerifyManifest(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid manifestId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IManifestRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var manifest = await repository.GetByIdAsync(tenantId, manifestId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var payloadValid = manifest.VerifyPayloadIntegrity();
|
||||
string? validationError = null;
|
||||
|
||||
if (!payloadValid)
|
||||
{
|
||||
validationError = _t("orchestrator.ledger.error.payload_digest_mismatch");
|
||||
}
|
||||
else if (manifest.IsExpired)
|
||||
{
|
||||
validationError = _t("orchestrator.ledger.error.manifest_expired");
|
||||
}
|
||||
|
||||
Infrastructure.JobEngineMetrics.ManifestVerified(tenantId, payloadValid && !manifest.IsExpired);
|
||||
|
||||
return Results.Ok(new ManifestVerificationResponse(
|
||||
ManifestId: manifestId,
|
||||
PayloadIntegrityValid: payloadValid,
|
||||
IsExpired: manifest.IsExpired,
|
||||
IsSigned: manifest.IsSigned,
|
||||
ValidationError: validationError));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user