Files
jobtrackingapp/JobTrackerApi/Controllers/ApplicationCalendarController.cs
T

145 lines
7.2 KiB
C#

using System.Security.Cryptography;
using System.Text;
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("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);
}