145 lines
7.2 KiB
C#
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);
|
|
}
|