feat(calendar): add application date integrations

This commit is contained in:
cesnimda
2026-08-31 22:24:38 +02:00
parent caf486dceb
commit 1152e05687
10 changed files with 547 additions and 2 deletions
@@ -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);
+44 -1
View File
@@ -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));