feat(calendar): add application date integrations
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
public sealed record ExternalCalendarEventRequest(
|
||||
string StableId,
|
||||
string Summary,
|
||||
string? Description,
|
||||
string? Location,
|
||||
DateTimeOffset StartsAtUtc,
|
||||
DateTimeOffset EndsAtUtc,
|
||||
bool AllDay);
|
||||
|
||||
public sealed record ExternalCalendarEventResult(string? Id, string? WebUrl);
|
||||
@@ -25,6 +25,7 @@ public interface IGmailOAuthService
|
||||
Task<IReadOnlyList<GmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken);
|
||||
Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
||||
Task<GmailSendResult> SendAsync(string ownerUserId, GmailSendRequest request, CancellationToken cancellationToken);
|
||||
Task<ExternalCalendarEventResult> CreateCalendarEventAsync(string ownerUserId, ExternalCalendarEventRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record GmailOAuthExchangeResult(string GmailAddress);
|
||||
@@ -47,7 +48,8 @@ internal sealed class GmailTokenResponse
|
||||
public sealed class GmailOAuthService : IGmailOAuthService
|
||||
{
|
||||
public const string SendScope = "https://www.googleapis.com/auth/gmail.send";
|
||||
private const string Scope = $"openid email profile https://www.googleapis.com/auth/gmail.readonly {SendScope}";
|
||||
public const string CalendarScope = "https://www.googleapis.com/auth/calendar.events";
|
||||
private const string Scope = $"openid email profile https://www.googleapis.com/auth/gmail.readonly {SendScope} {CalendarScope}";
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly IDataProtector _protector;
|
||||
@@ -419,6 +421,47 @@ public sealed class GmailOAuthService : IGmailOAuthService
|
||||
public static bool HasSendScope(string? scope) =>
|
||||
!string.IsNullOrWhiteSpace(scope) && scope.Split(' ', StringSplitOptions.RemoveEmptyEntries).Contains(SendScope, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public async Task<ExternalCalendarEventResult> CreateCalendarEventAsync(string ownerUserId, ExternalCalendarEventRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _db.GmailConnections.AsNoTracking().FirstOrDefaultAsync(item => item.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (connection is null || !HasCalendarScope(connection.Scope))
|
||||
throw new InvalidOperationException("Reconnect Google and approve calendar access before adding events.");
|
||||
|
||||
var payload = request.AllDay
|
||||
? JsonSerializer.Serialize(new
|
||||
{
|
||||
summary = request.Summary,
|
||||
description = request.Description,
|
||||
location = request.Location,
|
||||
start = new { date = request.StartsAtUtc.UtcDateTime.ToString("yyyy-MM-dd") },
|
||||
end = new { date = request.EndsAtUtc.UtcDateTime.ToString("yyyy-MM-dd") }
|
||||
})
|
||||
: JsonSerializer.Serialize(new
|
||||
{
|
||||
summary = request.Summary,
|
||||
description = request.Description,
|
||||
location = request.Location,
|
||||
start = new { dateTime = request.StartsAtUtc.ToString("O") },
|
||||
end = new { dateTime = request.EndsAtUtc.ToString("O") }
|
||||
});
|
||||
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
using var response = await client.PutAsync(
|
||||
$"https://www.googleapis.com/calendar/v3/calendars/primary/events/{Uri.EscapeDataString(request.StableId)}",
|
||||
new StringContent(payload, Encoding.UTF8, "application/json"),
|
||||
cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||
return new ExternalCalendarEventResult(
|
||||
document.RootElement.TryGetProperty("id", out var id) ? id.GetString() : null,
|
||||
document.RootElement.TryGetProperty("htmlLink", out var link) ? link.GetString() : null);
|
||||
}
|
||||
|
||||
public static bool HasCalendarScope(string? scope) =>
|
||||
!string.IsNullOrWhiteSpace(scope) && scope.Split(' ', StringSplitOptions.RemoveEmptyEntries).Contains(CalendarScope, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static void ValidateSendRequest(string to, string subject, string bodyText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(to)) throw new ArgumentException("Recipient is required.", nameof(to));
|
||||
|
||||
@@ -21,6 +21,7 @@ public interface IMicrosoftGraphOAuthService
|
||||
Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string conversationId, CancellationToken cancellationToken);
|
||||
Task<MicrosoftGraphMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
||||
Task<MicrosoftGraphSendResult> SendAsync(string ownerUserId, MicrosoftGraphSendRequest request, CancellationToken cancellationToken);
|
||||
Task<ExternalCalendarEventResult> CreateCalendarEventAsync(string ownerUserId, ExternalCalendarEventRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record MicrosoftGraphOAuthExchangeResult(string MailAddress);
|
||||
@@ -47,7 +48,8 @@ internal sealed class MicrosoftGraphTokenResponse
|
||||
public sealed class MicrosoftGraphOAuthService : IMicrosoftGraphOAuthService
|
||||
{
|
||||
public const string SendScope = "https://graph.microsoft.com/Mail.Send";
|
||||
private const string Scope = $"openid email profile offline_access https://graph.microsoft.com/Mail.Read {SendScope}";
|
||||
public const string CalendarScope = "https://graph.microsoft.com/Calendars.ReadWrite";
|
||||
private const string Scope = $"openid email profile offline_access https://graph.microsoft.com/Mail.Read {SendScope} {CalendarScope}";
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly IDataProtector _protector;
|
||||
@@ -340,6 +342,47 @@ public sealed class MicrosoftGraphOAuthService : IMicrosoftGraphOAuthService
|
||||
string.Equals(value, "Mail.Send", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public async Task<ExternalCalendarEventResult> CreateCalendarEventAsync(string ownerUserId, ExternalCalendarEventRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _db.MicrosoftGraphConnections.AsNoTracking().FirstOrDefaultAsync(item => item.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (connection is null || !HasCalendarScope(connection.Scope))
|
||||
throw new InvalidOperationException("Reconnect Outlook and approve calendar access before adding events.");
|
||||
|
||||
var startsAt = request.AllDay ? request.StartsAtUtc.UtcDateTime.Date : request.StartsAtUtc.UtcDateTime;
|
||||
var endsAt = request.AllDay ? request.EndsAtUtc.UtcDateTime.Date : request.EndsAtUtc.UtcDateTime;
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
subject = request.Summary,
|
||||
body = new { contentType = "Text", content = request.Description ?? string.Empty },
|
||||
location = new { displayName = request.Location ?? string.Empty },
|
||||
start = new { dateTime = startsAt.ToString("yyyy-MM-ddTHH:mm:ss"), timeZone = "UTC" },
|
||||
end = new { dateTime = endsAt.ToString("yyyy-MM-ddTHH:mm:ss"), timeZone = "UTC" },
|
||||
isAllDay = request.AllDay,
|
||||
transactionId = request.StableId
|
||||
});
|
||||
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
using var response = await client.PostAsync(
|
||||
"https://graph.microsoft.com/v1.0/me/events",
|
||||
new StringContent(payload, System.Text.Encoding.UTF8, "application/json"),
|
||||
cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||
return new ExternalCalendarEventResult(
|
||||
document.RootElement.TryGetProperty("id", out var id) ? id.GetString() : null,
|
||||
document.RootElement.TryGetProperty("webLink", out var link) ? link.GetString() : null);
|
||||
}
|
||||
|
||||
public static bool HasCalendarScope(string? scope)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope)) return false;
|
||||
return scope.Split(' ', StringSplitOptions.RemoveEmptyEntries).Any(value =>
|
||||
string.Equals(value, CalendarScope, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(value, "Calendars.ReadWrite", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static void ValidateSendRequest(string to, string subject, string bodyText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(to)) throw new ArgumentException("Recipient is required.", nameof(to));
|
||||
|
||||
Reference in New Issue
Block a user