First Commit

This commit is contained in:
cesnimda
2026-03-21 11:55:27 +01:00
commit 2e8a29b4d0
1757 changed files with 166084 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
Name: JobTracker Assistant
Purpose:
- Provide concise, repo-aware code help for the Job Tracker project (C#/.NET API, EF Core, React frontend, Docker).
When to pick this agent:
- Working on code changes inside this repository, reviewing controllers/models/migrations, creating or updating UI components, or modifying Docker setups.
Persona & Role:
- Focused, pragmatic engineering partner. Prioritizes minimal, safe edits; prefers explicit ask-before-running destructive actions (migrations, DB resets, docker-compose down).
Allowed tools / actions:
- Read and edit repository files.
- Draft changes using the repository's patch workflow (apply patches) and create short TODOs.
- Suggest and produce code, tests, and Dockerfile improvements.
- Run test suites and apply EF Core migrations automatically for development branches when making code changes.
Automatic vs production:
- Automatic test runs and schema migrations are enabled for development and feature branches.
- The agent will request explicit confirmation before running destructive or production-targeted operations (e.g., applying migrations to production, pushing images to registries, or deploying to production environments).
Avoid / restricted actions:
- Do not call external services or the internet without explicit permission.
- Do not run DB-destructive commands or production deployments without confirmation.
Style & Preferences:
- Keep changes minimal and well-scoped.
- Use existing repository conventions (nullable reference types, EF Core patterns, soft-delete handling).
- Provide short progress updates and an actionable next step after edits.
- Use the repository TODO list to track multi-step changes.
Example prompts to use this agent:
- "Help modernize `JobApplicationsController` to use async EF Core patterns and add input validation."
- "Create a small unit test for `JobApplication.DaysSince`."
- "Propose Docker multi-stage build improvements for `job-tracker-ui/Dockerfile`."
Clarifying questions this agent will ask automatically when needed:
- "May I run migrations or modify the database schema?"
- "Should I update production Docker / deployment configs or only local/dev files?"
Next customizations to add later:
- Add CI job templates for build + tests.
- Add a code-style/formatting config and pre-commit checks.
+17
View File
@@ -0,0 +1,17 @@
**/.git
**/.vs
**/.idea
**/.vscode
**/bin
**/obj
**/bin_build
**/*.db
**/*.db-*
job-tracker-ui/node_modules
job-tracker-ui/build
**/node_modules
**/npm-debug.log
+24
View File
@@ -0,0 +1,24 @@
# Copy this file to `.env` (same folder as docker-compose.yml) and fill in values.
#
# Used by docker-compose.yml
AUTH_JWT_KEY=CHANGE_ME_LONG_RANDOM_SECRET
AUTH_ADMIN_EMAIL=admin@example.com
AUTH_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD
AUTH_GOOGLE_CLIENT_ID=723556162227-llqucvpog2esn1dutmtvuul1lv374or6.apps.googleusercontent.com
# Optional: only needed if you want the UI to call a non-default API base URL.
# In production the UI defaults to `/api`.
REACT_APP_API_BASE_URL=
# Used by docker-compose.yml (email / password resets / notifications)
APP_PUBLIC_BASE_URL=https://jobs.cesnimda.uk
EMAIL_ENABLED=false
EMAIL_SMTP_HOST=smtp.gmail.com
EMAIL_SMTP_PORT=587
EMAIL_SMTP_USER=CHANGE_ME_GMAIL_ADDRESS
EMAIL_SMTP_PASSWORD=CHANGE_ME_GOOGLE_APP_PASSWORD
EMAIL_FROM=CHANGE_ME_GMAIL_ADDRESS
EMAIL_FROM_NAME=Job Tracker
EMAIL_SMTP_ENABLE_SSL=true
EMAIL_SMTP_TIMEOUT_MS=15000
+41
View File
@@ -0,0 +1,41 @@
# OS / editor
.DS_Store
Thumbs.db
.vscode/
.idea/
# Environment files
.env
.env.*
!.env.example
# Python
.venv/
__pycache__/
*.pyc
# .NET / Visual Studio
bin/
obj/
.vs/
*.user
*.suo
*.userosscache
*.sln.docstates
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Frontend build artifacts
node_modules/
build/
dist/
coverage/
# Local temp / scratch files
temp_job.json
temp_post_job.py
todo jobtracker.txt
+23
View File
@@ -0,0 +1,23 @@
[ApiController]
[Route("api/[controller]")]
public class AttachmentsController : ControllerBase
{
private readonly IWebHostEnvironment _env;
public AttachmentsController(IWebHostEnvironment env) => _env = env;
[HttpPost]
public async Task<IActionResult> Upload([FromForm] IFormFileCollection files, [FromForm] int jobId)
{
var folder = Path.Combine(_env.ContentRootPath, "Attachments", jobId.ToString());
Directory.CreateDirectory(folder);
foreach (var file in files)
{
var path = Path.Combine(folder, file.FileName);
using var stream = new FileStream(path, FileMode.Create);
await file.CopyToAsync(stream);
}
return Ok();
}
}
+27
View File
@@ -0,0 +1,27 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class CompaniesController : ControllerBase
{
private readonly JobTrackerContext _context;
public CompaniesController(JobTrackerContext context) => _context = context;
[HttpGet]
public async Task<IEnumerable<Company>> Get() =>
await _context.Companies.Include(c => c.Jobs).ToListAsync();
[HttpPost]
public async Task<ActionResult<Company>> Post(Company company)
{
_context.Companies.Add(company);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(Get), new { id = company.Id }, company);
}
}
}
+34
View File
@@ -0,0 +1,34 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class CorrespondenceController : ControllerBase
{
private readonly JobTrackerContext _context;
public CorrespondenceController(JobTrackerContext context) => _context = context;
// GET all messages for a job
[HttpGet("{jobId}")]
public async Task<IEnumerable<Correspondence>> GetForJob(int jobId)
{
return await _context.Correspondences
.Where(c => c.JobApplicationId == jobId)
.OrderBy(c => c.Date)
.ToListAsync();
}
// POST new message
[HttpPost]
public async Task<ActionResult<Correspondence>> Post(Correspondence message)
{
_context.Correspondences.Add(message);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetForJob), new { jobId = message.JobApplicationId }, message);
}
}
}
+45
View File
@@ -0,0 +1,45 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class JobApplicationsController : ControllerBase
{
private readonly JobTrackerContext _context;
public JobApplicationsController(JobTrackerContext context) => _context = context;
[HttpGet]
public async Task<IEnumerable<JobApplication>> Get() =>
await _context.JobApplications.Include(j => j.Company).ToListAsync();
[HttpPost]
public async Task<ActionResult<JobApplication>> Post(JobApplication job)
{
_context.JobApplications.Add(job);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(Get), new { id = job.Id }, job);
}
[HttpPut("{id}")]
public async Task<IActionResult> Put(int id, JobApplication updatedJob)
{
var job = await _context.JobApplications.FindAsync(id);
if (job == null) return NotFound();
job.JobTitle = updatedJob.JobTitle;
job.Status = updatedJob.Status;
job.ResponseReceived = updatedJob.ResponseReceived;
job.ResponseDate = updatedJob.ResponseDate;
job.Notes = updatedJob.Notes;
job.CoverLetterText = updatedJob.CoverLetterText;
job.JobUrl = updatedJob.JobUrl;
await _context.SaveChangesAsync();
return NoContent();
}
}
}
+74
View File
@@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using JobTrackerApi.Models;
namespace JobTrackerApi.Data
{
public class JobTrackerContext : IdentityDbContext<ApplicationUser>
{
public string? CurrentUserId { get; }
public JobTrackerContext(DbContextOptions<JobTrackerContext> options, JobTrackerApi.Services.ICurrentUserService currentUser) : base(options)
{
CurrentUserId = currentUser.UserId;
}
public DbSet<Company> Companies => Set<Company>();
public DbSet<JobApplication> JobApplications => Set<JobApplication>();
public DbSet<Correspondence> Correspondences => Set<Correspondence>();
public DbSet<Attachment> Attachments => Set<Attachment>();
public DbSet<RuleSettings> RuleSettings => Set<RuleSettings>();
public DbSet<UserRuleSettings> UserRuleSettings => Set<UserRuleSettings>();
public DbSet<JobEvent> JobEvents => Set<JobEvent>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Company>()
.HasQueryFilter(c => CurrentUserId == null || c.OwnerUserId == CurrentUserId);
modelBuilder.Entity<JobApplication>()
.HasQueryFilter(j => CurrentUserId == null || j.OwnerUserId == CurrentUserId);
modelBuilder.Entity<UserRuleSettings>()
.HasKey(x => x.OwnerUserId);
modelBuilder.Entity<UserRuleSettings>()
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<RuleSettings>()
.HasData(new RuleSettings { Id = 1 });
modelBuilder.Entity<JobApplication>()
.HasOne(j => j.Company)
.WithMany(c => c.Jobs)
.HasForeignKey(j => j.CompanyId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<JobApplication>()
.HasIndex(j => j.OwnerUserId);
modelBuilder.Entity<Company>()
.HasIndex(c => c.OwnerUserId);
modelBuilder.Entity<Correspondence>()
.HasOne(c => c.JobApplication)
.WithMany(j => j.Messages)
.HasForeignKey(c => c.JobApplicationId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Attachment>()
.HasOne(a => a.JobApplication)
.WithMany(j => j.Attachments)
.HasForeignKey(a => a.JobApplicationId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<JobEvent>()
.HasOne(e => e.JobApplication)
.WithMany(j => j.Events)
.HasForeignKey(e => e.JobApplicationId)
.OnDelete(DeleteBehavior.Cascade);
}
}
}
+24
View File
@@ -0,0 +1,24 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobTrackerApi", "JobTrackerApi\JobTrackerApi.csproj", "{C5DB7EBB-7221-0C11-4A27-A9C4AB5BE51D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C5DB7EBB-7221-0C11-4A27-A9C4AB5BE51D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C5DB7EBB-7221-0C11-4A27-A9C4AB5BE51D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C5DB7EBB-7221-0C11-4A27-A9C4AB5BE51D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C5DB7EBB-7221-0C11-4A27-A9C4AB5BE51D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {1068749D-D959-4077-B91B-3DBBB8B97BD9}
EndGlobalSection
EndGlobal
+5
View File
@@ -0,0 +1,5 @@
bin
obj
bin_build
*.db
*.db-*
@@ -0,0 +1,219 @@
using System.Globalization;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/admin/audit")]
[Authorize(Roles = "Admin")]
public sealed class AdminAuditController : ControllerBase
{
private readonly JobTrackerContext _db;
public AdminAuditController(JobTrackerContext db)
{
_db = db;
}
public sealed record AuditItemDto(
int Id,
string Type,
string? OldValue,
string? NewValue,
string? Note,
DateTime At,
int JobApplicationId,
string? JobTitle,
string? CompanyName,
string? OwnerUserId,
string? OwnerEmail,
string? OwnerUserName
);
[HttpGet]
public async Task<ActionResult<List<AuditItemDto>>> Get([FromQuery] int take = 200, CancellationToken cancellationToken = default)
{
if (take < 1) take = 1;
if (take > 2000) take = 2000;
var items = await _db.JobEvents
.IgnoreQueryFilters()
.AsNoTracking()
.OrderByDescending(e => e.At)
.Take(take)
.Select(e => new
{
e.Id,
e.Type,
e.OldValue,
e.NewValue,
e.Note,
e.At,
e.JobApplicationId
})
.ToListAsync(cancellationToken);
var jobIds = items.Select(x => x.JobApplicationId).Distinct().ToList();
var jobs = await _db.JobApplications
.IgnoreQueryFilters()
.AsNoTracking()
.Include(j => j.Company)
.Where(j => jobIds.Contains(j.Id))
.Select(j => new { j.Id, j.JobTitle, CompanyName = j.Company.Name, j.OwnerUserId })
.ToDictionaryAsync(x => x.Id, cancellationToken);
var ownerIds = jobs.Values
.Select(x => x.OwnerUserId)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct()
.ToList();
var owners = ownerIds.Count == 0
? new Dictionary<string, (string? Email, string? UserName)>()
: await _db.Users
.AsNoTracking()
.Where(u => ownerIds.Contains(u.Id))
.Select(u => new { u.Id, u.Email, u.UserName })
.ToDictionaryAsync(x => x.Id, x => (x.Email, x.UserName), cancellationToken);
var outList = new List<AuditItemDto>(items.Count);
foreach (var e in items)
{
jobs.TryGetValue(e.JobApplicationId, out var j);
(string? Email, string? UserName) owner = default;
if (j?.OwnerUserId is not null)
{
owners.TryGetValue(j.OwnerUserId, out owner);
}
outList.Add(new AuditItemDto(
e.Id,
e.Type,
e.OldValue,
e.NewValue,
e.Note,
e.At,
e.JobApplicationId,
j?.JobTitle,
j?.CompanyName,
j?.OwnerUserId,
owner.Email,
owner.UserName
));
}
return Ok(outList);
}
public sealed record UndoResultDto(bool Ok, string Message);
[HttpPost("{id:int}/undo")]
public async Task<ActionResult<UndoResultDto>> Undo([FromRoute] int id, CancellationToken cancellationToken = default)
{
var ev = await _db.JobEvents
.IgnoreQueryFilters()
.FirstOrDefaultAsync(e => e.Id == id, cancellationToken);
if (ev is null) return NotFound(new UndoResultDto(false, "Event not found."));
var job = await _db.JobApplications
.IgnoreQueryFilters()
.FirstOrDefaultAsync(j => j.Id == ev.JobApplicationId, cancellationToken);
if (job is null) return NotFound(new UndoResultDto(false, "Job application not found."));
string message;
string? undoOld = ev.NewValue;
string? undoNew = ev.OldValue;
switch ((ev.Type ?? "").Trim())
{
case "StatusChanged":
{
if (string.IsNullOrWhiteSpace(ev.OldValue))
return BadRequest(new UndoResultDto(false, "Cannot undo: missing OldValue."));
var prev = job.Status;
job.Status = ev.OldValue.Trim();
message = $"Reverted status '{prev}' -> '{job.Status}'.";
break;
}
case "FollowUpSet":
{
job.FollowUpAt = ParseNullableIso(ev.OldValue);
message = "Reverted follow-up date.";
break;
}
case "ResponseUpdated":
{
if (!TryParseResponse(ev.OldValue, out var received, out var date))
return BadRequest(new UndoResultDto(false, "Cannot undo: invalid OldValue."));
job.ResponseReceived = received;
job.ResponseDate = date;
message = "Reverted response fields.";
break;
}
case "Deleted":
{
job.IsDeleted = false;
job.DeletedAt = null;
message = "Restored job from trash.";
break;
}
case "Restored":
{
job.IsDeleted = true;
job.DeletedAt = DateTime.Now;
message = "Moved job back to trash.";
break;
}
default:
return BadRequest(new UndoResultDto(false, $"Undo is not supported for event type '{ev.Type}'."));
}
_db.JobEvents.Add(new JobEvent
{
JobApplicationId = job.Id,
Type = "Undo",
OldValue = undoOld,
NewValue = undoNew,
Note = $"Undid event #{ev.Id} ({ev.Type}). {message}",
At = DateTime.Now
});
await _db.SaveChangesAsync(cancellationToken);
return Ok(new UndoResultDto(true, message));
}
private static DateTime? ParseNullableIso(string? iso)
{
if (string.IsNullOrWhiteSpace(iso)) return null;
if (DateTime.TryParse(iso, null, DateTimeStyles.RoundtripKind, out var d)) return d;
if (DateTime.TryParse(iso, out d)) return d;
return null;
}
private static bool TryParseResponse(string? value, out bool received, out DateTime? date)
{
received = false;
date = null;
if (string.IsNullOrWhiteSpace(value)) return false;
var idx = value.IndexOf(':');
if (idx < 0) return false;
var b = value[..idx];
var rest = value[(idx + 1)..];
if (!bool.TryParse(b, out received)) return false;
date = ParseNullableIso(rest);
return true;
}
}
@@ -0,0 +1,139 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/attachments")]
public class AttachmentsController : ControllerBase
{
private readonly AppPaths _paths;
private readonly JobTrackerContext _db;
public AttachmentsController(AppPaths paths, JobTrackerContext db)
{
_paths = paths;
_db = db;
}
public sealed record AttachmentDto(int Id, string FileName, DateTime UploadDate, string FileType, long FileSize);
[HttpGet("{jobId:int}")]
public async Task<ActionResult<List<AttachmentDto>>> ListForJob([FromRoute] int jobId, CancellationToken cancellationToken)
{
var jobOk = await _db.JobApplications.AnyAsync(j => j.Id == jobId, cancellationToken);
if (!jobOk) return NotFound();
var items = await _db.Attachments
.AsNoTracking()
.Where(a => a.JobApplicationId == jobId)
.OrderByDescending(a => a.UploadDate)
.Select(a => new AttachmentDto(a.Id, a.FileName, a.UploadDate, a.FileType, a.FileSize))
.ToListAsync(cancellationToken);
return Ok(items);
}
[HttpGet("download/{id:int}")]
public async Task<IActionResult> Download([FromRoute] int id, CancellationToken cancellationToken)
{
var att = await _db.Attachments.AsNoTracking().FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
if (att is null) return NotFound();
if (string.IsNullOrWhiteSpace(att.FilePath) || !System.IO.File.Exists(att.FilePath))
return NotFound();
var contentType = string.IsNullOrWhiteSpace(att.FileType) ? "application/octet-stream" : att.FileType;
var fileName = Path.GetFileName(att.FileName);
return PhysicalFile(att.FilePath, contentType, fileName);
}
public sealed record RenameAttachmentRequest(string FileName);
[HttpPatch("{id:int}")]
public async Task<IActionResult> Rename([FromRoute] int id, [FromBody] RenameAttachmentRequest request, CancellationToken cancellationToken)
{
var att = await _db.Attachments.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
if (att is null) return NotFound();
var name = Path.GetFileName((request.FileName ?? "").Trim());
if (name.Length == 0) return BadRequest("FileName is required.");
var folder = Path.GetDirectoryName(att.FilePath) ?? _paths.AttachmentsRoot;
var newPath = Path.Combine(folder, name);
if (System.IO.File.Exists(att.FilePath) && !string.Equals(att.FilePath, newPath, StringComparison.OrdinalIgnoreCase))
{
System.IO.File.Move(att.FilePath, newPath, overwrite: true);
}
att.FileName = name;
att.FilePath = newPath;
await _db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete([FromRoute] int id, CancellationToken cancellationToken)
{
var att = await _db.Attachments.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
if (att is null) return NotFound();
var path = att.FilePath;
_db.Attachments.Remove(att);
await _db.SaveChangesAsync(cancellationToken);
try
{
if (!string.IsNullOrWhiteSpace(path) && System.IO.File.Exists(path))
System.IO.File.Delete(path);
}
catch
{
// best effort
}
return NoContent();
}
[HttpPost]
public async Task<IActionResult> Upload([FromForm] IFormFileCollection files, [FromForm] int jobId, CancellationToken cancellationToken)
{
if (jobId <= 0) return BadRequest("Valid jobId is required.");
if (files is null || files.Count == 0) return BadRequest("At least one file is required.");
var jobExists = await _db.JobApplications.AnyAsync(j => j.Id == jobId, cancellationToken);
if (!jobExists) return BadRequest("jobId does not exist.");
var folder = Path.Combine(_paths.AttachmentsRoot, jobId.ToString());
Directory.CreateDirectory(folder);
foreach (var file in files)
{
if (file.Length == 0) continue;
var safeName = Path.GetFileName(file.FileName);
var path = Path.Combine(folder, safeName);
await using var stream = new FileStream(path, FileMode.Create);
await file.CopyToAsync(stream, cancellationToken);
_db.Attachments.Add(new Attachment
{
JobApplicationId = jobId,
FileName = safeName,
FilePath = path,
UploadDate = DateTime.Now,
FileType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType,
FileSize = file.Length
});
}
await _db.SaveChangesAsync(cancellationToken);
return Ok();
}
}
}
+233
View File
@@ -0,0 +1,233 @@
using System.Security.Claims;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/auth")]
public sealed class AuthController : ControllerBase
{
private readonly IConfiguration _cfg;
private readonly UserManager<ApplicationUser> _users;
private readonly ITokenService _tokens;
private readonly IAppEmailSender _email;
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email)
{
_cfg = cfg;
_users = users;
_tokens = tokens;
_email = email;
}
[HttpGet("config")]
[AllowAnonymous]
public IActionResult Config()
{
var requireAuth = _cfg.GetValue("Auth:Require", false);
var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? "").Trim());
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
return Ok(new
{
requireAuth,
googleEnabled,
localEnabled = true,
allowRegistration
});
}
public sealed record LoginRequest(string Email, string Password);
public sealed record RegisterRequest(string Email, string Password);
public sealed record AuthResult(string AccessToken, string TokenType);
[HttpPost("login")]
[AllowAnonymous]
public async Task<ActionResult<AuthResult>> Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
{
var email = (request.Email ?? "").Trim();
var password = request.Password ?? "";
if (email.Length == 0) return BadRequest("Email is required.");
if (password.Length == 0) return BadRequest("Password is required.");
var user = await _users.FindByEmailAsync(email) ?? await _users.FindByNameAsync(email);
if (user is null) return Unauthorized();
var ok = await _users.CheckPasswordAsync(user, password);
if (!ok) return Unauthorized();
var token = await _tokens.CreateAccessTokenAsync(user, cancellationToken);
return Ok(new AuthResult(token, "Bearer"));
}
[HttpPost("register")]
[AllowAnonymous]
public async Task<ActionResult<AuthResult>> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken)
{
var allow = _cfg.GetValue("Auth:AllowRegistration", false);
if (!allow) return StatusCode(403, "Registration is disabled.");
var email = (request.Email ?? "").Trim();
var password = request.Password ?? "";
if (email.Length == 0) return BadRequest("Email is required.");
if (password.Length == 0) return BadRequest("Password is required.");
var existing = await _users.FindByEmailAsync(email);
if (existing is not null) return BadRequest("User already exists.");
var user = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = true };
var res = await _users.CreateAsync(user, password);
if (!res.Succeeded)
{
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
}
var token = await _tokens.CreateAccessTokenAsync(user, cancellationToken);
return Ok(new AuthResult(token, "Bearer"));
}
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> Me(CancellationToken cancellationToken)
{
var u = await _users.GetUserAsync(User);
if (u is not null)
{
var roles = await _users.GetRolesAsync(u);
return Ok(new
{
provider = "local",
id = u.Id,
email = u.Email,
userName = u.UserName,
roles
});
}
// Google direct bearer tokens (or any other external JWT) wont map to an Identity user.
var email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email");
var sub = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
var iss = User.FindFirstValue("iss") ?? "";
var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase) ? "google" : "external";
return Ok(new
{
provider,
id = sub,
email,
roles = Array.Empty<string>()
});
}
public sealed record UpdateProfileRequest(string? Email, string? UserName);
[HttpPut("profile")]
[Authorize]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request)
{
var u = await _users.GetUserAsync(User);
if (u is null)
{
// Google bearer tokens are accepted, but they don't map to Identity users in this build.
return StatusCode(501, "Profile updates are only supported for local username/password accounts.");
}
var email = string.IsNullOrWhiteSpace(request.Email) ? null : request.Email.Trim();
var userName = string.IsNullOrWhiteSpace(request.UserName) ? null : request.UserName.Trim();
if (email is not null) u.Email = email;
if (userName is not null) u.UserName = userName;
var res = await _users.UpdateAsync(u);
if (!res.Succeeded)
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
return NoContent();
}
public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword);
[HttpPost("change-password")]
[Authorize]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{
var u = await _users.GetUserAsync(User);
if (u is null)
{
return StatusCode(501, "Password changes are only supported for local username/password accounts.");
}
if (string.IsNullOrWhiteSpace(request.CurrentPassword)) return BadRequest("CurrentPassword is required.");
if (string.IsNullOrWhiteSpace(request.NewPassword)) return BadRequest("NewPassword is required.");
var res = await _users.ChangePasswordAsync(u, request.CurrentPassword, request.NewPassword);
if (!res.Succeeded)
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
return NoContent();
}
public sealed record RequestPasswordResetRequest(string Email);
[HttpPost("request-password-reset")]
[AllowAnonymous]
public async Task<IActionResult> RequestPasswordReset([FromBody] RequestPasswordResetRequest request, CancellationToken cancellationToken)
{
var email = (request.Email ?? "").Trim();
if (email.Length == 0) return NoContent();
var user = await _users.FindByEmailAsync(email);
if (user is null || string.IsNullOrWhiteSpace(user.Email))
{
// Avoid user enumeration.
return NoContent();
}
var token = await _users.GeneratePasswordResetTokenAsync(user);
var baseUrl = (_cfg["App:PublicBaseUrl"] ?? "").Trim().TrimEnd('/');
if (string.IsNullOrWhiteSpace(baseUrl))
{
baseUrl = $"{Request.Scheme}://{Request.Host}";
}
var link = $"{baseUrl}/reset-password?email={Uri.EscapeDataString(user.Email)}&token={Uri.EscapeDataString(token)}";
await _email.SendAsync(
user.Email,
"Password reset",
$"You requested a password reset for Job Tracker.\n\nReset link:\n{link}\n\nIf you did not request this, you can ignore this email.",
cancellationToken
);
return NoContent();
}
public sealed record ResetPasswordRequest(string Email, string Token, string NewPassword);
[HttpPost("reset-password")]
[AllowAnonymous]
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request)
{
var email = (request.Email ?? "").Trim();
var token = request.Token ?? "";
var newPassword = request.NewPassword ?? "";
if (email.Length == 0) return BadRequest("Email is required.");
if (token.Length == 0) return BadRequest("Token is required.");
if (newPassword.Length == 0) return BadRequest("NewPassword is required.");
var user = await _users.FindByEmailAsync(email);
if (user is null) return BadRequest("Invalid email or token.");
var res = await _users.ResetPasswordAsync(user, token, newPassword);
if (!res.Succeeded)
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
return NoContent();
}
}
@@ -0,0 +1,92 @@
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using JobTrackerApi.Data;
namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/backup")]
public class BackupController : ControllerBase
{
private readonly JobTrackerContext _db;
private readonly ILogger<BackupController> _logger;
private readonly IDataProtector _protector;
public BackupController(JobTrackerContext db, ILogger<BackupController> logger, IDataProtectionProvider dp)
{
_db = db;
_logger = logger;
_protector = dp.CreateProtector("JobTrackerApi.Backup.v1");
}
public sealed record BackupEnvelope(
string Version,
DateTime CreatedAt,
object Data
);
[HttpPost("encrypted")]
public async Task<IActionResult> Encrypted(CancellationToken cancellationToken)
{
if (!OperatingSystem.IsWindows())
{
return StatusCode(501, "Encrypted backups are only implemented for Windows (DPAPI) in this build.");
}
var data = await BuildExport(cancellationToken);
var envelope = new BackupEnvelope("jtbackup.v1", DateTime.Now, data);
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(
envelope,
new JsonSerializerOptions { WriteIndented = true }
);
// Data Protection encrypts payload using the app's key ring.
// On Windows, keys are encrypted at rest for the current user.
var protectedText = _protector.Protect(Convert.ToBase64String(jsonBytes));
var cipher = Encoding.UTF8.GetBytes(protectedText);
var fileName = $"jobtracker_backup_{DateTime.Now:yyyyMMdd_HHmmss}.jtbackup";
_logger.LogInformation("Generated encrypted backup {FileName} ({Bytes} bytes).", fileName, cipher.Length);
return File(cipher, "application/octet-stream", fileName);
}
private async Task<object> BuildExport(CancellationToken cancellationToken)
{
// Avoid navigation cycles by exporting as plain graphs / DTOs.
var companies = await _db.Companies.AsNoTracking().OrderBy(c => c.Name).ToListAsync(cancellationToken);
var jobs = await _db.JobApplications.AsNoTracking().OrderByDescending(j => j.DateApplied).ToListAsync(cancellationToken);
var jobIds = jobs.Select(j => j.Id).ToList();
var correspondence = await _db.Correspondences.AsNoTracking()
.Where(c => jobIds.Contains(c.JobApplicationId))
.OrderBy(c => c.Date)
.ToListAsync(cancellationToken);
var attachments = await _db.Attachments.AsNoTracking()
.Where(a => jobIds.Contains(a.JobApplicationId))
.OrderBy(a => a.UploadDate)
.ToListAsync(cancellationToken);
var events = await _db.JobEvents.AsNoTracking()
.Where(e => jobIds.Contains(e.JobApplicationId))
.OrderBy(e => e.At)
.ToListAsync(cancellationToken);
var rules = await _db.RuleSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
return new
{
Companies = companies,
JobApplications = jobs,
Correspondence = correspondence,
Attachments = attachments,
Events = events,
Rules = rules
};
}
}
}
@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Mvc;
namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/client-errors")]
public class ClientErrorsController : ControllerBase
{
private readonly ILogger<ClientErrorsController> _logger;
public ClientErrorsController(ILogger<ClientErrorsController> logger)
{
_logger = logger;
}
public sealed record ClientErrorReport(
string? ErrorId,
string? Message,
string? Stack,
string? ComponentStack,
string? Url,
string? UserAgent,
string? At
);
[HttpPost]
public IActionResult Report([FromBody] ClientErrorReport report)
{
_logger.LogError(
"ClientError {ErrorId} at {At} url={Url} ua={UserAgent} msg={Message}\n{Stack}\n{ComponentStack}",
report.ErrorId ?? "unknown",
report.At ?? "unknown",
report.Url ?? "unknown",
report.UserAgent ?? "unknown",
report.Message ?? "unknown",
report.Stack ?? "",
report.ComponentStack ?? ""
);
return NoContent();
}
}
}
@@ -0,0 +1,127 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using System.Security.Claims;
namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/companies")]
public class CompaniesController : ControllerBase
{
private readonly JobTrackerContext _db;
public CompaniesController(JobTrackerContext db)
{
_db = db;
}
private string? CurrentUserId =>
User?.FindFirstValue(ClaimTypes.NameIdentifier) ?? User?.FindFirstValue("sub");
[HttpGet]
public async Task<ActionResult<List<Company>>> GetAll(CancellationToken cancellationToken)
{
var userId = CurrentUserId;
var q = _db.Companies.AsQueryable();
if (!string.IsNullOrWhiteSpace(userId))
q = q.Where(c => c.OwnerUserId == userId);
var companies = await q
.OrderBy(c => c.Name)
.ToListAsync(cancellationToken);
return Ok(companies);
}
[HttpGet("{id:int}")]
public async Task<ActionResult<Company>> GetById([FromRoute] int id, CancellationToken cancellationToken)
{
var userId = CurrentUserId;
var q = _db.Companies.AsQueryable();
if (!string.IsNullOrWhiteSpace(userId))
q = q.Where(c => c.OwnerUserId == userId);
var company = await q.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
if (company is null) return NotFound();
return Ok(company);
}
public sealed record CreateCompanyRequest(string Name, string? Location, string? Source);
public sealed record UpdateCompanyRequest(
string Name,
string? Location,
string? Source,
string? RecruiterName,
string? RecruiterEmail,
string? RecruiterLinkedIn,
DateTime? LastContactedAt,
DateTime? NextContactAt,
string? PipelineStage
);
[HttpPost]
public async Task<ActionResult<Company>> Create([FromBody] CreateCompanyRequest request, CancellationToken cancellationToken)
{
var userId = CurrentUserId;
var name = (request.Name ?? "").Trim();
if (name.Length == 0) return BadRequest("Company name is required.");
var existingQuery = _db.Companies.AsQueryable();
if (!string.IsNullOrWhiteSpace(userId))
existingQuery = existingQuery.Where(c => c.OwnerUserId == userId);
var existing = await existingQuery
.FirstOrDefaultAsync(c => c.Name.ToLower() == name.ToLower(), cancellationToken);
if (existing is not null)
{
// Idempotent create: return existing instead of failing.
return Ok(existing);
}
var company = new Company
{
OwnerUserId = string.IsNullOrWhiteSpace(userId) ? null : userId,
Name = name,
Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim(),
Source = string.IsNullOrWhiteSpace(request.Source) ? null : request.Source.Trim(),
};
_db.Companies.Add(company);
await _db.SaveChangesAsync(cancellationToken);
return CreatedAtAction(nameof(GetById), new { id = company.Id }, company);
}
[HttpPut("{id:int}")]
public async Task<ActionResult<Company>> Update([FromRoute] int id, [FromBody] UpdateCompanyRequest request, CancellationToken cancellationToken)
{
var userId = CurrentUserId;
var q = _db.Companies.AsQueryable();
if (!string.IsNullOrWhiteSpace(userId))
q = q.Where(c => c.OwnerUserId == userId);
var company = await q.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
if (company is null) return NotFound();
var name = (request.Name ?? "").Trim();
if (name.Length == 0) return BadRequest("Company name is required.");
company.Name = name;
company.Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim();
company.Source = string.IsNullOrWhiteSpace(request.Source) ? null : request.Source.Trim();
company.RecruiterName = string.IsNullOrWhiteSpace(request.RecruiterName) ? null : request.RecruiterName.Trim();
company.RecruiterEmail = string.IsNullOrWhiteSpace(request.RecruiterEmail) ? null : request.RecruiterEmail.Trim();
company.RecruiterLinkedIn = string.IsNullOrWhiteSpace(request.RecruiterLinkedIn) ? null : request.RecruiterLinkedIn.Trim();
company.PipelineStage = string.IsNullOrWhiteSpace(request.PipelineStage) ? null : request.PipelineStage.Trim();
company.LastContactedAt = request.LastContactedAt;
company.NextContactAt = request.NextContactAt;
await _db.SaveChangesAsync(cancellationToken);
return Ok(company);
}
}
}
@@ -0,0 +1,71 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/correspondence")]
public class CorrespondenceController : ControllerBase
{
private readonly JobTrackerContext _db;
public CorrespondenceController(JobTrackerContext db)
{
_db = db;
}
// GET all messages for a job
[HttpGet("{jobId:int}")]
public async Task<ActionResult<List<Correspondence>>> GetForJob([FromRoute] int jobId, CancellationToken cancellationToken)
{
var jobOk = await _db.JobApplications.AnyAsync(j => j.Id == jobId, cancellationToken);
if (!jobOk) return NotFound();
var messages = await _db.Correspondences
.Where(c => c.JobApplicationId == jobId)
.OrderBy(c => c.Date)
.ToListAsync(cancellationToken);
return Ok(messages);
}
public sealed record CreateCorrespondenceRequest(int JobApplicationId, string From, string Content);
public sealed record CreateCorrespondenceRequestV2(
int JobApplicationId,
string From,
string Content,
string? Subject,
string? Channel,
DateTime? Date
);
// POST new message
[HttpPost]
public async Task<ActionResult<Correspondence>> Create([FromBody] CreateCorrespondenceRequestV2 request, CancellationToken cancellationToken)
{
if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
if (string.IsNullOrWhiteSpace(request.From)) return BadRequest("From is required.");
if (string.IsNullOrWhiteSpace(request.Content)) return BadRequest("Content is required.");
var exists = await _db.JobApplications.AnyAsync(j => j.Id == request.JobApplicationId, cancellationToken);
if (!exists) return BadRequest("jobApplicationId does not exist.");
var message = new Correspondence
{
JobApplicationId = request.JobApplicationId,
From = request.From.Trim(),
Subject = string.IsNullOrWhiteSpace(request.Subject) ? null : request.Subject.Trim(),
Channel = string.IsNullOrWhiteSpace(request.Channel) ? null : request.Channel.Trim(),
Content = request.Content,
Date = request.Date ?? DateTime.Now,
};
_db.Correspondences.Add(message);
await _db.SaveChangesAsync(cancellationToken);
return CreatedAtAction(nameof(GetForJob), new { jobId = message.JobApplicationId }, message);
}
}
}
@@ -0,0 +1,92 @@
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using JobTrackerApi.Data;
namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/export")]
public class ExportController : ControllerBase
{
private readonly JobTrackerContext _db;
public ExportController(JobTrackerContext db)
{
_db = db;
}
[HttpGet("jobs")]
public async Task<IActionResult> ExportJobs(
[FromQuery] string format = "json",
[FromQuery] bool includeDeleted = false,
CancellationToken cancellationToken = default
)
{
var query = _db.JobApplications
.AsNoTracking()
.Include(j => j.Company)
.AsQueryable();
if (!includeDeleted) query = query.Where(j => !j.IsDeleted);
var jobs = await query
.OrderByDescending(j => j.DateApplied)
.ToListAsync(cancellationToken);
var stamp = DateTime.Now.ToString("yyyy-MM-dd");
if (string.Equals(format, "csv", StringComparison.OrdinalIgnoreCase))
{
static string Esc(string? s)
{
s ??= "";
var needs = s.Contains(',') || s.Contains('"') || s.Contains('\n') || s.Contains('\r');
var q = s.Replace("\"", "\"\"");
return needs ? $"\"{q}\"" : q;
}
var sb = new StringBuilder();
sb.AppendLine(string.Join(",",
"Company",
"CompanyLocation",
"CompanySource",
"JobTitle",
"Status",
"DateApplied",
"Location",
"Salary",
"NextAction",
"FollowUpAt",
"JobUrl",
"Notes",
"CoverLetterText"
));
foreach (var j in jobs)
{
sb.AppendLine(string.Join(",",
Esc(j.Company?.Name),
Esc(j.Company?.Location),
Esc(j.Company?.Source),
Esc(j.JobTitle),
Esc(j.Status),
Esc(j.DateApplied.ToString("o")),
Esc(j.Location),
Esc(j.Salary),
Esc(j.NextAction),
Esc(j.FollowUpAt?.ToString("o")),
Esc(j.JobUrl),
Esc(j.Notes),
Esc(j.CoverLetterText)
));
}
return File(Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", $"job-tracker-export-{stamp}.csv");
}
return File(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(jobs), "application/json", $"job-tracker-export-{stamp}.json");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Mvc;
using JobTrackerApi.Services.JobImport;
namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/jobimport")]
public sealed class JobImportController : ControllerBase
{
private readonly JobImportService _import;
public JobImportController(JobImportService import)
{
_import = import;
}
public sealed record PreviewRequest(string Url);
[HttpPost("preview")]
public async Task<ActionResult<JobImportResult>> Preview([FromBody] PreviewRequest request, CancellationToken cancellationToken)
{
var result = await _import.PreviewAsync(request?.Url ?? "", cancellationToken);
if (!result.Success) return BadRequest(result);
return Ok(result);
}
}
@@ -0,0 +1,101 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/rules")]
public class RulesController : ControllerBase
{
private readonly JobTrackerContext _db;
public RulesController(JobTrackerContext db)
{
_db = db;
}
[HttpGet]
public async Task<ActionResult<RuleSettings>> Get(CancellationToken cancellationToken)
{
// Per-user rule settings when authenticated.
if (!string.IsNullOrWhiteSpace(_db.CurrentUserId))
{
var u = await _db.UserRuleSettings.FirstOrDefaultAsync(x => x.OwnerUserId == _db.CurrentUserId, cancellationToken);
if (u is null)
{
u = new UserRuleSettings { OwnerUserId = _db.CurrentUserId };
_db.UserRuleSettings.Add(u);
await _db.SaveChangesAsync(cancellationToken);
}
return Ok(new RuleSettings
{
Id = 1,
AppliedFollowUpDays = u.AppliedFollowUpDays,
AppliedGhostDays = u.AppliedGhostDays,
OfferFollowUpDays = u.OfferFollowUpDays,
OfferGhostDays = u.OfferGhostDays,
FeedbackFollowUpDays = u.FeedbackFollowUpDays,
FeedbackGhostDays = u.FeedbackGhostDays,
});
}
// Fallback global settings.
var s = await _db.RuleSettings.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
if (s is null)
{
s = new RuleSettings { Id = 1 };
_db.RuleSettings.Add(s);
await _db.SaveChangesAsync(cancellationToken);
}
return Ok(s);
}
[HttpPut]
public async Task<IActionResult> Update([FromBody] RuleSettings incoming, CancellationToken cancellationToken)
{
// Per-user rule settings when authenticated.
if (!string.IsNullOrWhiteSpace(_db.CurrentUserId))
{
var s = await _db.UserRuleSettings.FirstOrDefaultAsync(x => x.OwnerUserId == _db.CurrentUserId, cancellationToken);
if (s is null)
{
s = new UserRuleSettings { OwnerUserId = _db.CurrentUserId };
_db.UserRuleSettings.Add(s);
}
s.AppliedFollowUpDays = Clamp(incoming.AppliedFollowUpDays, 1, 365);
s.AppliedGhostDays = Clamp(incoming.AppliedGhostDays, 1, 365);
s.OfferFollowUpDays = Clamp(incoming.OfferFollowUpDays, 1, 365);
s.OfferGhostDays = Clamp(incoming.OfferGhostDays, 1, 365);
s.FeedbackFollowUpDays = Clamp(incoming.FeedbackFollowUpDays, 1, 365);
s.FeedbackGhostDays = Clamp(incoming.FeedbackGhostDays, 1, 365);
await _db.SaveChangesAsync(cancellationToken);
return NoContent();
}
// Fallback global settings.
var g = await _db.RuleSettings.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
if (g is null)
{
g = new RuleSettings { Id = 1 };
_db.RuleSettings.Add(g);
}
g.AppliedFollowUpDays = Clamp(incoming.AppliedFollowUpDays, 1, 365);
g.AppliedGhostDays = Clamp(incoming.AppliedGhostDays, 1, 365);
g.OfferFollowUpDays = Clamp(incoming.OfferFollowUpDays, 1, 365);
g.OfferGhostDays = Clamp(incoming.OfferGhostDays, 1, 365);
g.FeedbackFollowUpDays = Clamp(incoming.FeedbackFollowUpDays, 1, 365);
g.FeedbackGhostDays = Clamp(incoming.FeedbackGhostDays, 1, 365);
await _db.SaveChangesAsync(cancellationToken);
return NoContent();
}
private static int Clamp(int v, int min, int max) => v < min ? min : v > max ? max : v;
}
}
@@ -0,0 +1,143 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/users")]
[Authorize(Roles = "Admin")]
public sealed class UsersController : ControllerBase
{
private readonly UserManager<ApplicationUser> _users;
private readonly RoleManager<IdentityRole> _roles;
private readonly IAppEmailSender _email;
private readonly IConfiguration _cfg;
public UsersController(UserManager<ApplicationUser> users, RoleManager<IdentityRole> roles, IAppEmailSender email, IConfiguration cfg)
{
_users = users;
_roles = roles;
_email = email;
_cfg = cfg;
}
public sealed record UserDto(string Id, string? Email, string? UserName, bool EmailConfirmed, List<string> Roles);
[HttpGet]
public async Task<ActionResult<List<UserDto>>> List(CancellationToken cancellationToken)
{
var items = await _users.Users
.OrderBy(u => u.Email)
.ToListAsync(cancellationToken);
var outList = new List<UserDto>(items.Count);
foreach (var u in items)
{
var rs = await _users.GetRolesAsync(u);
outList.Add(new UserDto(u.Id, u.Email, u.UserName, u.EmailConfirmed, rs.ToList()));
}
return Ok(outList);
}
public sealed record CreateUserRequest(string Email, string Password, string[]? Roles);
[HttpPost]
public async Task<ActionResult<UserDto>> Create([FromBody] CreateUserRequest request, CancellationToken cancellationToken)
{
var email = (request.Email ?? "").Trim();
var password = request.Password ?? "";
if (email.Length == 0) return BadRequest("Email is required.");
if (password.Length == 0) return BadRequest("Password is required.");
var existing = await _users.FindByEmailAsync(email);
if (existing is not null) return BadRequest("User already exists.");
var u = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = true };
var res = await _users.CreateAsync(u, password);
if (!res.Succeeded)
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
var roles = (request.Roles ?? Array.Empty<string>()).Select(r => (r ?? "").Trim()).Where(r => r.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
foreach (var r in roles)
{
if (!await _roles.RoleExistsAsync(r))
await _roles.CreateAsync(new IdentityRole(r));
await _users.AddToRoleAsync(u, r);
}
var rs = await _users.GetRolesAsync(u);
return Ok(new UserDto(u.Id, u.Email, u.UserName, u.EmailConfirmed, rs.ToList()));
}
public sealed record SetRolesRequest(string[] Roles);
[HttpPut("{id}/roles")]
public async Task<IActionResult> SetRoles([FromRoute] string id, [FromBody] SetRolesRequest request, CancellationToken cancellationToken)
{
var u = await _users.FindByIdAsync(id);
if (u is null) return NotFound();
var desired = (request.Roles ?? Array.Empty<string>()).Select(r => (r ?? "").Trim()).Where(r => r.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var current = await _users.GetRolesAsync(u);
var toRemove = current.Where(r => !desired.Contains(r, StringComparer.OrdinalIgnoreCase)).ToList();
var toAdd = desired.Where(r => !current.Contains(r, StringComparer.OrdinalIgnoreCase)).ToList();
if (toRemove.Count > 0)
await _users.RemoveFromRolesAsync(u, toRemove);
foreach (var r in toAdd)
{
if (!await _roles.RoleExistsAsync(r))
await _roles.CreateAsync(new IdentityRole(r));
await _users.AddToRoleAsync(u, r);
}
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete([FromRoute] string id, CancellationToken cancellationToken)
{
var u = await _users.FindByIdAsync(id);
if (u is null) return NotFound();
var res = await _users.DeleteAsync(u);
if (!res.Succeeded)
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
return NoContent();
}
[HttpPost("{id}/send-password-reset")]
public async Task<IActionResult> SendPasswordReset([FromRoute] string id, CancellationToken cancellationToken)
{
var u = await _users.FindByIdAsync(id);
if (u is null) return NotFound();
if (string.IsNullOrWhiteSpace(u.Email)) return BadRequest("User has no email.");
var token = await _users.GeneratePasswordResetTokenAsync(u);
var baseUrl = (_cfg["App:PublicBaseUrl"] ?? "").Trim().TrimEnd('/');
if (string.IsNullOrWhiteSpace(baseUrl))
{
baseUrl = $"{Request.Scheme}://{Request.Host}";
}
var link = $"{baseUrl}/reset-password?email={Uri.EscapeDataString(u.Email)}&token={Uri.EscapeDataString(token)}";
await _email.SendAsync(
u.Email,
"Password reset",
$"An admin initiated a password reset for your Job Tracker account.\n\nReset link:\n{link}\n",
cancellationToken
);
return NoContent();
}
}
+24
View File
@@ -0,0 +1,24 @@
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY JobTrackerApi/JobTrackerApi.csproj JobTrackerApi/
COPY Data/ Data/
COPY Models/ Models/
COPY JobTrackerApi/ JobTrackerApi/
RUN dotnet publish JobTrackerApi/JobTrackerApi.csproj -c Release -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080
RUN mkdir -p /data
COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet","JobTrackerApi.dll"]
+23
View File
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RestoreIgnoreFailedSources>true</RestoreIgnoreFailedSources>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\Data\**\*.cs" />
<Compile Include="..\Models\**\*.cs" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
</ItemGroup>
</Project>
+6
View File
@@ -0,0 +1,6 @@
@JobTrackerApi_HostAddress = http://localhost:5202
GET {{JobTrackerApi_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -0,0 +1,146 @@
// <auto-generated />
using System;
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace JobTrackerApi.Migrations
{
[DbContext(typeof(JobTrackerContext))]
[Migration("20260310174114_AddCorrespondence")]
partial class AddCorrespondence
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.17");
modelBuilder.Entity("JobTrackerApi.Models.Company", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Companies");
});
modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<string>("From")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.ToTable("Correspondences");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CompanyId")
.HasColumnType("INTEGER");
b.Property<string>("CoverLetterText")
.HasColumnType("TEXT");
b.Property<DateTime>("DateApplied")
.HasColumnType("TEXT");
b.Property<string>("JobTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("JobUrl")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<DateTime?>("ResponseDate")
.HasColumnType("TEXT");
b.Property<bool>("ResponseReceived")
.HasColumnType("INTEGER");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("Applied");
b.HasKey("Id");
b.HasIndex("CompanyId");
b.ToTable("JobApplications");
});
modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany("Messages")
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.HasOne("JobTrackerApi.Models.Company", "Company")
.WithMany("Jobs")
.HasForeignKey("CompanyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Company");
});
modelBuilder.Entity("JobTrackerApi.Models.Company", b =>
{
b.Navigation("Jobs");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.Navigation("Messages");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,101 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
/// <inheritdoc />
public partial class AddCorrespondence : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Companies",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: false),
Location = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Companies", x => x.Id);
});
migrationBuilder.CreateTable(
name: "JobApplications",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
CompanyId = table.Column<int>(type: "INTEGER", nullable: false),
JobTitle = table.Column<string>(type: "TEXT", nullable: false),
DateApplied = table.Column<DateTime>(type: "TEXT", nullable: false),
Status = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "Applied"),
ResponseReceived = table.Column<bool>(type: "INTEGER", nullable: false),
ResponseDate = table.Column<DateTime>(type: "TEXT", nullable: true),
Notes = table.Column<string>(type: "TEXT", nullable: true),
CoverLetterText = table.Column<string>(type: "TEXT", nullable: true),
JobUrl = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_JobApplications", x => x.Id);
table.ForeignKey(
name: "FK_JobApplications_Companies_CompanyId",
column: x => x.CompanyId,
principalTable: "Companies",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Correspondences",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
JobApplicationId = table.Column<int>(type: "INTEGER", nullable: false),
Date = table.Column<DateTime>(type: "TEXT", nullable: false),
From = table.Column<string>(type: "TEXT", nullable: false),
Content = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Correspondences", x => x.Id);
table.ForeignKey(
name: "FK_Correspondences_JobApplications_JobApplicationId",
column: x => x.JobApplicationId,
principalTable: "JobApplications",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Correspondences_JobApplicationId",
table: "Correspondences",
column: "JobApplicationId");
migrationBuilder.CreateIndex(
name: "IX_JobApplications_CompanyId",
table: "JobApplications",
column: "CompanyId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Correspondences");
migrationBuilder.DropTable(
name: "JobApplications");
migrationBuilder.DropTable(
name: "Companies");
}
}
}
@@ -0,0 +1,168 @@
// <auto-generated />
using System;
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace JobTrackerApi.Migrations
{
[DbContext(typeof(JobTrackerContext))]
[Migration("20260310195000_AddJobFieldsAndSoftDelete")]
partial class AddJobFieldsAndSoftDelete
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.17");
modelBuilder.Entity("JobTrackerApi.Models.Company", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Source")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Companies");
});
modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<string>("From")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.ToTable("Correspondences");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CompanyId")
.HasColumnType("INTEGER");
b.Property<string>("CoverLetterText")
.HasColumnType("TEXT");
b.Property<DateTime>("DateApplied")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("FollowUpAt")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("JobTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("JobUrl")
.HasColumnType("TEXT");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("NextAction")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<DateTime?>("ResponseDate")
.HasColumnType("TEXT");
b.Property<bool>("ResponseReceived")
.HasColumnType("INTEGER");
b.Property<string>("Salary")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("Applied");
b.HasKey("Id");
b.HasIndex("CompanyId");
b.ToTable("JobApplications");
});
modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany("Messages")
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.HasOne("JobTrackerApi.Models.Company", "Company")
.WithMany("Jobs")
.HasForeignKey("CompanyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Company");
});
modelBuilder.Entity("JobTrackerApi.Models.Company", b =>
{
b.Navigation("Jobs");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.Navigation("Messages");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,91 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
/// <inheritdoc />
public partial class AddJobFieldsAndSoftDelete : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Source",
table: "Companies",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsDeleted",
table: "JobApplications",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "DeletedAt",
table: "JobApplications",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Location",
table: "JobApplications",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Salary",
table: "JobApplications",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "NextAction",
table: "JobApplications",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "FollowUpAt",
table: "JobApplications",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Source",
table: "Companies");
migrationBuilder.DropColumn(
name: "IsDeleted",
table: "JobApplications");
migrationBuilder.DropColumn(
name: "DeletedAt",
table: "JobApplications");
migrationBuilder.DropColumn(
name: "Location",
table: "JobApplications");
migrationBuilder.DropColumn(
name: "Salary",
table: "JobApplications");
migrationBuilder.DropColumn(
name: "NextAction",
table: "JobApplications");
migrationBuilder.DropColumn(
name: "FollowUpAt",
table: "JobApplications");
}
}
}
@@ -0,0 +1,210 @@
// <auto-generated />
using System;
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace JobTrackerApi.Migrations
{
[DbContext(typeof(JobTrackerContext))]
[Migration("20260311090000_AddAutomationRulesAndFields")]
partial class AddAutomationRulesAndFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.17");
modelBuilder.Entity("JobTrackerApi.Models.Company", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Source")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Companies");
});
modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<string>("From")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.ToTable("Correspondences");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CompanyId")
.HasColumnType("INTEGER");
b.Property<string>("CoverLetterText")
.HasColumnType("TEXT");
b.Property<DateTime>("DateApplied")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("FeedbackRequestedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("FollowUpAt")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("JobTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("JobUrl")
.HasColumnType("TEXT");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("NextAction")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<DateTime?>("ResponseDate")
.HasColumnType("TEXT");
b.Property<bool>("ResponseReceived")
.HasColumnType("INTEGER");
b.Property<string>("Salary")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("Applied");
b.HasKey("Id");
b.HasIndex("CompanyId");
b.ToTable("JobApplications");
});
modelBuilder.Entity("JobTrackerApi.Models.RuleSettings", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<int>("AppliedFollowUpDays")
.HasColumnType("INTEGER");
b.Property<int>("AppliedGhostDays")
.HasColumnType("INTEGER");
b.Property<int>("FeedbackFollowUpDays")
.HasColumnType("INTEGER");
b.Property<int>("FeedbackGhostDays")
.HasColumnType("INTEGER");
b.Property<int>("OfferFollowUpDays")
.HasColumnType("INTEGER");
b.Property<int>("OfferGhostDays")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("RuleSettings");
b.HasData(new
{
Id = 1,
AppliedFollowUpDays = 14,
AppliedGhostDays = 30,
OfferFollowUpDays = 7,
OfferGhostDays = 14,
FeedbackFollowUpDays = 7,
FeedbackGhostDays = 14
});
});
modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany("Messages")
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.HasOne("JobTrackerApi.Models.Company", "Company")
.WithMany("Jobs")
.HasForeignKey("CompanyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Company");
});
modelBuilder.Entity("JobTrackerApi.Models.Company", b =>
{
b.Navigation("Jobs");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.Navigation("Messages");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,54 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
/// <inheritdoc />
public partial class AddAutomationRulesAndFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "FeedbackRequestedAt",
table: "JobApplications",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "RuleSettings",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false),
AppliedFollowUpDays = table.Column<int>(type: "INTEGER", nullable: false),
AppliedGhostDays = table.Column<int>(type: "INTEGER", nullable: false),
OfferFollowUpDays = table.Column<int>(type: "INTEGER", nullable: false),
OfferGhostDays = table.Column<int>(type: "INTEGER", nullable: false),
FeedbackFollowUpDays = table.Column<int>(type: "INTEGER", nullable: false),
FeedbackGhostDays = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RuleSettings", x => x.Id);
});
migrationBuilder.InsertData(
table: "RuleSettings",
columns: new[] { "Id", "AppliedFollowUpDays", "AppliedGhostDays", "OfferFollowUpDays", "OfferGhostDays", "FeedbackFollowUpDays", "FeedbackGhostDays" },
values: new object[] { 1, 14, 30, 7, 14, 7, 14 });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "RuleSettings");
migrationBuilder.DropColumn(
name: "FeedbackRequestedAt",
table: "JobApplications");
}
}
}
@@ -0,0 +1,301 @@
// <auto-generated />
using System;
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace JobTrackerApi.Migrations
{
[DbContext(typeof(JobTrackerContext))]
[Migration("20260311093000_AddAttachmentsAndJobEvents")]
partial class AddAttachmentsAndJobEvents
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.17");
modelBuilder.Entity("JobTrackerApi.Models.Attachment", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FilePath")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FileType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("FileSize")
.HasColumnType("INTEGER");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UploadDate")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.ToTable("Attachments");
});
modelBuilder.Entity("JobTrackerApi.Models.Company", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Source")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Companies");
});
modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<string>("From")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.ToTable("Correspondences");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CompanyId")
.HasColumnType("INTEGER");
b.Property<string>("CoverLetterText")
.HasColumnType("TEXT");
b.Property<DateTime>("DateApplied")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("FeedbackRequestedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("FollowUpAt")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("JobTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("JobUrl")
.HasColumnType("TEXT");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("NextAction")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<DateTime?>("ResponseDate")
.HasColumnType("TEXT");
b.Property<bool>("ResponseReceived")
.HasColumnType("INTEGER");
b.Property<string>("Salary")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("Applied");
b.HasKey("Id");
b.HasIndex("CompanyId");
b.ToTable("JobApplications");
});
modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("At")
.HasColumnType("TEXT");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.Property<string>("NewValue")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("OldValue")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.ToTable("JobEvents");
});
modelBuilder.Entity("JobTrackerApi.Models.RuleSettings", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<int>("AppliedFollowUpDays")
.HasColumnType("INTEGER");
b.Property<int>("AppliedGhostDays")
.HasColumnType("INTEGER");
b.Property<int>("FeedbackFollowUpDays")
.HasColumnType("INTEGER");
b.Property<int>("FeedbackGhostDays")
.HasColumnType("INTEGER");
b.Property<int>("OfferFollowUpDays")
.HasColumnType("INTEGER");
b.Property<int>("OfferGhostDays")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("RuleSettings");
b.HasData(new
{
Id = 1,
AppliedFollowUpDays = 14,
AppliedGhostDays = 30,
OfferFollowUpDays = 7,
OfferGhostDays = 14,
FeedbackFollowUpDays = 7,
FeedbackGhostDays = 14
});
});
modelBuilder.Entity("JobTrackerApi.Models.Attachment", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany("Attachments")
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany("Messages")
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.HasOne("JobTrackerApi.Models.Company", "Company")
.WithMany("Jobs")
.HasForeignKey("CompanyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Company");
});
modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany("Events")
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.Company", b =>
{
b.Navigation("Jobs");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.Navigation("Attachments");
b.Navigation("Events");
b.Navigation("Messages");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
/// <inheritdoc />
public partial class AddAttachmentsAndJobEvents : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Attachments",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
JobApplicationId = table.Column<int>(type: "INTEGER", nullable: false),
FileName = table.Column<string>(type: "TEXT", nullable: false),
FilePath = table.Column<string>(type: "TEXT", nullable: false),
UploadDate = table.Column<DateTime>(type: "TEXT", nullable: false),
FileType = table.Column<string>(type: "TEXT", nullable: false),
FileSize = table.Column<long>(type: "INTEGER", nullable: false, defaultValue: 0L)
},
constraints: table =>
{
table.PrimaryKey("PK_Attachments", x => x.Id);
table.ForeignKey(
name: "FK_Attachments_JobApplications_JobApplicationId",
column: x => x.JobApplicationId,
principalTable: "JobApplications",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Attachments_JobApplicationId",
table: "Attachments",
column: "JobApplicationId");
migrationBuilder.CreateTable(
name: "JobEvents",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
JobApplicationId = table.Column<int>(type: "INTEGER", nullable: false),
Type = table.Column<string>(type: "TEXT", nullable: false),
OldValue = table.Column<string>(type: "TEXT", nullable: true),
NewValue = table.Column<string>(type: "TEXT", nullable: true),
Note = table.Column<string>(type: "TEXT", nullable: true),
At = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_JobEvents", x => x.Id);
table.ForeignKey(
name: "FK_JobEvents_JobApplications_JobApplicationId",
column: x => x.JobApplicationId,
principalTable: "JobApplications",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_JobEvents_JobApplicationId",
table: "JobEvents",
column: "JobApplicationId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "JobEvents");
migrationBuilder.DropTable(name: "Attachments");
}
}
}
@@ -0,0 +1,332 @@
// <auto-generated />
using System;
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace JobTrackerApi.Migrations
{
[DbContext(typeof(JobTrackerContext))]
[Migration("20260311103000_AddCompanyCrmAndChecklistAndIndexes")]
partial class AddCompanyCrmAndChecklistAndIndexes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.17");
modelBuilder.Entity("JobTrackerApi.Models.Attachment", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FilePath")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FileType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("FileSize")
.HasColumnType("INTEGER");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UploadDate")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.ToTable("Attachments");
});
modelBuilder.Entity("JobTrackerApi.Models.Company", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastContactedAt")
.HasColumnType("TEXT");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("NextContactAt")
.HasColumnType("TEXT");
b.Property<string>("PipelineStage")
.HasColumnType("TEXT");
b.Property<string>("RecruiterEmail")
.HasColumnType("TEXT");
b.Property<string>("RecruiterLinkedIn")
.HasColumnType("TEXT");
b.Property<string>("RecruiterName")
.HasColumnType("TEXT");
b.Property<string>("Source")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Companies");
});
modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<string>("From")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.ToTable("Correspondences");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CompanyId")
.HasColumnType("INTEGER");
b.Property<string>("CoverLetterText")
.HasColumnType("TEXT");
b.Property<DateTime>("DateApplied")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("FeedbackRequestedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("FollowUpAt")
.HasColumnType("TEXT");
b.Property<bool>("HasCoverLetter")
.HasColumnType("INTEGER");
b.Property<bool>("HasOtherAttachment")
.HasColumnType("INTEGER");
b.Property<bool>("HasPortfolio")
.HasColumnType("INTEGER");
b.Property<bool>("HasResume")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("JobTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("JobUrl")
.HasColumnType("TEXT");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("NextAction")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<DateTime?>("ResponseDate")
.HasColumnType("TEXT");
b.Property<bool>("ResponseReceived")
.HasColumnType("INTEGER");
b.Property<string>("Salary")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("Applied");
b.HasKey("Id");
b.HasIndex("CompanyId");
b.ToTable("JobApplications");
});
modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("At")
.HasColumnType("TEXT");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.Property<string>("NewValue")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("OldValue")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.ToTable("JobEvents");
});
modelBuilder.Entity("JobTrackerApi.Models.RuleSettings", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<int>("AppliedFollowUpDays")
.HasColumnType("INTEGER");
b.Property<int>("AppliedGhostDays")
.HasColumnType("INTEGER");
b.Property<int>("FeedbackFollowUpDays")
.HasColumnType("INTEGER");
b.Property<int>("FeedbackGhostDays")
.HasColumnType("INTEGER");
b.Property<int>("OfferFollowUpDays")
.HasColumnType("INTEGER");
b.Property<int>("OfferGhostDays")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("RuleSettings");
b.HasData(new
{
Id = 1,
AppliedFollowUpDays = 14,
AppliedGhostDays = 30,
OfferFollowUpDays = 7,
OfferGhostDays = 14,
FeedbackFollowUpDays = 7,
FeedbackGhostDays = 14
});
});
modelBuilder.Entity("JobTrackerApi.Models.Attachment", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany("Attachments")
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany("Messages")
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.HasOne("JobTrackerApi.Models.Company", "Company")
.WithMany("Jobs")
.HasForeignKey("CompanyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Company");
});
modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany("Events")
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.Company", b =>
{
b.Navigation("Jobs");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.Navigation("Attachments");
b.Navigation("Events");
b.Navigation("Messages");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,105 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
/// <inheritdoc />
public partial class AddCompanyCrmAndChecklistAndIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "RecruiterName",
table: "Companies",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "RecruiterEmail",
table: "Companies",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "RecruiterLinkedIn",
table: "Companies",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "LastContactedAt",
table: "Companies",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "NextContactAt",
table: "Companies",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PipelineStage",
table: "Companies",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "HasResume",
table: "JobApplications",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "HasCoverLetter",
table: "JobApplications",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "HasPortfolio",
table: "JobApplications",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "HasOtherAttachment",
table: "JobApplications",
type: "INTEGER",
nullable: false,
defaultValue: false);
// Indexes for common queries
migrationBuilder.Sql("CREATE INDEX IF NOT EXISTS IX_JobApplications_DateApplied ON JobApplications(DateApplied);");
migrationBuilder.Sql("CREATE INDEX IF NOT EXISTS IX_JobApplications_Status ON JobApplications(Status);");
migrationBuilder.Sql("CREATE INDEX IF NOT EXISTS IX_Companies_Name ON Companies(Name);");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DROP INDEX IF EXISTS IX_JobApplications_DateApplied;");
migrationBuilder.Sql("DROP INDEX IF EXISTS IX_JobApplications_Status;");
migrationBuilder.Sql("DROP INDEX IF EXISTS IX_Companies_Name;");
migrationBuilder.DropColumn(name: "RecruiterName", table: "Companies");
migrationBuilder.DropColumn(name: "RecruiterEmail", table: "Companies");
migrationBuilder.DropColumn(name: "RecruiterLinkedIn", table: "Companies");
migrationBuilder.DropColumn(name: "LastContactedAt", table: "Companies");
migrationBuilder.DropColumn(name: "NextContactAt", table: "Companies");
migrationBuilder.DropColumn(name: "PipelineStage", table: "Companies");
migrationBuilder.DropColumn(name: "HasResume", table: "JobApplications");
migrationBuilder.DropColumn(name: "HasCoverLetter", table: "JobApplications");
migrationBuilder.DropColumn(name: "HasPortfolio", table: "JobApplications");
migrationBuilder.DropColumn(name: "HasOtherAttachment", table: "JobApplications");
}
}
}
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
public partial class AddCorrespondenceEmailFields : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Subject",
table: "Correspondences",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Channel",
table: "Correspondences",
type: "TEXT",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "Subject", table: "Correspondences");
migrationBuilder.DropColumn(name: "Channel", table: "Correspondences");
}
}
}
@@ -0,0 +1,55 @@
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
[DbContext(typeof(JobTrackerApi.Data.JobTrackerContext))]
[Migration("20260311140000_AddJobImportFields")]
public partial class AddJobImportFields : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Description",
table: "JobApplications",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "TranslatedDescription",
table: "JobApplications",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DescriptionLanguage",
table: "JobApplications",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Tags",
table: "JobApplications",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "Deadline",
table: "JobApplications",
type: "TEXT",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "Description", table: "JobApplications");
migrationBuilder.DropColumn(name: "TranslatedDescription", table: "JobApplications");
migrationBuilder.DropColumn(name: "DescriptionLanguage", table: "JobApplications");
migrationBuilder.DropColumn(name: "Tags", table: "JobApplications");
migrationBuilder.DropColumn(name: "Deadline", table: "JobApplications");
}
}
}
@@ -0,0 +1,27 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
/// <inheritdoc />
public partial class AddShortSummary : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ShortSummary",
table: "JobApplications",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "ShortSummary", table: "JobApplications");
}
}
}
@@ -0,0 +1,316 @@
// <auto-generated />
using System;
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace JobTrackerApi.Migrations
{
[DbContext(typeof(JobTrackerContext))]
partial class JobTrackerContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.17");
modelBuilder.Entity("JobTrackerApi.Models.Company", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Source")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Companies");
});
modelBuilder.Entity("JobTrackerApi.Models.Attachment", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FilePath")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UploadDate")
.HasColumnType("TEXT");
b.Property<string>("FileType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("FileSize")
.HasColumnType("INTEGER");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.ToTable("Attachments");
});
modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<string>("From")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.ToTable("Correspondences");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CompanyId")
.HasColumnType("INTEGER");
b.Property<string>("CoverLetterText")
.HasColumnType("TEXT");
b.Property<DateTime>("DateApplied")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("Deadline")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<string>("DescriptionLanguage")
.HasColumnType("TEXT");
b.Property<DateTime?>("FeedbackRequestedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("FollowUpAt")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("JobTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("JobUrl")
.HasColumnType("TEXT");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("NextAction")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<DateTime?>("ResponseDate")
.HasColumnType("TEXT");
b.Property<bool>("ResponseReceived")
.HasColumnType("INTEGER");
b.Property<string>("Salary")
.HasColumnType("TEXT");
b.Property<string>("ShortSummary")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("Applied");
b.Property<string>("Tags")
.HasColumnType("TEXT");
b.Property<string>("TranslatedDescription")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CompanyId");
b.ToTable("JobApplications");
});
modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OldValue")
.HasColumnType("TEXT");
b.Property<string>("NewValue")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<DateTime>("At")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.ToTable("JobEvents");
});
modelBuilder.Entity("JobTrackerApi.Models.RuleSettings", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<int>("AppliedFollowUpDays")
.HasColumnType("INTEGER");
b.Property<int>("AppliedGhostDays")
.HasColumnType("INTEGER");
b.Property<int>("OfferFollowUpDays")
.HasColumnType("INTEGER");
b.Property<int>("OfferGhostDays")
.HasColumnType("INTEGER");
b.Property<int>("FeedbackFollowUpDays")
.HasColumnType("INTEGER");
b.Property<int>("FeedbackGhostDays")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("RuleSettings");
b.HasData(new
{
Id = 1,
AppliedFollowUpDays = 14,
AppliedGhostDays = 30,
OfferFollowUpDays = 7,
OfferGhostDays = 14,
FeedbackFollowUpDays = 7,
FeedbackGhostDays = 14
});
});
modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany("Messages")
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.Attachment", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany("Attachments")
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.HasOne("JobTrackerApi.Models.Company", "Company")
.WithMany("Jobs")
.HasForeignKey("CompanyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Company");
});
modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany("Events")
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.Company", b =>
{
b.Navigation("Jobs");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.Navigation("Attachments");
b.Navigation("Events");
b.Navigation("Messages");
});
#pragma warning restore 612, 618
}
}
}
+560
View File
@@ -0,0 +1,560 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using JobTrackerApi.Data;
using System.Data.Common;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using System.Diagnostics;
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Security.Cryptography;
using JobTrackerApi.Services.JobImport;
using JobTrackerApi.Services.JobImport.Plugins;
using JobTrackerApi.Services.JobImport.Translation;
var builder = WebApplication.CreateBuilder(args);
// Avoid Windows EventLog provider issues in local dev environments.
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddDebug();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddSingleton<IAppEmailSender, SmtpEmailSender>();
builder.Services.AddSingleton<AppPaths>();
// Add DbContext
builder.Services.AddDbContext<JobTrackerContext>((sp, options) =>
{
var cfg = sp.GetRequiredService<IConfiguration>();
var paths = sp.GetRequiredService<AppPaths>();
var cs = cfg.GetConnectionString("JobTracker");
if (string.IsNullOrWhiteSpace(cs))
{
cs = $"Data Source={paths.GetDbPath()}";
}
options.UseSqlite(cs);
// We create Identity tables on startup in environments where `dotnet ef` isn't available.
// That can cause EF to detect "pending model changes" and throw on Migrate(). Ignore it.
options.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning));
});
// Enable CORS (allowlist by default)
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowReact", policy =>
{
var origins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? Array.Empty<string>();
if (origins.Length == 0)
{
origins = new[] { "http://localhost:3000" };
}
if (origins.Any(x => x.Trim() == "*"))
{
policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader();
}
else
{
policy.WithOrigins(origins.Select(x => x.Trim()).Where(x => x.Length > 0).ToArray())
.AllowAnyMethod()
.AllowAnyHeader();
}
});
});
// Add controllers
builder.Services.AddControllers();
builder.Services.AddDataProtection();
builder.Services.AddHostedService<RulesHostedService>();
builder.Services.AddHostedService<DailyExportHostedService>();
builder.Services.AddHttpClient("jobimport")
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All
});
// Local summarizer service (FastAPI). Default URL can be overridden via configuration `Summarizer:BaseUrl`.
builder.Services.AddHttpClient("summarizer", client =>
{
var baseUrl = builder.Configuration["Summarizer:BaseUrl"] ?? "http://127.0.0.1:8001";
client.BaseAddress = new Uri(baseUrl);
client.Timeout = TimeSpan.FromSeconds(30);
});
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
builder.Services.AddIdentityCore<ApplicationUser>(options =>
{
options.User.RequireUniqueEmail = true;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 8;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<JobTrackerContext>()
.AddSignInManager();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddSingleton<UniversalJobParser>();
builder.Services.AddSingleton<IJobSitePlugin, FinnPlugin>();
builder.Services.AddSingleton<IJobSitePlugin, NavPlugin>();
builder.Services.AddSingleton<IJobSitePlugin, LinkedInPlugin>();
builder.Services.AddSingleton<IJobSitePlugin, JobbnorgePlugin>();
var translationProvider = (builder.Configuration["Translation:Provider"] ?? "none").Trim().ToLowerInvariant();
builder.Services.AddSingleton<ITranslationService>(sp =>
{
return translationProvider switch
{
"libretranslate" => new LibreTranslateService(sp.GetRequiredService<IHttpClientFactory>(), sp.GetRequiredService<IConfiguration>()),
_ => new NoOpTranslationService()
};
});
builder.Services.AddScoped<JobImportService>();
var requireAuth = builder.Configuration.GetValue("Auth:Require", false);
var googleClientId = (builder.Configuration["Auth:GoogleClientId"] ?? "").Trim();
var jwtKey = (builder.Configuration["Auth:JwtKey"] ?? "").Trim();
var ephemeralJwtKey = false;
if (string.IsNullOrWhiteSpace(jwtKey))
{
if (requireAuth)
throw new InvalidOperationException("Auth is required but Auth:JwtKey is not configured.");
jwtKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:JwtKey"] = jwtKey });
ephemeralJwtKey = true;
}
var issuer = (builder.Configuration["Auth:JwtIssuer"] ?? "JobTrackerApi").Trim();
var audience = (builder.Configuration["Auth:JwtAudience"] ?? "job-tracker-ui").Trim();
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = "smart";
options.DefaultChallengeScheme = "smart";
})
.AddPolicyScheme("smart", "Smart JWT", options =>
{
options.ForwardDefaultSelector = ctx =>
{
if (string.IsNullOrWhiteSpace(googleClientId))
return "local";
var auth = ctx.Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return "local";
var token = auth["Bearer ".Length..].Trim();
var handler = new JwtSecurityTokenHandler();
if (!handler.CanReadToken(token))
return "local";
try
{
var jwt = handler.ReadJwtToken(token);
var iss = jwt.Issuer ?? "";
return iss is "accounts.google.com" or "https://accounts.google.com"
? "google"
: "local";
}
catch
{
return "local";
}
};
})
.AddJwtBearer("local", options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = issuer,
ValidateAudience = true,
ValidAudience = audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(jwtKey)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(2),
NameClaimType = System.Security.Claims.ClaimTypes.Name,
RoleClaimType = System.Security.Claims.ClaimTypes.Role,
};
});
if (!string.IsNullOrWhiteSpace(googleClientId))
{
builder.Services.AddAuthentication().AddJwtBearer("google", options =>
{
// Validate Google ID tokens (sent from the frontend) as bearer tokens.
options.Authority = "https://accounts.google.com";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuers = new[] { "accounts.google.com", "https://accounts.google.com" },
ValidateAudience = true,
ValidAudience = googleClientId,
ValidateLifetime = true,
};
});
}
builder.Services.AddAuthorization(options =>
{
if (requireAuth)
{
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
}
});
var app = builder.Build();
if (ephemeralJwtKey)
{
app.Logger.LogWarning("Auth:JwtKey was not configured. Generated an ephemeral key; local login tokens will be invalid after restart.");
}
var enableHttpsRedirect = app.Configuration.GetValue("HttpsRedirection:Enabled", false);
var enableHsts = app.Configuration.GetValue("HttpsRedirection:Hsts", false);
if (enableHsts) app.UseHsts();
if (enableHttpsRedirect) app.UseHttpsRedirection();
// Structured request logging for easy diagnosis.
app.Use(async (ctx, next) =>
{
var sw = Stopwatch.StartNew();
try
{
await next();
sw.Stop();
var sub = ctx.User?.Claims?.FirstOrDefault(c => c.Type is "sub" or "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
app.Logger.LogInformation(
"HTTP {Method} {Path} {StatusCode} {ElapsedMs}ms trace={TraceId} sub={Sub}",
ctx.Request.Method,
ctx.Request.Path.Value ?? "",
ctx.Response.StatusCode,
sw.ElapsedMilliseconds,
ctx.TraceIdentifier,
sub ?? ""
);
}
catch (Exception ex)
{
sw.Stop();
app.Logger.LogError(
ex,
"HTTP {Method} {Path} 500 {ElapsedMs}ms trace={TraceId}",
ctx.Request.Method,
ctx.Request.Path.Value ?? "",
sw.ElapsedMilliseconds,
ctx.TraceIdentifier
);
throw;
}
});
// Apply EF migrations on startup (SQLite dev DB lives in the repo).
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
var paths = scope.ServiceProvider.GetRequiredService<AppPaths>();
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
// Bridge older dev DBs that were modified via ad-hoc ALTER TABLE (before migrations were applied).
// If the schema already contains the columns added by migration 20260310195000, record that migration
// so EF doesn't try to apply it again and fail on duplicate columns.
const string legacyMigrationId = "20260310195000_AddJobFieldsAndSoftDelete";
const string legacyProductVersion = "7.0.17";
using DbConnection conn = db.Database.GetDbConnection();
conn.Open();
static bool HasTable(DbConnection c, string table)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=$name LIMIT 1;";
var p = cmd.CreateParameter();
p.ParameterName = "$name";
p.Value = table;
cmd.Parameters.Add(p);
return cmd.ExecuteScalar() is not null;
}
static bool HasColumn(DbConnection c, string table, string column)
{
using var cmd = c.CreateCommand();
cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}' LIMIT 1;";
return cmd.ExecuteScalar() is not null;
}
static bool HasMigration(DbConnection c, string migrationId)
{
if (!HasTable(c, "__EFMigrationsHistory")) return false;
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId=$id LIMIT 1;";
var p = cmd.CreateParameter();
p.ParameterName = "$id";
p.Value = migrationId;
cmd.Parameters.Add(p);
return cmd.ExecuteScalar() is not null;
}
static void Exec(DbConnection c, string sql)
{
using var cmd = c.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
static void EnsureColumn(DbConnection c, string table, string column, string ddl)
{
if (!HasColumn(c, table, column)) Exec(c, ddl);
}
static void EnsureIdentityTables(DbConnection c)
{
// EF migrations are used for the app schema. In some environments `dotnet ef` isnt available,
// so create the ASP.NET Core Identity tables directly if they dont exist yet.
if (HasTable(c, "AspNetUsers")) return;
Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetRoles" (
"Id" TEXT NOT NULL CONSTRAINT "PK_AspNetRoles" PRIMARY KEY,
"Name" TEXT NULL,
"NormalizedName" TEXT NULL,
"ConcurrencyStamp" TEXT NULL
);
""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetUsers" (
"Id" TEXT NOT NULL CONSTRAINT "PK_AspNetUsers" PRIMARY KEY,
"UserName" TEXT NULL,
"NormalizedUserName" TEXT NULL,
"Email" TEXT NULL,
"NormalizedEmail" TEXT NULL,
"EmailConfirmed" INTEGER NOT NULL,
"PasswordHash" TEXT NULL,
"SecurityStamp" TEXT NULL,
"ConcurrencyStamp" TEXT NULL,
"PhoneNumber" TEXT NULL,
"PhoneNumberConfirmed" INTEGER NOT NULL,
"TwoFactorEnabled" INTEGER NOT NULL,
"LockoutEnd" TEXT NULL,
"LockoutEnabled" INTEGER NOT NULL,
"AccessFailedCount" INTEGER NOT NULL
);
""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetRoleClaims" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetRoleClaims" PRIMARY KEY AUTOINCREMENT,
"RoleId" TEXT NOT NULL,
"ClaimType" TEXT NULL,
"ClaimValue" TEXT NULL,
CONSTRAINT "FK_AspNetRoleClaims_AspNetRoles_RoleId" FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetUserClaims" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetUserClaims" PRIMARY KEY AUTOINCREMENT,
"UserId" TEXT NOT NULL,
"ClaimType" TEXT NULL,
"ClaimValue" TEXT NULL,
CONSTRAINT "FK_AspNetUserClaims_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetUserLogins" (
"LoginProvider" TEXT NOT NULL,
"ProviderKey" TEXT NOT NULL,
"ProviderDisplayName" TEXT NULL,
"UserId" TEXT NOT NULL,
CONSTRAINT "PK_AspNetUserLogins" PRIMARY KEY ("LoginProvider", "ProviderKey"),
CONSTRAINT "FK_AspNetUserLogins_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetUserRoles" (
"UserId" TEXT NOT NULL,
"RoleId" TEXT NOT NULL,
CONSTRAINT "PK_AspNetUserRoles" PRIMARY KEY ("UserId", "RoleId"),
CONSTRAINT "FK_AspNetUserRoles_AspNetRoles_RoleId" FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_AspNetUserRoles_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetUserTokens" (
"UserId" TEXT NOT NULL,
"LoginProvider" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"Value" TEXT NULL,
CONSTRAINT "PK_AspNetUserTokens" PRIMARY KEY ("UserId", "LoginProvider", "Name"),
CONSTRAINT "FK_AspNetUserTokens_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "RoleNameIndex" ON "AspNetRoles" ("NormalizedName");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetRoleClaims_RoleId" ON "AspNetRoleClaims" ("RoleId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "EmailIndex" ON "AspNetUsers" ("NormalizedEmail");""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "UserNameIndex" ON "AspNetUsers" ("NormalizedUserName");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserClaims_UserId" ON "AspNetUserClaims" ("UserId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserLogins_UserId" ON "AspNetUserLogins" ("UserId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserRoles_RoleId" ON "AspNetUserRoles" ("RoleId");""");
}
EnsureIdentityTables(conn);
static void EnsureUserRuleSettingsTable(DbConnection c)
{
if (HasTable(c, "UserRuleSettings")) return;
Exec(c, """
CREATE TABLE IF NOT EXISTS "UserRuleSettings" (
"OwnerUserId" TEXT NOT NULL CONSTRAINT "PK_UserRuleSettings" PRIMARY KEY,
"AppliedFollowUpDays" INTEGER NOT NULL,
"AppliedGhostDays" INTEGER NOT NULL,
"OfferFollowUpDays" INTEGER NOT NULL,
"OfferGhostDays" INTEGER NOT NULL,
"FeedbackFollowUpDays" INTEGER NOT NULL,
"FeedbackGhostDays" INTEGER NOT NULL
);
""");
}
EnsureUserRuleSettingsTable(conn);
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
// and at least one of the new columns already exists.
var isLegacy =
HasMigration(conn, "20260310174114_AddCorrespondence") &&
!HasMigration(conn, legacyMigrationId) &&
(HasColumn(conn, "Companies", "Source") || HasColumn(conn, "JobApplications", "IsDeleted"));
if (isLegacy)
{
EnsureColumn(conn, "Companies", "Source", "ALTER TABLE Companies ADD COLUMN Source TEXT NULL;");
EnsureColumn(conn, "JobApplications", "IsDeleted", "ALTER TABLE JobApplications ADD COLUMN IsDeleted INTEGER NOT NULL DEFAULT 0;");
EnsureColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE JobApplications ADD COLUMN DeletedAt TEXT NULL;");
EnsureColumn(conn, "JobApplications", "Location", "ALTER TABLE JobApplications ADD COLUMN Location TEXT NULL;");
EnsureColumn(conn, "JobApplications", "Salary", "ALTER TABLE JobApplications ADD COLUMN Salary TEXT NULL;");
EnsureColumn(conn, "JobApplications", "NextAction", "ALTER TABLE JobApplications ADD COLUMN NextAction TEXT NULL;");
EnsureColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE JobApplications ADD COLUMN FollowUpAt TEXT NULL;");
// Ensure the persisted short summary column exists for older dev DBs.
EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;");
// Multi-user support: scope data to the authenticated user.
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
// Legacy DBs may be missing later correspondence columns (Subject/Channel).
if (HasTable(conn, "Correspondences"))
{
EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
}
// Record the migration as applied.
Exec(
conn,
"INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) " +
$"VALUES ('{legacyMigrationId}', '{legacyProductVersion}');"
);
}
// Some dev DBs may not match the "legacy" fingerprint above but still lack
// the ShortSummary column. Ensure it exists unconditionally if missing.
EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;");
// Ensure ownership columns exist even on non-legacy DBs.
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
// Ensure data folder exists before creating/opening SQLite files.
Directory.CreateDirectory(paths.DataRoot);
db.Database.Migrate();
// Optional: seed an initial admin user for local username/password login.
// Set Auth:AdminEmail and Auth:AdminPassword to enable.
var adminEmail = (app.Configuration["Auth:AdminEmail"] ?? "").Trim();
var adminPassword = (app.Configuration["Auth:AdminPassword"] ?? "").Trim();
if (!string.IsNullOrWhiteSpace(adminEmail) && !string.IsNullOrWhiteSpace(adminPassword))
{
const string adminRole = "Admin";
if (!roles.RoleExistsAsync(adminRole).GetAwaiter().GetResult())
{
roles.CreateAsync(new IdentityRole(adminRole)).GetAwaiter().GetResult();
}
var existing = users.FindByEmailAsync(adminEmail).GetAwaiter().GetResult();
if (existing is null)
{
var u = new ApplicationUser { UserName = adminEmail, Email = adminEmail, EmailConfirmed = true };
var created = users.CreateAsync(u, adminPassword).GetAwaiter().GetResult();
if (created.Succeeded)
{
users.AddToRoleAsync(u, adminRole).GetAwaiter().GetResult();
app.Logger.LogInformation("Seeded admin user: {Email}", adminEmail);
}
else
{
app.Logger.LogWarning("Failed to seed admin user: {Errors}", string.Join("; ", created.Errors.Select(e => e.Description)));
}
}
else
{
var inRole = users.IsInRoleAsync(existing, adminRole).GetAwaiter().GetResult();
if (!inRole) users.AddToRoleAsync(existing, adminRole).GetAwaiter().GetResult();
}
// One-time claim of legacy data for the admin user so enabling auth doesn't "hide" existing records.
var admin = users.FindByEmailAsync(adminEmail).GetAwaiter().GetResult();
if (admin is not null)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE Companies SET OwnerUserId=$uid WHERE OwnerUserId IS NULL;
UPDATE JobApplications SET OwnerUserId=$uid WHERE OwnerUserId IS NULL;
""";
var p = cmd.CreateParameter();
p.ParameterName = "$uid";
p.Value = admin.Id;
cmd.Parameters.Add(p);
cmd.ExecuteNonQuery();
}
}
}
app.UseCors("AllowReact");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5202",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7107;http://localhost:5202",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+38
View File
@@ -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);
}
+110
View File
@@ -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);
}
}
}
}
+69
View File
@@ -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;
}
}
}
}
+73
View File
@@ -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);
}
}
@@ -0,0 +1,44 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Cors": {
"Origins": [
"http://localhost:3000",
"https://jobs.cesnimda.uk"
]
},
"Exports": {
"DailyEnabled": true,
"DailyFolder": "exports",
"DailyHourLocal": 2
},
"Auth": {
"Require": true,
"AllowRegistration": false,
"JwtKey": "Y00VuqZehhsMiNa8elch7q7FOlPm5ncugKJtMOpFn3P2xNtrZVfvGxVP2bKbnzL6rI08/H6vZGNBYh1dHh71/g==",
"JwtIssuer": "JobTrackerApi",
"JwtAudience": "job-tracker-ui",
"JwtExpiresMinutes": 720,
"AdminEmail": "dj@cesnimda.co.uk",
"AdminPassword": "Leethacks12",
"GoogleClientId": "723556162227-llqucvpog2esn1dutmtvuul1lv374or6.apps.googleusercontent.com"
},
"App": {
"PublicBaseUrl": "https://jobs.cesnimda.uk"
},
"Email": {
"Enabled": false,
"SmtpHost": "smtp.gmail.com",
"SmtpPort": 587,
"SmtpUser": "CHANGE_ME_GMAIL_ADDRESS",
"SmtpPassword": "CHANGE_ME_GOOGLE_APP_PASSWORD",
"From": "CHANGE_ME_GMAIL_ADDRESS",
"FromName": "Job Tracker",
"SmtpEnableSsl": true,
"SmtpTimeoutMs": 15000
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"HttpsRedirection": {
"Enabled": false,
"Hsts": false
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
{
"runtimeOptions": {
"tfm": "net9.0",
"frameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "9.0.0"
},
{
"name": "Microsoft.AspNetCore.App",
"version": "9.0.0"
}
],
"configProperties": {
"System.GC.Server": true,
"System.Reflection.NullabilityInfoContext.IsSupported": true,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}
@@ -0,0 +1,5 @@
{
"Version": 1,
"ManifestType": "Build",
"Endpoints": []
}

Some files were not shown because too many files have changed in this diff Show More