First Commit
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
public sealed class AppPaths
|
||||
{
|
||||
public string DataRoot { get; }
|
||||
public string AttachmentsRoot { get; }
|
||||
|
||||
public AppPaths(IConfiguration cfg, IHostEnvironment env)
|
||||
{
|
||||
var dataRoot = (cfg["Data:Root"] ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(dataRoot)) dataRoot = env.ContentRootPath;
|
||||
if (!Path.IsPathRooted(dataRoot)) dataRoot = Path.Combine(env.ContentRootPath, dataRoot);
|
||||
|
||||
Directory.CreateDirectory(dataRoot);
|
||||
DataRoot = dataRoot;
|
||||
|
||||
var attachmentsRoot = (cfg["Data:AttachmentsRoot"] ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(attachmentsRoot)) attachmentsRoot = Path.Combine(DataRoot, "Attachments");
|
||||
if (!Path.IsPathRooted(attachmentsRoot)) attachmentsRoot = Path.Combine(env.ContentRootPath, attachmentsRoot);
|
||||
|
||||
Directory.CreateDirectory(attachmentsRoot);
|
||||
AttachmentsRoot = attachmentsRoot;
|
||||
}
|
||||
|
||||
public string GetDbPath(string fileName = "jobtracker.db") => Path.Combine(DataRoot, fileName);
|
||||
|
||||
public string GetExportsRoot(string? configuredFolder)
|
||||
{
|
||||
var folder = (configuredFolder ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(folder)) return Path.Combine(DataRoot, "exports");
|
||||
return Path.IsPathRooted(folder) ? folder : Path.Combine(DataRoot, folder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public interface ICurrentUserService
|
||||
{
|
||||
string? UserId { get; }
|
||||
}
|
||||
|
||||
public sealed class CurrentUserService : ICurrentUserService
|
||||
{
|
||||
private readonly IHttpContextAccessor _http;
|
||||
|
||||
public CurrentUserService(IHttpContextAccessor http)
|
||||
{
|
||||
_http = http;
|
||||
}
|
||||
|
||||
public string? UserId
|
||||
{
|
||||
get
|
||||
{
|
||||
var u = _http.HttpContext?.User;
|
||||
if (u is null) return null;
|
||||
if (u.Identity?.IsAuthenticated != true) return null;
|
||||
return u.FindFirstValue(ClaimTypes.NameIdentifier) ?? u.FindFirstValue("sub");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using JobTrackerApi.Data;
|
||||
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
public sealed class DailyExportHostedService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _sp;
|
||||
private readonly ILogger<DailyExportHostedService> _logger;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly AppPaths _paths;
|
||||
|
||||
public DailyExportHostedService(
|
||||
IServiceProvider sp,
|
||||
ILogger<DailyExportHostedService> logger,
|
||||
IConfiguration cfg,
|
||||
AppPaths paths)
|
||||
{
|
||||
_sp = sp;
|
||||
_logger = logger;
|
||||
_cfg = cfg;
|
||||
_paths = paths;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var enabled = _cfg.GetValue("Exports:DailyEnabled", true);
|
||||
if (!enabled)
|
||||
{
|
||||
_logger.LogInformation("Daily export disabled (Exports:DailyEnabled=false).");
|
||||
return;
|
||||
}
|
||||
|
||||
var hour = _cfg.GetValue("Exports:DailyHourLocal", 2);
|
||||
if (hour < 0 || hour > 23) hour = 2;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var next = new DateTime(now.Year, now.Month, now.Day, hour, 0, 0);
|
||||
if (next <= now) next = next.AddDays(1);
|
||||
var delay = next - now;
|
||||
|
||||
_logger.LogInformation("Next daily export scheduled at {Next}.", next);
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, stoppingToken);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await RunExport(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Daily export failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunExport(CancellationToken ct)
|
||||
{
|
||||
var folder = _paths.GetExportsRoot(_cfg["Exports:DailyFolder"]);
|
||||
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
using var scope = _sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
|
||||
var companies = await db.Companies.AsNoTracking().OrderBy(c => c.Name).ToListAsync(ct);
|
||||
var jobs = await db.JobApplications.AsNoTracking().OrderByDescending(j => j.DateApplied).ToListAsync(ct);
|
||||
var correspondence = await db.Correspondences.AsNoTracking().OrderBy(c => c.Date).ToListAsync(ct);
|
||||
var attachments = await db.Attachments.AsNoTracking().OrderBy(a => a.UploadDate).ToListAsync(ct);
|
||||
var events = await db.JobEvents.AsNoTracking().OrderBy(e => e.At).ToListAsync(ct);
|
||||
var rules = await db.RuleSettings.AsNoTracking().FirstOrDefaultAsync(ct);
|
||||
|
||||
// If multi-user ownership is present, write one export per owner.
|
||||
var owners = jobs
|
||||
.Select(j => j.OwnerUserId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (owners.Count <= 1)
|
||||
{
|
||||
var export = new
|
||||
{
|
||||
Version = "dailyexport.v1",
|
||||
CreatedAt = DateTime.Now,
|
||||
Companies = companies,
|
||||
JobApplications = jobs,
|
||||
Correspondence = correspondence,
|
||||
Attachments = attachments,
|
||||
Events = events,
|
||||
Rules = rules
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(export, new JsonSerializerOptions { WriteIndented = true });
|
||||
var file = Path.Combine(folder, $"daily_export_{DateTime.Now:yyyyMMdd}.json");
|
||||
await File.WriteAllTextAsync(file, json, ct);
|
||||
|
||||
_logger.LogInformation("Daily export written: {File}.", file);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var owner in owners)
|
||||
{
|
||||
var ownerKey = string.IsNullOrWhiteSpace(owner) ? "_unassigned" : owner;
|
||||
var ownerJobs = jobs.Where(j => j.OwnerUserId == owner).ToList();
|
||||
var ownerJobIds = ownerJobs.Select(j => j.Id).ToHashSet();
|
||||
|
||||
var export = new
|
||||
{
|
||||
Version = "dailyexport.v2",
|
||||
CreatedAt = DateTime.Now,
|
||||
OwnerUserId = owner,
|
||||
Companies = companies.Where(c => c.OwnerUserId == owner).ToList(),
|
||||
JobApplications = ownerJobs,
|
||||
Correspondence = correspondence.Where(c => ownerJobIds.Contains(c.JobApplicationId)).ToList(),
|
||||
Attachments = attachments.Where(a => ownerJobIds.Contains(a.JobApplicationId)).ToList(),
|
||||
Events = events.Where(e => ownerJobIds.Contains(e.JobApplicationId)).ToList(),
|
||||
Rules = rules
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(export, new JsonSerializerOptions { WriteIndented = true });
|
||||
var file = Path.Combine(folder, $"daily_export_{ownerKey}_{DateTime.Now:yyyyMMdd}.json");
|
||||
await File.WriteAllTextAsync(file, json, ct);
|
||||
|
||||
_logger.LogInformation("Daily export written: {File}.", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace JobTrackerApi.Services.JobImport;
|
||||
|
||||
public interface IJobSitePlugin
|
||||
{
|
||||
bool CanHandle(string url);
|
||||
JobImportResult Parse(string html, string url);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
|
||||
namespace JobTrackerApi.Services.JobImport;
|
||||
|
||||
public sealed record JobImportResult
|
||||
{
|
||||
public string? Title { get; init; }
|
||||
public string? Company { get; init; }
|
||||
public string? Location { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? TranslatedDescription { get; init; }
|
||||
public string? Language { get; init; } // ISO-ish, e.g. "en", "no"
|
||||
public string[] Tags { get; init; } = Array.Empty<string>();
|
||||
public string SourceUrl { get; init; } = "";
|
||||
public DateTime? Deadline { get; init; }
|
||||
|
||||
public bool Success { get; init; }
|
||||
public string? Parser { get; init; } // "universal", "finn", ...
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using JobTrackerApi.Services.JobImport.Translation;
|
||||
|
||||
namespace JobTrackerApi.Services.JobImport;
|
||||
|
||||
public sealed class JobImportService
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly UniversalJobParser _universal;
|
||||
private readonly IEnumerable<IJobSitePlugin> _plugins;
|
||||
private readonly ITranslationService _translation;
|
||||
|
||||
public JobImportService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
UniversalJobParser universal,
|
||||
IEnumerable<IJobSitePlugin> plugins,
|
||||
ITranslationService translation)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_universal = universal;
|
||||
_plugins = plugins;
|
||||
_translation = translation;
|
||||
}
|
||||
|
||||
public async Task<JobImportResult> PreviewAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryValidateUrl(url, out var normalized, out var error))
|
||||
{
|
||||
return new JobImportResult
|
||||
{
|
||||
SourceUrl = url ?? "",
|
||||
Success = false,
|
||||
Parser = "none",
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
|
||||
var html = await FetchHtmlAsync(normalized, cancellationToken);
|
||||
if (html is null)
|
||||
{
|
||||
return new JobImportResult
|
||||
{
|
||||
SourceUrl = normalized,
|
||||
Success = false,
|
||||
Parser = "fetch",
|
||||
Error = "Failed to fetch HTML."
|
||||
};
|
||||
}
|
||||
|
||||
var parsed = _universal.Parse(html, normalized);
|
||||
if (!parsed.Success)
|
||||
{
|
||||
foreach (var plugin in _plugins.Where(p => p.CanHandle(normalized)))
|
||||
{
|
||||
try
|
||||
{
|
||||
var p = plugin.Parse(html, normalized);
|
||||
if (p.Success)
|
||||
{
|
||||
parsed = p;
|
||||
break;
|
||||
}
|
||||
parsed = p; // keep last failure for debugging
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
parsed = new JobImportResult
|
||||
{
|
||||
SourceUrl = normalized,
|
||||
Success = false,
|
||||
Parser = plugin.GetType().Name,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed.Success) return parsed with { SourceUrl = normalized };
|
||||
|
||||
var lang = LanguageDetector.Detect(parsed.Description);
|
||||
var tags = SkillTagger.Detect(parsed.Description);
|
||||
string? translated = null;
|
||||
if (string.Equals(lang, "no", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(parsed.Description))
|
||||
{
|
||||
translated = await _translation.TranslateToEnglishAsync(parsed.Description!, "no", cancellationToken);
|
||||
}
|
||||
|
||||
return parsed with
|
||||
{
|
||||
SourceUrl = normalized,
|
||||
Language = lang,
|
||||
Tags = tags,
|
||||
TranslatedDescription = translated
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string?> FetchHtmlAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
using var client = _httpClientFactory.CreateClient("jobimport");
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
req.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) JobTracker/1.0");
|
||||
req.Headers.TryAddWithoutValidation("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
|
||||
req.Headers.TryAddWithoutValidation("Accept-Language", "en-US,en;q=0.8,no;q=0.6,nb;q=0.6");
|
||||
|
||||
using var res = await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
if ((int)res.StatusCode >= 300 && (int)res.StatusCode < 400) return null; // avoid redirect chains to non-html.
|
||||
if (!res.IsSuccessStatusCode) return null;
|
||||
|
||||
var ct = res.Content.Headers.ContentType?.MediaType ?? "";
|
||||
if (ct.Length > 0 && !ct.Contains("html", StringComparison.OrdinalIgnoreCase) && !ct.Contains("xml", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Still read: many sites omit content-type. Best-effort.
|
||||
}
|
||||
|
||||
// Cap to avoid huge downloads.
|
||||
var bytes = await res.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
if (bytes.Length > 4_000_000) return null;
|
||||
return System.Text.Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
|
||||
private static bool TryValidateUrl(string? url, out string normalized, out string error)
|
||||
{
|
||||
normalized = "";
|
||||
error = "";
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
error = "URL is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri))
|
||||
{
|
||||
error = "Invalid URL.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (uri.Scheme is not ("http" or "https"))
|
||||
{
|
||||
error = "Only http/https URLs are supported.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (uri.IsLoopback || string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
error = "Local URLs are not allowed.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block literal private IPs.
|
||||
if (IPAddress.TryParse(uri.Host, out var ip))
|
||||
{
|
||||
if (IsPrivateIp(ip))
|
||||
{
|
||||
error = "Private IP URLs are not allowed.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
normalized = uri.ToString();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsPrivateIp(IPAddress ip)
|
||||
{
|
||||
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
{
|
||||
var b = ip.GetAddressBytes();
|
||||
return b[0] == 10 ||
|
||||
(b[0] == 172 && b[1] >= 16 && b[1] <= 31) ||
|
||||
(b[0] == 192 && b[1] == 168) ||
|
||||
(b[0] == 169 && b[1] == 254);
|
||||
}
|
||||
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
|
||||
{
|
||||
return ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace JobTrackerApi.Services.JobImport;
|
||||
|
||||
public static class LanguageDetector
|
||||
{
|
||||
// Lightweight heuristic: good enough to distinguish Norwegian vs English for job ads.
|
||||
public static string Detect(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return "en";
|
||||
var t = text.AsSpan();
|
||||
|
||||
// Norwegian characters strongly indicate "no".
|
||||
for (var i = 0; i < t.Length; i++)
|
||||
{
|
||||
var ch = char.ToLowerInvariant(t[i]);
|
||||
if (ch is 'æ' or 'ø' or 'å') return "no";
|
||||
}
|
||||
|
||||
var lower = text.ToLower(CultureInfo.InvariantCulture);
|
||||
var hits = 0;
|
||||
hits += lower.Contains(" stilling ") || lower.Contains(" stillingen ") ? 2 : 0;
|
||||
hits += lower.Contains(" søker ") || lower.Contains(" s\u00F8ker ") ? 2 : 0;
|
||||
hits += lower.Contains(" arbeidsoppgaver") ? 2 : 0;
|
||||
hits += lower.Contains(" kvalifikasjoner") ? 2 : 0;
|
||||
hits += lower.Contains(" vi tilbyr") ? 2 : 0;
|
||||
hits += lower.Contains(" krav ") ? 1 : 0;
|
||||
hits += lower.Contains(" og ") ? 1 : 0;
|
||||
hits += lower.Contains(" ikke ") ? 1 : 0;
|
||||
hits += lower.Contains(" du ") || lower.Contains(" deg ") ? 1 : 0;
|
||||
|
||||
return hits >= 4 ? "no" : "en";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
|
||||
namespace JobTrackerApi.Services.JobImport.Plugins;
|
||||
|
||||
public sealed class FinnPlugin : IJobSitePlugin
|
||||
{
|
||||
public bool CanHandle(string url) => url.Contains("finn.no", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public JobImportResult Parse(string html, string url)
|
||||
{
|
||||
var meta = HtmlExtract.ReadMeta(html);
|
||||
var title = meta.TryGetValue("og:title", out var t) ? t : HtmlExtract.ReadTitle(html);
|
||||
var desc = meta.TryGetValue("og:description", out var d) ? d : null;
|
||||
var company = ExtractCompanyFromTitle(title);
|
||||
|
||||
return new JobImportResult
|
||||
{
|
||||
SourceUrl = url,
|
||||
Title = CleanTitle(title),
|
||||
Company = company,
|
||||
Location = meta.TryGetValue("job:location", out var loc) ? loc : null,
|
||||
Description = HtmlExtract.ToPlainText(desc),
|
||||
Parser = "finn",
|
||||
Success = !string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(desc),
|
||||
};
|
||||
}
|
||||
|
||||
private static string? CleanTitle(string? title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title)) return null;
|
||||
// FINN often appends " - FINN.no" etc.
|
||||
var s = title.Replace(" - FINN.no", "", StringComparison.OrdinalIgnoreCase).Trim();
|
||||
return s.Length == 0 ? title : s;
|
||||
}
|
||||
|
||||
private static string? ExtractCompanyFromTitle(string? title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title)) return null;
|
||||
// Common pattern: "Role hos Company" / "Role - Company"
|
||||
var s = title;
|
||||
var idx = s.LastIndexOf(" - ", StringComparison.Ordinal);
|
||||
if (idx > 0 && idx < s.Length - 3) return s[(idx + 3)..].Trim();
|
||||
idx = s.LastIndexOf(" hos ", StringComparison.OrdinalIgnoreCase);
|
||||
if (idx > 0 && idx < s.Length - 5) return s[(idx + 5)..].Trim();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
|
||||
namespace JobTrackerApi.Services.JobImport.Plugins;
|
||||
|
||||
public sealed class JobbnorgePlugin : IJobSitePlugin
|
||||
{
|
||||
public bool CanHandle(string url) => url.Contains("jobbnorge.no", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public JobImportResult Parse(string html, string url)
|
||||
{
|
||||
var meta = HtmlExtract.ReadMeta(html);
|
||||
var title = meta.TryGetValue("og:title", out var t) ? t : HtmlExtract.ReadTitle(html);
|
||||
var desc = meta.TryGetValue("og:description", out var d) ? d : null;
|
||||
|
||||
return new JobImportResult
|
||||
{
|
||||
SourceUrl = url,
|
||||
Title = title,
|
||||
Description = HtmlExtract.ToPlainText(desc),
|
||||
Parser = "jobbnorge",
|
||||
Success = !string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(desc),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
|
||||
namespace JobTrackerApi.Services.JobImport.Plugins;
|
||||
|
||||
public sealed class LinkedInPlugin : IJobSitePlugin
|
||||
{
|
||||
public bool CanHandle(string url) => url.Contains("linkedin.com/jobs", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public JobImportResult Parse(string html, string url)
|
||||
{
|
||||
// LinkedIn heavily relies on JS; meta tags are often the best available without a headless browser.
|
||||
var meta = HtmlExtract.ReadMeta(html);
|
||||
var title = meta.TryGetValue("og:title", out var t) ? t : HtmlExtract.ReadTitle(html);
|
||||
var desc = meta.TryGetValue("og:description", out var d) ? d : null;
|
||||
|
||||
return new JobImportResult
|
||||
{
|
||||
SourceUrl = url,
|
||||
Title = title,
|
||||
Company = meta.TryGetValue("og:site_name", out var sn) ? sn : null,
|
||||
Description = HtmlExtract.ToPlainText(desc),
|
||||
Parser = "linkedin",
|
||||
Success = !string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(desc),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
|
||||
namespace JobTrackerApi.Services.JobImport.Plugins;
|
||||
|
||||
public sealed class NavPlugin : IJobSitePlugin
|
||||
{
|
||||
public bool CanHandle(string url)
|
||||
=> url.Contains("arbeidsplassen.nav.no", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.Contains("nav.no", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public JobImportResult Parse(string html, string url)
|
||||
{
|
||||
var meta = HtmlExtract.ReadMeta(html);
|
||||
var title = meta.TryGetValue("og:title", out var t) ? t : HtmlExtract.ReadTitle(html);
|
||||
var desc = meta.TryGetValue("og:description", out var d) ? d : null;
|
||||
var siteName = meta.TryGetValue("og:site_name", out var sn) ? sn : null;
|
||||
|
||||
return new JobImportResult
|
||||
{
|
||||
SourceUrl = url,
|
||||
Title = title,
|
||||
Company = siteName, // better than nothing; universal parser often gets this anyway.
|
||||
Description = HtmlExtract.ToPlainText(desc),
|
||||
Parser = "nav",
|
||||
Success = !string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(desc),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace JobTrackerApi.Services.JobImport;
|
||||
|
||||
public static class SkillTagger
|
||||
{
|
||||
private static readonly (string Tag, Regex Pattern)[] Patterns =
|
||||
{
|
||||
("C#", new Regex(@"\bC#\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
||||
(".NET", new Regex(@"\b\.NET\b|\bASP\.NET\b|\bDOTNET\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
||||
("Python", new Regex(@"\bPython\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
||||
("Docker", new Regex(@"\bDocker\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
||||
("Azure", new Regex(@"\bAzure\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
||||
("AWS", new Regex(@"\bAWS\b|\bAmazon Web Services\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
||||
("React", new Regex(@"\bReact\b|\bReact\.js\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
||||
("TypeScript", new Regex(@"\bTypeScript\b|\bTS\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
||||
("SQL", new Regex(@"\bSQL\b|\bPostgreSQL\b|\bMySQL\b|\bSQLite\b|\bMS\s*SQL\b|\bT-?SQL\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
||||
("Kubernetes", new Regex(@"\bKubernetes\b|\bK8s\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
||||
};
|
||||
|
||||
public static string[] Detect(string? description)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(description)) return Array.Empty<string>();
|
||||
var tags = new List<string>(capacity: 8);
|
||||
foreach (var (tag, pattern) in Patterns)
|
||||
{
|
||||
if (pattern.IsMatch(description)) tags.Add(tag);
|
||||
}
|
||||
return tags.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobTrackerApi.Services.JobImport.Translation;
|
||||
|
||||
public interface ITranslationService
|
||||
{
|
||||
Task<string?> TranslateToEnglishAsync(string text, string sourceLanguage, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobTrackerApi.Services.JobImport.Translation;
|
||||
|
||||
public sealed class LibreTranslateService : ITranslationService
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly string _baseUrl;
|
||||
private readonly string? _apiKey;
|
||||
|
||||
public LibreTranslateService(IHttpClientFactory httpClientFactory, IConfiguration cfg)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_baseUrl = (cfg["Translation:LibreTranslate:BaseUrl"] ?? "").Trim().TrimEnd('/');
|
||||
_apiKey = string.IsNullOrWhiteSpace(cfg["Translation:LibreTranslate:ApiKey"]) ? null : cfg["Translation:LibreTranslate:ApiKey"]!.Trim();
|
||||
}
|
||||
|
||||
public async Task<string?> TranslateToEnglishAsync(string text, string sourceLanguage, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
if (string.IsNullOrWhiteSpace(_baseUrl)) return null;
|
||||
|
||||
using var client = _httpClientFactory.CreateClient();
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/translate")
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
q = text,
|
||||
source = sourceLanguage,
|
||||
target = "en",
|
||||
format = "text",
|
||||
api_key = _apiKey
|
||||
})
|
||||
};
|
||||
|
||||
using var res = await client.SendAsync(req, cancellationToken);
|
||||
if (!res.IsSuccessStatusCode) return null;
|
||||
|
||||
var body = await res.Content.ReadFromJsonAsync<LibreTranslateResponse>(cancellationToken: cancellationToken);
|
||||
return string.IsNullOrWhiteSpace(body?.translatedText) ? null : body!.translatedText;
|
||||
}
|
||||
|
||||
private sealed record LibreTranslateResponse(string? translatedText);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobTrackerApi.Services.JobImport.Translation;
|
||||
|
||||
public sealed class NoOpTranslationService : ITranslationService
|
||||
{
|
||||
public Task<string?> TranslateToEnglishAsync(string text, string sourceLanguage, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace JobTrackerApi.Services.JobImport;
|
||||
|
||||
public sealed class UniversalJobParser
|
||||
{
|
||||
private static readonly Regex JsonLdScriptRegex =
|
||||
new(@"<script[^>]+type\s*=\s*[""']application/ld\+json[""'][^>]*>(?<json>[\s\S]*?)</script>",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public JobImportResult Parse(string html, string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
return new JobImportResult { SourceUrl = url, Success = false, Parser = "universal", Error = "Empty HTML." };
|
||||
}
|
||||
|
||||
foreach (Match m in JsonLdScriptRegex.Matches(html))
|
||||
{
|
||||
var json = (m.Groups["json"].Value ?? "").Trim();
|
||||
if (json.Length == 0) continue;
|
||||
|
||||
// Some sites embed multiple JSON objects in one script; try best-effort.
|
||||
var candidates = SplitJsonLdPayload(json);
|
||||
foreach (var c in candidates)
|
||||
{
|
||||
if (!TryParseJobPosting(c, url, out var result)) continue;
|
||||
return result with { Parser = "universal", Success = true };
|
||||
}
|
||||
}
|
||||
|
||||
return new JobImportResult { SourceUrl = url, Success = false, Parser = "universal", Error = "No JobPosting schema found." };
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitJsonLdPayload(string raw)
|
||||
{
|
||||
// Many pages have valid JSON; keep it simple. If parsing fails, try trimming common junk.
|
||||
yield return raw;
|
||||
yield return raw.Trim().TrimEnd(';');
|
||||
}
|
||||
|
||||
private static bool TryParseJobPosting(string json, string url, out JobImportResult result)
|
||||
{
|
||||
result = new JobImportResult { SourceUrl = url, Parser = "universal", Success = false };
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var node = FindJobPostingNode(doc.RootElement);
|
||||
if (node is null) return false;
|
||||
|
||||
var job = node.Value;
|
||||
var title = GetString(job, "title");
|
||||
var description = GetString(job, "description");
|
||||
var company = GetString(job, "hiringOrganization", "name")
|
||||
?? GetString(job, "hiringOrganization", "legalName");
|
||||
var location = ExtractLocation(job);
|
||||
var deadline = ParseDateTime(GetString(job, "validThrough"));
|
||||
|
||||
description = HtmlExtract.ToPlainText(description);
|
||||
|
||||
result = new JobImportResult
|
||||
{
|
||||
SourceUrl = url,
|
||||
Title = title,
|
||||
Company = company,
|
||||
Location = location,
|
||||
Description = description,
|
||||
Deadline = deadline,
|
||||
Success = !string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(description),
|
||||
Parser = "universal"
|
||||
};
|
||||
return result.Success;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonElement? FindJobPostingNode(JsonElement root)
|
||||
{
|
||||
// Accept: { "@type":"JobPosting", ... }
|
||||
if (IsJobPosting(root)) return root;
|
||||
|
||||
// Accept: { "@graph":[...]} or arrays.
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (root.TryGetProperty("@graph", out var g) && g.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var el in g.EnumerateArray())
|
||||
{
|
||||
var found = FindJobPostingNode(el);
|
||||
if (found is not null) return found;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
var found = FindJobPostingNode(prop.Value);
|
||||
if (found is not null) return found;
|
||||
}
|
||||
}
|
||||
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var el in root.EnumerateArray())
|
||||
{
|
||||
var found = FindJobPostingNode(el);
|
||||
if (found is not null) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsJobPosting(JsonElement el)
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) return false;
|
||||
if (!el.TryGetProperty("@type", out var typeEl)) return false;
|
||||
|
||||
if (typeEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return string.Equals(typeEl.GetString(), "JobPosting", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (typeEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var t in typeEl.EnumerateArray())
|
||||
{
|
||||
if (t.ValueKind == JsonValueKind.String &&
|
||||
string.Equals(t.GetString(), "JobPosting", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement el, params string[] path)
|
||||
{
|
||||
var cur = el;
|
||||
for (var i = 0; i < path.Length; i++)
|
||||
{
|
||||
if (cur.ValueKind != JsonValueKind.Object) return null;
|
||||
if (!cur.TryGetProperty(path[i], out var next)) return null;
|
||||
cur = next;
|
||||
}
|
||||
|
||||
return cur.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => cur.GetString(),
|
||||
JsonValueKind.Number => cur.ToString(),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ExtractLocation(JsonElement job)
|
||||
{
|
||||
// jobLocation can be object or array; address fields vary.
|
||||
if (!job.TryGetProperty("jobLocation", out var jl)) return null;
|
||||
var addr = FindFirstAddress(jl);
|
||||
if (addr is null) return null;
|
||||
|
||||
var city = GetString(addr.Value, "addressLocality");
|
||||
var region = GetString(addr.Value, "addressRegion");
|
||||
var country = GetString(addr.Value, "addressCountry");
|
||||
|
||||
var parts = new[] { city, region, country }.Where(x => !string.IsNullOrWhiteSpace(x)).ToArray();
|
||||
return parts.Length == 0 ? null : string.Join(", ", parts);
|
||||
}
|
||||
|
||||
private static JsonElement? FindFirstAddress(JsonElement jobLocation)
|
||||
{
|
||||
if (jobLocation.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (jobLocation.TryGetProperty("address", out var a))
|
||||
{
|
||||
if (a.ValueKind == JsonValueKind.Object) return a;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (jobLocation.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var el in jobLocation.EnumerateArray())
|
||||
{
|
||||
var addr = FindFirstAddress(el);
|
||||
if (addr is not null) return addr;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTime? ParseDateTime(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
if (DateTime.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var dt)) return dt;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class HtmlExtract
|
||||
{
|
||||
private static readonly Regex TitleRegex =
|
||||
new(@"<title[^>]*>(?<t>[\s\S]*?)</title>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex TagRegex =
|
||||
new(@"<[^>]+>", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex WsRegex =
|
||||
new(@"\s+", RegexOptions.Compiled);
|
||||
|
||||
public static string? ReadTitle(string html)
|
||||
{
|
||||
var m = TitleRegex.Match(html);
|
||||
if (!m.Success) return null;
|
||||
return DecodeHtmlEntities(m.Groups["t"].Value).Trim();
|
||||
}
|
||||
|
||||
public static Dictionary<string, string> ReadMeta(string html)
|
||||
{
|
||||
// Very small meta extractor: picks up OpenGraph + standard meta tags.
|
||||
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (Match m in Regex.Matches(html, @"<meta\s+[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled))
|
||||
{
|
||||
var tag = m.Value;
|
||||
var key = GetAttr(tag, "property") ?? GetAttr(tag, "name");
|
||||
var content = GetAttr(tag, "content");
|
||||
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(content)) continue;
|
||||
if (!dict.ContainsKey(key)) dict[key] = DecodeHtmlEntities(content).Trim();
|
||||
}
|
||||
|
||||
return dict;
|
||||
}
|
||||
|
||||
public static string? ToPlainText(string? htmlOrText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(htmlOrText)) return null;
|
||||
var s = DecodeHtmlEntities(htmlOrText);
|
||||
s = TagRegex.Replace(s, " ");
|
||||
s = WsRegex.Replace(s, " ").Trim();
|
||||
return s.Length == 0 ? null : s;
|
||||
}
|
||||
|
||||
private static string? GetAttr(string tag, string attr)
|
||||
{
|
||||
var m = Regex.Match(tag, attr + @"\s*=\s*(?<q>[""'])(?<v>[\s\S]*?)(\k<q>)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
if (m.Success) return m.Groups["v"].Value;
|
||||
|
||||
// Unquoted attribute values.
|
||||
m = Regex.Match(tag, attr + @"\s*=\s*(?<v>[^\s>]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
return m.Success ? m.Groups["v"].Value : null;
|
||||
}
|
||||
|
||||
private static string DecodeHtmlEntities(string s)
|
||||
=> System.Net.WebUtility.HtmlDecode(s);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
public sealed record FollowUpDecision(bool NeedsFollowUp, string? Reason, bool ShouldGhost);
|
||||
|
||||
public static class RulesEngine
|
||||
{
|
||||
public static async Task<RuleSettings> GetSettings(JobTrackerContext db, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(db.CurrentUserId))
|
||||
{
|
||||
var u = await db.UserRuleSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.OwnerUserId == db.CurrentUserId, cancellationToken);
|
||||
|
||||
if (u is not null)
|
||||
{
|
||||
return new RuleSettings
|
||||
{
|
||||
Id = 1,
|
||||
AppliedFollowUpDays = u.AppliedFollowUpDays,
|
||||
AppliedGhostDays = u.AppliedGhostDays,
|
||||
OfferFollowUpDays = u.OfferFollowUpDays,
|
||||
OfferGhostDays = u.OfferGhostDays,
|
||||
FeedbackFollowUpDays = u.FeedbackFollowUpDays,
|
||||
FeedbackGhostDays = u.FeedbackGhostDays,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var s = await db.RuleSettings.AsNoTracking().FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
|
||||
return s ?? new RuleSettings { Id = 1 };
|
||||
}
|
||||
|
||||
public static FollowUpDecision Evaluate(
|
||||
RuleSettings s,
|
||||
JobApplication job,
|
||||
DateTime now,
|
||||
DateTime? lastMessageAt
|
||||
)
|
||||
{
|
||||
if (job.IsDeleted) return new FollowUpDecision(false, null, false);
|
||||
|
||||
var status = job.Status ?? "Applied";
|
||||
if (status == "Interviewing") status = "Interview";
|
||||
|
||||
// Last activity: any explicit follow-up date, response date, feedback request, or correspondence message.
|
||||
var last = Max(
|
||||
job.DateApplied,
|
||||
job.ResponseDate,
|
||||
job.FollowUpAt,
|
||||
job.FeedbackRequestedAt,
|
||||
lastMessageAt
|
||||
);
|
||||
|
||||
var daysSinceLast = (now - last).TotalDays;
|
||||
|
||||
// Applied: if no response and enough time passed since applied.
|
||||
if (string.Equals(status, "Applied", StringComparison.OrdinalIgnoreCase) && !job.ResponseReceived)
|
||||
{
|
||||
var daysSinceApplied = (now - job.DateApplied).TotalDays;
|
||||
if (daysSinceApplied >= s.AppliedFollowUpDays)
|
||||
return new FollowUpDecision(true, $"No reply after {s.AppliedFollowUpDays}d", daysSinceApplied >= s.AppliedGhostDays);
|
||||
return new FollowUpDecision(false, null, daysSinceApplied >= s.AppliedGhostDays);
|
||||
}
|
||||
|
||||
// Offer/accepted waiting on next step
|
||||
if (string.Equals(status, "Offer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (daysSinceLast >= s.OfferFollowUpDays)
|
||||
return new FollowUpDecision(true, $"Stalled after {s.OfferFollowUpDays}d", daysSinceLast >= s.OfferGhostDays);
|
||||
return new FollowUpDecision(false, null, daysSinceLast >= s.OfferGhostDays);
|
||||
}
|
||||
|
||||
// Rejected but feedback requested
|
||||
if (string.Equals(status, "Rejected", StringComparison.OrdinalIgnoreCase) && job.FeedbackRequestedAt is not null)
|
||||
{
|
||||
var daysSinceReq = (now - job.FeedbackRequestedAt.Value).TotalDays;
|
||||
if (daysSinceReq >= s.FeedbackFollowUpDays)
|
||||
return new FollowUpDecision(true, $"Feedback requested {s.FeedbackFollowUpDays}d ago", daysSinceReq >= s.FeedbackGhostDays);
|
||||
return new FollowUpDecision(false, null, daysSinceReq >= s.FeedbackGhostDays);
|
||||
}
|
||||
|
||||
// Waiting status: treat as follow-up based on applied follow-up days.
|
||||
if (string.Equals(status, "Waiting", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (daysSinceLast >= s.AppliedFollowUpDays)
|
||||
return new FollowUpDecision(true, $"Waiting {s.AppliedFollowUpDays}d", daysSinceLast >= s.AppliedGhostDays);
|
||||
return new FollowUpDecision(false, null, daysSinceLast >= s.AppliedGhostDays);
|
||||
}
|
||||
|
||||
// Default: no follow-up rule. Do not auto-ghost other statuses.
|
||||
return new FollowUpDecision(false, null, false);
|
||||
}
|
||||
|
||||
public static DateTime Max(DateTime a, params DateTime?[] rest)
|
||||
{
|
||||
var m = a;
|
||||
foreach (var r in rest)
|
||||
{
|
||||
if (r is not null && r.Value > m) m = r.Value;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using JobTrackerApi.Data;
|
||||
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
// Periodically applies "auto ghost" transitions.
|
||||
public sealed class RulesHostedService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
public RulesHostedService(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Small initial delay to let app start.
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
|
||||
var settings = await RulesEngine.GetSettings(db, stoppingToken);
|
||||
var now = DateTime.Now;
|
||||
|
||||
// Get last correspondence per job (single query).
|
||||
var lastMsg = await db.Correspondences
|
||||
.GroupBy(c => c.JobApplicationId)
|
||||
.Select(g => new { JobApplicationId = g.Key, Last = g.Max(x => x.Date) })
|
||||
.ToDictionaryAsync(x => x.JobApplicationId, x => (DateTime?)x.Last, stoppingToken);
|
||||
|
||||
var jobs = await db.JobApplications
|
||||
.Where(j => !j.IsDeleted && j.Status != "Ghosted")
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
var changed = 0;
|
||||
foreach (var j in jobs)
|
||||
{
|
||||
lastMsg.TryGetValue(j.Id, out var lm);
|
||||
var d = RulesEngine.Evaluate(settings, j, now, lm);
|
||||
if (d.ShouldGhost)
|
||||
{
|
||||
j.Status = "Ghosted";
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed > 0)
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort background job; swallow errors.
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMinutes(30), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public interface IAppEmailSender
|
||||
{
|
||||
Task SendAsync(string toEmail, string subject, string bodyText, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class SmtpEmailSender : IAppEmailSender
|
||||
{
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ILogger<SmtpEmailSender> _logger;
|
||||
|
||||
public SmtpEmailSender(IConfiguration cfg, ILogger<SmtpEmailSender> logger)
|
||||
{
|
||||
_cfg = cfg;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task SendAsync(string toEmail, string subject, string bodyText, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var host = (_cfg["Email:SmtpHost"] ?? "").Trim();
|
||||
var user = (_cfg["Email:SmtpUser"] ?? "").Trim();
|
||||
var pass = (_cfg["Email:SmtpPassword"] ?? "").Trim();
|
||||
var from = (_cfg["Email:From"] ?? user).Trim();
|
||||
var fromName = (_cfg["Email:FromName"] ?? "Job Tracker").Trim();
|
||||
|
||||
var port = _cfg.GetValue("Email:SmtpPort", 587);
|
||||
if (port <= 0) port = 587;
|
||||
|
||||
var enableSsl = _cfg.GetValue("Email:SmtpEnableSsl", true);
|
||||
var timeoutMs = _cfg.GetValue("Email:SmtpTimeoutMs", 15000);
|
||||
if (timeoutMs <= 0) timeoutMs = 15000;
|
||||
|
||||
var enabled = _cfg.GetValue("Email:Enabled", false);
|
||||
if (!enabled)
|
||||
{
|
||||
_logger.LogWarning("Email sending is disabled (Email:Enabled=false). Suppressed email to {To} subject={Subject}", toEmail, subject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(host)) throw new InvalidOperationException("Email:SmtpHost is not configured.");
|
||||
if (string.IsNullOrWhiteSpace(from)) throw new InvalidOperationException("Email:From is not configured.");
|
||||
|
||||
using var msg = new MailMessage();
|
||||
msg.From = new MailAddress(from, string.IsNullOrWhiteSpace(fromName) ? null : fromName);
|
||||
msg.To.Add(new MailAddress(toEmail));
|
||||
msg.Subject = subject;
|
||||
msg.Body = bodyText;
|
||||
msg.IsBodyHtml = false;
|
||||
|
||||
using var smtp = new SmtpClient(host, port)
|
||||
{
|
||||
EnableSsl = enableSsl,
|
||||
DeliveryMethod = SmtpDeliveryMethod.Network,
|
||||
Timeout = timeoutMs,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(user))
|
||||
{
|
||||
smtp.Credentials = new NetworkCredential(user, pass);
|
||||
}
|
||||
|
||||
// SmtpClient has no CancellationToken support; run on thread pool.
|
||||
await Task.Run(() => smtp.Send(msg), cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
public interface ISummarizerService
|
||||
{
|
||||
Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30);
|
||||
}
|
||||
|
||||
public class SummarizerService : ISummarizerService
|
||||
{
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public SummarizerService(IHttpClientFactory httpFactory, IMemoryCache cache)
|
||||
{
|
||||
_httpFactory = httpFactory;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public async Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
|
||||
var key = $"summ:{text.GetHashCode()}:{maxLength}:{minLength}";
|
||||
if (_cache.TryGetValue<string>(key, out var cached)) return cached;
|
||||
|
||||
var client = _httpFactory.CreateClient("summarizer");
|
||||
var payload = JsonSerializer.Serialize(new { text, max_length = maxLength, min_length = minLength });
|
||||
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
|
||||
try
|
||||
{
|
||||
var res = await client.PostAsync("/summarize", content);
|
||||
if (!res.IsSuccessStatusCode) return null;
|
||||
|
||||
using var stream = await res.Content.ReadAsStreamAsync();
|
||||
using var doc = await JsonDocument.ParseAsync(stream);
|
||||
if (doc.RootElement.TryGetProperty("summary", out var el))
|
||||
{
|
||||
var s = el.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(s)) _cache.Set(key, s, TimeSpan.FromHours(6));
|
||||
return s;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
Task<string> CreateAccessTokenAsync(ApplicationUser user, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class TokenService : ITokenService
|
||||
{
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly UserManager<ApplicationUser> _users;
|
||||
|
||||
public TokenService(IConfiguration cfg, UserManager<ApplicationUser> users)
|
||||
{
|
||||
_cfg = cfg;
|
||||
_users = users;
|
||||
}
|
||||
|
||||
public async Task<string> CreateAccessTokenAsync(ApplicationUser user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var jwtKey = (_cfg["Auth:JwtKey"] ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(jwtKey))
|
||||
{
|
||||
throw new InvalidOperationException("Auth:JwtKey is not configured.");
|
||||
}
|
||||
|
||||
var issuer = (_cfg["Auth:JwtIssuer"] ?? "JobTrackerApi").Trim();
|
||||
var audience = (_cfg["Auth:JwtAudience"] ?? "job-tracker-ui").Trim();
|
||||
|
||||
var minutes = _cfg.GetValue("Auth:JwtExpiresMinutes", 60 * 12);
|
||||
if (minutes < 5) minutes = 5;
|
||||
if (minutes > 60 * 24 * 30) minutes = 60 * 24 * 30;
|
||||
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
|
||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var roles = await _users.GetRolesAsync(user);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, user.Id),
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(user.Email))
|
||||
claims.Add(new Claim(ClaimTypes.Email, user.Email));
|
||||
if (!string.IsNullOrWhiteSpace(user.UserName))
|
||||
claims.Add(new Claim(ClaimTypes.Name, user.UserName));
|
||||
|
||||
foreach (var r in roles)
|
||||
claims.Add(new Claim(ClaimTypes.Role, r));
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: issuer,
|
||||
audience: audience,
|
||||
claims: claims,
|
||||
notBefore: now.AddSeconds(-5),
|
||||
expires: now.AddMinutes(minutes),
|
||||
signingCredentials: creds
|
||||
);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user