First Commit
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) won’t 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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user