304 lines
13 KiB
C#
304 lines
13 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using JobTrackerApi.Data;
|
|
using JobTrackerApi.Models;
|
|
using JobTrackerApi.Services.EmailProviders;
|
|
using System.Text.Json;
|
|
|
|
namespace JobTrackerApi.Controllers
|
|
{
|
|
[ApiController]
|
|
// Explicitly authorized. These endpoints are all tenant-scoped user data, so they must not
|
|
// depend on the Auth:Require fallback policy being switched on: a deployment that lost that flag
|
|
// would otherwise serve them anonymously. docs/production-readiness-review.md.
|
|
[Route("api/correspondence")]
|
|
[Authorize(AuthenticationSchemes = "local")]
|
|
public class CorrespondenceController : ControllerBase
|
|
{
|
|
private readonly JobTrackerContext _db;
|
|
|
|
public CorrespondenceController(JobTrackerContext db)
|
|
{
|
|
_db = db;
|
|
}
|
|
|
|
// Resolve correspondence through its parent job so the DbContext's user-scoped
|
|
// job filter still protects raw id endpoints in multi-user deployments.
|
|
private Task<Correspondence?> FindOwnedMessageAsync(int correspondenceId, CancellationToken cancellationToken)
|
|
{
|
|
return _db.Correspondences
|
|
.Include(c => c.JobApplication)
|
|
.FirstOrDefaultAsync(c => c.Id == correspondenceId, cancellationToken);
|
|
}
|
|
|
|
public sealed record CorrespondenceInboxItemDto(
|
|
int Id,
|
|
int JobApplicationId,
|
|
string? CompanyName,
|
|
string? JobTitle,
|
|
string From,
|
|
string? Direction,
|
|
string? Subject,
|
|
string? Channel,
|
|
DateTime Date,
|
|
string ContentPreview,
|
|
string? ExternalThreadId,
|
|
string? ExternalMessageId,
|
|
string? Provider,
|
|
string? ExternalFrom,
|
|
string? ExternalTo,
|
|
int LabelCount,
|
|
int AttachmentCount);
|
|
|
|
public sealed record CorrespondenceInboxPageDto(
|
|
List<CorrespondenceInboxItemDto> Items,
|
|
int Page,
|
|
int PageSize,
|
|
int Total,
|
|
int TotalPages);
|
|
|
|
[HttpGet]
|
|
public async Task<ActionResult<List<CorrespondenceInboxItemDto>>> GetInbox(
|
|
[FromQuery] string? q,
|
|
[FromQuery] string? direction,
|
|
[FromQuery] string? linkState,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var query = BuildInboxQuery(q, direction, linkState);
|
|
var items = await LoadInboxItemsAsync(query, 0, 200, cancellationToken);
|
|
return Ok(items);
|
|
}
|
|
|
|
[HttpGet("page")]
|
|
public async Task<ActionResult<CorrespondenceInboxPageDto>> GetInboxPage(
|
|
[FromQuery] string? q,
|
|
[FromQuery] string? direction,
|
|
[FromQuery] string? linkState,
|
|
[FromQuery] int page = 1,
|
|
[FromQuery] int pageSize = 50,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
page = Math.Max(1, page);
|
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
|
var query = BuildInboxQuery(q, direction, linkState);
|
|
var total = await query.CountAsync(cancellationToken);
|
|
var totalPages = total == 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
|
|
if (totalPages > 0) page = Math.Min(page, totalPages);
|
|
var items = await LoadInboxItemsAsync(query, (page - 1) * pageSize, pageSize, cancellationToken);
|
|
return Ok(new CorrespondenceInboxPageDto(items, page, pageSize, total, totalPages));
|
|
}
|
|
|
|
private IQueryable<Correspondence> BuildInboxQuery(string? q, string? direction, string? linkState)
|
|
{
|
|
var query = _db.Correspondences
|
|
.AsNoTracking()
|
|
.Include(c => c.JobApplication)
|
|
.ThenInclude(j => j.Company)
|
|
.AsQueryable();
|
|
|
|
if (!string.IsNullOrWhiteSpace(q))
|
|
{
|
|
var needle = q.Trim();
|
|
query = query.Where(c =>
|
|
(c.Subject != null && EF.Functions.Like(c.Subject, $"%{needle}%")) ||
|
|
EF.Functions.Like(c.Content, $"%{needle}%") ||
|
|
(c.ExternalFrom != null && EF.Functions.Like(c.ExternalFrom, $"%{needle}%")) ||
|
|
(c.JobApplication.JobTitle != null && EF.Functions.Like(c.JobApplication.JobTitle, $"%{needle}%")) ||
|
|
(c.JobApplication.Company.Name != null && EF.Functions.Like(c.JobApplication.Company.Name, $"%{needle}%")));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(direction) && !string.Equals(direction, "all", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
query = query.Where(c => c.Direction == direction);
|
|
}
|
|
|
|
if (string.Equals(linkState, "linked", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
query = query.Where(c => c.ExternalThreadId != null);
|
|
}
|
|
else if (string.Equals(linkState, "manual", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
query = query.Where(c => c.ExternalThreadId == null);
|
|
}
|
|
|
|
return query;
|
|
}
|
|
|
|
private static async Task<List<CorrespondenceInboxItemDto>> LoadInboxItemsAsync(
|
|
IQueryable<Correspondence> query,
|
|
int skip,
|
|
int take,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var rows = await query
|
|
.OrderByDescending(c => c.Date)
|
|
.ThenByDescending(c => c.Id)
|
|
.Skip(skip)
|
|
.Take(take)
|
|
.Select(c => new
|
|
{
|
|
c.Id,
|
|
c.JobApplicationId,
|
|
CompanyName = c.JobApplication.Company != null ? c.JobApplication.Company.Name : null,
|
|
JobTitle = c.JobApplication.JobTitle,
|
|
c.From,
|
|
c.Direction,
|
|
c.Subject,
|
|
c.Channel,
|
|
c.Date,
|
|
ContentPreview = c.Content.Length <= 220 ? c.Content : c.Content.Substring(0, 220),
|
|
c.ExternalThreadId,
|
|
c.ExternalMessageId,
|
|
c.Provider,
|
|
c.ExternalFrom,
|
|
c.ExternalTo,
|
|
c.ExternalLabelsJson,
|
|
c.AttachmentMetadataJson,
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var items = rows.Select(c => new CorrespondenceInboxItemDto(
|
|
c.Id,
|
|
c.JobApplicationId,
|
|
c.CompanyName,
|
|
c.JobTitle,
|
|
c.From,
|
|
c.Direction,
|
|
c.Subject,
|
|
c.Channel,
|
|
c.Date,
|
|
c.ContentPreview,
|
|
c.ExternalThreadId,
|
|
c.ExternalMessageId,
|
|
c.Provider,
|
|
c.ExternalFrom,
|
|
c.ExternalTo,
|
|
DeserializeLabels(c.ExternalLabelsJson).Count,
|
|
DeserializeAttachments(c.AttachmentMetadataJson).Count)).ToList();
|
|
|
|
return items;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
[HttpGet("message/{id:int}")]
|
|
public async Task<ActionResult<EmailMessageDetailDto>> GetMessage([FromRoute] int id, CancellationToken cancellationToken)
|
|
{
|
|
var message = await FindOwnedMessageAsync(id, cancellationToken);
|
|
if (message is null) return NotFound();
|
|
|
|
return Ok(new EmailMessageDetailDto(
|
|
message.ExternalMessageId ?? $"correspondence-{message.Id}",
|
|
message.ExternalThreadId ?? string.Empty,
|
|
message.Subject ?? string.Empty,
|
|
message.ExternalFrom ?? message.From,
|
|
message.ExternalTo ?? string.Empty,
|
|
new DateTimeOffset(DateTime.SpecifyKind(message.Date, DateTimeKind.Local)),
|
|
message.Content.Length <= 220 ? message.Content : message.Content[..220],
|
|
message.Content,
|
|
DeserializeLabels(message.ExternalLabelsJson),
|
|
DeserializeAttachments(message.AttachmentMetadataJson)));
|
|
}
|
|
|
|
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,
|
|
string? Direction,
|
|
string? ExternalMessageId,
|
|
string? ExternalThreadId,
|
|
string? ExternalFrom,
|
|
string? ExternalTo,
|
|
string? ExternalLabelsJson,
|
|
string? AttachmentMetadataJson
|
|
);
|
|
|
|
// 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(),
|
|
Direction = string.IsNullOrWhiteSpace(request.Direction) ? null : request.Direction.Trim(),
|
|
ExternalMessageId = string.IsNullOrWhiteSpace(request.ExternalMessageId) ? null : request.ExternalMessageId.Trim(),
|
|
ExternalThreadId = string.IsNullOrWhiteSpace(request.ExternalThreadId) ? null : request.ExternalThreadId.Trim(),
|
|
ExternalFrom = string.IsNullOrWhiteSpace(request.ExternalFrom) ? null : request.ExternalFrom.Trim(),
|
|
ExternalTo = string.IsNullOrWhiteSpace(request.ExternalTo) ? null : request.ExternalTo.Trim(),
|
|
ExternalLabelsJson = string.IsNullOrWhiteSpace(request.ExternalLabelsJson) ? null : request.ExternalLabelsJson.Trim(),
|
|
AttachmentMetadataJson = string.IsNullOrWhiteSpace(request.AttachmentMetadataJson) ? null : request.AttachmentMetadataJson.Trim(),
|
|
Provider = "manual",
|
|
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);
|
|
}
|
|
|
|
|
|
[HttpDelete("{id:int}")]
|
|
public async Task<IActionResult> Delete([FromRoute] int id, CancellationToken cancellationToken)
|
|
{
|
|
var message = await FindOwnedMessageAsync(id, cancellationToken);
|
|
if (message is null) return NotFound();
|
|
|
|
_db.Correspondences.Remove(message);
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
return NoContent();
|
|
}
|
|
|
|
private static IReadOnlyList<string> DeserializeLabels(string? json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<string>();
|
|
try { return JsonSerializer.Deserialize<List<string>>(json) ?? new List<string>(); }
|
|
catch (JsonException) { return Array.Empty<string>(); }
|
|
}
|
|
|
|
private static IReadOnlyList<EmailAttachmentRef> DeserializeAttachments(string? json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<EmailAttachmentRef>();
|
|
try
|
|
{
|
|
return (JsonSerializer.Deserialize<List<CorrespondenceAttachmentMetadata>>(json) ?? new List<CorrespondenceAttachmentMetadata>())
|
|
.Select(item => new EmailAttachmentRef(item.FileName, item.MimeType, item.SizeBytes, item.GmailAttachmentId, item.Inline))
|
|
.ToList();
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return Array.Empty<EmailAttachmentRef>();
|
|
}
|
|
}
|
|
}
|
|
}
|