a9bb22e530
Expose bounded draft CRUD with owned-job validation and revision conflicts. Saving drafts never contacts providers or sends email.
200 lines
8.1 KiB
C#
200 lines
8.1 KiB
C#
using System.Security.Claims;
|
|
using System.Net.Mail;
|
|
using JobTrackerApi.Data;
|
|
using JobTrackerApi.Models;
|
|
using JobTrackerApi.Services.EmailProviders;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace JobTrackerApi.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/email/drafts")]
|
|
[Authorize(AuthenticationSchemes = "local")]
|
|
public sealed class EmailDraftsController(
|
|
JobTrackerContext db,
|
|
IEmailProviderRegistry providers,
|
|
TimeProvider timeProvider) : ControllerBase
|
|
{
|
|
public sealed record DraftDto(
|
|
Guid Id,
|
|
int JobApplicationId,
|
|
string Provider,
|
|
string To,
|
|
string Subject,
|
|
string BodyText,
|
|
string? ThreadId,
|
|
long Revision,
|
|
DateTime CreatedAtUtc,
|
|
DateTime UpdatedAtUtc);
|
|
|
|
public sealed record CreateDraftRequest(
|
|
int JobApplicationId,
|
|
string? Provider,
|
|
string? To,
|
|
string? Subject,
|
|
string? BodyText,
|
|
string? ThreadId);
|
|
|
|
public sealed record UpdateDraftRequest(long Revision, string? To, string? Subject, string? BodyText);
|
|
|
|
[HttpGet]
|
|
public async Task<ActionResult<IReadOnlyList<DraftDto>>> List(
|
|
[FromQuery] int jobApplicationId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var ownerUserId = GetOwnerUserId();
|
|
if (ownerUserId is null) return Unauthorized();
|
|
if (jobApplicationId <= 0) return BadRequest("A valid job application is required.");
|
|
|
|
return Ok(await db.EmailDrafts.AsNoTracking()
|
|
.Where(draft => draft.OwnerUserId == ownerUserId && draft.JobApplicationId == jobApplicationId)
|
|
.OrderByDescending(draft => draft.UpdatedAtUtc)
|
|
.Select(draft => new DraftDto(
|
|
draft.Id,
|
|
draft.JobApplicationId,
|
|
draft.Provider,
|
|
draft.To,
|
|
draft.Subject,
|
|
draft.BodyText,
|
|
draft.ThreadId,
|
|
draft.Revision,
|
|
draft.CreatedAtUtc,
|
|
draft.UpdatedAtUtc))
|
|
.ToListAsync(cancellationToken));
|
|
}
|
|
|
|
[HttpGet("{id:guid}")]
|
|
public async Task<ActionResult<DraftDto>> Get(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
var ownerUserId = GetOwnerUserId();
|
|
if (ownerUserId is null) return Unauthorized();
|
|
var draft = await db.EmailDrafts.AsNoTracking()
|
|
.FirstOrDefaultAsync(item => item.Id == id && item.OwnerUserId == ownerUserId, cancellationToken);
|
|
return draft is null ? NotFound() : Ok(ToDto(draft));
|
|
}
|
|
|
|
[HttpPost]
|
|
public async Task<ActionResult<DraftDto>> Create(CreateDraftRequest request, CancellationToken cancellationToken)
|
|
{
|
|
var ownerUserId = GetOwnerUserId();
|
|
if (ownerUserId is null) return Unauthorized();
|
|
if (request.JobApplicationId <= 0) return BadRequest("A valid job application is required.");
|
|
var provider = providers.Get(request.Provider);
|
|
if (provider is null) return BadRequest("Unknown email provider.");
|
|
if (!TryNormalizeContent(request.To, request.Subject, request.BodyText, out var recipient, out var subject, out var bodyText, out var error))
|
|
return BadRequest(error);
|
|
var threadId = string.IsNullOrWhiteSpace(request.ThreadId) ? null : request.ThreadId.Trim();
|
|
if (threadId?.Length > 512) return BadRequest("Thread ID must be at most 512 characters.");
|
|
|
|
var ownsJob = await db.JobApplications.AsNoTracking()
|
|
.AnyAsync(job => job.Id == request.JobApplicationId && job.OwnerUserId == ownerUserId, cancellationToken);
|
|
if (!ownsJob) return NotFound();
|
|
|
|
var now = timeProvider.GetUtcNow().UtcDateTime;
|
|
var draft = new EmailDraft
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
OwnerUserId = ownerUserId,
|
|
JobApplicationId = request.JobApplicationId,
|
|
Provider = provider.ProviderKey.ToLowerInvariant(),
|
|
To = recipient,
|
|
Subject = subject,
|
|
BodyText = bodyText,
|
|
ThreadId = threadId,
|
|
Revision = 1,
|
|
CreatedAtUtc = now,
|
|
UpdatedAtUtc = now,
|
|
};
|
|
db.EmailDrafts.Add(draft);
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
return CreatedAtAction(nameof(Get), new { id = draft.Id }, ToDto(draft));
|
|
}
|
|
|
|
[HttpPut("{id:guid}")]
|
|
public async Task<ActionResult<DraftDto>> Update(Guid id, UpdateDraftRequest request, CancellationToken cancellationToken)
|
|
{
|
|
var ownerUserId = GetOwnerUserId();
|
|
if (ownerUserId is null) return Unauthorized();
|
|
if (request.Revision <= 0) return BadRequest("A positive revision is required.");
|
|
if (!TryNormalizeContent(request.To, request.Subject, request.BodyText, out var recipient, out var subject, out var bodyText, out var error))
|
|
return BadRequest(error);
|
|
|
|
var now = timeProvider.GetUtcNow().UtcDateTime;
|
|
var affected = await db.EmailDrafts
|
|
.Where(draft => draft.Id == id && draft.OwnerUserId == ownerUserId && draft.Revision == request.Revision)
|
|
.ExecuteUpdateAsync(setters => setters
|
|
.SetProperty(draft => draft.To, recipient)
|
|
.SetProperty(draft => draft.Subject, subject)
|
|
.SetProperty(draft => draft.BodyText, bodyText)
|
|
.SetProperty(draft => draft.Revision, draft => draft.Revision + 1)
|
|
.SetProperty(draft => draft.UpdatedAtUtc, now), cancellationToken);
|
|
if (affected == 0)
|
|
{
|
|
var exists = await db.EmailDrafts.AsNoTracking()
|
|
.AnyAsync(draft => draft.Id == id && draft.OwnerUserId == ownerUserId, cancellationToken);
|
|
return exists
|
|
? Conflict(new ProblemDetails { Title = "Draft revision conflict", Detail = "Reload the latest draft before saving again." })
|
|
: NotFound();
|
|
}
|
|
|
|
return await Get(id, cancellationToken);
|
|
}
|
|
|
|
[HttpDelete("{id:guid}")]
|
|
public async Task<IActionResult> Delete(Guid id, [FromQuery] long revision, CancellationToken cancellationToken)
|
|
{
|
|
var ownerUserId = GetOwnerUserId();
|
|
if (ownerUserId is null) return Unauthorized();
|
|
if (revision <= 0) return BadRequest("A positive revision is required.");
|
|
|
|
var affected = await db.EmailDrafts
|
|
.Where(draft => draft.Id == id && draft.OwnerUserId == ownerUserId && draft.Revision == revision)
|
|
.ExecuteDeleteAsync(cancellationToken);
|
|
if (affected == 1) return NoContent();
|
|
var exists = await db.EmailDrafts.AsNoTracking()
|
|
.AnyAsync(draft => draft.Id == id && draft.OwnerUserId == ownerUserId, cancellationToken);
|
|
return exists
|
|
? Conflict(new ProblemDetails { Title = "Draft revision conflict", Detail = "Reload the latest draft before deleting it." })
|
|
: NotFound();
|
|
}
|
|
|
|
private string? GetOwnerUserId() =>
|
|
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
|
|
|
private static bool TryNormalizeContent(
|
|
string? to,
|
|
string? subject,
|
|
string? bodyText,
|
|
out string recipient,
|
|
out string normalizedSubject,
|
|
out string normalizedBody,
|
|
out string error)
|
|
{
|
|
recipient = to?.Trim() ?? string.Empty;
|
|
normalizedSubject = subject?.Trim() ?? string.Empty;
|
|
normalizedBody = bodyText ?? string.Empty;
|
|
error = string.Empty;
|
|
if (recipient.Length > 320 || (recipient.Length > 0 && !MailAddress.TryCreate(recipient, out _)))
|
|
error = "Recipient must be empty or a valid address of at most 320 characters.";
|
|
else if (normalizedSubject.Length > 998)
|
|
error = "Subject must be at most 998 characters.";
|
|
else if (normalizedBody.Length > 200_000)
|
|
error = "Body must be at most 200000 characters.";
|
|
return error.Length == 0;
|
|
}
|
|
|
|
private static DraftDto ToDto(EmailDraft draft) => new(
|
|
draft.Id,
|
|
draft.JobApplicationId,
|
|
draft.Provider,
|
|
draft.To,
|
|
draft.Subject,
|
|
draft.BodyText,
|
|
draft.ThreadId,
|
|
draft.Revision,
|
|
draft.CreatedAtUtc,
|
|
draft.UpdatedAtUtc);
|
|
}
|