Dashboard upgrades, workflows added and assitant emailer

This commit is contained in:
cesnimda
2026-03-21 13:25:13 +01:00
parent 8cc4b0dfce
commit 51a539068f
9 changed files with 1358 additions and 1421 deletions
@@ -0,0 +1,77 @@
using System.Reflection;
using JobTrackerApi.Data;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/admin/system")]
[Authorize(Roles = "Admin")]
public sealed class AdminSystemController : ControllerBase
{
private readonly IConfiguration _cfg;
private readonly AppPaths _paths;
private readonly JobTrackerContext _db;
private readonly ISummarizerService _summarizer;
private readonly IWebHostEnvironment _env;
public AdminSystemController(IConfiguration cfg, AppPaths paths, JobTrackerContext db, ISummarizerService summarizer, IWebHostEnvironment env)
{
_cfg = cfg;
_paths = paths;
_db = db;
_summarizer = summarizer;
_env = env;
}
public sealed record StorageStatusDto(string DataRoot, string DbPath, bool DbExists, long? DbSizeBytes, int CompanyCount, int JobCount, int DeletedCount);
public sealed record EmailStatusDto(bool Enabled, string? Host, int Port, bool EnableSsl, string? From, string? FromName);
public sealed record SystemStatusDto(
string Environment,
string ContentRoot,
string Version,
StorageStatusDto Storage,
EmailStatusDto Email,
SummarizerMetrics Summarizer
);
[HttpGet]
public async Task<ActionResult<SystemStatusDto>> Get(CancellationToken cancellationToken)
{
var dbPath = _paths.GetDbPath();
var dbFile = new FileInfo(dbPath);
var jobs = await _db.JobApplications.AsNoTracking().ToListAsync(cancellationToken);
var companies = await _db.Companies.AsNoTracking().CountAsync(cancellationToken);
var summarizer = await _summarizer.GetMetricsAsync(cancellationToken);
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
return Ok(new SystemStatusDto(
Environment: _env.EnvironmentName,
ContentRoot: _env.ContentRootPath,
Version: version,
Storage: new StorageStatusDto(
DataRoot: _paths.DataRoot,
DbPath: dbPath,
DbExists: dbFile.Exists,
DbSizeBytes: dbFile.Exists ? dbFile.Length : null,
CompanyCount: companies,
JobCount: jobs.Count,
DeletedCount: jobs.Count(x => x.IsDeleted)
),
Email: new EmailStatusDto(
Enabled: _cfg.GetValue("Email:Enabled", false),
Host: (_cfg["Email:SmtpHost"] ?? "").Trim(),
Port: _cfg.GetValue("Email:SmtpPort", 587),
EnableSsl: _cfg.GetValue("Email:SmtpEnableSsl", true),
From: (_cfg["Email:From"] ?? "").Trim(),
FromName: (_cfg["Email:FromName"] ?? "").Trim()
),
Summarizer: summarizer
));
}
}
@@ -24,6 +24,50 @@ namespace JobTrackerApi.Controllers
private string? CurrentUserId =>
User?.FindFirstValue(ClaimTypes.NameIdentifier) ?? User?.FindFirstValue("sub");
private static IEnumerable<string> SplitTags(string? s)
{
if (string.IsNullOrWhiteSpace(s)) yield break;
var trimmed = s.Trim();
List<string>? jsonTags = null;
if (trimmed.StartsWith("[") && trimmed.EndsWith("]"))
{
try
{
jsonTags = JsonSerializer.Deserialize<List<string>>(trimmed);
}
catch
{
jsonTags = null;
}
}
if (jsonTags is not null)
{
foreach (var x in jsonTags)
{
var t = (x ?? string.Empty).Trim();
if (t.Length == 0) continue;
yield return t;
}
yield break;
}
foreach (var raw in trimmed.Split(new[] { ',', ';', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries))
{
var t = raw.Trim();
if (t.Length == 0) continue;
yield return t;
}
}
private static string NormalizeForComparison(string value)
{
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
return new string(value.Trim().ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray());
}
public sealed record PagedResult<T>(List<T> Items, int Total, int Page, int PageSize);
@@ -1016,6 +1060,256 @@ namespace JobTrackerApi.Controllers
return Ok(outList);
}
public sealed record FunnelStagePoint(string Label, int Count);
public sealed record ResponseRatePoint(string Label, int Total, int Responses, double Rate);
public sealed record CompanyActivityPoint(int CompanyId, string Company, int Count, int Responses, double ResponseRate);
public sealed record TagTrendSeries(string Tag, List<int> Counts);
public sealed record TagTrendPoint(string Month, List<int> Counts);
public sealed record AnalyticsOverviewDto(
List<FunnelStagePoint> Funnel,
List<ResponseRatePoint> ResponseRateBySource,
List<CompanyActivityPoint> TopCompanies,
double? MedianDaysToFirstResponse,
int TotalResponses,
int TotalActive
);
public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason);
public sealed record DuplicateCheckResult(bool HasDuplicates, List<DuplicateCandidateDto> Matches);
public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn);
public sealed record TagTrendResponse(List<string> Months, List<TagTrendSeries> Series);
[HttpGet("analytics-overview")]
public async Task<ActionResult<AnalyticsOverviewDto>> GetAnalyticsOverview(CancellationToken cancellationToken)
{
var activeJobs = await _db.JobApplications
.AsNoTracking()
.Include(j => j.Company)
.Where(j => !j.IsDeleted)
.ToListAsync(cancellationToken);
var funnelMap = new Dictionary<string, int>
{
["Applied"] = activeJobs.Count(j => string.Equals(j.Status, "Applied", StringComparison.OrdinalIgnoreCase)),
["Interview"] = activeJobs.Count(j => string.Equals(j.Status, "Interview", StringComparison.OrdinalIgnoreCase) || string.Equals(j.Status, "Interviewing", StringComparison.OrdinalIgnoreCase)),
["Offer"] = activeJobs.Count(j => string.Equals(j.Status, "Offer", StringComparison.OrdinalIgnoreCase)),
["Rejected"] = activeJobs.Count(j => string.Equals(j.Status, "Rejected", StringComparison.OrdinalIgnoreCase)),
["Ghosted"] = activeJobs.Count(j => string.Equals(j.Status, "Ghosted", StringComparison.OrdinalIgnoreCase)),
};
var funnel = funnelMap.Select(x => new FunnelStagePoint(x.Key, x.Value)).ToList();
var responseRateBySource = activeJobs
.GroupBy(j => string.IsNullOrWhiteSpace(j.Company?.Source) ? "Unknown source" : j.Company!.Source!.Trim())
.Select(g => new ResponseRatePoint(
g.Key,
g.Count(),
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
))
.OrderByDescending(x => x.Total)
.ThenByDescending(x => x.Rate)
.Take(6)
.ToList();
var topCompanies = activeJobs
.GroupBy(j => new { j.CompanyId, Name = j.Company.Name })
.Select(g => new CompanyActivityPoint(
g.Key.CompanyId,
g.Key.Name,
g.Count(),
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
))
.OrderByDescending(x => x.Count)
.ThenByDescending(x => x.ResponseRate)
.Take(8)
.ToList();
var responseDays = activeJobs
.Where(j => (j.ResponseReceived || j.ResponseDate is not null) && j.ResponseDate is not null)
.Select(j => Math.Max(0, (j.ResponseDate!.Value - j.DateApplied).TotalDays))
.OrderBy(x => x)
.ToList();
double? medianDays = null;
if (responseDays.Count > 0)
{
var mid = responseDays.Count / 2;
medianDays = responseDays.Count % 2 == 0
? Math.Round((responseDays[mid - 1] + responseDays[mid]) / 2d, 1)
: Math.Round(responseDays[mid], 1);
}
return Ok(new AnalyticsOverviewDto(
Funnel: funnel,
ResponseRateBySource: responseRateBySource,
TopCompanies: topCompanies,
MedianDaysToFirstResponse: medianDays,
TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null),
TotalActive: activeJobs.Count
));
}
[HttpGet("tag-trends")]
public async Task<ActionResult<TagTrendResponse>> GetTagTrends(
[FromQuery] int months = 6,
[FromQuery] int limit = 5,
CancellationToken cancellationToken = default)
{
if (months < 3) months = 3;
if (months > 24) months = 24;
if (limit < 3) limit = 3;
if (limit > 10) limit = 10;
var endMonth = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1).AddMonths(1);
var startMonth = endMonth.AddMonths(-months);
var jobs = await _db.JobApplications
.AsNoTracking()
.Where(j => !j.IsDeleted && j.DateApplied >= startMonth && j.DateApplied < endMonth)
.Select(j => new { j.DateApplied, j.Tags })
.ToListAsync(cancellationToken);
var overall = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var monthKeys = Enumerable.Range(0, months).Select(i => startMonth.AddMonths(i).ToString("yyyy-MM")).ToList();
var seriesMap = new Dictionary<string, Dictionary<string, int>>(StringComparer.OrdinalIgnoreCase);
foreach (var job in jobs)
{
var key = $"{job.DateApplied:yyyy-MM}";
foreach (var tag in SplitTags(job.Tags))
{
overall[tag] = (overall.TryGetValue(tag, out var count) ? count : 0) + 1;
if (!seriesMap.TryGetValue(tag, out var byMonth))
{
byMonth = new Dictionary<string, int>(StringComparer.Ordinal);
seriesMap[tag] = byMonth;
}
byMonth[key] = (byMonth.TryGetValue(key, out var monthCount) ? monthCount : 0) + 1;
}
}
var topTags = overall
.OrderByDescending(x => x.Value)
.ThenBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
.Take(limit)
.Select(x => x.Key)
.ToList();
var series = topTags
.Select(tag => new TagTrendSeries(
tag,
monthKeys.Select(month => seriesMap.TryGetValue(tag, out var byMonth) && byMonth.TryGetValue(month, out var count) ? count : 0).ToList()
))
.ToList();
return Ok(new TagTrendResponse(monthKeys, series));
}
[HttpGet("duplicate-check")]
public async Task<ActionResult<DuplicateCheckResult>> CheckDuplicates(
[FromQuery] int companyId,
[FromQuery] string? jobTitle,
[FromQuery] string? jobUrl,
[FromQuery] int? excludeId,
CancellationToken cancellationToken)
{
var normalizedTitle = NormalizeForComparison(jobTitle ?? string.Empty);
var normalizedUrl = (jobUrl ?? string.Empty).Trim();
if (companyId <= 0 && normalizedTitle.Length == 0 && normalizedUrl.Length == 0)
{
return Ok(new DuplicateCheckResult(false, new List<DuplicateCandidateDto>()));
}
var query = _db.JobApplications
.AsNoTracking()
.Include(j => j.Company)
.Where(j => !j.IsDeleted);
if (excludeId is not null && excludeId.Value > 0)
{
query = query.Where(j => j.Id != excludeId.Value);
}
var candidates = await query
.OrderByDescending(j => j.DateApplied)
.Take(200)
.ToListAsync(cancellationToken);
var matches = candidates
.Select(j =>
{
var reasons = new List<string>();
if (!string.IsNullOrWhiteSpace(normalizedUrl) && string.Equals((j.JobUrl ?? string.Empty).Trim(), normalizedUrl, StringComparison.OrdinalIgnoreCase))
{
reasons.Add("same URL");
}
if (companyId > 0 && j.CompanyId == companyId && normalizedTitle.Length > 0)
{
var existingTitle = NormalizeForComparison(j.JobTitle);
if (existingTitle == normalizedTitle || existingTitle.Contains(normalizedTitle) || normalizedTitle.Contains(existingTitle))
{
reasons.Add("same company and similar title");
}
}
return new { Job = j, Reasons = reasons };
})
.Where(x => x.Reasons.Count > 0)
.Take(5)
.Select(x => new DuplicateCandidateDto(
x.Job.Id,
x.Job.JobTitle,
x.Job.Company?.Name ?? string.Empty,
x.Job.JobUrl,
x.Job.Status,
x.Job.DateApplied,
string.Join(", ", x.Reasons)
))
.ToList();
return Ok(new DuplicateCheckResult(matches.Count > 0, matches));
}
[HttpGet("{id:int}/followup-draft")]
public async Task<ActionResult<FollowUpDraftDto>> GetFollowUpDraft([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.AsNoTracking()
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var lastMessage = await _db.Correspondences
.AsNoTracking()
.Where(c => c.JobApplicationId == id)
.OrderByDescending(c => c.Date)
.FirstOrDefaultAsync(cancellationToken);
var reason = string.IsNullOrWhiteSpace(job.NextAction)
? (job.FollowUpAt is not null && job.FollowUpAt.Value.Date <= DateTime.Today ? "Scheduled follow-up is due." : "No recent response has been logged.")
: job.NextAction!;
var subject = $"Following up on {job.JobTitle} application";
var companyName = job.Company?.Name ?? "your team";
var reference = lastMessage?.Subject ?? job.JobTitle;
var summary = job.ShortSummary;
var body = string.Join("\n\n", new[]
{
$"Hi {companyName},",
$"I wanted to follow up on my application for the {job.JobTitle} role. I'm still very interested in the opportunity and would love to hear if there are any updates on next steps.",
!string.IsNullOrWhiteSpace(summary) ? $"Quick reminder of fit: {summary}" : null,
$"Context: {reason}",
$"If helpful, I can also provide any additional information related to {reference}.",
"Thanks for your time,\n[Your name]"
}.Where(x => !string.IsNullOrWhiteSpace(x)));
return Ok(new FollowUpDraftDto(subject, body, reason, DateTime.Today));
}
[HttpGet("summarizer-metrics")]
public async Task<ActionResult<SummarizerMetrics>> GetSummarizerMetrics(CancellationToken cancellationToken)
{