Dashboard upgrades, workflows added and assitant emailer
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user