Files
jobtrackingapp/JobTrackerApi/Controllers/ApplicationCalendarController.cs
T

175 lines
9.0 KiB
C#

using System.Security.Cryptography;
using System.Text;
using System.Globalization;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/calendar")]
[Authorize(AuthenticationSchemes = "local")]
public sealed class ApplicationCalendarController(
JobTrackerContext db,
ICurrentUserService currentUser,
IGmailOAuthService google,
IMicrosoftGraphOAuthService microsoft) : ControllerBase
{
[HttpGet("agenda")]
public async Task<ActionResult<List<CalendarAgendaItemDto>>> Agenda([FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken cancellationToken)
{
var owner = RequiredOwner();
if (from == default || to < from || to.DayNumber - from.DayNumber > 61 || to == DateOnly.MaxValue)
return BadRequest("Choose a calendar range of at most 62 days.");
var start = from.ToDateTime(TimeOnly.MinValue);
var end = to.AddDays(1).ToDateTime(TimeOnly.MinValue);
var jobs = await db.JobApplications.AsNoTracking()
.Where(job => job.OwnerUserId == owner && !job.IsDeleted)
.Where(job => (job.Deadline >= start && job.Deadline < end) || (job.FollowUpAt >= start && job.FollowUpAt < end))
.Select(job => new { job.Id, job.JobTitle, CompanyName = job.Company.OwnerUserId == owner ? job.Company.Name : null, job.Deadline, job.FollowUpAt })
.ToListAsync(cancellationToken);
var rows = new List<CalendarAgendaItemDto>();
foreach (var job in jobs)
{
Add("deadline", job.Deadline);
Add("follow-up", job.FollowUpAt);
void Add(string kind, DateTime? recorded)
{
if (recorded is not { } date || date < start || date >= end) return;
// Calendar grouping uses the stored calendar date; legacy timezone/precision is not reconstructed.
rows.Add(new(job.Id, job.JobTitle, job.CompanyName, kind, date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), date));
}
}
return Ok(rows.OrderBy(row => row.Date).ThenBy(row => row.Kind).ThenBy(row => row.JobApplicationId).ToList());
}
[HttpGet("status")]
public async Task<ActionResult<CalendarStatusDto>> Status(CancellationToken cancellationToken)
{
var owner = RequiredOwner();
var googleConnection = await google.GetConnectionAsync(owner, cancellationToken);
var microsoftConnection = await microsoft.GetConnectionAsync(owner, cancellationToken);
return Ok(new CalendarStatusDto(
new CalendarProviderStatusDto(googleConnection is not null, GmailOAuthService.HasCalendarScope(googleConnection?.Scope)),
new CalendarProviderStatusDto(microsoftConnection is not null, MicrosoftGraphOAuthService.HasCalendarScope(microsoftConnection?.Scope))));
}
[HttpPost("jobs/{jobId:int}/events")]
public async Task<ActionResult<CalendarEventDto>> CreateEvent(int jobId, [FromBody] CreateCalendarEventRequest request, CancellationToken cancellationToken)
{
var owner = RequiredOwner();
var job = await LoadJobAsync(jobId, owner, cancellationToken);
if (job is null) return NotFound();
if (!TryBuildEvent(job, owner, request.Kind, out var calendarEvent, out var validationError))
return ValidationProblem(validationError);
try
{
var provider = request.Provider?.Trim().ToLowerInvariant();
var result = provider switch
{
"google" => await google.CreateCalendarEventAsync(owner, calendarEvent!, cancellationToken),
"microsoft" => await microsoft.CreateCalendarEventAsync(owner, calendarEvent!, cancellationToken),
_ => throw new ArgumentException("Choose Google Calendar or Outlook Calendar.")
};
return Ok(new CalendarEventDto(provider!, result.Id, result.WebUrl));
}
catch (ArgumentException ex)
{
return ValidationProblem(ex.Message);
}
catch (InvalidOperationException ex)
{
return Conflict(new ProblemDetails { Title = "Calendar permission required", Detail = ex.Message, Status = StatusCodes.Status409Conflict });
}
catch (HttpRequestException)
{
return Problem("The calendar provider is temporarily unavailable.", statusCode: StatusCodes.Status502BadGateway);
}
}
[HttpGet("jobs/{jobId:int}/events.ics")]
public async Task<IActionResult> DownloadEvent(int jobId, [FromQuery] string? kind, CancellationToken cancellationToken)
{
var owner = RequiredOwner();
var job = await LoadJobAsync(jobId, owner, cancellationToken);
if (job is null) return NotFound();
if (!TryBuildEvent(job, owner, kind, out var calendarEvent, out var validationError))
return ValidationProblem(validationError);
var value = calendarEvent!;
var start = value.AllDay ? $"DTSTART;VALUE=DATE:{value.StartsAtUtc:yyyyMMdd}" : $"DTSTART:{value.StartsAtUtc.UtcDateTime:yyyyMMdd'T'HHmmss'Z'}";
var end = value.AllDay ? $"DTEND;VALUE=DATE:{value.EndsAtUtc:yyyyMMdd}" : $"DTEND:{value.EndsAtUtc.UtcDateTime:yyyyMMdd'T'HHmmss'Z'}";
var ics = string.Join("\r\n", new[]
{
"BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Jobjakt//Application Calendar//EN", "CALSCALE:GREGORIAN",
"BEGIN:VEVENT", $"UID:{value.StableId}@jobs.cesnimda.uk", $"DTSTAMP:{DateTime.UtcNow:yyyyMMdd'T'HHmmss'Z'}",
start, end, $"SUMMARY:{EscapeIcs(value.Summary)}",
$"DESCRIPTION:{EscapeIcs(value.Description)}", $"LOCATION:{EscapeIcs(value.Location)}",
"END:VEVENT", "END:VCALENDAR", ""
});
return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", $"jobjakt-{job.Id}-{NormalizeKind(kind)}.ics");
}
private Task<JobApplication?> LoadJobAsync(int jobId, string owner, CancellationToken cancellationToken) =>
db.JobApplications.Include(job => job.Company)
.FirstOrDefaultAsync(job => job.Id == jobId && job.OwnerUserId == owner && !job.IsDeleted, cancellationToken);
private static bool TryBuildEvent(JobApplication job, string owner, string? requestedKind, out ExternalCalendarEventRequest? result, out string error)
{
var kind = NormalizeKind(requestedKind);
DateTimeOffset start;
DateTimeOffset end;
bool allDay;
string label;
if (kind == "follow-up" && job.FollowUpAt is { } followUp)
{
start = ToUtcOffset(followUp);
end = start.AddMinutes(30);
allDay = false;
label = "Follow up";
}
else if (kind == "deadline" && job.Deadline is { } deadline)
{
start = new DateTimeOffset(DateTime.SpecifyKind(deadline.Date, DateTimeKind.Utc));
end = start.AddDays(1);
allDay = true;
label = "Application deadline";
}
else
{
result = null;
error = kind == "follow-up" ? "Set a follow-up date before adding it to a calendar." : "This application has no deadline to add.";
return false;
}
var company = job.Company?.Name?.Trim();
var summary = string.IsNullOrWhiteSpace(company) ? $"{label}: {job.JobTitle}" : $"{label}: {job.JobTitle} at {company}";
var stableId = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{owner}|{job.Id}|{kind}"))).ToLowerInvariant();
result = new ExternalCalendarEventRequest(stableId, summary, job.NextAction, job.Location, start, end, allDay);
error = string.Empty;
return true;
}
private static DateTimeOffset ToUtcOffset(DateTime value) => value.Kind switch
{
DateTimeKind.Utc => new DateTimeOffset(value),
DateTimeKind.Local => value.ToUniversalTime(),
_ => new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc))
};
private static string NormalizeKind(string? kind) => string.Equals(kind?.Trim(), "deadline", StringComparison.OrdinalIgnoreCase) ? "deadline" : "follow-up";
private static string EscapeIcs(string? value) => (value ?? string.Empty).Replace("\\", "\\\\").Replace(";", "\\;").Replace(",", "\\,").Replace("\r\n", "\\n").Replace("\r", "\\n").Replace("\n", "\\n");
private string RequiredOwner() => currentUser.UserId ?? throw new UnauthorizedAccessException("Authentication required.");
public sealed record CalendarProviderStatusDto(bool Connected, bool Writable);
public sealed record CalendarStatusDto(CalendarProviderStatusDto Google, CalendarProviderStatusDto Microsoft);
public sealed record CreateCalendarEventRequest(string? Provider, string? Kind);
public sealed record CalendarEventDto(string Provider, string? Id, string? WebUrl);
public sealed record CalendarAgendaItemDto(int JobApplicationId, string JobTitle, string? CompanyName, string Kind, string Date, DateTime RecordedAt);
}