163 lines
13 KiB
C#
163 lines
13 KiB
C#
using System.Globalization;
|
|
using System.Text.Json;
|
|
using JobTrackerApi.Data;
|
|
using JobTrackerApi.Models;
|
|
using JobTrackerApi.Services;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace JobTrackerApi.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/admin/processing")]
|
|
[Authorize(Roles = "Admin")]
|
|
public sealed class AdminProcessingController(JobTrackerContext db, IConfiguration configuration, TimeProvider clock) : ControllerBase
|
|
{
|
|
private static readonly HashSet<string> EventKinds = Enum.GetNames<OperationEventKind>().ToHashSet(StringComparer.Ordinal);
|
|
public sealed record IdentityDto(string Id, string? Name, string? Email);
|
|
public sealed record JobDto(int Id, string Title, string? Company);
|
|
public sealed record DocumentDto(string Name, string? Type, long Bytes, int? Pages, string? State);
|
|
public sealed record ItemDto(Guid Id, IdentityDto Owner, string TaskType, string Status, string? Stage, int AttemptCount,
|
|
int MaxAttempts, DateTime CreatedAtUtc, DateTime? StartedAtUtc, DateTime? CompletedAtUtc, DateTime? LastHeartbeatAtUtc,
|
|
double DurationSeconds, string Worker, bool WorkerEnabled, string? FailureCode, JobDto? Job, DocumentDto? Document);
|
|
public sealed record WorkerDto(string Name, bool FlagEnabled, bool IndividualOperationsRecorded);
|
|
public sealed record PageDto(IReadOnlyList<ItemDto> Items, int Total, int Page, int PageSize,
|
|
IReadOnlyDictionary<string, int> Counts, IReadOnlyList<WorkerDto> Workers, string CvInterpretation);
|
|
public sealed record EventDto(string Id, DateTime AtUtc, string Kind, string Status, string? Stage, int Attempt, string? FailureCode);
|
|
public sealed record EventPageDto(IReadOnlyList<EventDto> Items, bool HasMore, int RetentionDays);
|
|
|
|
[HttpGet]
|
|
public async Task<ActionResult<PageDto>> List([FromQuery] string? status = null, [FromQuery] string? taskType = null,
|
|
[FromQuery] string? owner = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 25, CancellationToken cancellationToken = default)
|
|
{
|
|
if (page is < 1 or > 1000 || pageSize is < 1 or > 100 || owner?.Length > 255
|
|
|| (status is not null && OperationEventLog.SafeStatus(status) == "unknown")
|
|
|| (taskType is not null && taskType is not (CvProcessingQueue.TaskType or StrategySnapshotService.TaskType or "other"))) return BadRequest();
|
|
var all = ReadOperations();
|
|
var query = all;
|
|
if (status is not null) query = query.Where(item => item.Status == status);
|
|
if (!string.IsNullOrWhiteSpace(owner)) query = query.Where(item => item.OwnerUserId == owner);
|
|
if (taskType == "other") query = query.Where(item => item.TaskType != CvProcessingQueue.TaskType && item.TaskType != StrategySnapshotService.TaskType);
|
|
else if (taskType is not null) query = query.Where(item => item.TaskType == taskType);
|
|
var total = await query.CountAsync(cancellationToken);
|
|
var operations = await query.OrderByDescending(item => item.CreatedAtUtc).ThenByDescending(item => item.Id)
|
|
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(cancellationToken);
|
|
var grouped = await all.GroupBy(item => item.Status).Select(group => new { Status = group.Key, Count = group.Count() }).ToListAsync(cancellationToken);
|
|
var counts = grouped.GroupBy(item => OperationEventLog.SafeStatus(item.Status)).ToDictionary(group => group.Key, group => group.Sum(item => item.Count));
|
|
var workers = new[]
|
|
{
|
|
new WorkerDto("AiOperations", Enabled("AiOperations"), true),
|
|
new WorkerDto("CvDocumentImports", Enabled("CvDocumentImports"), true),
|
|
new WorkerDto("JobEnrichment", Enabled("JobEnrichment"), false),
|
|
new WorkerDto("Rules", Enabled("Rules"), false),
|
|
new WorkerDto("FollowUpReminders", Enabled("FollowUpReminders"), false),
|
|
new WorkerDto("DailyExport", Enabled("DailyExport"), false),
|
|
new WorkerDto("SavedJobSearchAlerts", configuration.GetValue("Workers:SavedJobSearchAlertsEnabled", true), false),
|
|
};
|
|
return Ok(new PageDto(await Project(operations, false, cancellationToken), total, page, pageSize, counts, workers, "deterministic"));
|
|
}
|
|
|
|
[HttpGet("{id:guid}")]
|
|
public async Task<ActionResult<ItemDto>> Detail(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
var operation = await ReadOperations().SingleOrDefaultAsync(item => item.Id == id, cancellationToken);
|
|
if (operation is null) return NotFound();
|
|
return Ok((await Project(new[] { operation }, true, cancellationToken)).Single());
|
|
}
|
|
|
|
[HttpGet("{id:guid}/events")]
|
|
public async Task<ActionResult<EventPageDto>> Events(Guid id, [FromQuery] long? after = null, [FromQuery] long? before = null,
|
|
[FromQuery] int take = 100, CancellationToken cancellationToken = default)
|
|
{
|
|
if (take is < 1 or > 100 || after < 0 || before < 0 || (after is not null && before is not null)) return BadRequest();
|
|
var owner = await db.UserOperations.IgnoreQueryFilters().Where(item => item.Id == id).Select(item => item.OwnerUserId).SingleOrDefaultAsync(cancellationToken);
|
|
if (owner is null) return NotFound();
|
|
var cutoff = clock.GetUtcNow().UtcDateTime.AddDays(-OperationEventLog.RetentionDays);
|
|
var query = db.UserOperationEvents.IgnoreQueryFilters().AsNoTracking().Where(item => item.OperationId == id && item.OwnerUserId == owner && item.AtUtc >= cutoff);
|
|
if (after is not null) query = query.Where(item => item.Id > after);
|
|
if (before is not null) query = query.Where(item => item.Id < before);
|
|
var rows = await (after is not null ? query.OrderBy(item => item.Id) : query.OrderByDescending(item => item.Id)).Take(take + 1).ToListAsync(cancellationToken);
|
|
var hasMore = rows.Count > take;
|
|
rows = rows.Take(take).ToList();
|
|
if (after is null) rows.Reverse();
|
|
var items = rows.Select(item => new EventDto(item.Id.ToString(CultureInfo.InvariantCulture), Utc(item.AtUtc),
|
|
EventKinds.Contains(item.Kind) ? item.Kind : "Unknown",
|
|
OperationEventLog.SafeStatus(item.Status), OperationEventLog.SafeStage(item.Stage), Math.Clamp(item.Attempt, 0, 10), OperationEventLog.SafeFailure(item.FailureCode))).ToArray();
|
|
return Ok(new EventPageDto(items, hasMore, OperationEventLog.RetentionDays));
|
|
}
|
|
|
|
private bool Enabled(string worker) => configuration.GetValue($"Workers:{worker}Enabled", false);
|
|
private IQueryable<UserOperation> ReadOperations() => db.UserOperations.IgnoreQueryFilters().AsNoTracking().Select(item => new UserOperation
|
|
{
|
|
Id = item.Id, OwnerUserId = item.OwnerUserId, TaskType = item.TaskType, SubjectType = item.SubjectType, SubjectId = item.SubjectId,
|
|
Status = item.Status, ProgressStage = item.ProgressStage, AttemptCount = item.AttemptCount, MaxAttempts = item.MaxAttempts,
|
|
CreatedAtUtc = item.CreatedAtUtc, StartedAtUtc = item.StartedAtUtc, CompletedAtUtc = item.CompletedAtUtc,
|
|
LastHeartbeatAtUtc = item.LastHeartbeatAtUtc, FailureCategory = item.FailureCategory,
|
|
});
|
|
private static DateTime Utc(DateTime value) => DateTime.SpecifyKind(value, DateTimeKind.Utc);
|
|
private static DateTime? Utc(DateTime? value) => value is null ? null : Utc(value.Value);
|
|
private static string SafeTask(string value) => value is CvProcessingQueue.TaskType or StrategySnapshotService.TaskType ? value : "other";
|
|
private static int? Subject(UserOperation operation)
|
|
{
|
|
if (operation.SubjectId?.Length > 128) return null;
|
|
if (operation.TaskType == CvProcessingQueue.TaskType && operation.SubjectType == CvProcessingQueue.SubjectType)
|
|
return int.TryParse(operation.SubjectId, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0 ? id : null;
|
|
if (operation.TaskType == StrategySnapshotService.TaskType && operation.SubjectType == "job_strategy")
|
|
{
|
|
try { return StrategySnapshotService.DecodeSubject(operation.SubjectId).JobId; }
|
|
catch (AiOperationFailure) { return null; }
|
|
}
|
|
return null;
|
|
}
|
|
private static string FileName(string value) => new string(value.Split('/', '\\').Last().Where(character => !char.IsControl(character)).Take(120).ToArray());
|
|
private static int? Pages(string? json)
|
|
{
|
|
if (json is null || json.Length > 2 * 1024 * 1024) return null;
|
|
try
|
|
{
|
|
using var document = JsonDocument.Parse(json);
|
|
if (document.RootElement.ValueKind == JsonValueKind.Object &&
|
|
(document.RootElement.TryGetProperty("PageCount", out var field) || document.RootElement.TryGetProperty("pageCount", out field)) &&
|
|
field.ValueKind == JsonValueKind.Number && field.TryGetInt32(out var count) && count is > 0 and <= 5000) return count;
|
|
}
|
|
catch (JsonException) { }
|
|
return null;
|
|
}
|
|
|
|
private async Task<IReadOnlyList<ItemDto>> Project(IReadOnlyList<UserOperation> operations, bool includePages, CancellationToken token)
|
|
{
|
|
var ownerIds = operations.Select(item => item.OwnerUserId).Distinct().ToArray();
|
|
var users = await db.Users.AsNoTracking().Where(item => ownerIds.Contains(item.Id))
|
|
.Select(item => new { item.Id, Name = item.DisplayName ?? item.UserName, item.Email })
|
|
.Select(item => new IdentityDto(item.Id, item.Name != null && item.Name.Length > 160 ? item.Name.Substring(0, 160) + "…" : item.Name,
|
|
item.Email != null && item.Email.Length > 254 ? item.Email.Substring(0, 254) + "…" : item.Email)).ToDictionaryAsync(item => item.Id, token);
|
|
var cvIds = operations.Where(item => item.TaskType == CvProcessingQueue.TaskType).Select(Subject).OfType<int>().ToArray();
|
|
var jobIds = operations.Where(item => item.TaskType == StrategySnapshotService.TaskType).Select(Subject).OfType<int>().ToArray();
|
|
var runs = await db.CvExtractionRuns.IgnoreQueryFilters().AsNoTracking().Where(item => cvIds.Contains(item.Id))
|
|
.Select(item => new { item.Id, item.OwnerUserId, item.ArtifactId, item.ParserVersion, item.Trigger, item.Status, DocumentJson = includePages ? item.DocumentJson : null }).ToDictionaryAsync(item => item.Id, token);
|
|
var artifactIds = runs.Values.Select(item => item.ArtifactId).OfType<int>().ToArray();
|
|
var artifacts = await db.CvUploadArtifacts.IgnoreQueryFilters().AsNoTracking().Where(item => artifactIds.Contains(item.Id))
|
|
.Select(item => new { item.Id, item.OwnerUserId, item.OriginalFileName, item.MimeType, item.ByteSize }).ToDictionaryAsync(item => item.Id, token);
|
|
var jobs = await db.JobApplications.IgnoreQueryFilters().AsNoTracking().Where(item => jobIds.Contains(item.Id))
|
|
.Select(item => new { item.Id, item.OwnerUserId, JobTitle = item.JobTitle.Length > 200 ? item.JobTitle.Substring(0, 200) + "…" : item.JobTitle,
|
|
Company = item.Company.OwnerUserId == item.OwnerUserId ? (item.Company.Name.Length > 160 ? item.Company.Name.Substring(0, 160) + "…" : item.Company.Name) : null }).ToDictionaryAsync(item => item.Id, token);
|
|
return operations.Select(operation =>
|
|
{
|
|
var subject = Subject(operation);
|
|
var run = operation.TaskType == CvProcessingQueue.TaskType && subject is int cvId && runs.TryGetValue(cvId, out var foundRun) && foundRun.OwnerUserId == operation.OwnerUserId ? foundRun : null;
|
|
var job = operation.TaskType == StrategySnapshotService.TaskType && subject is int jobId && jobs.TryGetValue(jobId, out var foundJob) && foundJob.OwnerUserId == operation.OwnerUserId ? new JobDto(foundJob.Id, foundJob.JobTitle, foundJob.Company) : null;
|
|
var artifact = run?.ArtifactId is int artifactId && artifacts.TryGetValue(artifactId, out var foundArtifact) && foundArtifact.OwnerUserId == operation.OwnerUserId ? foundArtifact : null;
|
|
var document = artifact is null ? null : new DocumentDto(FileName(artifact.OriginalFileName),
|
|
artifact.MimeType is "application/pdf" or "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ? artifact.MimeType : null,
|
|
Math.Max(0, artifact.ByteSize), Pages(run?.DocumentJson), run?.Status is "pending_review" or "applied" or "expired" or "failed" or "cancelled" or "running" or "queued" ? run.Status : null);
|
|
var worker = SafeTask(operation.TaskType) == "other" ? "untracked" : run is { ParserVersion: ProfileCvController.DocumentParserVersion, Trigger: "document_import" } ? "document" : "ai";
|
|
var duration = operation.StartedAtUtc is DateTime started ? Math.Max(0, ((operation.CompletedAtUtc ?? clock.GetUtcNow().UtcDateTime) - started).TotalSeconds) : 0;
|
|
return new ItemDto(operation.Id, users.GetValueOrDefault(operation.OwnerUserId) ?? new(operation.OwnerUserId, null, null), SafeTask(operation.TaskType),
|
|
OperationEventLog.SafeStatus(operation.Status), OperationEventLog.SafeStage(operation.ProgressStage), Math.Clamp(operation.AttemptCount, 0, 10),
|
|
Math.Clamp(operation.MaxAttempts, 1, 10), Utc(operation.CreatedAtUtc), Utc(operation.StartedAtUtc), Utc(operation.CompletedAtUtc), Utc(operation.LastHeartbeatAtUtc),
|
|
duration, worker, worker != "untracked" && (Enabled("AiOperations") || (worker == "document" && Enabled("CvDocumentImports"))), OperationEventLog.SafeFailure(operation.FailureCategory), job, document);
|
|
}).ToArray();
|
|
}
|
|
}
|