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
@@ -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();
}
}